mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
Merge branch 'master' into feature/instance-version-2
This commit is contained in:
102 files changed
+1289
-340
No files matched your search
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"image":"mcr.microsoft.com/devcontainers/universal:2",
|
||||
"postCreateCommand": "wget https://factorio.com/get-download/1.1.107/headless/linux64 && tar -xf linux64 && pnpm install",
|
||||
}
|
||||
@@ -16,15 +16,15 @@ jobs:
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: pnpm i --no-optional --no-frozen-lockfile
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- name: Run tests
|
||||
if: ${{ matrix.node-version != '18.x' }}
|
||||
@@ -34,19 +34,20 @@ jobs:
|
||||
run: pnpm run ci-cover
|
||||
- name : Upload coverage to Codecov
|
||||
if: ${{ matrix.node-version == '18.x' }}
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: pnpm i --no-optional --no-frozen-lockfile
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
+28
-3
@@ -27,15 +27,40 @@ Many thanks to the following for contributing to this release:
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
- Added clean script to speed up publishing and debugging of build steps [#640](https://github.com/clusterio/clusterio/pull/640).
|
||||
- Added sub-tile option on plugin page headers [#642](https://github.com/clusterio/clusterio/pull/642).
|
||||
- Greatly improved plugin and module template generation [#641](https://github.com/clusterio/clusterio/pull/641).
|
||||
|
||||
### Changes
|
||||
|
||||
- Added tests for sendEvent and sendRequest throwing the correct error messages [#638](https://github.com/clusterio/clusterio/pull/638).
|
||||
- Updated all web ui pages to use correct page header component [#643](https://github.com/clusterio/clusterio/pull/643).
|
||||
- Fixed IPC errors reporting undefined when no stack trace was present [#624](https://github.com/clusterio/clusterio/pull/624).
|
||||
- Fixed loopback routing for instances reporting the wrong error message [#625](https://github.com/clusterio/clusterio/pull/625).
|
||||
- Fixed docs linking to non-existing js files after ts migration [#633](https://github.com/clusterio/clusterio/pull/633).
|
||||
- Fixed CI workflows using deprecated version of nodejs [#637](https://github.com/clusterio/clusterio/pull/637).
|
||||
- Fixed test running on windows that should be linux only [#628](https://github.com/clusterio/clusterio/pull/628).
|
||||
- Fixed log events being sent from a host over an invalid websocket [#639](https://github.com/clusterio/clusterio/pull/639).
|
||||
- Bumped Typescript version to 5.5 and implemented `${configDir}` in base configs for "outDir" [#648](https://github.com/clusterio/clusterio/pull/648).
|
||||
- Fixed spaces passed in arguments to the installer causing it to break [#620](https://github.com/clusterio/clusterio/issues/620)
|
||||
- Fixed numeric admin name breaking login [#536](https://github.com/clusterio/clusterio/issues/536)
|
||||
- Added factorio.executable_path option which allows overriding the default path to the Factorio executable run.
|
||||
- Added instance version display [#573](https://github.com/clusterio/clusterio/pull/573)
|
||||
|
||||
### Breaking changes
|
||||
### Breaking Changes
|
||||
|
||||
- @clusterio/controller export InstanceInfo has added factorioVersion parameter
|
||||
- Previously silent errors for controller.sendEvent now throw exceptions [#625](https://github.com/clusterio/clusterio/pull/625).
|
||||
- @clusterio/controller export InstanceInfo has added factorioVersion parameter.
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@CCpersonguy](https://github.com/CCpersonguy)
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Danielv123](https://github.com/Danielv123)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
[@Laar](https://github.com/Laar)
|
||||
[@psihius](https://github.com/psihius)
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.18
|
||||
|
||||
|
||||
@@ -209,12 +209,17 @@ See the [readme for @clusterio/controller](/packages/controller/README.md) for m
|
||||
|
||||
## Managing Factorio mods
|
||||
|
||||
Clusterio currently only provides a manual method for running mods on servers by copying them either to each instance's individual `mods` folder, or by copying them into a host installation's `sharedMods` folder which will link/copy them to the `mods` folder of each instance on that host if the file doesn't already exist in the `mods` folder for that instance.
|
||||
Mods are copied from `sharedMods` on Windows and symlinked on Linux, this means that when mods are removed from `sharedMods` the mods will also be removed from the instance's `mods` folder on Linux if it got there by being linked by Clusterio, but not on Windows.
|
||||
Mods in clusterio are managed as parts of modpacks. There is currently no mod portal integration, so you first need to navigate to the mods section of the web UI and upload your mods. You can then create a modpack from the uploaded mods by navigating to the mods page, hitting `create`. Once you have a modpack, you can assign it to an instance under Instance config -> Factorio -> Mod pack. You can also set a default modpack for new instances in the controller settings.
|
||||
|
||||
For managing the mod settings, the `mod-settings.dat` and `mod-list.json` files will also be linked/copied over if present in `sharedMods`, but since Factorio creates these files automatically it might be necessary to delete them from each instance's `mods` folder for changes to them in `sharedMods` to take effect.
|
||||

|
||||
|
||||
Better remote management of mods is a planned feature.
|
||||
Mod settings can be managed under the modpack configuration page in the web UI. For mod settings to show, you first need to run an export. This is done by creating an instance with the modpack assigned, opening the instance page and pressing More -> Export data.
|
||||
|
||||

|
||||
|
||||
Once this is done, the mod settings will show up under the modpack configuration page.
|
||||
|
||||

|
||||
|
||||
|
||||
## Setting up shared storage
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Clean the repository of build and execution artifacts.
|
||||
*
|
||||
* clean: A quick clean that removes all build artifacts making the output safe to publish
|
||||
* clean-tests: Removes all build and test artifacts should not be required if tests are setup correctly.
|
||||
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
const yargs = require("yargs");
|
||||
|
||||
const npmPackage = require("./package.json");
|
||||
const workspaces = npmPackage.workspaces
|
||||
.map(p => p.slice(0, -2))
|
||||
.filter(p => !p.includes("external_plugins"));
|
||||
|
||||
let DRY = false;
|
||||
|
||||
/**
|
||||
* Attempt to remove a file / directory if it exists.
|
||||
*
|
||||
* @param {string} filePath The path to remove.
|
||||
*/
|
||||
async function tryRemove(filePath) {
|
||||
if (await fs.exists(filePath)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(filePath);
|
||||
}
|
||||
|
||||
if (!DRY) {
|
||||
await fs.remove(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all build artifacts
|
||||
*/
|
||||
async function removeBuildArtifacts() {
|
||||
const tasks = [];
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
const basePath = path.resolve(workspace);
|
||||
const packages = await fs.readdir(basePath);
|
||||
for (const packageName of packages) {
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all test artifacts
|
||||
*/
|
||||
async function removeTestArtifacts() {
|
||||
const artifacts = [
|
||||
".nyc_output", "coverage", "temp", "static",
|
||||
];
|
||||
|
||||
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all execution artifacts
|
||||
*/
|
||||
async function removeExecutionArtifacts() {
|
||||
const artifacts = [
|
||||
"database", "instances", "logs", "mods",
|
||||
"config-control.json", "config-controller.json", "config-host.json",
|
||||
"FactorioAdminToken.txt", "plugin-list.json",
|
||||
];
|
||||
|
||||
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function for this script
|
||||
*/
|
||||
async function main() {
|
||||
await yargs
|
||||
.scriptName("clean")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("dry", {
|
||||
nargs: 0,
|
||||
describe: "Will not delete any files",
|
||||
default: false,
|
||||
type: "boolean",
|
||||
})
|
||||
.command("all", "Remove all artifacts", () => {}, argv => {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
removeTestArtifacts();
|
||||
removeExecutionArtifacts();
|
||||
})
|
||||
.command("tests", "Remove all build and test artifacts", () => {}, argv => {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
removeTestArtifacts();
|
||||
})
|
||||
.command("fast", "Remove all build artifacts", () => {}, argv => {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.strict()
|
||||
.parse();
|
||||
}
|
||||
|
||||
// Run main if started from command line
|
||||
if (module === require.main) {
|
||||
main();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -30,7 +30,7 @@ There's also the virtual connector which lets two links in the same program be c
|
||||
## Message
|
||||
|
||||
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
|
||||
The builtin messages are defined in [packages/lib/link/messages.js](/packages/lib/link/messages.js) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
|
||||
The builtin messages are defined in [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
|
||||
|
||||
The messages define which links they are valid on and only attaches handlers for those links.
|
||||
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
|
||||
@@ -6,7 +6,7 @@ Clusterio uses a field based configuration system to manage settings for the con
|
||||
Plugins can add their own config fields for the controller and instances configs.
|
||||
The fields are defined early in the startup, and fields for disabled plugins will still be created.
|
||||
|
||||
The built-in config fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
|
||||
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
|
||||
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
|
||||
|
||||
|
||||
|
||||
@@ -319,6 +319,6 @@ You will need commit access to the repository as well publish access to the @clu
|
||||
4. Publish the packages.
|
||||
|
||||
```sh
|
||||
rm -r ./{plugins,packages}/*/dist
|
||||
pnpm clean
|
||||
pnpm -r --filter='./{plugins,packages}/*' publish
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
# Protocol
|
||||
|
||||
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
|
||||
The protocol is implemented in [packages/lib/link](/packages/lib/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
|
||||
The protocol is implemented in [packages/lib/src/link](/packages/lib/src/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
|
||||
|
||||
Two general kinds of messages are supported: events and requests.
|
||||
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
|
||||
@@ -166,7 +166,7 @@ Events are messages send in one direction over the link, that the receiver is ex
|
||||
By convention message types ending in `*_event` are events.
|
||||
The data payload of an event consists solely of event specific properties.
|
||||
|
||||
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core events and their contents.
|
||||
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core events and their contents.
|
||||
Plugins may defined their own events as well.
|
||||
|
||||
#### `*_event`
|
||||
@@ -181,7 +181,7 @@ They function a lot like HTTP requests except that both parties of a connection
|
||||
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
|
||||
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
|
||||
|
||||
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core requests and their contents.
|
||||
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core requests and their contents.
|
||||
Plugins may define their own requests as well.
|
||||
|
||||
#### `*_request`
|
||||
|
||||
+14
-2
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"files": [],
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
@@ -11,6 +12,9 @@
|
||||
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
|
||||
"lint": "eslint packages plugins test",
|
||||
"lint-fix": "eslint --fix packages plugins test",
|
||||
"clean": "node ./clean.js fast",
|
||||
"clean-tests": "node ./clean.js tests",
|
||||
"clean-all": "node ./clean.js all",
|
||||
"docs": "typedoc",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
@@ -23,11 +27,18 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"plugins/*",
|
||||
"external_plugins/*",
|
||||
"external_plugins/*/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/ctl": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
@@ -41,7 +52,8 @@
|
||||
"mocha": "^10.2.0",
|
||||
"nyc": "^15.1.0",
|
||||
"phin": "^3.7.0",
|
||||
"typedoc": "^0.24.8",
|
||||
"typescript": "^5.1.6"
|
||||
"typedoc": "^0.26.3",
|
||||
"typescript": "^5.5.3",
|
||||
"yargs": "^17.7.2"
|
||||
}
|
||||
}
|
||||
@@ -248,8 +248,12 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
.command("config", "Manage Controller config", lib.configCommand)
|
||||
.command("bootstrap", "Bootstrap access to cluster", yargs => {
|
||||
yargs
|
||||
.command("create-admin <name>", "Create a cluster admin")
|
||||
.command("generate-user-token <name>", "Generate authentication token for the given user")
|
||||
.command("create-admin <name>", "Create a cluster admin", yargs => {
|
||||
yargs.positional("name", { describe: "Name of the admin user", type: "string" });
|
||||
})
|
||||
.command("generate-user-token <name>", "Generate authentication token for the given user", yargs => {
|
||||
yargs.positional("name", { describe: "Name of the user", type: "string" });
|
||||
})
|
||||
.command("generate-host-token <id>", "Generate authentication token for the given host", yargs => {
|
||||
yargs.positional("id", { describe: "ID of the host", type: "number" });
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.7.4",
|
||||
@@ -29,14 +29,13 @@
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"winston": "^3.10.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"ws": "^8.13.0",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@swc/core": "^1.4.0",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
@@ -51,10 +50,11 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.4.14",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-dev-middleware": "^6.1.1",
|
||||
"webpack-merge": "^5.9.0"
|
||||
"webpack-merge": "^5.9.0",
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -95,7 +95,7 @@ export default class Controller {
|
||||
public shouldRestart: boolean = false;
|
||||
_fallbackedRequests: Map<lib.RequestClass<unknown, unknown>, lib.RequestHandler<unknown, unknown>> = new Map();
|
||||
_registeredRequests: Map<lib.RequestClass<unknown, unknown>, lib.RequestHandler<unknown, unknown>> = new Map();
|
||||
_registeredEvents = new Map();
|
||||
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
|
||||
_snoopedEvents = new Map();
|
||||
|
||||
devMiddleware: any | null = null;
|
||||
@@ -1351,10 +1351,10 @@ export default class Controller {
|
||||
throw Error(`Unknown type ${requestOrEvent.constructor.type}.`);
|
||||
}
|
||||
|
||||
async sendRequest(request: any, dst: lib.Address) {
|
||||
sendRequest(request: any, dst: lib.Address) {
|
||||
let connection;
|
||||
if (dst.type === lib.Address.controller) {
|
||||
throw new Error("controller as dst is not supported.");
|
||||
throw new Error(`Message would return back to sender ${dst}.`);
|
||||
|
||||
} else if (dst.type === lib.Address.control) {
|
||||
connection = this.wsServer.controlConnections.get(dst.id);
|
||||
@@ -1389,24 +1389,33 @@ export default class Controller {
|
||||
sendEvent<T>(event: lib.Event<T>, dst: lib.Address) {
|
||||
let connection;
|
||||
if (dst.type === lib.Address.controller) {
|
||||
throw new Error("controller as dst is not supported.");
|
||||
throw new Error(`Message would return back to sender ${dst}.`);
|
||||
|
||||
} else if (dst.type === lib.Address.control) {
|
||||
connection = this.wsServer.controlConnections.get(dst.id);
|
||||
if (!connection) {
|
||||
throw new Error("Target control connection does not exist.");
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.instance) {
|
||||
let instance = this.instances.get(dst.id);
|
||||
if (!instance) {
|
||||
return;
|
||||
throw new Error(`Instance with ID ${dst.id} does not exist`);
|
||||
}
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null) {
|
||||
return;
|
||||
throw new Error("Instance is not assigned to a host");
|
||||
}
|
||||
connection = this.wsServer.hostConnections.get(hostId);
|
||||
if (!connection) {
|
||||
throw new Error("Host containing instance is not connected");
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.host) {
|
||||
connection = this.wsServer.hostConnections.get(dst.id);
|
||||
if (!connection) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.broadcast) {
|
||||
if (dst.id === lib.Address.control) {
|
||||
@@ -1424,7 +1433,7 @@ export default class Controller {
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(`Unexpected broacdast target ${dst.id}`);
|
||||
throw new Error(`Unexpected broadcast target ${dst.id}`);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1432,9 +1441,7 @@ export default class Controller {
|
||||
throw new Error(`Unknown address type ${dst.type}`);
|
||||
}
|
||||
|
||||
if (connection) {
|
||||
connection.sendEvent(event, dst);
|
||||
}
|
||||
connection.sendEvent(event, dst);
|
||||
}
|
||||
|
||||
async sendToHostByInstanceId<Req extends InstanceId, Res>(request: Req & lib.Request<Req, Res>): Promise<Res>;
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,4 @@
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
|
||||
@@ -106,15 +107,25 @@ async function safeOutputFile(file, data, options={}) {
|
||||
}
|
||||
|
||||
async function execFile(cmd, args) {
|
||||
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
|
||||
const escaped = args.map(escapeArg);
|
||||
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
let child = child_process.execFile(cmd, args, { shell: true }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
let child = child_process.execFile(
|
||||
cmd,
|
||||
escaped,
|
||||
{
|
||||
shell: true,
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
let stdout = new LineSplitter({ readableObjectMode: true });
|
||||
stdout.on("data", line => { logger.verbose(line.toString()); });
|
||||
child.stdout.pipe(stdout);
|
||||
@@ -551,6 +562,25 @@ async function inquirerMissingArgs(args) {
|
||||
},
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (args.httpPort) { answers.httpPort = args.httpPort; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "httpPort",
|
||||
message: "HTTP port to listen on",
|
||||
default: 8080,
|
||||
validate: input => {
|
||||
if (!input) {
|
||||
return "May not be empty";
|
||||
}
|
||||
if (!Number.isInteger(Number(input))) {
|
||||
return "Must be a number";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "host") {
|
||||
@@ -680,6 +710,7 @@ async function inquirerMissingArgs(args) {
|
||||
{ name: "Command Line", value: "ctl" },
|
||||
{ name: "Web UI", value: "web" },
|
||||
{ name: "No Typescript", value: "js" },
|
||||
{ name: "No Config", value: "no_config" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -775,6 +806,9 @@ async function main() {
|
||||
.option("admin", {
|
||||
nargs: 1, describe: "Admin account name [standalone/controller]", type: "string",
|
||||
})
|
||||
.option("http-port", {
|
||||
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
||||
})
|
||||
.option("host-name", {
|
||||
nargs: 1, describe: "Host name [host]", type: "string",
|
||||
})
|
||||
@@ -854,6 +888,7 @@ async function main() {
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
logger.info("Setting up controller");
|
||||
await execController(["bootstrap", "create-admin", answers.admin]);
|
||||
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
||||
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
|
||||
adminToken = result.stdout.split("\n").slice(-2)[0];
|
||||
}
|
||||
@@ -868,6 +903,8 @@ async function main() {
|
||||
result = await execController(["bootstrap", "generate-host-token", hostId]);
|
||||
let hostToken = result.stdout.split("\n").slice(-2)[0];
|
||||
|
||||
// Default to localhost on correct port for host in standalone mode
|
||||
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}`]);
|
||||
await execHost(["config", "set", "host.controller_token", hostToken]);
|
||||
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
|
||||
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
|
||||
function escapeArg(arg) {
|
||||
// When shell: true is used execFile does not escaping of arguments, they are
|
||||
// simply joined by space and then passed to the shell.
|
||||
|
||||
// For Windows the command is cmd.exe /d /s /c "command arg arg ..."
|
||||
// The parsing and escaping on windows is cursed. The following references are not for mortals:
|
||||
// https://daviddeley.com/autohotkey/parameters/parameters.htm#WIN
|
||||
// https://stackoverflow.com/a/4095133
|
||||
// https://ss64.com/nt/syntax-esc.html
|
||||
if (process.platform === "win32") {
|
||||
if (!/[\t "%&<>\\^|]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
const escaped = [];
|
||||
let pos = 0;
|
||||
while (pos < arg.length) {
|
||||
if (arg[pos] === "\\") {
|
||||
// If a backslash is followed by a quote or the end of the string, then
|
||||
// double the number of backslashes because why not!
|
||||
let peek = pos + 1;
|
||||
while (peek !== arg.length && arg[peek] === "\\") {
|
||||
peek += 1;
|
||||
}
|
||||
if (peek === arg.length || arg[peek] === '"') {
|
||||
const count = peek - pos;
|
||||
escaped.push("\\".repeat(count * 2));
|
||||
pos = peek;
|
||||
continue;
|
||||
} else {
|
||||
escaped.push("\\");
|
||||
}
|
||||
} else if (arg[pos] === '"') {
|
||||
escaped.push('""');
|
||||
} else if (arg[pos] === "%") {
|
||||
// Expect a pct environment variable to contain %, because cmd does variable substitutions
|
||||
// before parsing string quotes, and there's no mechanism for escaping % symbols, and this
|
||||
// is a half sane workaround. The less sane workaround is to replace % with %%cd:~,%.
|
||||
escaped.push("%pct%");
|
||||
} else {
|
||||
escaped.push(arg[pos]);
|
||||
}
|
||||
pos += 1;
|
||||
}
|
||||
return `"${escaped.join("")}"`;
|
||||
}
|
||||
|
||||
// For all other platforms the command is /bin/sh -c <command arg arg ...>
|
||||
// The command and its arguments are passed as a single argument to the shell.
|
||||
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeArg,
|
||||
};
|
||||
@@ -76,8 +76,15 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
const files = new Map();
|
||||
const prepare = [];
|
||||
|
||||
// Some basic flags for selecting files to include
|
||||
const javascriptOnly = templates.includes("js");
|
||||
const webpack = templates.includes("controller") || templates.includes("web");
|
||||
const config = !templates.includes("no_config") && templates.some(value => (
|
||||
["controller", "host", "instance", "ctl"].includes(value)
|
||||
));
|
||||
const webpack = config || templates.includes("controller") || templates.includes("web");
|
||||
|
||||
// A count of the number of isolated contexts the plugin runs under
|
||||
let pluginContexts = 0;
|
||||
|
||||
// Get the file extension and path to the templates
|
||||
const ext = javascriptOnly ? "js" : "ts";
|
||||
@@ -88,7 +95,6 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
files.set(".gitignore", path.join(commonPath, ".gitignore"));
|
||||
files.set(".npmignore", path.join(commonPath, ".npmignore"));
|
||||
files.set("package.json", path.join(commonPath, "package.json"));
|
||||
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
|
||||
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
|
||||
|
||||
// Files and dependencies to support typescript
|
||||
@@ -105,24 +111,30 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --env production");
|
||||
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath,
|
||||
templates.includes("web") ? `web/plugin.${ext}x` : `web/no_plugin.${ext}x`
|
||||
));
|
||||
if (templates.includes("web")) {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||
pluginContexts += 1;
|
||||
} else {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/no_plugin.${ext}x`));
|
||||
}
|
||||
}
|
||||
|
||||
// Files for the controller
|
||||
if (templates.includes("controller")) {
|
||||
files.set(`controller.${ext}`, path.join(templatePath, `controller.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
|
||||
// Files for hosts
|
||||
if (templates.includes("host")) {
|
||||
files.set(`host.${ext}`, path.join(templatePath, `host.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
|
||||
// Files for instances
|
||||
if (templates.includes("instance")) {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
|
||||
// Files for lua modules
|
||||
@@ -133,16 +145,26 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
|
||||
if (templates.includes("instance")) {
|
||||
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
|
||||
} else {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Files for the control tool
|
||||
if (templates.includes("ctl")) {
|
||||
files.set(`ctl.${ext}`, path.join(templatePath, `ctl.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
|
||||
// If there are more than one contexts then include the message file
|
||||
if (pluginContexts > 1) {
|
||||
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
|
||||
}
|
||||
|
||||
// Properties that will control the replacements in the templates
|
||||
const properties = {
|
||||
config: config,
|
||||
typescript: !javascriptOnly,
|
||||
controller: templates.includes("controller"),
|
||||
host: templates.includes("host"),
|
||||
@@ -150,6 +172,7 @@ async function copyPluginTemplates(pluginName, templates) {
|
||||
module: templates.includes("module"),
|
||||
ctl: templates.includes("ctl"),
|
||||
web: templates.includes("web"),
|
||||
multi_context: pluginContexts > 1,
|
||||
plugin_name: pluginName,
|
||||
prepare: prepare.join(" && "),
|
||||
ext: ext,
|
||||
|
||||
@@ -6,8 +6,10 @@ local MyModule = {
|
||||
on_nth_tick = {},
|
||||
}
|
||||
|
||||
--- global is 'synced' between players, you should use your plugin name to avoid conflicts
|
||||
-- setupGlobalData should either be removed or called during clusterio_api.events.on_server_startup
|
||||
--- global is 'synced' between players, you should use your plugin name to avoid name conflicts
|
||||
-- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup
|
||||
-- There are edge cases in the following events: pre player left, player left, console command, and config changed
|
||||
-- If you use any of these events and access globalData then make sure to call this function first
|
||||
local globalData = {}
|
||||
local function setupGlobalData()
|
||||
if global["__plugin_name__"] == nil then
|
||||
@@ -15,6 +17,12 @@ local function setupGlobalData()
|
||||
-- starting values go here
|
||||
}
|
||||
end
|
||||
MyModule.on_load()
|
||||
end
|
||||
|
||||
--- The on_load function is called independently for each client when they first load the map
|
||||
-- It should be used to restore global aliases and metatables not registered with script.register_metatable
|
||||
function MyModule.on_load()
|
||||
globalData = global["__plugin_name__"]
|
||||
end
|
||||
|
||||
@@ -28,7 +36,7 @@ local function bar()
|
||||
game.print("bar")
|
||||
end
|
||||
|
||||
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_load
|
||||
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
|
||||
MyModule.events[clusterio_api.events.on_server_startup] = function(event)
|
||||
setupGlobalData()
|
||||
game.print(game.table_to_json(event))
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.17"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
//%if typescript
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"@types/node": "^20.4.5",
|
||||
//%endif
|
||||
//%if typescript & web
|
||||
@@ -36,9 +36,9 @@
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
//%endif
|
||||
"@clusterio/lib": "^2.0.0-alpha.17"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
const {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} = require("./messages");
|
||||
//%endif
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
@@ -15,14 +19,18 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
storageDirty = false;
|
||||
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
async init() {
|
||||
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onControllerConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
|
||||
@@ -45,6 +53,7 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
async onPlayerEvent(instance, event) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
@@ -57,6 +66,7 @@ class ControllerPlugin extends BaseControllerPlugin {
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
const { BaseCtlPlugin } = require("@clusterio/ctl");
|
||||
const { CommandTree, Command } = require("@clusterio/lib");
|
||||
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
/* eslint-disable no-console */
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseHostPlugin } = require("@clusterio/host");
|
||||
//%if multi_context
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
//%endif
|
||||
|
||||
class HostPlugin extends BaseHostPlugin {
|
||||
//%if multi_context
|
||||
async init() {
|
||||
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onHostConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
|
||||
@@ -16,6 +20,7 @@ class HostPlugin extends BaseHostPlugin {
|
||||
async onShutdown() {
|
||||
this.logger.info("host::onShutdown");
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
@@ -28,6 +33,7 @@ class HostPlugin extends BaseHostPlugin {
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
//%if multi_context
|
||||
const Messages = require("./messages");
|
||||
|
||||
lib.definePermission({
|
||||
@@ -13,6 +14,7 @@ lib.definePermission({
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
lib.definePermission({
|
||||
@@ -34,9 +36,13 @@ const plugin = {
|
||||
name: "__plugin_name__",
|
||||
title: "__plugin_name__",
|
||||
description: "Example Description. Plugin. Change me in index.js",
|
||||
//%if controller
|
||||
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
|
||||
|
||||
controllerEntrypoint: "./controller",
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
"__plugin_name__.myControllerField": {
|
||||
title: "My Controller Field",
|
||||
@@ -46,9 +52,13 @@ const plugin = {
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if host
|
||||
//%if host & config // Blank line for formatting
|
||||
|
||||
hostEntrypoint: "./host",
|
||||
//%endif
|
||||
//%if host
|
||||
hostEntrypoint: "./dist/node/host",
|
||||
//%endif
|
||||
//%if host & config
|
||||
hostConfigFields: {
|
||||
"__plugin_name__.myHostField": {
|
||||
title: "My Host Field",
|
||||
@@ -58,9 +68,13 @@ const plugin = {
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if instance
|
||||
//%if instance & config | module & config // Blank line for formatting
|
||||
|
||||
instanceEntrypoint: "./instance",
|
||||
//%endif
|
||||
//%if instance | module // Modules load an empty instance plugin
|
||||
instanceEntrypoint: "./dist/node/instance",
|
||||
//%endif
|
||||
//%if instance & config
|
||||
instanceConfigFields: {
|
||||
"__plugin_name__.myInstanceField": {
|
||||
title: "My Instance Field",
|
||||
@@ -70,9 +84,13 @@ const plugin = {
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if ctl
|
||||
//%if ctl & config // Blank line for formatting
|
||||
|
||||
ctlEntrypoint: "./ctl",
|
||||
//%endif
|
||||
//%if ctl
|
||||
ctlEntrypoint: "./dist/node/ctl",
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
controlConfigFields: {
|
||||
"__plugin_name__.myControlField": {
|
||||
title: "My Control Field",
|
||||
@@ -82,14 +100,18 @@ const plugin = {
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
messages: [
|
||||
Messages.PluginExampleEvent,
|
||||
Messages.PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
Messages.ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
],
|
||||
//%endif
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
@@ -97,11 +119,6 @@ const plugin = {
|
||||
"/__plugin_name__",
|
||||
],
|
||||
//%endif
|
||||
//%if controller & !web // The controller always includes web entry even if there is no content
|
||||
|
||||
webEntrypoint: "./web",
|
||||
routes: [],
|
||||
//%endif
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
//%if multi_context
|
||||
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
|
||||
//%endif
|
||||
|
||||
class InstancePlugin extends BaseInstancePlugin {
|
||||
//%if multi_context | module
|
||||
async init() {
|
||||
//%if multi_context
|
||||
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if module
|
||||
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
|
||||
//%endif
|
||||
//%if multi_context | module
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onInstanceConfigFieldChanged(field, curr, prev) {
|
||||
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
|
||||
@@ -26,13 +33,16 @@ class InstancePlugin extends BaseInstancePlugin {
|
||||
|
||||
async onPlayerEvent(event) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
//%if module
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
//%endif
|
||||
}
|
||||
|
||||
async handlePluginExampleRequest(request) {
|
||||
@@ -42,6 +52,7 @@ class InstancePlugin extends BaseInstancePlugin {
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
async handlePluginExampleIPC(event) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use strict";
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
|
||||
class InstancePlugin extends BaseInstancePlugin {
|
||||
// This class is empty because an instance plugin must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InstancePlugin,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {
|
||||
useContext, useEffect, useState,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
useCallback, useSyncExternalStore,
|
||||
//%endif
|
||||
} from "react";
|
||||
@@ -10,35 +10,39 @@ import React, {
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler,
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
function MyTemplatePage() {
|
||||
let control = useContext(ControlContext);
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
const plugin = control.plugins.get("__plugin_name__");
|
||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
<h2>__plugin_name__</h2>
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
<PageHeader title="__plugin_name__" />
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
|
||||
//%endif
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
||||
|
||||
//%endif
|
||||
@@ -47,15 +51,24 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
permission: "__plugin_name__.page.view",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
];
|
||||
//%if multi_context
|
||||
|
||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
//%if controller// Subscribing requires web content and the controller
|
||||
|
||||
useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
@@ -63,6 +76,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
@@ -75,4 +89,5 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "./messages";
|
||||
//%endif
|
||||
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
@@ -14,14 +18,18 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
storageDirty = false;
|
||||
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
async init() {
|
||||
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
|
||||
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
|
||||
@@ -44,6 +52,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
|
||||
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
@@ -56,6 +65,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
async handleExampleSubscription(request: lib.SubscriptionRequest) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
type Control = any; // import type { Control } from "@clusterio/ctl";
|
||||
import { BaseCtlPlugin } from "@clusterio/ctl";
|
||||
import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
|
||||
import { CommandTree, Command } from "@clusterio/lib";
|
||||
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
/* eslint-disable no-console */
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseHostPlugin } from "@clusterio/host";
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
//%endif
|
||||
|
||||
export class HostPlugin extends BaseHostPlugin {
|
||||
//%if multi_context
|
||||
async init() {
|
||||
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
|
||||
@@ -15,6 +19,7 @@ export class HostPlugin extends BaseHostPlugin {
|
||||
async onShutdown() {
|
||||
this.logger.info("host::onShutdown");
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
@@ -27,4 +32,5 @@ export class HostPlugin extends BaseHostPlugin {
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
import * as Messages from "./messages";
|
||||
|
||||
lib.definePermission({
|
||||
@@ -12,6 +13,7 @@ lib.definePermission({
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
lib.definePermission({
|
||||
@@ -28,37 +30,46 @@ lib.definePermission({
|
||||
description: "Example Description. View. Change me in index.ts",
|
||||
});
|
||||
//%endif
|
||||
//%if config
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
//%if controller
|
||||
//%endif
|
||||
//%if controller & config
|
||||
export interface ControllerConfigFields {
|
||||
"__plugin_name__.myControllerField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if host
|
||||
//%if host & config
|
||||
export interface HostConfigFields {
|
||||
"__plugin_name__.myHostField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if instance
|
||||
//%if instance & config
|
||||
export interface InstanceConfigFields {
|
||||
"__plugin_name__.myInstanceField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if ctl
|
||||
//%if ctl & config
|
||||
export interface ControlConfigFields {
|
||||
"__plugin_name__.myControlField": string;
|
||||
}
|
||||
//%endif
|
||||
//%if config
|
||||
}
|
||||
//%endif
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "__plugin_name__",
|
||||
title: "__plugin_name__",
|
||||
description: "Example Description. Plugin. Change me in index.ts",
|
||||
//%if controller
|
||||
//%// There are a lot of statements here to make the formatting look good with and without 'config'
|
||||
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
"__plugin_name__.myControllerField": {
|
||||
title: "My Controller Field",
|
||||
@@ -68,9 +79,13 @@ export const plugin: lib.PluginDeclaration = {
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if host
|
||||
//%if host & config // Blank line for formatting
|
||||
|
||||
//%endif
|
||||
//%if host
|
||||
hostEntrypoint: "./dist/node/host",
|
||||
//%endif
|
||||
//%if host & config
|
||||
hostConfigFields: {
|
||||
"__plugin_name__.myHostField": {
|
||||
title: "My Host Field",
|
||||
@@ -80,9 +95,13 @@ export const plugin: lib.PluginDeclaration = {
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if instance
|
||||
//%if instance & config | module & config // Blank line for formatting
|
||||
|
||||
//%endif
|
||||
//%if instance | module // Modules load an empty instance plugin
|
||||
instanceEntrypoint: "./dist/node/instance",
|
||||
//%endif
|
||||
//%if instance & config
|
||||
instanceConfigFields: {
|
||||
"__plugin_name__.myInstanceField": {
|
||||
title: "My Instance Field",
|
||||
@@ -92,9 +111,13 @@ export const plugin: lib.PluginDeclaration = {
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if ctl
|
||||
//%if ctl & config // Blank line for formatting
|
||||
|
||||
//%endif
|
||||
//%if ctl
|
||||
ctlEntrypoint: "./dist/node/ctl",
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
controlConfigFields: {
|
||||
"__plugin_name__.myControlField": {
|
||||
title: "My Control Field",
|
||||
@@ -104,14 +127,18 @@ export const plugin: lib.PluginDeclaration = {
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
messages: [
|
||||
Messages.PluginExampleEvent,
|
||||
Messages.PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
Messages.ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
],
|
||||
//%endif
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
@@ -119,9 +146,4 @@ export const plugin: lib.PluginDeclaration = {
|
||||
"/__plugin_name__",
|
||||
],
|
||||
//%endif
|
||||
//%if controller & !web // The controller always includes web entry even if there is no content
|
||||
|
||||
webEntrypoint: "./web",
|
||||
routes: [],
|
||||
//%endif
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
type PuginExampleIPC = {
|
||||
@@ -10,13 +12,19 @@ type PuginExampleIPC = {
|
||||
//%endif
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
//%if multi_context | module
|
||||
async init() {
|
||||
//%endif
|
||||
//%if multi_context
|
||||
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
//%if module
|
||||
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
|
||||
//%endif
|
||||
//%if multi_context | module
|
||||
}
|
||||
//%endif
|
||||
|
||||
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
|
||||
@@ -33,9 +41,12 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
if (this.instance.status === "running") {
|
||||
this.sendRcon("/sc __plugin_name__.foo()");
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
@@ -48,6 +59,7 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
async handlePluginExampleIPC(event: PuginExampleIPC) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
// This class is empty because an instance plugin must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -10,35 +10,39 @@ import React, {
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler,
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
} from "../messages";
|
||||
//%endif
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
function MyTemplatePage() {
|
||||
const control = useContext(ControlContext);
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
|
||||
const [subscribableData, synced] = plugin.useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
<h2>__plugin_name__</h2>
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
<PageHeader title="__plugin_name__" />
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
|
||||
//%endif
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
|
||||
|
||||
//%endif
|
||||
@@ -49,20 +53,22 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller | !web
|
||||
//%if !controller
|
||||
permission: undefined, // "__plugin_name__.example.permission.request",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
];
|
||||
//%if multi_context
|
||||
|
||||
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
|
||||
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
|
||||
//%endif
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
|
||||
useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
@@ -70,6 +76,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
//%if multi_context
|
||||
|
||||
async handlePluginExampleEvent(event: PluginExampleEvent) {
|
||||
this.logger.info(JSON.stringify(event));
|
||||
@@ -82,4 +89,5 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
}
|
||||
//%endif
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"as-table": "^1.0.55",
|
||||
"fs-extra": "^11.1.1",
|
||||
"phin": "^3.7.0",
|
||||
@@ -35,6 +35,6 @@
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,4 @@
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"jimp": "^0.22.8",
|
||||
@@ -43,6 +43,6 @@
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/set-blocking": "^2.0.0",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ export class HostRouter {
|
||||
return false;
|
||||
}
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError("Instance is not running."), message.src
|
||||
new lib.ResponseError(msg || "Instance is not running."), message.src
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -169,6 +169,7 @@ export default class Instance extends lib.Link {
|
||||
let serverOptions = {
|
||||
logger: this.logger,
|
||||
version: this.config.get("factorio.version"),
|
||||
executablePath: this.config.get("factorio.executable_path") ?? undefined,
|
||||
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
|
||||
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
|
||||
rconPassword: this.config.get("factorio.rcon_password") ?? undefined,
|
||||
|
||||
@@ -407,6 +407,8 @@ export interface FactorioServerOptions {
|
||||
* the latest version found in `factorioDir`.
|
||||
*/
|
||||
version?: string,
|
||||
/** Path to executable to invoke when starting the server */
|
||||
executablePath?: string;
|
||||
/** UDP port to host game on. */
|
||||
gamePort?: number,
|
||||
/** TCP port to use for RCON. */
|
||||
@@ -450,6 +452,8 @@ export interface FactorioServerOptions {
|
||||
* @extends events.EventEmitter
|
||||
*/
|
||||
export class FactorioServer extends events.EventEmitter {
|
||||
/** Path to executable to invoke when starting the server */
|
||||
executablePath?: string;
|
||||
/** UDP port used for hosting the Factorio game server on */
|
||||
gamePort: number;
|
||||
/** TCP port used for RCON on the Factorio game server */
|
||||
@@ -504,6 +508,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
|
||||
this._logger = options.logger || lib.logger;
|
||||
this._targetVersion = options.version || "latest";
|
||||
this.executablePath = options.executablePath;
|
||||
/** UDP port used for hosting the Factorio game server on */
|
||||
this.gamePort = options.gamePort || randomDynamicPort();
|
||||
/** TCP port used for RCON on the Factorio game server */
|
||||
@@ -564,8 +569,8 @@ export class FactorioServer extends events.EventEmitter {
|
||||
|
||||
// TODO at the moment this is just a helper function, it has the potential for schema checking
|
||||
handle<Event>(eventName: string, handler: (event: Event) => Promise<void>) {
|
||||
this.on(`ipc-${eventName}`, (event) => handler(event).catch(err => {
|
||||
this._logger.error(`Error handling ipc event:\n${err.stack}`);
|
||||
this.on(`ipc-${eventName}`, (event) => handler(event).catch((err: Error) => {
|
||||
this._logger.error(`Error handling ipc event:\n${err.stack ?? err.message}`);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1155,11 +1160,15 @@ export class FactorioServer extends events.EventEmitter {
|
||||
/**
|
||||
* Get Factorio binary path
|
||||
*
|
||||
* Get the path to the factorio binary depending on the platform (MacOS support)
|
||||
* Get the path to the factorio binary depending on the configuration
|
||||
* and or the platform (MacOS support)
|
||||
*
|
||||
* @returns Path to factorio binary
|
||||
*/
|
||||
binaryPath() {
|
||||
if (this.executablePath) {
|
||||
return this.dataPath("..", this.executablePath);
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return this.dataPath("..", "MacOS", "factorio");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,4 @@
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -41,6 +41,6 @@
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,7 @@ export interface InstanceConfigFields {
|
||||
"instance.auto_start": boolean;
|
||||
|
||||
"factorio.version": string;
|
||||
"factorio.executable_path": string | null;
|
||||
"factorio.game_port": number | null;
|
||||
"factorio.host_assigned_game_port": number | null;
|
||||
"factorio.rcon_port": number | null;
|
||||
@@ -311,6 +312,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
},
|
||||
"factorio.executable_path": {
|
||||
description:
|
||||
"Relative path from the Factorio installation directory to the executable to run. " +
|
||||
"Defaults to auto detect the path, only needed in special setups.",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
},
|
||||
"factorio.game_port": {
|
||||
description: "UDP port to run game on, uses a port in host.factorio_port_range if null",
|
||||
restartRequired: true,
|
||||
|
||||
@@ -34,6 +34,7 @@ export abstract class BaseConnector extends events.EventEmitter {
|
||||
}
|
||||
|
||||
protected abstract send(message: libData.MessageRoutable): void;
|
||||
abstract get valid(): boolean;
|
||||
|
||||
forward(message: libData.MessageRoutable) {
|
||||
const seq = this._seq;
|
||||
@@ -346,6 +347,14 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
get hasSession() {
|
||||
return ["connected", "resuming"].includes(this._state);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the connector is valid and can accept new messages,
|
||||
* only needs to be checked on fringe cases during setup and tear down.
|
||||
*/
|
||||
get valid() {
|
||||
return this.hasSession;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -723,4 +732,14 @@ export class VirtualConnector extends BaseConnector {
|
||||
send(message: libData.Message) {
|
||||
this.other.emit("message", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the connector is valid and can accept new messages,
|
||||
* only needs to be checked on fringe cases during setup and tear down.
|
||||
*
|
||||
* For a virtual connector this is always true.
|
||||
*/
|
||||
get valid() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -538,6 +538,9 @@ export class Link {
|
||||
if (!entry) {
|
||||
throw new Error(`Attempt to send unregistered Request ${request.constructor.name}`);
|
||||
}
|
||||
if (this.connector.src.addressedTo(dst)) {
|
||||
throw new Error(`Message would return back to sender ${dst}.`);
|
||||
}
|
||||
if (this.validateSent) {
|
||||
entry.requestFromJSON(JSON.parse(JSON.stringify(request)));
|
||||
}
|
||||
@@ -572,6 +575,9 @@ export class Link {
|
||||
if (!entry) {
|
||||
throw new Error(`Attempt to send unregistered Event ${event.constructor.name}`);
|
||||
}
|
||||
if (this.connector.src.addressedTo(dst)) {
|
||||
throw new Error(`Message would return back to sender ${dst}.`);
|
||||
}
|
||||
if (this.validateSent) {
|
||||
entry.eventFromJSON(JSON.parse(JSON.stringify(event)));
|
||||
}
|
||||
|
||||
@@ -207,7 +207,9 @@ export class LinkTransport extends Transport {
|
||||
}
|
||||
|
||||
try {
|
||||
this.link.send(new libData.LogMessageEvent(info));
|
||||
if (this.link.connector.valid) {
|
||||
this.link.send(new libData.LogMessageEvent(info));
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore session lost errors.
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
|
||||
@@ -26,6 +26,5 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"include": ["index.ts", "browser.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
"allowJs": true,
|
||||
},
|
||||
}
|
||||
@@ -17,7 +17,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.1.4",
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"@swc/core": "^1.4.0",
|
||||
"antd": "^5.13.0",
|
||||
"assert": "^2.0.0",
|
||||
@@ -39,7 +40,7 @@
|
||||
"style-loader": "^3.3.3",
|
||||
"swc-loader": "^0.2.6",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"util": "^0.12.5",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
|
||||
@@ -270,7 +270,7 @@ function computeTreeData(
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (def.type === "object") {
|
||||
if (def.type === "object" && !Object.keys(control.inputComponents).includes(def.inputComponent!)) {
|
||||
let restartRequiredProps = new Set(def.restartRequiredProps || []);
|
||||
childNode.title = <Form.Item
|
||||
label={<>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { InboxOutlined } from "@ant-design/icons";
|
||||
import { DraggingContext } from "../model/is_dragging";
|
||||
import { useContext } from "react";
|
||||
|
||||
// This component is used to display a dropzone when a file is being dragged over the parent component
|
||||
export function Dropzone({ disabled = false }: { disabled?: boolean }) {
|
||||
const isDroppingFile = useContext(DraggingContext);
|
||||
|
||||
const textStyle: { color?: string } = {};
|
||||
let text = "Drop to upload";
|
||||
if (disabled) {
|
||||
textStyle.color = "red";
|
||||
text = "Target is offline";
|
||||
}
|
||||
const borderColor = disabled ? "gray" : "rgb(22, 119, 255)";
|
||||
|
||||
return <div
|
||||
className={`dropzone ${disabled ? "disabled" : "enabled"}`} // Don't remove this class, linked to SiteLayout
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: "90",
|
||||
backgroundColor: "#88888844",
|
||||
borderRadius: "20px",
|
||||
border: `dashed 2px ${borderColor}`,
|
||||
display: isDroppingFile ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="dropzone-icon"
|
||||
style={{
|
||||
fontSize: "72px",
|
||||
color: "rgb(22, 119, 255)",
|
||||
display: "flex",
|
||||
zIndex: "100",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<InboxOutlined style={textStyle} />
|
||||
<p style={{ ...textStyle, fontSize: "24px", display: "block", textAlign: "center", marginTop: "8px" }}>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Descriptions, Dropdown, Menu, MenuProps, Modal, Space, Spin, Typography } from "antd";
|
||||
import type { ItemType } from "antd/es/menu/hooks/useItems";
|
||||
import { Alert, Button, Descriptions, Dropdown, MenuProps, Modal, Space, Spin, Typography } from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
import DownOutlined from "@ant-design/icons/DownOutlined";
|
||||
|
||||
@@ -25,6 +24,7 @@ import { useHost } from "../model/host";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import Link from "./Link";
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
const { Title } = Typography;
|
||||
|
||||
type InstanceDescriptionProps = {
|
||||
@@ -74,7 +74,7 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
let instance = props.instance;
|
||||
let instanceId = instance.id!;
|
||||
|
||||
let instanceButtonMenuItems: ItemType[] = [];
|
||||
let instanceButtonMenuItems: MenuItem[] = [];
|
||||
if (account.hasPermission("core.instance.export_data")) {
|
||||
instanceButtonMenuItems.push({
|
||||
disabled: exportingData || instance.status !== "stopped",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
|
||||
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload } from "antd";
|
||||
import ImportOutlined from "@ant-design/icons/ImportOutlined";
|
||||
import PlusOutlined from "@ant-design/icons/PlusOutlined";
|
||||
import UploadOutlined from "@ant-design/icons/UploadOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -17,6 +16,8 @@ import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ModDetails from "./ModDetails";
|
||||
import { Dropzone } from "./Dropzone";
|
||||
import UploadButton from "./UploadButton";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -55,11 +56,13 @@ function ImportModPackButton() {
|
||||
name="string"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ async validator(rule, value) {
|
||||
if (value) {
|
||||
lib.ModPack.fromModPackString(value);
|
||||
}
|
||||
}},
|
||||
{
|
||||
async validator(rule, value) {
|
||||
if (value) {
|
||||
lib.ModPack.fromModPackString(value);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 6, maxRows: 6 }} />
|
||||
@@ -172,12 +175,13 @@ export default function ModsPage() {
|
||||
name="file"
|
||||
accept=".zip"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
headers={{
|
||||
"X-Access-Token": control.connector.token||"",
|
||||
"X-Access-Token": control.connector.token || "",
|
||||
}}
|
||||
action={`${webRoot}api/upload-mod`}
|
||||
>
|
||||
<Button icon={<UploadOutlined/>}>Upload</Button>
|
||||
<UploadButton />
|
||||
</Upload>;
|
||||
}
|
||||
|
||||
@@ -219,54 +223,76 @@ export default function ModsPage() {
|
||||
})}
|
||||
/>
|
||||
<SectionHeader title="Stored Mods" extra={uploadButton} />
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "title",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => (
|
||||
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Version",
|
||||
key: "version",
|
||||
align: "right",
|
||||
render: (_, mod) => <>
|
||||
{`${mod.version} `}
|
||||
<Typography.Text type="secondary">{`/ ${mod.factorioVersion}`}</Typography.Text>
|
||||
</>,
|
||||
},
|
||||
{
|
||||
title: "Filename",
|
||||
dataIndex: "filename",
|
||||
responsive: ["xl"],
|
||||
sorter: (a, b) => strcmp(a.filename, b.filename),
|
||||
},
|
||||
{
|
||||
title: "Size",
|
||||
key: "size",
|
||||
responsive: ["lg"],
|
||||
render: (_, mod) => lib.formatBytes(mod.size),
|
||||
align: "right",
|
||||
sorter: (a, b) => a.size - b.size,
|
||||
},
|
||||
{
|
||||
title: "Action",
|
||||
key: "action",
|
||||
responsive: ["lg"],
|
||||
render: (_, mod) => actions(mod),
|
||||
},
|
||||
]}
|
||||
expandable={{
|
||||
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
|
||||
expandedRowClassName: () => "no-expanded-padding",
|
||||
|
||||
<Upload.Dragger
|
||||
className="save-list-dragger"
|
||||
openFileDialogOnClick={false}
|
||||
name="file"
|
||||
accept=".zip"
|
||||
multiple
|
||||
headers={{
|
||||
"X-Access-Token": control.connector.token || "",
|
||||
}}
|
||||
dataSource={[...mods.values()]}
|
||||
pagination={false}
|
||||
rowKey={mod => mod.id}
|
||||
/>
|
||||
action={`${webRoot}api/upload-mod`}
|
||||
showUploadList={false}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: "1010",
|
||||
}}
|
||||
>
|
||||
<Dropzone />
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "title",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => (
|
||||
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Version",
|
||||
key: "version",
|
||||
align: "right",
|
||||
render: (_, mod) => <>
|
||||
{`${mod.version} `}
|
||||
<Typography.Text type="secondary">{`/ ${mod.factorioVersion}`}</Typography.Text>
|
||||
</>,
|
||||
},
|
||||
{
|
||||
title: "Filename",
|
||||
dataIndex: "filename",
|
||||
responsive: ["xl"],
|
||||
sorter: (a, b) => strcmp(a.filename, b.filename),
|
||||
},
|
||||
{
|
||||
title: "Size",
|
||||
key: "size",
|
||||
responsive: ["lg"],
|
||||
render: (_, mod) => lib.formatBytes(mod.size),
|
||||
align: "right",
|
||||
sorter: (a, b) => a.size - b.size,
|
||||
},
|
||||
{
|
||||
title: "Action",
|
||||
key: "action",
|
||||
responsive: ["lg"],
|
||||
render: (_, mod) => actions(mod),
|
||||
},
|
||||
]}
|
||||
expandable={{
|
||||
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
|
||||
expandedRowClassName: () => "no-expanded-padding",
|
||||
}}
|
||||
dataSource={[...mods.values()]}
|
||||
pagination={false}
|
||||
rowKey={mod => mod.id}
|
||||
/>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
<PluginExtra component="ModsPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import React from "react";
|
||||
import { Space, Typography } from "antd";
|
||||
const { Text } = Typography;
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string|React.ReactElement;
|
||||
title: string | React.ReactElement;
|
||||
subTitle?: string | React.ReactElement;
|
||||
extra?: React.JSX.Element;
|
||||
};
|
||||
|
||||
export default function PageHeader(props: PageHeaderProps): React.JSX.Element {
|
||||
return <div className="page-header">
|
||||
<h2 className="page-header-title">{props.title}</h2>
|
||||
<Space>
|
||||
<h2 className="page-header-title">{props.title}</h2>
|
||||
{props.subTitle && <Text type="secondary">{props.subTitle}</Text>}
|
||||
</Space>
|
||||
{props.extra && <div className="page-header-extra">{props.extra}</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { PluginWebApi } from "@clusterio/lib";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PageHeader from "./PageHeader";
|
||||
import notify from "../util/notify";
|
||||
|
||||
export default function PluginViewPage() {
|
||||
@@ -39,7 +40,7 @@ export default function PluginViewPage() {
|
||||
let pluginMeta = pluginList.find(p => p.name === pluginName);
|
||||
if (!pluginMeta) {
|
||||
return <PageLayout nav={nav}>
|
||||
<h2>Plugin not found</h2>
|
||||
<PageHeader title="Plugin not found" />
|
||||
<p>Plugin with name {pluginName} was not found on the controller.</p>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { PluginWebApi } from "@clusterio/lib";
|
||||
import notify from "../util/notify";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PageHeader from "./PageHeader";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -47,7 +48,7 @@ export default function PluginsPage() {
|
||||
}
|
||||
|
||||
return <PageLayout nav={[{ name: "Plugins" }]}>
|
||||
<h2>Plugins</h2>
|
||||
<PageHeader title="Plugins" />
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
|
||||
@@ -15,6 +15,8 @@ import SectionHeader from "./SectionHeader";
|
||||
import { useInstances } from "../model/instance";
|
||||
import { useSavesOfInstance } from "../model/saves";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { Dropzone } from "./Dropzone";
|
||||
import UploadButton from "./UploadButton";
|
||||
|
||||
|
||||
type ModalProps = {
|
||||
@@ -150,7 +152,7 @@ function TransferModal(props: ModalProps) {
|
||||
autoFocus
|
||||
showSearch
|
||||
filterOption={(input, option) => (
|
||||
(option?.title!.toLowerCase().indexOf(input.toLowerCase())??-1) >= 0
|
||||
(option?.title!.toLowerCase().indexOf(input.toLowerCase()) ?? -1) >= 0
|
||||
)}
|
||||
>
|
||||
{[...instances.values()].filter(
|
||||
@@ -214,8 +216,8 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
title: "Name",
|
||||
render: (_, save) => <>
|
||||
{save.name}
|
||||
{save.loaded && <Tooltip title="Currently loaded save"><CaretLeftOutlined/></Tooltip>}
|
||||
{save.loadByDefault && <Tooltip title="Save loaded by default"><LeftOutlined/></Tooltip>}
|
||||
{save.loaded && <Tooltip title="Currently loaded save"><CaretLeftOutlined /></Tooltip>}
|
||||
{save.loadByDefault && <Tooltip title="Save loaded by default"><LeftOutlined /></Tooltip>}
|
||||
</>,
|
||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||
},
|
||||
@@ -240,7 +242,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
expandable={{
|
||||
columnWidth: 33,
|
||||
expandRowByClick: true,
|
||||
expandedRowRender: save => <Space wrap style={{marginBottom: 0}}>
|
||||
expandedRowRender: save => <Space wrap style={{ marginBottom: 0 }}>
|
||||
{account.hasPermission("core.instance.start") && <Button
|
||||
loading={starting}
|
||||
disabled={props.instance.status !== "stopped"}
|
||||
@@ -346,14 +348,24 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
return <div>
|
||||
<SectionHeader title="Saves" extra={<Space>
|
||||
{account.hasPermission("core.instance.save.upload") && <Upload {...uploadProps} >
|
||||
<Button disabled={hostOffline}>Upload save</Button>
|
||||
<UploadButton disabled={hostOffline}>
|
||||
Upload save
|
||||
</UploadButton>
|
||||
</Upload>}
|
||||
{account.hasPermission("core.instance.save.create") && <CreateSaveModal instance={props.instance} />}
|
||||
</Space>} />
|
||||
{
|
||||
account.hasPermission("core.instance.save.upload")
|
||||
? <Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
|
||||
{saveTable}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: "1010",
|
||||
}}
|
||||
>
|
||||
<Dropzone disabled={hostOffline} />
|
||||
{saveTable}
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
: saveTable
|
||||
}
|
||||
@@ -362,7 +374,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
{file.name}
|
||||
<Progress
|
||||
percent={file.percent}
|
||||
format={percent => `${Math.floor(percent||0)}%`}
|
||||
format={percent => `${Math.floor(percent || 0)}%`}
|
||||
status={file.status === "error" ? "exception" : "normal"}
|
||||
/>
|
||||
</List.Item>)}
|
||||
|
||||
@@ -9,16 +9,36 @@ import ErrorBoundary from "./ErrorBoundary";
|
||||
import ErrorPage from "./ErrorPage";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { pages } from "../pages";
|
||||
import { DraggingContext } from "../model/is_dragging";
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
const { Header, Sider } = Layout;
|
||||
|
||||
function isActiveDropzone(element: HTMLElement | null): boolean {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const checkDepth = 5;
|
||||
let depth = 0;
|
||||
while (element && depth < checkDepth) {
|
||||
if (element.classList.contains("dropzone")) {
|
||||
return element.classList.contains("enabled");
|
||||
}
|
||||
element = element.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function SiteLayout() {
|
||||
let navigate = useNavigate();
|
||||
let [currentSidebarPath, setCurrentSidebarPath] = useState<string|null>(null);
|
||||
let [currentSidebarPath, setCurrentSidebarPath] = useState<string | null>(null);
|
||||
let account = useAccount();
|
||||
let plugins = useContext(ControlContext).plugins;
|
||||
const [dragging, setDragging] = useState(0);
|
||||
|
||||
function SetSidebar(props: { path: string|null }) {
|
||||
function SetSidebar(props: { path: string | null }) {
|
||||
useEffect(() => {
|
||||
setCurrentSidebarPath(props.path);
|
||||
});
|
||||
@@ -26,7 +46,7 @@ export default function SiteLayout() {
|
||||
}
|
||||
|
||||
let accountMenuProps: MenuProps = {
|
||||
onClick: ({ key }: {key:string}) => {
|
||||
onClick: ({ key }: { key: string }) => {
|
||||
if (key === "user") {
|
||||
navigate(`/users/${account.name}/view`);
|
||||
} else if (key === "logOut") {
|
||||
@@ -45,7 +65,7 @@ export default function SiteLayout() {
|
||||
combinedPages.push(...plugin.pages);
|
||||
}
|
||||
|
||||
let menuItems = [];
|
||||
let menuItems: MenuItem[] = [];
|
||||
let menuGroups = new Map();
|
||||
for (let { sidebarName, sidebarGroup, permission, path } of combinedPages) {
|
||||
if (
|
||||
@@ -72,59 +92,91 @@ export default function SiteLayout() {
|
||||
menuItems.push({ type: "group", label: name, children: group, key: name });
|
||||
}
|
||||
|
||||
return <Layout style={{ minHeight: "100vh" }}>
|
||||
<Header className="header">
|
||||
<div className="site-logo" />
|
||||
<span className="site-name">Clusterio</span>
|
||||
<span className="site-version">{ webUiPackage.version }</span>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
defaultSelectedKeys={["1"]}
|
||||
items={[{ label: "Dashboard", key: "1" }]}
|
||||
/>
|
||||
<Dropdown
|
||||
className="account-dropdown-header"
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
menu={accountMenuProps}
|
||||
>
|
||||
<UserOutlined/>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Layout className="site-layout">
|
||||
<Sider
|
||||
collapsible
|
||||
collapsedWidth={0}
|
||||
breakpoint="md"
|
||||
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
|
||||
width={250}
|
||||
className="site-layout-sider"
|
||||
>
|
||||
// Prevent issues with event ordering
|
||||
let dragChange = 0;
|
||||
function setDraggingProxy(value: number) {
|
||||
dragChange += value;
|
||||
setDragging(dragging + dragChange);
|
||||
}
|
||||
|
||||
return <Layout
|
||||
style={{ minHeight: "100vh" }}
|
||||
onDragEnter={() => setDraggingProxy(1)}
|
||||
onDragLeave={() => setDraggingProxy(-1)}
|
||||
// Reset dragging state when dropped
|
||||
onDrop={(e) => {
|
||||
setDragging(0);
|
||||
// Prevent dropping outside of dropzone
|
||||
if (!isActiveDropzone(e.target as HTMLElement)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
// Allow entire document to be a drag target
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
// Change cursor
|
||||
if (isActiveDropzone(e.target as HTMLElement)) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DraggingContext.Provider value={dragging > 0}>
|
||||
<Header className="header">
|
||||
<div className="site-logo" />
|
||||
<span className="site-name">Clusterio</span>
|
||||
<span className="site-version">{webUiPackage.version}</span>
|
||||
<Menu
|
||||
mode="inline"
|
||||
defaultOpenKeys={[...menuGroups.keys()]}
|
||||
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
|
||||
style={{ height: "100%", borderRight: 0 }}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
items={menuItems}
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
defaultSelectedKeys={["1"]}
|
||||
items={[{ label: "Dashboard", key: "1" }]}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout className="site-layout-content-container">
|
||||
<Routes>
|
||||
{combinedPages.map(({path, sidebarPath, content}) => <Route
|
||||
path={path}
|
||||
key={path}
|
||||
element={<Fragment key={path}>
|
||||
<SetSidebar path={sidebarPath ? sidebarPath : path} />
|
||||
<ErrorBoundary Component={ErrorPage}>
|
||||
{content}
|
||||
</ErrorBoundary>
|
||||
</Fragment>}
|
||||
/>)}
|
||||
<Route element={<SetSidebar path={null} />} />
|
||||
</Routes>
|
||||
<Dropdown
|
||||
className="account-dropdown-header"
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
menu={accountMenuProps}
|
||||
>
|
||||
<UserOutlined />
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Layout className="site-layout">
|
||||
<Sider
|
||||
collapsible
|
||||
collapsedWidth={0}
|
||||
breakpoint="md"
|
||||
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
|
||||
width={250}
|
||||
className="site-layout-sider"
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
defaultOpenKeys={[...menuGroups.keys()]}
|
||||
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
|
||||
style={{ height: "100%", borderRight: 0 }}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout className="site-layout-content-container">
|
||||
<Routes>
|
||||
{combinedPages.map(({ path, sidebarPath, content }) => <Route
|
||||
path={path}
|
||||
key={path}
|
||||
element={<Fragment key={path}>
|
||||
<SetSidebar path={sidebarPath ? sidebarPath : path} />
|
||||
<ErrorBoundary Component={ErrorPage}>
|
||||
{content}
|
||||
</ErrorBoundary>
|
||||
</Fragment>}
|
||||
/>)}
|
||||
<Route element={<SetSidebar path={null} />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</DraggingContext.Provider>
|
||||
</Layout>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useContext } from "react";
|
||||
import { Button } from "antd";
|
||||
import { InboxOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
|
||||
import { DraggingContext } from "../model/is_dragging";
|
||||
|
||||
export default function UploadButton(props: React.ComponentProps<typeof Button>) {
|
||||
const isDroppingFile = useContext(DraggingContext);
|
||||
const borderColor = props.disabled ? "gray" : "rgb(22, 119, 255)";
|
||||
return <Button
|
||||
className={`dropzone ${props.disabled ? "disabled" : "enabled"}`}
|
||||
{...props}
|
||||
style={{
|
||||
...props.style,
|
||||
// Distinct border when dropping file
|
||||
border: isDroppingFile ? `dashed 2px ${borderColor}` : undefined,
|
||||
}}
|
||||
icon={isDroppingFile ? <InboxOutlined /> : props.icon || <UploadOutlined />}
|
||||
>
|
||||
{props.children || "Upload"}
|
||||
</Button>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/// <reference path="../global.d.ts" />
|
||||
/// <reference path="../global.d.ts" preserve="true" />
|
||||
export { default as bootstrap } from "./bootstrap";
|
||||
export { default as notify, notifyErrorHandler } from "./util/notify";
|
||||
export { default as BaseWebPlugin } from "./BaseWebPlugin";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useContext, useSyncExternalStore } from "react";
|
||||
import { useCallback, useContext, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { Config, InstanceConfigGetRequest } from "@clusterio/lib";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
export function useInstance(id?: number) {
|
||||
@@ -11,3 +13,17 @@ export function useInstances() {
|
||||
const subscribe = useCallback((callback: () => void) => control.instances.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => control.instances.getSnapshot());
|
||||
}
|
||||
|
||||
export function useInstanceConfig(id?: number) {
|
||||
let control = useContext(ControlContext);
|
||||
const [config, setConfig] = useState(undefined as undefined | Static<typeof Config.jsonSchema>);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
control.send(new InstanceConfigGetRequest(id))
|
||||
.then(conf => setConfig(conf));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const DraggingContext = createContext(false);
|
||||
@@ -185,6 +185,8 @@ body {
|
||||
.save-list-dragger .ant-upload.ant-upload-drag {
|
||||
border: none;
|
||||
background: none;
|
||||
height: auto;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.save-list-dragger .ant-upload.ant-upload-btn {
|
||||
|
||||
@@ -4,7 +4,4 @@
|
||||
{ "path": "../lib/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "src/**/*.tsx", "src/**/*.ts", "package.json", "global.d.ts" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,14 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/react": "^18.2.21",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0"
|
||||
|
||||
@@ -5,7 +5,4 @@
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -357,8 +357,14 @@ end
|
||||
|
||||
-- Download inventory from controller
|
||||
inventory_sync.events[defines.events.on_player_joined_game] = function(event)
|
||||
-- Send acquire request even if an active download is currently in plogress
|
||||
local player = game.get_player(event.player_index)
|
||||
|
||||
-- It's possible Factorio doesn't invoke the on_player_created event when loading a save in single player
|
||||
if not global.inventory_sync.players[player.name] then
|
||||
create_player(player, false)
|
||||
end
|
||||
|
||||
-- Send acquire request even if an active download is currently in plogress
|
||||
inventory_sync.acquire(player, false)
|
||||
|
||||
-- Clear active upload if it existsi
|
||||
|
||||
@@ -18,22 +18,22 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/react": "^18.2.21",
|
||||
"antd": "^5.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0"
|
||||
|
||||
@@ -5,7 +5,4 @@
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
|
||||
import { BaseWebPlugin, PageLayout, ControlContext } from "@clusterio/web_ui";
|
||||
import { BaseWebPlugin, PageLayout, PageHeader, ControlContext } from "@clusterio/web_ui";
|
||||
import { DatabaseStatsRequest, DatabaseStatsResponse } from "../messages";
|
||||
|
||||
import "./style.css";
|
||||
@@ -18,7 +18,7 @@ function InventoryPage() {
|
||||
}, []);
|
||||
|
||||
return <PageLayout nav={[{ name: "Inventory sync" }]}>
|
||||
<h2>Inventory sync</h2>
|
||||
<PageHeader title="Inventory sync" />
|
||||
{statsData && <>
|
||||
<p>Database size: {Math.round(statsData.databaseSize / 1000)}kB</p>
|
||||
<p>Database entries: {statsData.databaseEntries}</p>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
@@ -26,8 +26,8 @@
|
||||
"jsonwebtoken": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/node": "^20.4.5",
|
||||
@@ -36,7 +36,7 @@
|
||||
"phin": "^3.7.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0"
|
||||
|
||||
@@ -5,7 +5,4 @@
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -18,19 +18,19 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/react": "^18.2.21",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0"
|
||||
|
||||
@@ -5,7 +5,4 @@
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -18,14 +18,14 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/react": "^18.2.21",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0"
|
||||
|
||||
@@ -5,7 +5,4 @@
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -18,15 +18,15 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.0"
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"fs-extra": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.18",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.18",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.4.5",
|
||||
@@ -34,7 +34,7 @@
|
||||
"antd": "^5.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.5.3",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0"
|
||||
|
||||
@@ -5,7 +5,4 @@
|
||||
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Input, Table, Typography } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, Control, ControlContext,
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext,
|
||||
notifyErrorHandler, useItemMetadata, useLocale,
|
||||
} from "@clusterio/web_ui";
|
||||
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages";
|
||||
@@ -60,7 +60,7 @@ function StoragePage() {
|
||||
let numberFormat = new Intl.NumberFormat("en-US");
|
||||
|
||||
return <PageLayout nav={[{ name: "Storage" }]}>
|
||||
<h2>Storage</h2>
|
||||
<PageHeader title="Storage" />
|
||||
<Paragraph>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
|
||||
@@ -2,4 +2,5 @@ packages:
|
||||
- "packages/*"
|
||||
- "plugins/*"
|
||||
- "external_plugins/*"
|
||||
- "external_plugins/*/*"
|
||||
- "test/"
|
||||
@@ -1,14 +1,36 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const { Controller } = require("@clusterio/controller");
|
||||
const { ControllerConfig } = require("@clusterio/lib");
|
||||
const { ControllerConfig, Address, RequestError } = require("@clusterio/lib");
|
||||
const { EventEmitter } = require("stream");
|
||||
|
||||
class MockEvent {}
|
||||
|
||||
class MockInstanceConfig extends EventEmitter {
|
||||
constructor(data = new Map()) {
|
||||
super();
|
||||
this._data = data;
|
||||
}
|
||||
|
||||
get(field) {
|
||||
return this._data.get(field);
|
||||
}
|
||||
|
||||
set(field, value) {
|
||||
return this._data.set(field, value);
|
||||
}
|
||||
}
|
||||
|
||||
describe("controller/src/Controller", function() {
|
||||
describe("class Controller", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
before(function() {
|
||||
let controller, mockInstanceConfig;
|
||||
before(async function() {
|
||||
controller = new Controller({}, [], "", new ControllerConfig("controller"));
|
||||
mockInstanceConfig = new MockInstanceConfig(new Map([
|
||||
["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []],
|
||||
]));
|
||||
await controller.instanceCreate(mockInstanceConfig);
|
||||
});
|
||||
function check(list, ip, present = true) {
|
||||
if (present === false) {
|
||||
@@ -50,5 +72,112 @@ describe("controller/src/Controller", function() {
|
||||
check(list, "192.168.0.18", false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".sendRequest()", function() {
|
||||
it("should error on loopback", function() {
|
||||
assert.throws(
|
||||
() => controller.sendRequest(new MockEvent(), Address.fromShorthand("controller")),
|
||||
new Error(`Message would return back to sender ${Address.fromShorthand("controller")}.`)
|
||||
);
|
||||
});
|
||||
it("should error on invalid controller", function() {
|
||||
assert.throws(
|
||||
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ controlId: 99 })),
|
||||
new RequestError("Target control connection does not exist.")
|
||||
);
|
||||
});
|
||||
it("should error on invalid instance", function() {
|
||||
assert.throws(
|
||||
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 99 })),
|
||||
new RequestError("Instance with ID 99 does not exist")
|
||||
);
|
||||
});
|
||||
it("should error on unassigned instance", function() {
|
||||
mockInstanceConfig.set("instance.assigned_host", null);
|
||||
assert.throws(
|
||||
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
|
||||
new RequestError("Instance is not assigned to a host")
|
||||
);
|
||||
});
|
||||
it("should error on instance with disconnected host", function() {
|
||||
mockInstanceConfig.set("instance.assigned_host", 99);
|
||||
assert.throws(
|
||||
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
|
||||
new RequestError("Host containing instance is not connected")
|
||||
);
|
||||
});
|
||||
it("should error on invalid host", function() {
|
||||
assert.throws(
|
||||
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ hostId: 99 })),
|
||||
new RequestError("Host is not connected")
|
||||
);
|
||||
});
|
||||
it("should error on invalid broadcast", function() {
|
||||
// This is different to sendEvent because requests do not support broadcasting
|
||||
assert.throws(
|
||||
() => controller.sendRequest(new MockEvent(), new Address(Address.broadcast, 99)),
|
||||
new Error(`Unknown address type ${Address.broadcast}`)
|
||||
);
|
||||
});
|
||||
it("should error on invalid address", function() {
|
||||
assert.throws(
|
||||
() => controller.sendRequest(new MockEvent(), new Address(99, 99)),
|
||||
new Error("Unknown address type 99")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".sendEvent()", function() {
|
||||
it("should error on loopback", function() {
|
||||
assert.throws(
|
||||
() => controller.sendEvent(new MockEvent(), Address.fromShorthand("controller")),
|
||||
new Error(`Message would return back to sender ${Address.fromShorthand("controller")}.`)
|
||||
);
|
||||
});
|
||||
it("should error on invalid controller", function() {
|
||||
assert.throws(
|
||||
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ controlId: 99 })),
|
||||
new Error("Target control connection does not exist.")
|
||||
);
|
||||
});
|
||||
it("should error on invalid instance", function() {
|
||||
assert.throws(
|
||||
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 99 })),
|
||||
new Error("Instance with ID 99 does not exist")
|
||||
);
|
||||
});
|
||||
it("should error on unassigned instance", function() {
|
||||
mockInstanceConfig.set("instance.assigned_host", null);
|
||||
assert.throws(
|
||||
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
|
||||
new Error("Instance is not assigned to a host")
|
||||
);
|
||||
});
|
||||
it("should error on instance with disconnected host", function() {
|
||||
mockInstanceConfig.set("instance.assigned_host", 99);
|
||||
assert.throws(
|
||||
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
|
||||
new Error("Host containing instance is not connected")
|
||||
);
|
||||
});
|
||||
it("should error on invalid host", function() {
|
||||
assert.throws(
|
||||
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ hostId: 99 })),
|
||||
new Error("Host is not connected")
|
||||
);
|
||||
});
|
||||
it("should error on invalid broadcast", function() {
|
||||
assert.throws(
|
||||
() => controller.sendEvent(new MockEvent(), new Address(Address.broadcast, 99)),
|
||||
new Error("Unexpected broadcast target 99")
|
||||
);
|
||||
});
|
||||
it("should error on invalid address", function() {
|
||||
assert.throws(
|
||||
() => controller.sendEvent(new MockEvent(), new Address(99, 99)),
|
||||
new Error("Unknown address type 99")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
if (module === require.main) {
|
||||
process.stdout.write(JSON.stringify(process.argv.slice(2)));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
const child_process = require("child_process");
|
||||
const util = require("util");
|
||||
const assert = require("assert").strict;
|
||||
const path = require("path");
|
||||
|
||||
const { escapeArg } = require("../../packages/create/escape_arg");
|
||||
const execFile = util.promisify(child_process.execFile);
|
||||
|
||||
async function exec(file, args) {
|
||||
const { stdout, stderr } = await execFile(
|
||||
file,
|
||||
args.map(escapeArg),
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
{ shell: true, cwd: __dirname, env: { ...process.env, pct: "%"} }
|
||||
);
|
||||
return { stdout: JSON.parse(stdout), stderr };
|
||||
}
|
||||
|
||||
const strings = [
|
||||
'"a"',
|
||||
"a sentence of text.",
|
||||
" irregular \tspaces ",
|
||||
'"C:\\path\\"',
|
||||
"C:\\path\\",
|
||||
'\\ "\\" \\\\ "\\\\" \\\\\\ "\\\\\\" \\\\\\\\ "\\\\\\\\" \\\\\\\\\\ "\\\\\\\\\\" \\\\\\\\\\\ "\\\\\\\\\\\\"',
|
||||
'"C:\\path"',
|
||||
"C:\\path",
|
||||
"%OS%",
|
||||
"!OS!",
|
||||
"😮",
|
||||
];
|
||||
for (let i=1; i < 128; i++) {
|
||||
const char = String.fromCodePoint(i);
|
||||
// Skip newline and carriage return because those are difficult to escape
|
||||
// Skip most alphanumeric characters as they don't behave differently to each other.
|
||||
if (/[\n\r1-9B-Zb-z]/.test(char)) {
|
||||
continue;
|
||||
}
|
||||
strings.push(char, `${char}${char}`, `${char}"`);
|
||||
}
|
||||
|
||||
// Test 100 strings at a time.
|
||||
const tests = [[]];
|
||||
for (const string of strings) {
|
||||
if (tests[tests.length - 1].length >= 100) {
|
||||
tests.push([]);
|
||||
}
|
||||
tests[tests.length - 1].push(string);
|
||||
}
|
||||
|
||||
|
||||
describe("create/escape_arg", function() {
|
||||
it("should correctly escape strings when args are forwarded", async function() {
|
||||
const ext = process.platform === "win32" ? ".cmd" : ".sh";
|
||||
for (const args of tests) {
|
||||
assert.deepEqual(
|
||||
await exec(`.${path.sep}forward_args${ext}`, ["echo_args.js", ...args]),
|
||||
{ stdout: args, stderr: "" }
|
||||
);
|
||||
}
|
||||
});
|
||||
it("should correctly escape strings when invoking node directly", async function() {
|
||||
for (const args of tests) {
|
||||
assert.deepEqual(
|
||||
await exec("node", ["echo_args.js", ...args]),
|
||||
{ stdout: args, stderr: "" }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
@ECHO off
|
||||
REM this is how npm and package.json bin entries forwards arguments on Windows
|
||||
node.exe %*
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# this is how package.json bin entries forwards arguments on Linux
|
||||
exec node "$@"
|
||||
@@ -206,6 +206,12 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
});
|
||||
it("should auto start instances with auto_start enabled", async function() {
|
||||
// On windows there's currently no way to automate graceful shutdown of the host
|
||||
// process as CTRL+C is some weird terminal thing and SIGINT isn't a thing.
|
||||
if (process.platform === "win32") {
|
||||
this.skip();
|
||||
}
|
||||
|
||||
slowTest(this);
|
||||
|
||||
let hostProcess;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
const assert = require("assert").strict;
|
||||
const fs = require("fs-extra");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const path = require("path");
|
||||
const events = require("events");
|
||||
const phin = require("phin");
|
||||
const child_process = require("child_process");
|
||||
const util = require("util");
|
||||
|
||||
const cwd = path.join("temp", "test_integration");
|
||||
|
||||
async function exec(command, options = {}) {
|
||||
// Uncomment to show commands run in tests
|
||||
// console.log(command);
|
||||
options = { cwd, ...options };
|
||||
return await util.promisify(child_process.exec)(command, options);
|
||||
}
|
||||
|
||||
describe("Integration of create tool", function () {
|
||||
beforeEach(async function () {
|
||||
await fs.ensureDir(cwd);
|
||||
});
|
||||
afterEach(async function () {
|
||||
await fs.remove(cwd);
|
||||
});
|
||||
it("should create a new standalone installation", async function () {
|
||||
this.skip();
|
||||
await exec([
|
||||
"node ../../packages/create",
|
||||
"--mode standalone",
|
||||
"--admin Danielv123",
|
||||
"--http-port 8099",
|
||||
"--host-name localhost",
|
||||
"--public-address localhost",
|
||||
"--factorio-dir test/factorio",
|
||||
"--plugins --",
|
||||
].join(" "));
|
||||
|
||||
// Check that the generated config is correct
|
||||
const controllerConfig = await fs.readJson(path.join(cwd, "config-controller.json"));
|
||||
assert.equal(controllerConfig["controller.http_port"], 8099);
|
||||
|
||||
const hostConfig = await fs.readJson(path.join(cwd, "config-host.json"));
|
||||
assert.equal(hostConfig["host.name"], "local");
|
||||
assert.equal(hostConfig["host.public_address"], "localhost");
|
||||
assert.equal(hostConfig["host.factorio_directory"], "test/factorio");
|
||||
assert.equal(hostConfig["host.controller_url"], "http://localhost:8099");
|
||||
}).timeout(1200000);
|
||||
it("should create a new controller installation", async function () {
|
||||
this.skip();
|
||||
await exec([
|
||||
"node ../../packages/create",
|
||||
"--mode controller",
|
||||
"--admin Danielv123",
|
||||
"--http-port 8099",
|
||||
"--public-address localhost",
|
||||
"--plugins --",
|
||||
].join(" "));
|
||||
|
||||
// Check that the generated config is correct
|
||||
const controllerConfig = await fs.readJson(path.join(cwd, "config-controller.json"));
|
||||
assert.equal(controllerConfig["controller.http_port"], 8099);
|
||||
|
||||
await assert.rejects(fs.readJson(path.join(cwd, "config-host.json")));
|
||||
}).timeout(60000);
|
||||
});
|
||||
@@ -189,6 +189,12 @@ before(async function() {
|
||||
format: new lib.TerminalFormat(),
|
||||
}));
|
||||
|
||||
// If fast test is enabled then output that it is
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
if (process.env.FAST_TEST) {
|
||||
console.log("FAST_TEST is present in env, slow tests will be skipped.");
|
||||
}
|
||||
|
||||
await fs.remove(instancesDir);
|
||||
await fs.remove(modsDir);
|
||||
await fs.remove(databaseDir);
|
||||
|
||||
@@ -5,6 +5,9 @@ const lib = require("@clusterio/lib");
|
||||
const { Host, Instance, InstanceConnection } = require("@clusterio/host");
|
||||
const { ControlConnection, Controller, HostConnection, InstanceInfo } = require("@clusterio/controller");
|
||||
|
||||
// Although there are no imports, the file has side-effects such as "before" which can effect tests
|
||||
require("./index");
|
||||
|
||||
const addr = lib.Address.fromShorthand;
|
||||
|
||||
class Control extends lib.Link { }
|
||||
@@ -419,4 +422,30 @@ describe("Integration of link routing", function() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("event should not be routable on loopback", function() {
|
||||
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
|
||||
it(`should not loopback on ${srcName}`, async function() {
|
||||
const src = get(srcName);
|
||||
const dstAddr = src instanceof Controller ? addr("controller") : src.connector.src;
|
||||
assert.throws(
|
||||
() => send(src, dstAddr, new TestEvent()),
|
||||
new Error(`Message would return back to sender ${dstAddr}.`)
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("request should not be routable on loopback", function() {
|
||||
for (const srcName of ["controller", "hostA", "instanceA1", "controlA"]) {
|
||||
it(`should not loopback on ${srcName}`, async function() {
|
||||
const src = get(srcName);
|
||||
const dstAddr = src instanceof Controller ? addr("controller") : src.connector.src;
|
||||
assert.throws(
|
||||
() => send(src, dstAddr, new TestRequest()),
|
||||
new Error(`Message would return back to sender ${dstAddr}.`)
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user