mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 15:14:52 +09:00
Merge branch 'master' into feature/http-logging
This commit is contained in:
197 files changed
+2689
-867
No files matched your search
+3
-2
@@ -35,6 +35,7 @@ module.exports = {
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"valid-jsdoc": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
@@ -319,7 +320,7 @@ module.exports = {
|
||||
"semi-style": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "error",
|
||||
"sort-vars": "off",
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": "off",
|
||||
"space-in-parens": ["error", "never"],
|
||||
@@ -390,7 +391,7 @@ module.exports = {
|
||||
"node/process-exit-as-throw": "error",
|
||||
"node/shebang": ["error", {
|
||||
"convertPath": {
|
||||
"*.ts": ["^(.+)\\.ts$", "dist/$1.js"],
|
||||
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
|
||||
},
|
||||
}],
|
||||
},
|
||||
|
||||
+165
-3
@@ -1,5 +1,169 @@
|
||||
# Changelog
|
||||
|
||||
<!-- Template for entries. Add sections that apply as changes are made.
|
||||
## Version 2.0.0-alpha.xx
|
||||
|
||||
### Major Features
|
||||
|
||||
- Major additions and/or major improvements to managing clusters.
|
||||
|
||||
### Features
|
||||
|
||||
- New features added.
|
||||
|
||||
### Changes
|
||||
|
||||
- Bugfixes and small changes to existing behaviour.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changes which break backwards compatibility with older hosts or plugins code.
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0-alpha.17
|
||||
|
||||
### Features
|
||||
|
||||
- Made the host an instance is assigned to link to the host page for that host in the Web UI [#587](https://github.com/clusterio/clusterio/pull/587).
|
||||
- Added pagination to the saves list.
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed an unhandled error in Subspace Storage crashing the host.
|
||||
- Fixed an unhandled rejection in disconnect logic crashing the host.
|
||||
- Fixed instance screen in Web UI not updating when an instance was reassigned.
|
||||
- Fixed an error thrown in the rendering of one page in the Web UI causing all pages to become inaccessible.
|
||||
- Fixed Web UI not showing instance saves when the permission to list and subscribe the saves is granted but the user is not a cluster admin.
|
||||
- Similarly fixed Web UI not showing the mod pack create, delete, list and host revoke token actions if the user is not a cluster admin.
|
||||
- Fixed Research Sync applying progress of a previous techology level to the level on the controller when it's the active research during instance startup.
|
||||
- Fixed data export with a mod using shorthand notation for the default value of a color-setting causing the ModPack data to be set to invalid data.
|
||||
- Fixed per user per instance player stats not being deleted when the instance it's for is deleted.
|
||||
- Fixed mods setting the item prototype icons property to an object mapping instead of an array breaking the data export.
|
||||
- Fixed a bad mod in the host's mods folder causing instance start to fail instead of the host re-downloading the mod.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed the permission `core.instance.save.list.subscribe` to `core.instance.save.subscribe`.
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Danielv123](https://github.com/Danielv123)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.16
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed message sequences not being renumbered when forwarded between links causing reconnects to send duplicated messages.
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.15
|
||||
|
||||
### Features
|
||||
|
||||
- Added support for `color-settings` Mod settings [#568](https://github.com/clusterio/clusterio/issues/568).
|
||||
- Added `clusterio_controller_pending_requests` and `clusterio_host_pending_requests` metrics.
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed generated systemd file for clusteriohost not enabling source maps.
|
||||
- Fixed ctl not exporting types used for adding commands to ctl plugins.
|
||||
- Fixed controller only plugins throwing an error about missing a config field on startup out when added to hosts.
|
||||
- Fixed installer incorrectly claiming the options --factorio-dir and --no-download-headless conflicts with each other.
|
||||
- Fixed a memory leak on every request sent over the WebSocket.
|
||||
- Fixed assertion error on closing or invalidating a link with messages forwarded from a virtual link.
|
||||
- Fixed host hanging if stopped while an instance is in the process of creating a save during startup.
|
||||
- Fixed `instance.auto_start` option.
|
||||
- Fixed host log spammed with errors when plugins broadcast messages to instances with the plugin disabled.
|
||||
- Fixed properties of object config fields not being possible to remove.
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.14
|
||||
|
||||
This release does not retain backwards compatibility with 2.0.0-alpha.13 meaning all parts of the cluster needs to updated at the same time.
|
||||
See the [migration guide](/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added mod management. Mods can be uploaded to the controller and included in mod packs that can be assigned to instances and run.
|
||||
- Added host port ranges which the instance ports will be automatically assigned a port from if factorio.game_port is not set.
|
||||
- Added system info such as CPU model, hostname and kernel along with basic metrics for CPU usage, memory usage and disk usage for the controller and hosts.
|
||||
- Added ability to remotely view and modify the configuration of hosts.
|
||||
|
||||
### Features
|
||||
|
||||
- Added logging of Clusterio version on startup
|
||||
- Added `clusterio_controller_log_bytes` metric counting the size of the log files on the controller.
|
||||
- Added warning logged when plugins on a connecting host does not match the ones installed on the controller.
|
||||
- Added ability to revoke user tokens.
|
||||
- Added restart required indicator to config fields in the Web UI.
|
||||
- Added ability to revoke host tokens.
|
||||
- Added support for plugins to run in the context of a host.
|
||||
- Added more feedback when the web UI and or its plugins fails to load.
|
||||
- Added autosaving of data held in memory on the controller such as configs, instances, users, mods to prevent large data loss on crashing.
|
||||
- Added `system_info` metric with kernel, instruction set, CPU Model, hostname and Node.js version of the controller.
|
||||
- Added `clusterio_host_system_info` metric with kernel, instruction set, CPU Model, hostname and Node.js version of hosts.
|
||||
- Added `system_cpu_seconds_total`, `system_cpu_idle_seconds_total`, `system_memory_capacity_bytes`, `system_memory_available_bytes`, `system_disk_capacity_bytes`, `system_disk_available_bytes` metrics for CPU, memory and disk usage on the controller.
|
||||
- Added `clusterio_host_system_cpu_seconds_total`, `clusterio_host_system_cpu_idle_seconds_total`, `clusterio_host_system_memory_capacity_bytes`, `clusterio_host_system_memory_available_bytes`, `clusterio_host_system_disk_capacity_bytes`, `clusterio_host_system_disk_available_bytes` metrics for CPU, memory and disk usage on hosts.
|
||||
- Added support for reading the client's IP address from the `X-Forwarded-For` header when the source address is in the `controller.trusted_proxies` config option.
|
||||
- Added field showing the IP address a host is connecting from.
|
||||
- Added per player statistic tracking when they were first seen joining a particular server.
|
||||
- Added ability to remotely restart and/or stop the controller and hosts.
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed filenames with extensions that are invalid on Windows such as "con.zip" not being treated as invalid.
|
||||
- Fixed generate host token modal not using a random id when the optional id field is unset.
|
||||
- Exported Factorio data is now tied to mod packs instead of being per cluster.
|
||||
- Fixed export failing if a mod uses a different root folder mod_version in its zip file.
|
||||
- Fixed sorting of user last seen column in the Web UI.
|
||||
- The list users permission is now granted by default to players.
|
||||
- Fixed incorrect handling of hosts and instance with an id of 0 causing them to not update properly.
|
||||
- Fixed unable to unassign instances in the Web UI.
|
||||
- Fixed host crashing if the Factorio server is not marked executable.
|
||||
- Fixed errors logged from instance not being attributed to the instance in certain situations.
|
||||
- Hosts no longer print messages from instances into their console.
|
||||
- Clusterio now treat unhandled Node.js errors and promise rejections as fatal and exit immediately.
|
||||
- Fixed a potential for data loss after a hard system crash due to fsync not being called on files written.
|
||||
- Fixed Factorio server crashing in the research_sync plugin when a mod enables a previously disabled technology and a player attempts to research it.
|
||||
- Fixed Factorio server crashing when the game starts with tick_paused = true
|
||||
- Removed migration of alpha 10's single file log format.
|
||||
- Log errors linking mods on Windows instead of preventing startup.
|
||||
- Added locale support for modules patched into to the game save.
|
||||
- Added support for defining a module_exports.lua file for modules patched into the game save.
|
||||
- Fixed controller logs being split on server's local time day boundary instead of UTC day boundary.
|
||||
- Fixed Unicode BOM in mod locale files breaking instance data export.
|
||||
- Added a warning to the server-setting.json that is automatically written to instance folders stating changes will be overwritten.
|
||||
- Changed save patching to use a `.tmp.zip` extension for the temporary file created instead of `zip.tmp`.
|
||||
- Fixed Inventory Sync wiping the player's inventory when logging out while in the Space Exploration remote view mode.
|
||||
- Fixed the public /api/plugins endponit leaking filesystem paths on the controller.
|
||||
- Refactored list subscriptions in the Web UI to cache and only fetch updated entries when navigating to previously seen list.
|
||||
- Added caching of save lists on the controller, enabling the saves of instances on offline hosts to still be seen.
|
||||
- Hosts in the assign instance dropdown is now sorted by name.
|
||||
- Added `non_blocking_saving` as one of the default settings added to `factorio.settings` on new instances.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed usage of the terms master and slave to controller and host in all places they occur.
|
||||
- Mods located in `sharedMods` are no longer copied to instances' mods folder.
|
||||
- The instances' mods folder is now managed through mod packs, mods not part of the mod pack will be automatically deleted.
|
||||
- Removed ability to import sub packages like `@clusterio/lib/link` from.
|
||||
- Replaced `clusterio_controller_http_hits_total` metric with `clusterio_controller_http_endpoint_duration_seconds`.
|
||||
- Replaced `clusterio_controller_last_query_log_duration_seconds` metric with `clusterio_controller_query_log_duration_seconds` summary.
|
||||
- Reworked the network protocol to be simpler to work and reason with.
|
||||
- Bumped minimum supported Node.js version to v18.
|
||||
- Ported the codebase to TypeScript
|
||||
- Changed the config format stored on disk to be much simpler.
|
||||
|
||||
### Contributors
|
||||
|
||||
Many thanks to the following for contributing code in this release:
|
||||
[@Hornwitser](https://github.com/Hornwitser) [@Luciole0x](https://github.com/Luciole0x) [@Cooldude2606](https://github.com/Cooldude2606) [@Danielv123](https://github.com/Danielv123) [@Reavershark](https://github.com/Reavershark) [@Laar](https://github.com/Laar) [@rstrom1763](https://github.com/rstrom1763)
|
||||
|
||||
## Version 2.0.0
|
||||
|
||||
### Major Features
|
||||
@@ -77,8 +241,7 @@
|
||||
- Removed UPSdisplay plugin.
|
||||
UPS statistics is exported by the statistics exporter plugin.
|
||||
- Controller now defaults to hosting on https on port 8443.
|
||||
- Renamed master to controller.
|
||||
- Renamed slave/client to host.
|
||||
- Renamed client to slave.
|
||||
- Removed rotation of factorio-current.log files.
|
||||
|
||||
### Breaking Changes
|
||||
@@ -145,7 +308,6 @@
|
||||
- Removed automatic creation of self-signed TLS certificate.
|
||||
- Node.js versions below 12 are no longer supported.
|
||||
|
||||
|
||||
## Version 1.2.4
|
||||
|
||||
- Removed broken remote combinator signaling.
|
||||
|
||||
@@ -39,6 +39,7 @@ If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
|
||||
* [Running Clusterio](#running-clusterio)
|
||||
* [Setting up remote hosts](#setting-up-remote-hosts)
|
||||
* [Setting up clusterioctl](#setting-up-clusterioctl)
|
||||
* [Known issues](#known-issues)
|
||||
* [Common problems](#Common-problems)
|
||||
|
||||
|
||||
@@ -265,6 +266,24 @@ If you want to use a different user for the command line interface, you can gene
|
||||
npx clusteriocontroller bootstrap generate-user-token <username>
|
||||
|
||||
|
||||
## Known Issues
|
||||
|
||||
- When the `server_select` plugin is installed you may see `[error] Unhandled event server_select:UpdateInstancesEvent` logged when starting an instance.
|
||||
This should be ignored as it does not indicate an error occured.
|
||||
- After updating Factorio you may get the error `Error: Expected empty response but got "Cannot execute command. Error: [string "clusterio_private.update_instance(..."]:1: attempt to index global 'clusterio_private' (a nil value)?` on instance start.
|
||||
This is due to Factorio applying a migration replacing the patched content of the save with the original scenario, attempting to start the instance again should resolve the issue.
|
||||
- In the mod pack view of the web interface the "base" mod is displayed with an error complaining about the mod being missing from the controller storage.
|
||||
This is due to the "base" mod currently not being connectly handled as a built-in mod to Factorio in the web interface, this error message should be ignored.
|
||||
- After migrating from Alpha 13 to Alpha 14 running an instance data export fails with the error `Upload failed: 401 Unauthorized` due to the host token being issued to a slave.
|
||||
To workaround this find the id of the host in the host config and generate a new host token with the same id.
|
||||
Stop the host and replace the value of the `host.controller_token` field in `config-host.json` with the new host token before starting it up again.
|
||||
- After migrating from Alpha 13 to Alpha 14 you get kicked out from the web interface due to the master to controller rename.
|
||||
If you don't have the authentication token readily available you can manually migrate the token by opening the Inspect/Developer Tools on the page and running the following piece of JavaScript in the Console:
|
||||
```
|
||||
localStorage.controller_token = localStorage.master_token
|
||||
```
|
||||
|
||||
|
||||
## Common problems
|
||||
|
||||
### EACCESS [...] LISTEN 443
|
||||
|
||||
@@ -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 `info.js` export.
|
||||
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.
|
||||
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
|
||||
|
||||
|
||||
|
||||
+17
-3
@@ -19,6 +19,7 @@ Though we have some conventions and workflows that we kindly ask you to follow.
|
||||
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
|
||||
- [Code Style](#code-style)
|
||||
- [Naming Style](#naming-style)
|
||||
- [Units](#units)
|
||||
- [Indenting](#indenting)
|
||||
- [Line Length](#line-length)
|
||||
- [Markdown](#markdown)
|
||||
@@ -80,6 +81,9 @@ Note that to use `clusterioctl` you will have to create a control config first:
|
||||
|
||||
Once you've set up the cluster you can use `node packages/controller run` to run the controller and `node packages/host run` to run the host which connects to the controller.
|
||||
|
||||
When editing TypeScript files ran by Node.js you need to run `pnpm build` to compile the files to JavaScript before restarting the controller/host/ctl for changes take effect.
|
||||
To have TypeScript watch for changes and rebuild immediadly you may run `pnpm watch` instead.
|
||||
|
||||
For web development on the controller there are also the following flags:
|
||||
|
||||
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
|
||||
@@ -202,7 +206,7 @@ But there are at least a few things that have been agreed upon.
|
||||
JavaScript variables in general uses camelCase.
|
||||
The exception is for classes/constructors which use PascalCase and variables destructured from over the wire objects.
|
||||
|
||||
Fields in configs and messages sent over links uses lowercase_underscore.
|
||||
Fields in configs uses lowercase_underscore.
|
||||
|
||||
JavaScript files are named using lowercase_underscore for files that export multiple items.
|
||||
Files containing and exporting a single class should be named the same as the class in PascalCase.
|
||||
@@ -217,6 +221,16 @@ Note that no sub-modules of the lib package is imported directly from the outsid
|
||||
|
||||
For lua code lowercase_underscore is used for everything.
|
||||
|
||||
### Units
|
||||
|
||||
When naming variables whos unit is not a base SI unit like seconds or metres the unit used should be suffixed in the variable name:
|
||||
E.g.:
|
||||
|
||||
const startTimeMs = date.now();
|
||||
const endNs = process.hrtime();
|
||||
|
||||
This makes it easier to spot different unit bases are being mixed in the code.
|
||||
|
||||
|
||||
### Strings
|
||||
|
||||
@@ -305,6 +319,6 @@ You will need commit access to the repository as well publish access to the @clu
|
||||
4. Publish the packages.
|
||||
|
||||
```sh
|
||||
# Warning: Double check your current working directory is the repository before running this
|
||||
pnpm -r --filter='!./external_plugins/**' publish
|
||||
rm -r ./{plugins,packages}/*/dist
|
||||
pnpm -r --filter='./{plugins,packages}/*' publish
|
||||
```
|
||||
@@ -0,0 +1,523 @@
|
||||
# Migrating Plugins to Alpha 14
|
||||
|
||||
Between version 2.0.0-alpha.13 and 2.0.0-alpha.14 the codebase underwent major refactoring and was ported to TypeScript.
|
||||
In order to support alpha 14 there are several major changes that need to be done to existing plugins, these are in no particular order
|
||||
|
||||
|
||||
## Rename master and slave
|
||||
|
||||
All references to slave and master in the code was renamed to controller and host respectively which means that code referencing these in plugins must be renamed.
|
||||
In short this means performing a global search and replace on the codebase replacing:
|
||||
|
||||
- `master` with `controller`
|
||||
- `Master` with `Controller`
|
||||
- `slave` with `host`
|
||||
- `Slave` with `Host`
|
||||
|
||||
in all places it occurs, even as part of longer names such as changing `BaseMasterPlugin` to `BaseControllerPlugin`.
|
||||
|
||||
|
||||
## Change lib imports to import directly from the top level package
|
||||
|
||||
Previously code could import sub packages of lib such as lib/lua_tools.
|
||||
These imports need to be changed to all import from lib directly.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
|
||||
// Using imports
|
||||
new libErrors.RequestError(...)
|
||||
libLuaTools.escapeString(...)
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
// Using library components
|
||||
new lib.RequestError(...)
|
||||
lib.escapeString(...)
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
// Using library components
|
||||
new lib.RequestError(...)
|
||||
lib.escapeString(...)
|
||||
```
|
||||
|
||||
|
||||
## Change import of base plugins
|
||||
|
||||
Previously `BaseMasterPlugin` and `BaseInstancePlugin` were imported from `@clusterio/lib`, these have been moved to `@clusterio/controller` and `@clusterio/host` respectively.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
const { BaseMasterPlugin } = require("@clusterio/lib");
|
||||
const { BaseInstancePlugin } = require("@clusterio/lib");
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
const { BaseInstancePlugin } = require("@clusterio/host");
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
```
|
||||
|
||||
|
||||
## Change main entrypoint of plugin
|
||||
|
||||
Previously the info of a plugin would be loaded by requiring `./plugin_dir/info`, this has been changed to require `./plugin_dir` which in turn means that `info.js` is no longer the default entrypoint.
|
||||
To solve this you can either add `"main": "info.js",` to package.json or rename `info.js` to `index.js` (the rest of this guide assumes the entrypoint was renamed to `index.js`).
|
||||
The motivation behind this change was to allow TypeScript to output its build to a sub-folder of the plugin, if you're using TypeScript you'll likely have the entrypoint be located in `dist/node/index.js` instead.
|
||||
|
||||
The expected place where the declaration of a plugin is exported has also changed from `module.exports` to `module.exports.plugin`:
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
// info.js
|
||||
module.exports = {
|
||||
name: "plugin_name",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
// index.js
|
||||
const plugin = {
|
||||
name: "plugin_name",
|
||||
...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
// index.ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "plugin_name",
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
### Modify exposed path in webpack.config.js
|
||||
|
||||
The `./info` import path exposed in the ModuleFederationPlugin in the `webpack.config.js` needs to be changed to `./`.
|
||||
E.g.:
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
// In webpack.config.js
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
...
|
||||
exposes: {
|
||||
"./info": "./info.js",
|
||||
...
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
**Alpha 14**
|
||||
```js
|
||||
// In webpack.config.js
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
...
|
||||
exposes: {
|
||||
"./": "./index.js",
|
||||
...
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
## Remove usage of ConfigGroup
|
||||
|
||||
The Config system was refactored and the `ConfigGroup` concept was completely removed.
|
||||
Config fields are now defined directly on the exported plugin info.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
class MasterConfigGroup extends lib.PluginConfigGroup { }
|
||||
MasterConfigGroup.groupName = "plugin_name";
|
||||
MasterConfigGroup.defaultAccess = ["master", "control"];
|
||||
MasterConfigGroup.define({
|
||||
name: "option",
|
||||
title: "Option",
|
||||
description: "A config option.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
name: "plugin_name",
|
||||
MasterConfigGroup,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const plugin = {
|
||||
name: "plugin_name",
|
||||
controllerConfigFields: {
|
||||
"plugin_name.option": {
|
||||
title: "Option",
|
||||
description: "A config option.",
|
||||
type: "string",
|
||||
initial_value: "foo",
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
// Declare the type of the field to make the type of controller.config.get(field) correctly deduced.
|
||||
declare module "@clusterio/lib" {
|
||||
export interface ControllerConfigFields {
|
||||
"plugin_name.option": null | string;
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "plugin_name",
|
||||
controllerConfigFields: {
|
||||
"plugin_name.option": {
|
||||
title: "Option",
|
||||
description: "A config option.",
|
||||
type: "string",
|
||||
initialValue: "foo",
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
### Use onControllerConfigFieldChanged instead of listering to the fieldChanged event
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
this.controller.config.on("fieldChanged", (group, field, prev) => {
|
||||
if (group.name === "plugin_name" && field === "option") {
|
||||
const curr = group.get(field);
|
||||
// Do stuff with curr and or prev
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
async onControllerConfigFieldChanged(field, curr, prev) {
|
||||
if (field === "plugin_name.option") {
|
||||
// Do stuff with curr and or prev
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
if (field === "plugin_name.option") {
|
||||
// Do stuff with curr and or prev
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Redefine link messages as classes
|
||||
|
||||
The link layer has been majorly refactored and Request and Events are now defined as [JSON Serialisable Classes](./json-serialisable-classes.md).
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
module.exports = {
|
||||
name: "plugin_name",
|
||||
messages: {
|
||||
forwarded: new lib.Event({
|
||||
type: "plugin_name:forwarded",
|
||||
links: ["instance-slave", "slave-master"],
|
||||
forwardTo: "master",
|
||||
eventProperties: {
|
||||
"string_value": { type: "string" },
|
||||
"number_value": { type: "number" },
|
||||
},
|
||||
}),
|
||||
simple: new lib.Request({
|
||||
type: "plugin_name:simple",
|
||||
links: ["master-slave", "slave-instance"],
|
||||
forwardTo: "instance",
|
||||
requestProperties: {
|
||||
"instance_id": { type: "number" },
|
||||
"string_value": { type: "string" },
|
||||
},
|
||||
responseProperties: {
|
||||
"number_value": { type: "number" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
class ForwardedEvent {
|
||||
static type = "event";
|
||||
static src = "instance";
|
||||
static dst = "controller";
|
||||
static plugin = "plugin_name";
|
||||
|
||||
constructor(stringValue, numberValue) {
|
||||
this.stringValue = stringValue;
|
||||
this.numberValue = numberValue;
|
||||
}
|
||||
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
required: ["stringValue", "numberValue"],
|
||||
properties: {
|
||||
"stringValue": { type: "string" },
|
||||
"numberValue": { type: "number" },
|
||||
},
|
||||
};
|
||||
|
||||
static fromJSON(json) {
|
||||
return new this(json.stringValue, json.numberValue);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleRequest {
|
||||
static type = "request";
|
||||
static src = "controller";
|
||||
static dst = "instance";
|
||||
static plugin = "plugin_name";
|
||||
|
||||
constructor(stringValue) {
|
||||
this.stringValue = stringValue;
|
||||
}
|
||||
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
required: ["stringValue"],
|
||||
properties: {
|
||||
"stringValue": { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
static Response = lib.JsonNumber
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
name: "plugin_name",
|
||||
messages: [
|
||||
ForwardedEvent,
|
||||
SimpleRequest,
|
||||
],
|
||||
...
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
ForwardedEvent,
|
||||
SimpleRequest,
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
export class ForwardedEvent {
|
||||
declare ["constructor"]: typeof ForwardedEvent;
|
||||
static type = "event" as const;
|
||||
static src = "instance" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "plugin_name" as const;
|
||||
|
||||
constructor(
|
||||
public stringValue: string,
|
||||
public numberValue: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"stringValue": Type.String(),
|
||||
"numberValue": Type.Number(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.stringValue, json.numberValue);
|
||||
}
|
||||
}
|
||||
|
||||
export class SimpleRequest {
|
||||
declare ["constructor"]: typeof SimpleRequest;
|
||||
static type = "request" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "instance" as const;
|
||||
static plugin = "plugin_name" as const;
|
||||
|
||||
constructor(
|
||||
public stringValue: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"stringValue": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.stringValue);
|
||||
}
|
||||
|
||||
static Response = lib.JsonNumber
|
||||
}
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "plugin_name",
|
||||
messages: [
|
||||
ForwardedEvent,
|
||||
SimpleRequest,
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Refactor message sending
|
||||
|
||||
Message handlers are now explicitly registered instead of relying on magic name lookups that don't make sense unless you know them.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
const lib = require("@clusterio/lib");
|
||||
|
||||
class ControllerPlugin extends lib.BaseControllerPlugin {
|
||||
// event name is magically created by concatenating EventHandler or
|
||||
// RequestHandler to the name in the messages export in in info.js.
|
||||
async forwardedEventHandler(message) {
|
||||
// message.data contains the raw JSON data sent.
|
||||
if (message.data.string_value) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (JS)**
|
||||
```js
|
||||
const { BaseControllerPlugin } = require("@clusterio/lib");
|
||||
const { ForwardedEvent } = require("./index.js");
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
// Explicit binding between message class and the handler for it
|
||||
this.controller.handle(ForwardedEvent, this.handleForwardedEvent.bind(this));
|
||||
// Note: On an instance plugin you would use this.instance.handle
|
||||
}
|
||||
|
||||
async handleForwardedEvent(event, src, dst) {
|
||||
// Event is an instance of the ForwardedEvent class
|
||||
if (event.stringValue) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// src and dst are instances of lib.Address and describe where
|
||||
// the message came from and who it was addressed to.
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
}
|
||||
```
|
||||
|
||||
**Alpha 14 (TS)**
|
||||
```ts
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
import { ForwardedEvent } from "./index.js";
|
||||
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
// When using TypeScript this call is type checked to make sure the handler accepts the right class.
|
||||
this.controller.handle(ForwardedEvent, this.handleForwardedEvent.bind(this));
|
||||
// Note: On an instance plugin you would use this.instance.handle
|
||||
}
|
||||
|
||||
async handleForwardedEvent(event: ForwardedEvent, src: lib.Address, dst: lib.Address) {
|
||||
// Event is an instance of the ForwardedEvent class
|
||||
if (event.stringValue) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// src and dst describe where the message came from and who it was addressed to.
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Refactor message sending
|
||||
|
||||
Message are now addressed end to end instead of using the confusing forwardTo and broadcastTo properties of the message definition.
|
||||
When broadcasting events the special `"allInstances"`, `"allHosts"`, and `"allControls"` shorthand addresses can be used.
|
||||
There's two methods for sending messages `.send` and `.sendTo` where `.send` is a shorthand for `.sendTo` with an address filled in as the other side of the link, this is mostly only useful in control plugins where the other side of the link is the controller.
|
||||
|
||||
**Alpha 13**
|
||||
```js
|
||||
// from instance plugin
|
||||
this.info.messages.forwarded.send(this.instance, {
|
||||
string_value: "foo",
|
||||
number_value: 123,
|
||||
});
|
||||
|
||||
// from master plugin
|
||||
const instance_id = ...
|
||||
const response = await this.forwardRequestToInstance(this.info.messages.simple, {
|
||||
instance_id,
|
||||
string_value: "foo",
|
||||
});
|
||||
const value = response.number_value;
|
||||
```
|
||||
|
||||
**Alpha 14**
|
||||
```js
|
||||
const { ForwardedEvent } = require("./info.js");
|
||||
|
||||
// from instance plugin
|
||||
this.instance.sendTo("controller", new ForwardedEvent("foo", 123));
|
||||
|
||||
// from controller plugin
|
||||
const instanceId = ...
|
||||
const value = await this.controller.sendTo({ instanceId }, new SimpleRequest("foo"));
|
||||
// if using TypeScript then the type of value will be inferred as number here
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# Migrating from 2.0.0-alpha.13 to 2.0.0-alpha.14
|
||||
|
||||
Alpha 14 comes with significant breaking changes for existing clusters, most notably the rename of master and slave to controller and host.
|
||||
Please study the entire migration guide carefully before attempting to update an existing cluster.
|
||||
|
||||
## Before updating
|
||||
|
||||
If you're using mods you need to create mod pack string for the mod configurations you're currently running.
|
||||
You can do this by running the follow RCON command on an instance that is currently running the mod configuration you want to use (you can also run this command in single player and find the string printed in Factorio's [Log file](https://wiki.factorio.com/Log_file)):
|
||||
|
||||
/sc local m={name="pack",description="",factorio_version=game.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}} for n,v in pairs(game.active_mods) do table.insert(m.mods,{name=n,enabled=true,version=v}) end local function set(s,c) for n,v in pairs(s) do m.settings[c][n]=v end end set(settings.startup,"startup") set(settings.global,"runtime-global") if game.player then set(game.player.mod_settings,"runtime-per-user") else set(settings.player,"runtime-per-user") end local e=game.encode_string(game.table_to_json(m)) log(e) rcon.print(e)
|
||||
|
||||
This should produce a [Mod Pack String](https://forums.factorio.com/viewtopic.php?f=96&t=103578) which you need to copy and paste somewhere to keep it for later.
|
||||
Mod Pack strings looks something like this:
|
||||
|
||||
eNqFketuwyAMhd/Fv0OUVpW25lWmqSLESdAIRNh0l4h3n9mlitZt5QdCx/7so8MKXs8ILSzaPEEFPZKJdmEbvIgiDNpwiDaczhjpU93Vu/r+Tmpz6Anah/V7RqcJRUavO4c9tBwTVnAF5upCGJeIsYx3tvsPbQTdb0lKHYlnPJHY0+ONvcdjvW8gP1ZAyGz9KL5XINaR0yLPXEFMnu2ManSh0+6j/GOFitqXG4mjNSUidVm5wlm7JMYG7Qhl3BX8FjyqZ9vztOk+NM2fvRPaceLbzbN+UejQFFOWXzfArvk6v2HWD9ZLv5JfxA1UwsubPBaMKhHGklLO76vAwfQ=
|
||||
|
||||
If you have multiple mod configuration create a mod pack string for each and note them down.
|
||||
|
||||
Double check that you are on a supported version of Node.js by running `node --version`, the version should be v18 or later, if not you will need to update Node.js before continuing.
|
||||
|
||||
|
||||
## Migrate installations
|
||||
|
||||
**Note:** Check that plugins you are using have updates avaialble for alpha 14 before attempting to update, if they do not either wait for the plugin to be updated or uninstall the plugin from all places it is installed on first.
|
||||
|
||||
Shutdown all slaves and the master server and then migrate each installations by running the following command:
|
||||
|
||||
npm exec @clusterio/create -y -- --migrate-rename --log-level=verbose
|
||||
|
||||
This will convert the existing installation's usage of the master and slave names in packages, configs, database and logs to the new controller and host terms and update the installed packages.
|
||||
|
||||
If you're using systemd then copy the generated clusteriocontroller.service and/or clusteriohost.service files from the systemd folder to /etc/systemd/system/ and remove the old clusteriomaster.service and clusterioslave.service files.
|
||||
|
||||
Once the installations have been migrated start up the controller and hosts again.
|
||||
|
||||
|
||||
## Post installation
|
||||
|
||||
Import the Mod Pack strings that was created beforehand on the controller and upload the mods needed by those mod packs to the controller.
|
||||
You can assign a default mod pack in the controller config which instances use by default or set the mod pack on the per instance basis in the instance config.
|
||||
Once you have assigned a mod pack to an instance run a data export on the instance in order to properly populate the mod settings in the mod and get item icons and item names resolved in the web interface.
|
||||
+107
-149
@@ -12,8 +12,6 @@ The plugin classes have pre-defined hooks that are called during various stages
|
||||
- [Plugin Configuration](#plugin-configuration)
|
||||
- [Communicating with Factorio](#communicating-with-factorio)
|
||||
- [Defining Link Messages](#defining-link-messages)
|
||||
- [Defining Events](#defining-events)
|
||||
- [Defining Requests](#defining-requests)
|
||||
- [Sending Link Messages](#sending-link-messages)
|
||||
- [Handling connection events](#handling-connection-events)
|
||||
- [Collecting Statistics](#collecting-statistics)
|
||||
@@ -25,7 +23,7 @@ The plugin classes have pre-defined hooks that are called during various stages
|
||||
The basic file structure of a plugin is the following.
|
||||
|
||||
plugin_name/
|
||||
+- info.js
|
||||
+- index.js
|
||||
+- package.json
|
||||
+- controller.js
|
||||
+- instance.js
|
||||
@@ -44,14 +42,12 @@ A possible workflow for developing plugins is to place the plugin in a sub-direc
|
||||
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriocontroller, clusteriohost or clusterioctl.
|
||||
Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on Windows).
|
||||
|
||||
For a plugin the most important file is the `info.js` file.
|
||||
Without it the plugin will not recognized by Clusterio.
|
||||
Here's an example of it:
|
||||
For a plugin to be recognised by Clusterio it needs to export an entry named `plugin` from its main entrypoint.
|
||||
By default the main entrypoint is the `index.js` file, but this may be changed by setting the `"main"` entry to a different file in `package.json`.
|
||||
Here's an example of `index.js`:
|
||||
|
||||
```js
|
||||
const lib = require("@clusterio/lib"); // For messages
|
||||
|
||||
module.exports = {
|
||||
module.exports.plugin = {
|
||||
name: "foo_frobber",
|
||||
title: "Foo Frobber",
|
||||
description: "Does advanced frobnication",
|
||||
@@ -202,8 +198,8 @@ For plugin hooks expections thrown are automatically catched and logged, but for
|
||||
## Plugin Configuration
|
||||
|
||||
Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you.
|
||||
You can take advantage of it by declaring config fields under either `controllerConfigFields` or `instanceConfigFields` in the `info.js` export.
|
||||
For example in info.js:
|
||||
You can take advantage of it by declaring config fields under either `controllerConfigFields` or `instanceConfigFields` in the `plugin` export.
|
||||
For example in index.js:
|
||||
|
||||
```ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
@@ -327,175 +323,137 @@ Events are simple one-way notifications that invoke a handler on the target it's
|
||||
Requests are pairs of request and response messages where the request is sent to the target and the response is the reply back from the target.
|
||||
The requests are similar to HTTP requests, except both parties of a link may initiate one.
|
||||
|
||||
|
||||
### Defining Events
|
||||
|
||||
Events are defined as properties of the messages object exported by `info.js` that map to instances of the `Event` class from `lib/link`.
|
||||
The name of the property correspond to the handler invoked on the plugin class.
|
||||
The Event constructor takes an object of properties that define the event, for example the following could be defined in `info.js`:
|
||||
Messages are defined as items of the `plugin.messages` array exported by `index.js`.
|
||||
For example, the following could be defined in `plugin`:
|
||||
|
||||
```js
|
||||
messages: {
|
||||
startFrobnication: new lib.Event({
|
||||
type: "foo_frobber:start_frobnication",
|
||||
links: ["controller-host", "host-instance"],
|
||||
forwardTo: "instance",
|
||||
eventRequired: ["frobnication_type"],
|
||||
eventProperties: {
|
||||
"frobnication_type": { type: "string" },
|
||||
"urgent": { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
```
|
||||
|
||||
This specifies an event that can be sent from the controller to a host, and from a host to an instance.
|
||||
It also specifies that the event must contain the property `frobnication_type`, with a string value in the data payload and that it may optionally contain a boolean `urgent` property.
|
||||
It will also be forwarded by hosts to a specific instance.
|
||||
|
||||
The following properties are recognized by the Event constructor:
|
||||
|
||||
#### type
|
||||
|
||||
The message type sent over the wire.
|
||||
This must start with the name of the plugin followed by colon and and be unique for the plugin.
|
||||
The type of the message sent over the socket will have the suffix `_event` appended to it.
|
||||
|
||||
#### links
|
||||
|
||||
An array of strings describing which links this event can be sent over.
|
||||
Direction matters, `"controller-host"` means the event can be sent from the controller to the host, but can't be sent back the other way, unless `"host-controller"` is also present in the links array.
|
||||
|
||||
The available endpoints are `controller`, `host`, `instance`, and `control`.
|
||||
Controller talks with host and control, and host talks to instance.
|
||||
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from controller to instance it must have both `"controller-host"` and `"host-instance"` in the links array).
|
||||
See `forwardTo` and `broadcastTo` for ways to forward an event to the next link in a chain.
|
||||
|
||||
#### forwardTo
|
||||
|
||||
Target to forward an event to.
|
||||
Can either be `"controller"`, to indicate a host should forward it to the controller, or `"instance"`, to indicate it should be forwarded to the instances specified by the `instance_id` event property.
|
||||
This works by using a default handler for the event at the links that forward it.
|
||||
|
||||
#### broadcastTo
|
||||
|
||||
Target to broadcast this message towards.
|
||||
A value of "instance" means the event will be broadcast to all instances downstream of the target it's sent to, but not back from where it came from.
|
||||
Currently, only "instance" is supported.
|
||||
This means that sending the event to a host from an instance will cause it to be broadcast to all instances of that host except for the instance it came from.
|
||||
|
||||
#### eventRequired
|
||||
|
||||
By default all properties defined in `eventProperties` are required to be present in the payload for the event.
|
||||
This can be overriden by specifying an array of properties which are required here.
|
||||
This is equivalent to using the `required` keyword in JSON schema.
|
||||
|
||||
#### eventProperties
|
||||
|
||||
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the event.
|
||||
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
|
||||
See [this guide][guide] for an introduction to writing JSON schemas.
|
||||
|
||||
The forwardTo and broadcastTo can be combined such that specifying `"controller"` as the forwardTo value and `"instance"` as the broadcastTo value will cause the event to be broadcast to all instances in the cluster.
|
||||
For this to work, you will need to specify `instance-host`, `host-controller`, `controller-host`, and `host-instance` as the links.
|
||||
|
||||
Keep in mind when forwarding events that if the target an event is being forwarded to is not online, the event will be dropped.
|
||||
Use a request if you need a confirmation that the message was received.
|
||||
|
||||
### Definining Requests
|
||||
|
||||
Requests are defined as properties of the messages object exported by `info.js` that map to instances of the `Request` class from `lib/link`.
|
||||
The name of the property corresponds to the handler invoked on the plugin class.
|
||||
The Request constructor takes an object of properties that define the event.
|
||||
For example, the following could be defined in `info.js`:
|
||||
|
||||
```js
|
||||
messages: {
|
||||
reportFrobnication: new lib.Request({
|
||||
type: "foo_frobber:report_frobnication",
|
||||
links: ["controller-host", "host-instance"],
|
||||
forwardTo: "instance",
|
||||
requestRequired: ["verbosity"],
|
||||
requestProperties: {
|
||||
"verbosity": { type: "integer" },
|
||||
"special": { type: "boolean" },
|
||||
},
|
||||
responseProperties: {
|
||||
"report": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
messages: [
|
||||
class Frobnicate {
|
||||
static plugin = "foo_frobber"; // Plugin name
|
||||
static type = "request";
|
||||
static src = "controller";
|
||||
static dst = "instance";
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
"verbosity": { type: "integer" },
|
||||
"special": { type: "boolean" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
required: ["verbosity"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(json) {
|
||||
this.verbosity = json.verbosity;
|
||||
this.special = json.special;
|
||||
}
|
||||
static fromJSON(json) {
|
||||
return new Frobnicate(json);
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
verbosity: this.verbosity,
|
||||
special: this.special,
|
||||
};
|
||||
}
|
||||
static Response = {
|
||||
jsonSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
"report": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
fromJSON(json) {
|
||||
return json
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
This specifies a request that can be sent from the controller to a host, and from a host to an instance.
|
||||
The request data must contain the property `verbosity` with an integer number as the value, as well as the `instance_id` property (implied by `forwardTo: "instance"`) and it may also contain a boolean `special` property.
|
||||
It also defines that the response sent must contain a `report` property mapping to an array of strings.
|
||||
When received by a host, it will also be forwarded to the instance specified by `instance_id`.
|
||||
This specifies a request that can be sent from the controller to an instance, being routed through the correct host behind the scenes.
|
||||
The request data must contain the property `verbosity` with an integer number as the value, and it may also contain a boolean `special` property.
|
||||
It also defines that the response sent may contain a `report` property mapping to an array of strings.
|
||||
|
||||
The following properties are recognized by the Request constructor:
|
||||
The following properties are recognized as part of a message class:
|
||||
|
||||
#### type
|
||||
#### static plugin
|
||||
|
||||
The message type sent over the wire.
|
||||
This must start with the name of the plugin followed by colon and and be unique for the plugin.
|
||||
The type of the message sent over the socket will have the suffix `_request` appended to it for the request and `_response` appended to it for the response.
|
||||
Must contain the name of the plugin this message is defined by.
|
||||
This property is not used in core messages.
|
||||
|
||||
#### links
|
||||
#### static type
|
||||
|
||||
An array of strings describing which links this request can be sent over.
|
||||
Direction matters; `"controller-host"` means the request can be sent from the controller to the host and the host can reply to the controller, but the host can't send a request to the controller unless `"host-controller"` is also present in the links array.
|
||||
A string describing the message type sent over the wire.
|
||||
This can be either `"request"` or `"event"`.
|
||||
The primary difference is whether they allow for a response.
|
||||
|
||||
The available endpoints are `controller`, `host`, `instance`, and `control`.
|
||||
Controller talks with host and control, and host talks to instance.
|
||||
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from controller to instance it must have both `"controller-host"` and `"host-instance"` in the links array).
|
||||
See `forwardTo` for ways to forward a request to the next link in a chain.
|
||||
#### static src
|
||||
|
||||
#### forwardTo
|
||||
A string or an array of strings describing where this message is allowed to originate from.
|
||||
The available endpoints are `"controller"`, `"host"`, `"instance"`, or `"control"`.
|
||||
Messages sent from a different source will be rejected.
|
||||
|
||||
Target to forward the request to.
|
||||
Can either be `"controller"` to indicate a host should forward it to the controller when receiving it from an instance, or `"instance"` to indicate it should be forwarded to the instances specified by the `instance_id` request property.
|
||||
This works by using a default handler for the request by the links that forward it.
|
||||
#### static dst
|
||||
|
||||
#### requestRequired
|
||||
A string or array of strings describing where this message may be addressed to.
|
||||
The available endpoints are `"controller"`, `"host"`, `"instance"`, or `"control"`.
|
||||
If the target is multiple hops away it the message will be forwarded towards its destination by the intermediaries.
|
||||
For example a message sent from a control connection an instance will be forwarded by the controller to the relevant host and from the host to the correct instance.
|
||||
Messages sent to a different destination will be rejected.
|
||||
|
||||
By default all properties defined in `requestProperties` are required to be present in the payload for the request.
|
||||
This can be overriden by specifying an array of properties which are required here.
|
||||
This is equivalent to using the `required` keyword in JSON schema.
|
||||
#### static jsonSchema
|
||||
|
||||
#### requestProperties
|
||||
A JSON schema that specifies what's valid to send in the message.
|
||||
|
||||
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the request.
|
||||
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
|
||||
See [this guide][guide] for an introduction to writing JSON schemas
|
||||
#### toJSON
|
||||
|
||||
#### responseRequired
|
||||
Optional method to serialize an instance of the class to a JSON object, see the [documentation for `JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description) for the semantics of this method.
|
||||
This should return an object that's valid according to `jsonSchema` and accepted by `fromJSON`, and may be omitted if the default serialisation by `JSON.stringify()` already does this.
|
||||
|
||||
Same as the requestRequired only for the response sent back by the target.
|
||||
#### static fromJSON
|
||||
|
||||
#### responseProperties
|
||||
Required function to restore the class from a JSON object.
|
||||
This function is called when the message reaches its destination and is expected to return an identical instance of the class.
|
||||
I.e. the result of `fromJSON(JSON.parse(JSON.stringify(instance)))` should be a complete copy of the message.
|
||||
|
||||
Same as the requestProperties only for the response sent back by the target.
|
||||
#### static Response
|
||||
|
||||
Optional [JSON sterilisable class](/docs/devs/json-serialisable-classes.md) that specifies the response to the request.
|
||||
Required properties are `jsonSchema` and `fromJSON`.
|
||||
|
||||
## Sending Link Messages
|
||||
|
||||
Link messages are sent by calling the `.send()` method on the Event/Request instance with the link you want to send it over and the data to send.
|
||||
For `InstancePlugin` code the link to the host is the `instance` itself, which is accessible through the `.instance` property of the `InstancePlugin`.
|
||||
The `.info` property of the plugin class exposes the data exported from the plugin's `info.js` module.
|
||||
In other words:
|
||||
Link messages are sent by calling the `.sendTo()` method on a connection object with the destination and the data you want to send, where the data is an instance of one of the defined message classes in plugin.messages.
|
||||
For example:
|
||||
|
||||
```js
|
||||
// In an InstancePlugin class
|
||||
async frobnicate() {
|
||||
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
|
||||
const response = await this.instance.sendTo("controller", new messages.Frobnicate({
|
||||
verbosity: 2,
|
||||
special: false,
|
||||
}));
|
||||
console.log(response); // { report: [...] }
|
||||
}
|
||||
```
|
||||
|
||||
For the Request class the send method is async and returns the response data received from the target it was sent to, or throws an error if the request failed.
|
||||
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
|
||||
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
|
||||
|
||||
Example destinations:
|
||||
|
||||
```js
|
||||
sendTo("controller", message);
|
||||
sendTo("allHosts", message);
|
||||
sendTo("allControls", message);
|
||||
sendTo("allInstances", message);
|
||||
sendTo({ hostId: 123 }, message);
|
||||
sendTo({ instanceId: 123 }, message);
|
||||
sendTo({ controlId: 123 }, message);
|
||||
```
|
||||
|
||||
### Handling connection events
|
||||
|
||||
@@ -510,7 +468,7 @@ For example the sending of an event from an `InstancePlugin` class can be stoppe
|
||||
|
||||
```js
|
||||
if (this.host.connected) {
|
||||
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
|
||||
this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
}
|
||||
```
|
||||
|
||||
@@ -619,4 +577,4 @@ class CtlPlugin extends BaseCtlPlugin {
|
||||
module.exports = {
|
||||
CtlPlugin,
|
||||
}
|
||||
```
|
||||
```
|
||||
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"test": "pnpm run build && mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
|
||||
"fast-test": "FAST_TEST=y pnpm test",
|
||||
"cover": "nyc pnpm test",
|
||||
@@ -27,6 +28,7 @@
|
||||
"@clusterio/ctl": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@swc/core": "^1.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.45.0",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
webpack.config.js
|
||||
dist/web/build
|
||||
dist/browser
|
||||
@@ -37,6 +37,24 @@ import { version } from "./package.json";
|
||||
// globals
|
||||
let controller: Controller;
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_controller_pending_requests",
|
||||
"Count of pending link requests currently waiting in memory on the controller.",
|
||||
{
|
||||
callback: (gauge) => {
|
||||
let count = 0;
|
||||
if (controller) {
|
||||
for (const host of controller.wsServer.hostConnections.values()) {
|
||||
count += host.pendingRequestCount;
|
||||
}
|
||||
for (const control of controller.wsServer.controlConnections.values()) {
|
||||
count += control.pendingRequestCount;
|
||||
}
|
||||
}
|
||||
gauge.set(count);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_controller_host_mapping",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { bootstrap } from "./controller";
|
||||
export { default as Controller } from "./src/Controller";
|
||||
export { default as ControllerUser } from "./src/ControllerUser";
|
||||
export { default as ControllerRouter } from "./src/ControllerRouter";
|
||||
export { default as ControlConnection } from "./src/ControlConnection";
|
||||
export { default as HostConnection } from "./src/HostConnection";
|
||||
export { default as InstanceInfo } from "./src/InstanceInfo";
|
||||
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
|
||||
export { default as UserManager } from "./src/UserManager";
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"main": "dist/index.js",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusteriocontroller": "./dist/controller.js"
|
||||
"clusteriocontroller": "./dist/node/controller.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio"
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.13",
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.7.4",
|
||||
@@ -36,7 +36,8 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.13",
|
||||
"@clusterio/web_ui": "^2.0.0-alpha.17",
|
||||
"@swc/core": "^1.4.0",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "^4.17.17",
|
||||
|
||||
@@ -6,7 +6,6 @@ import type WsServerConnector from "./WsServerConnector";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
const { logger } = lib;
|
||||
import ControllerRouter from "./ControllerRouter";
|
||||
import * as routes from "./routes";
|
||||
|
||||
|
||||
@@ -24,7 +23,7 @@ export default class BaseConnection extends lib.Link {
|
||||
public _controller: Controller
|
||||
) {
|
||||
super(connector);
|
||||
this.router = new ControllerRouter(this._controller);
|
||||
this.router = _controller.router;
|
||||
for (let [Request, handler] of this._controller._registeredRequests) { this.handle(Request, handler); }
|
||||
for (let [Request, handler] of this._controller._fallbackedRequests) { this.fallbackRequest(Request, handler); }
|
||||
for (let [Event, handler] of this._controller._registeredEvents) { this.handle(Event, handler); }
|
||||
|
||||
@@ -13,8 +13,8 @@ import type HostConnection from "./HostConnection";
|
||||
* Controller plugins are subclasses of this class which get instantiated by
|
||||
* the controller on startup when the plugin is enabled in the config.
|
||||
* To be discovered the class must be exported under the name `ControllerPlugin`
|
||||
* in the module specified by the `controllerEntrypoint` in the plugin's info.js
|
||||
* file.
|
||||
* in the module specified by the `controllerEntrypoint` in the plugin's
|
||||
* `plugin` export.
|
||||
*/
|
||||
export default class BaseControllerPlugin {
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ import WsServer from "./WsServer";
|
||||
import HostConnection from "./HostConnection";
|
||||
import HostInfo from "./HostInfo";
|
||||
import BaseControllerPlugin from "./BaseControllerPlugin";
|
||||
import ControllerRouter from "./ControllerRouter";
|
||||
|
||||
const endpointDurationSummary = new Summary(
|
||||
"clusterio_controller_http_endpoint_duration_seconds",
|
||||
@@ -75,6 +76,7 @@ export default class Controller {
|
||||
|
||||
/** WebSocket server */
|
||||
wsServer: WsServer;
|
||||
router = new ControllerRouter(this);
|
||||
trustedProxies: BlockList;
|
||||
debugEvents: events.EventEmitter = new events.EventEmitter();
|
||||
private _events: events.EventEmitter = new events.EventEmitter();
|
||||
@@ -273,7 +275,7 @@ export default class Controller {
|
||||
Controller.addAppRoutes(this.app, this.pluginInfos);
|
||||
|
||||
if (!args.dev) {
|
||||
let manifestPath = path.join(__dirname, "..", "web", "manifest.json");
|
||||
let manifestPath = path.join(__dirname, "..", "..", "web", "manifest.json");
|
||||
|
||||
let manifest = await Controller.loadJsonObject(manifestPath);
|
||||
if (!manifest["main.js"]) {
|
||||
@@ -352,7 +354,7 @@ export default class Controller {
|
||||
|
||||
if (args.dev) {
|
||||
// eslint-disable-next-line node/no-missing-require
|
||||
webpackConfigs.push(require("../../webpack.config")({})); // Path outside of build
|
||||
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
|
||||
}
|
||||
if (args.devPlugin) {
|
||||
let devPlugins = new Map();
|
||||
@@ -617,7 +619,7 @@ export default class Controller {
|
||||
const host = HostInfo.fromJSON(json);
|
||||
if (host.connected) {
|
||||
host.connected = false;
|
||||
host.updatedAt = Date.now();
|
||||
host.updatedAtMs = Date.now();
|
||||
}
|
||||
hosts.set(id, host);
|
||||
}
|
||||
@@ -638,13 +640,13 @@ export default class Controller {
|
||||
) as Static<typeof InstanceInfo.jsonSchema>[];
|
||||
for (let json of serialized) {
|
||||
if (!json.config) { // migrate: from pre Alpha 14 format.
|
||||
json = { config: json as any, status: "running" }; // Use running to force updatedAt
|
||||
json = { config: json as any, status: "running" }; // Use running to force updatedAtMs
|
||||
}
|
||||
const instance = InstanceInfo.fromJSON(json, "controller");
|
||||
const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown";
|
||||
if (instance.status !== status) {
|
||||
instance.status = status;
|
||||
instance.updatedAt = Date.now();
|
||||
instance.updatedAtMs = Date.now();
|
||||
}
|
||||
instances.set(instance.id, instance);
|
||||
}
|
||||
@@ -771,7 +773,7 @@ export default class Controller {
|
||||
// Set folder to serve static content from (the website)
|
||||
const staticOptions = { immutable: true, maxAge: 1000 * 86400 * 365 };
|
||||
app.use("/static",
|
||||
express.static(path.join(__dirname, "..", "..", "dist", "web", "static"), staticOptions)
|
||||
express.static(path.join(__dirname, "..", "..", "web", "static"), staticOptions)
|
||||
);
|
||||
app.use("/static", express.static("static", staticOptions)); // Used for data export files
|
||||
|
||||
@@ -795,7 +797,7 @@ export default class Controller {
|
||||
|
||||
async handleSystemInfoSubscription(request: lib.SubscriptionRequest) {
|
||||
const systems = [...this.systems.values()].filter(
|
||||
metric => metric.updatedAt > request.lastRequestTime,
|
||||
metric => metric.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return systems.length ? new lib.SystemInfoUpdateEvent(systems) : null;
|
||||
}
|
||||
@@ -816,7 +818,7 @@ export default class Controller {
|
||||
hostsUpdated(hosts: HostInfo[]) {
|
||||
const now = Date.now();
|
||||
for (const host of hosts) {
|
||||
host.updatedAt = now;
|
||||
host.updatedAtMs = now;
|
||||
}
|
||||
this.hostsDirty = true;
|
||||
let updates = hosts.map(host => host.toHostDetails());
|
||||
@@ -825,7 +827,7 @@ export default class Controller {
|
||||
|
||||
async handleHostSubscription(request: lib.SubscriptionRequest) {
|
||||
const hosts = [...this.hosts.values()].filter(
|
||||
host => host.updatedAt > request.lastRequestTime,
|
||||
host => host.updatedAtMs > request.lastRequestTimeMs,
|
||||
).map(host => host.toHostDetails());
|
||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
||||
}
|
||||
@@ -910,9 +912,11 @@ export default class Controller {
|
||||
*/
|
||||
clearSavesOfInstance(instanceId: number) {
|
||||
const updates = [];
|
||||
const now = Date.now();
|
||||
for (const [id, save] of this.saves) {
|
||||
if (save.instanceId === instanceId) {
|
||||
save.isDeleted = true;
|
||||
save.updatedAtMs = now;
|
||||
updates.push(save);
|
||||
this.saves.delete(id);
|
||||
}
|
||||
@@ -973,8 +977,9 @@ export default class Controller {
|
||||
);
|
||||
} else {
|
||||
instance.status = "unassigned";
|
||||
this.instanceDetailsUpdated([instance]);
|
||||
}
|
||||
instance.updatedAtMs = Date.now();
|
||||
this.instanceDetailsUpdated([instance]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -995,10 +1000,11 @@ export default class Controller {
|
||||
this.instancesDirty = true;
|
||||
|
||||
this.clearSavesOfInstance(instanceId);
|
||||
this.userManager.clearStatsOfInstance(instanceId);
|
||||
|
||||
let prev = instance.status;
|
||||
instance.status = "deleted";
|
||||
instance.updatedAt = Date.now();
|
||||
instance.updatedAtMs = Date.now();
|
||||
this.instanceDetailsUpdated([instance]);
|
||||
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
@@ -1026,7 +1032,7 @@ export default class Controller {
|
||||
|
||||
addInstanceHooks(instance: InstanceInfo) {
|
||||
instance.config.on("fieldChanged", (field: string, curr: any, prev: any) => {
|
||||
instance.updatedAt = Date.now();
|
||||
instance.updatedAtMs = Date.now();
|
||||
if (field === "instance.name") {
|
||||
this.instanceDetailsUpdated([instance]);
|
||||
}
|
||||
@@ -1043,7 +1049,7 @@ export default class Controller {
|
||||
|
||||
async handleInstanceDetailsSubscription(request: lib.SubscriptionRequest) {
|
||||
const instances = [...this.instances.values()].filter(
|
||||
instance => instance.updatedAt > request.lastRequestTime,
|
||||
instance => instance.updatedAtMs > request.lastRequestTimeMs,
|
||||
).map(instance => instance.toInstanceDetails());
|
||||
return instances.length ? new lib.InstanceDetailsUpdatesEvent(instances) : null;
|
||||
}
|
||||
@@ -1054,7 +1060,7 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsSubscription(request: lib.SubscriptionRequest) {
|
||||
const saves = [...this.saves.values()].filter(save => save.updatedAt > request.lastRequestTime);
|
||||
const saves = [...this.saves.values()].filter(save => save.updatedAtMs > request.lastRequestTimeMs);
|
||||
return saves.length ? new lib.InstanceSaveDetailsUpdatesEvent(saves) : null;
|
||||
}
|
||||
|
||||
@@ -1062,7 +1068,7 @@ export default class Controller {
|
||||
modPacksUpdated(modPacks: lib.ModPack[]) {
|
||||
const now = Date.now();
|
||||
for (const modPack of modPacks) {
|
||||
modPack.updatedAt = now;
|
||||
modPack.updatedAtMs = now;
|
||||
}
|
||||
this.modPacksDirty = true;
|
||||
this.subscriptions.broadcast(new lib.ModPackUpdatesEvent(modPacks));
|
||||
@@ -1071,20 +1077,20 @@ export default class Controller {
|
||||
|
||||
async handleModPackSubscription(request: lib.SubscriptionRequest) {
|
||||
const modPacks = [...this.modPacks.values()].filter(
|
||||
modPack => modPack.updatedAt > request.lastRequestTime,
|
||||
modPack => modPack.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return modPacks.length ? new lib.ModPackUpdatesEvent(modPacks) : null;
|
||||
}
|
||||
|
||||
modsUpdated(mods: lib.ModInfo[]) {
|
||||
// ModStore sets updatedAt for mods
|
||||
// ModStore sets updatedAtMs for mods
|
||||
this.subscriptions.broadcast(new lib.ModUpdatesEvent(mods));
|
||||
lib.invokeHook(this.plugins, "onModsUpdated", mods);
|
||||
}
|
||||
|
||||
async handleModSubscription(request: lib.SubscriptionRequest) {
|
||||
const mods = [...this.modStore.files.values()].filter(
|
||||
mod => mod.updatedAt > request.lastRequestTime,
|
||||
mod => mod.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return mods.length ? new lib.ModUpdatesEvent(mods) : null;
|
||||
}
|
||||
@@ -1092,7 +1098,7 @@ export default class Controller {
|
||||
usersUpdated(users: ControllerUser[]) {
|
||||
const now = Date.now();
|
||||
for (const user of users) {
|
||||
user.updatedAt = now;
|
||||
user.updatedAtMs = now;
|
||||
}
|
||||
this.userManager.dirty = true;
|
||||
this.subscriptions.broadcast(new lib.UserUpdatesEvent(users));
|
||||
@@ -1100,7 +1106,7 @@ export default class Controller {
|
||||
|
||||
async handleUserSubscription(request: lib.SubscriptionRequest) {
|
||||
const users = [...this.userManager.users.values()].filter(
|
||||
user => user.updatedAt > request.lastRequestTime,
|
||||
user => user.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return users.length ? new lib.UserUpdatesEvent(users) : null;
|
||||
}
|
||||
@@ -1257,7 +1263,7 @@ export default class Controller {
|
||||
mainBundle = stats.toJson().assetsByChunkName["main"];
|
||||
}
|
||||
|
||||
fs.readFile(path.join(__dirname, "..", "..", "web", "index.html"), "utf8").then((content) => {
|
||||
fs.readFile(path.join(__dirname, "..", "..", "..", "web", "index.html"), "utf8").then((content) => {
|
||||
res.type("text/html");
|
||||
res.send(content
|
||||
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
|
||||
@@ -1409,7 +1415,11 @@ export default class Controller {
|
||||
}
|
||||
|
||||
} else if (dst.id === lib.Address.instance || dst.id === lib.Address.host) {
|
||||
const plugin = event.constructor.plugin;
|
||||
for (let hostConnection of this.wsServer.hostConnections.values()) {
|
||||
if (plugin && !hostConnection.plugins.has(plugin)) {
|
||||
continue;
|
||||
}
|
||||
hostConnection.sendEvent(event, dst);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import ControlConnection from "./ControlConnection";
|
||||
const { logger } = lib;
|
||||
|
||||
export default class ControllerRouter {
|
||||
pendingRequests: Map<any, any> = new Map();
|
||||
|
||||
constructor(
|
||||
public controller: Controller
|
||||
) {}
|
||||
@@ -16,12 +14,14 @@ export default class ControllerRouter {
|
||||
/**
|
||||
* @param origin - Source link of the message.
|
||||
* @param message - Link the message originated from.
|
||||
* @param entry - message entry if message is a request or an event.
|
||||
* @param hasFallback - true if fallback handling is available.
|
||||
* @returns true if the message was handled
|
||||
*/
|
||||
forwardMessage(
|
||||
origin: ControlConnection | HostConnection,
|
||||
message: lib.MessageRoutable,
|
||||
entry: lib.RequestEntry | lib.EventEntry | undefined,
|
||||
hasFallback: boolean
|
||||
): boolean {
|
||||
if (!["request", "response", "responseError", "event"].includes(message.type)) {
|
||||
@@ -32,7 +32,7 @@ export default class ControllerRouter {
|
||||
let nextHop: ControlConnection | HostConnection | undefined;
|
||||
let msg: string = "";
|
||||
if (dst.type === lib.Address.broadcast) {
|
||||
this.broadcastMessage(origin, message);
|
||||
this.broadcastMessage(origin, message, entry as lib.EventEntry);
|
||||
return true;
|
||||
} else if (dst.type === lib.Address.host) {
|
||||
nextHop = this.controller.wsServer.hostConnections.get(dst.id);
|
||||
@@ -84,7 +84,7 @@ export default class ControllerRouter {
|
||||
if (message.type === "request") {
|
||||
nextHop.forwardRequest(message as lib.MessageRequest, origin);
|
||||
} else {
|
||||
nextHop.connector.send(message);
|
||||
nextHop.connector.forward(message);
|
||||
}
|
||||
} else {
|
||||
this.warnUnrouted(message, msg);
|
||||
@@ -96,20 +96,24 @@ export default class ControllerRouter {
|
||||
broadcastMessage(
|
||||
origin: lib.Link,
|
||||
message: lib.MessageRoutable,
|
||||
entry: lib.EventEntry,
|
||||
) {
|
||||
let dst = message.dst;
|
||||
if (message.type !== "event") {
|
||||
this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`);
|
||||
} else if (dst.id === lib.Address.host || dst.id === lib.Address.instance) {
|
||||
return;
|
||||
}
|
||||
const plugin = entry.Event.plugin;
|
||||
if (dst.id === lib.Address.host || dst.id === lib.Address.instance) {
|
||||
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
|
||||
if (hostConnection !== origin) {
|
||||
hostConnection.connector.send(message);
|
||||
if (hostConnection !== origin && (!plugin || hostConnection.plugins.has(plugin))) {
|
||||
hostConnection.connector.forward(message);
|
||||
}
|
||||
}
|
||||
} else if (dst.id === lib.Address.control) {
|
||||
for (let controlConnection of this.controller.wsServer.controlConnections.values()) {
|
||||
if (controlConnection !== origin) {
|
||||
controlConnection.connector.send(message);
|
||||
controlConnection.connector.forward(message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -126,5 +130,3 @@ export default class ControllerRouter {
|
||||
logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ControllerRouter;
|
||||
@@ -35,7 +35,7 @@ export default class ControllerUser extends User implements IControllerUser {
|
||||
json.is_banned,
|
||||
json.is_whitelisted,
|
||||
json.ban_reason,
|
||||
json.updated_at,
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
playerStats,
|
||||
instanceStats,
|
||||
|
||||
@@ -57,6 +57,7 @@ export default class HostConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
this.connector.on("close", () => {
|
||||
const now = Date.now();
|
||||
// Update status to unknown for instances on this host.
|
||||
const instances: InstanceInfo[] = [];
|
||||
for (let instance of this._controller.instances.values()) {
|
||||
@@ -67,6 +68,7 @@ export default class HostConnection extends BaseConnection {
|
||||
instances.push(instance);
|
||||
let prev = instance.status;
|
||||
instance.status = "unknown";
|
||||
instance.updatedAtMs = now;
|
||||
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
}
|
||||
this._controller.instanceDetailsUpdated(instances);
|
||||
@@ -176,14 +178,17 @@ export default class HostConnection extends BaseConnection {
|
||||
// changed. Hosts also send status updates on assignInstance which
|
||||
// for hacky reason is also used to push config changes and
|
||||
// restablish status after a connection loss.
|
||||
if (instance.status === request.status) {
|
||||
if (
|
||||
instance.status === request.status
|
||||
&& instance.gamePort === request.gamePort
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prev = instance.status;
|
||||
instance.status = request.status;
|
||||
instance.gamePort = request.gamePort;
|
||||
instance.updatedAt = Date.now();
|
||||
instance.updatedAtMs = Date.now();
|
||||
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
this._controller.instanceDetailsUpdated([instance]);
|
||||
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
|
||||
@@ -220,7 +225,7 @@ export default class HostConnection extends BaseConnection {
|
||||
let prev = controllerInstance.status;
|
||||
controllerInstance.status = instanceData.status;
|
||||
controllerInstance.gamePort = instanceData.gamePort;
|
||||
controllerInstance.updatedAt = Date.now();
|
||||
controllerInstance.updatedAtMs = Date.now();
|
||||
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
|
||||
instanceUpdates.push(controllerInstance);
|
||||
await lib.invokeHook(
|
||||
@@ -281,7 +286,7 @@ export default class HostConnection extends BaseConnection {
|
||||
if (existingSave && save.equals(existingSave)) {
|
||||
continue;
|
||||
}
|
||||
save.updatedAt = now;
|
||||
save.updatedAtMs = now;
|
||||
this._controller.saves.set(save.id, save);
|
||||
updates.push(save);
|
||||
}
|
||||
@@ -290,7 +295,7 @@ export default class HostConnection extends BaseConnection {
|
||||
for (const [id, save] of this._controller.saves) {
|
||||
if (save.instanceId === event.instanceId && !updatedSaves.has(id)) {
|
||||
save.isDeleted = true;
|
||||
save.updatedAt = now;
|
||||
save.updatedAtMs = now;
|
||||
updates.push(save);
|
||||
this._controller.saves.delete(id);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default class HostInfo {
|
||||
constructor(
|
||||
/** Id of this host */
|
||||
public id: number,
|
||||
/** Version this host last connected with */
|
||||
/** Name of this host */
|
||||
public name: string,
|
||||
/** Version this host last connected with */
|
||||
public version: string,
|
||||
@@ -23,7 +23,7 @@ export default class HostInfo {
|
||||
/** Unix timestamp in seconds host token must be issued after to be valid */
|
||||
public tokenValidAfter: number = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAt: number = 0,
|
||||
public updatedAtMs: number = 0,
|
||||
/** True if this host has been deleted */
|
||||
public isDeleted: boolean = false,
|
||||
) { }
|
||||
@@ -37,7 +37,7 @@ export default class HostInfo {
|
||||
"remote_address": Type.String(),
|
||||
"public_address": Type.String(),
|
||||
"token_valid_after": Type.Number(),
|
||||
"updated_at": Type.Number(),
|
||||
"updated_at_ms": Type.Number(),
|
||||
"is_deleted": Type.Boolean(),
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export default class HostInfo {
|
||||
json.remote_address,
|
||||
json.public_address,
|
||||
json.token_valid_after,
|
||||
json.updated_at,
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default class HostInfo {
|
||||
remote_address: this.remoteAddress,
|
||||
public_address: this.publicAddress,
|
||||
token_valid_after: this.tokenValidAfter,
|
||||
updated_at: this.updatedAt,
|
||||
updated_at_ms: this.updatedAtMs,
|
||||
is_deleted: this.isDeleted,
|
||||
};
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export default class HostInfo {
|
||||
this.remoteAddress,
|
||||
this.publicAddress,
|
||||
this.tokenValidAfter,
|
||||
this.updatedAt,
|
||||
this.updatedAtMs,
|
||||
this.isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,10 +67,10 @@ export default class HttpCloser {
|
||||
* If there's requests in progress that still haven't been completed
|
||||
* after the timeout value passed they will be aborted.
|
||||
*
|
||||
* @param timeout -
|
||||
* @param timeoutMs -
|
||||
* Time in ms to wait for connections to close.
|
||||
*/
|
||||
async close(timeout:number = 5000): Promise<void> {
|
||||
async close(timeoutMs: number = 5000): Promise<void> {
|
||||
let activeSockets = new Set();
|
||||
for (let response of this._responses) {
|
||||
if (!response.headersSent) {
|
||||
@@ -97,7 +97,7 @@ export default class HttpCloser {
|
||||
// Node.js, see nodejs/node#27428.
|
||||
socket.destroy();
|
||||
}
|
||||
}, timeout);
|
||||
}, timeoutMs);
|
||||
this._server.close(err => {
|
||||
clearTimeout(timer);
|
||||
if (err) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class InstanceInfo {
|
||||
public config: lib.InstanceConfig,
|
||||
public status: lib.InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public updatedAt = 0,
|
||||
public updatedAtMs = 0,
|
||||
) {
|
||||
this.config = config;
|
||||
this.status = status;
|
||||
@@ -21,7 +21,7 @@ export default class InstanceInfo {
|
||||
"config": lib.InstanceConfig.jsonSchema,
|
||||
"status": lib.InstanceStatus,
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"updatedAt": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
|
||||
@@ -29,7 +29,7 @@ export default class InstanceInfo {
|
||||
lib.InstanceConfig.fromJSON(json.config, location),
|
||||
json.status,
|
||||
json.gamePort,
|
||||
json.updatedAt,
|
||||
json.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default class InstanceInfo {
|
||||
this.config.get("instance.assigned_host") ?? undefined,
|
||||
this.gamePort,
|
||||
this.status,
|
||||
this.updatedAt,
|
||||
this.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,4 +113,13 @@ export default class UserManager {
|
||||
this.onlineUsers.delete(user);
|
||||
}
|
||||
}
|
||||
|
||||
clearStatsOfInstance(instanceId: number) {
|
||||
for (const user of this.users.values()) {
|
||||
this.notifyLeave(user, instanceId);
|
||||
user.instanceStats.delete(instanceId);
|
||||
user.recalculatePlayerStats();
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ async function getMetrics(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
let requests: Promise<InstanceType<typeof lib.HostMetricsRequest["Response"]> | null>[] = [];
|
||||
let timeout = controller.config.get("controller.metrics_timeout") * 1000;
|
||||
let timeoutMs = controller.config.get("controller.metrics_timeout") * 1000;
|
||||
for (let [hostId, hostConnection] of controller.wsServer.hostConnections) {
|
||||
if (!hostConnection.connected) {
|
||||
continue;
|
||||
@@ -68,7 +68,7 @@ async function getMetrics(req: Request, res: Response, next: any) {
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
timeout, null
|
||||
timeoutMs, null
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.browser.json" },
|
||||
{ "path": "../web_ui/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "../lib" },
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" },
|
||||
],
|
||||
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.web.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.web.json" },
|
||||
{ "path": "../web_ui/tsconfig.web.json" },
|
||||
],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"outDir": "dist/web/build",
|
||||
}
|
||||
}
|
||||
@@ -281,6 +281,14 @@ async function migrateRename(args) {
|
||||
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
|
||||
}
|
||||
|
||||
if (
|
||||
await fs.pathExists("sharedMods")
|
||||
&& !await fs.pathExists("mods")
|
||||
) {
|
||||
logger.info("Moving sharedMods/ to mods/");
|
||||
await fs.rename("sharedMods", "mods");
|
||||
}
|
||||
|
||||
if (!args.dev) {
|
||||
let pkg = JSON.parse(await fs.readFile("package.json"));
|
||||
if (pkg.dependencies) {
|
||||
@@ -297,9 +305,27 @@ async function migrateRename(args) {
|
||||
await execFile(`npm${scriptExt}`, ["update"]);
|
||||
}
|
||||
|
||||
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
|
||||
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
|
||||
if (hasRunMaster || hasRunSlave) {
|
||||
logger.info("Writing run scripts");
|
||||
let mode = "standalone";
|
||||
if (!hasRunSlave) { mode = "controller"; }
|
||||
if (!hasRunMaster) { mode = "host"; }
|
||||
await writeScripts(mode);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Migration complete, you may now delete the following left over files and directories (if present):" +
|
||||
"\n- config-master.json\n- config-slave.json\n- logs/master\n- logs/slave\n- logs/cluster-prerename"
|
||||
"\n- config-master.json" +
|
||||
"\n- config-slave.json" +
|
||||
"\n- logs/master" +
|
||||
"\n- logs/slave" +
|
||||
"\n- logs/cluster-prerename" +
|
||||
"\n- systemd/clusteriomaster.service" +
|
||||
"\n- systemd/clusterioslave.service" +
|
||||
"\n- run-master.sh / run-master.cmd" +
|
||||
"\n- run-slave.sh / run-slave.cmd"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -348,7 +374,7 @@ async function installClusterio(mode, plugins) {
|
||||
for (let plugin of plugins) {
|
||||
if (!pluginList.has(plugin)) {
|
||||
// eslint-disable-next-line node/global-require
|
||||
let pluginInfo = require(require.resolve(path.posix.join(plugin, "info"), { paths: [process.cwd()] }));
|
||||
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
|
||||
pluginList.set(pluginInfo.name, plugin);
|
||||
}
|
||||
}
|
||||
@@ -462,6 +488,7 @@ Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
Environment=NODE_OPTIONS=--enable-source-maps
|
||||
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=8
|
||||
@@ -585,7 +612,7 @@ async function inquirerMissingArgs(args) {
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (answers.downloadHeadless) {
|
||||
if (!answers.factorioDir && answers.downloadHeadless) {
|
||||
answers.factorioDir = "factorio";
|
||||
}
|
||||
}
|
||||
@@ -733,7 +760,7 @@ async function main() {
|
||||
"Can be set to false using --no-download-headless.",
|
||||
type: "boolean",
|
||||
})
|
||||
.conflicts("factorio-dir", "download-headless");
|
||||
;
|
||||
}
|
||||
|
||||
args = args.argv;
|
||||
@@ -753,6 +780,9 @@ async function main() {
|
||||
|
||||
let answers = await inquirerMissingArgs(args);
|
||||
if (answers.downloadHeadless) {
|
||||
if (answers.factorioDir !== "factorio") {
|
||||
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
|
||||
}
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.13b",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bootstrap } from "./ctl";
|
||||
export * from "./ctl";
|
||||
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
|
||||
|
||||
if (module === require.main) {
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"main": "dist/index.js",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusterioctl": "./dist/ctl.js"
|
||||
"clusterioctl": "./dist/node/ctl.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio"
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.13",
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"as-table": "^1.0.55",
|
||||
"fs-extra": "^11.1.1",
|
||||
"phin": "^3.7.0",
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
|
||||
* Ctl plugins are subclasses of this class which get instantiated by
|
||||
* clusterioctl in order to extend its functionallity. To be discovered the
|
||||
* class must be exported under the name `CtlPlugin` in the module
|
||||
* specified by the `ctlEntrypoint` in the plugin's info.js file.
|
||||
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
|
||||
*/
|
||||
export default class BaseCtlPlugin {
|
||||
/**
|
||||
|
||||
@@ -1097,7 +1097,13 @@ modPackCommands.add(new lib.Command({
|
||||
|
||||
function setModPackSettings(
|
||||
modPack: lib.ModPack,
|
||||
args: { boolSetting?: string[], intSetting?: string[], doubleSetting?: string[], stringSetting?: string[] },
|
||||
args: {
|
||||
boolSetting?: string[],
|
||||
intSetting?: string[],
|
||||
doubleSetting?: string[],
|
||||
stringSetting?: string[],
|
||||
colorSetting?: string[],
|
||||
},
|
||||
) {
|
||||
function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) {
|
||||
for (let i = 0; i + 2 < settings.length; i += 3) {
|
||||
@@ -1134,6 +1140,13 @@ function setModPackSettings(
|
||||
return number;
|
||||
});
|
||||
doSettings(args.stringSetting || [], value => value);
|
||||
doSettings(args.colorSetting || [], value => {
|
||||
let color = JSON.parse(value);
|
||||
if (typeof color !== "object" || color === null) {
|
||||
throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties");
|
||||
}
|
||||
return color;
|
||||
});
|
||||
}
|
||||
|
||||
function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
|
||||
@@ -1173,6 +1186,7 @@ modPackCommands.add(new lib.Command({
|
||||
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
|
||||
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
|
||||
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
|
||||
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
|
||||
});
|
||||
}],
|
||||
handler: async function(
|
||||
@@ -1186,6 +1200,7 @@ modPackCommands.add(new lib.Command({
|
||||
intSetting?: string[],
|
||||
doubleSetting?: string[],
|
||||
stringSetting?: string[],
|
||||
colorSetting?: string[],
|
||||
},
|
||||
control: Control,
|
||||
) {
|
||||
@@ -1244,6 +1259,7 @@ modPackCommands.add(new lib.Command({
|
||||
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
|
||||
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
|
||||
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
|
||||
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
|
||||
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
|
||||
});
|
||||
}],
|
||||
@@ -1261,6 +1277,7 @@ modPackCommands.add(new lib.Command({
|
||||
intSetting?: string[],
|
||||
doubleSetting?: string[],
|
||||
stringSetting?: string[],
|
||||
colorSetting?: string[],
|
||||
removeSetting?: string[],
|
||||
},
|
||||
control: Control,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "../lib" },
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
+26
-5
@@ -26,6 +26,27 @@ import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import Host from "./src/Host";
|
||||
|
||||
let host: Host | undefined;
|
||||
|
||||
void new lib.Gauge(
|
||||
"clusterio_host_pending_requests",
|
||||
"Count of pending link requests currently waiting in memory on the host.",
|
||||
{
|
||||
labels: ["host_id"],
|
||||
callback: (gauge) => {
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
let count = host.pendingRequestCount;
|
||||
for (const connection of host.instanceConnections.values()) {
|
||||
count += connection.pendingRequestCount;
|
||||
count += connection.instance.pendingRequestCount;
|
||||
}
|
||||
gauge.labels(String(host.config.get("host.id"))).set(count);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export class HostConnector extends lib.WebSocketClientConnector {
|
||||
constructor(
|
||||
@@ -205,7 +226,7 @@ async function startHost() {
|
||||
}
|
||||
|
||||
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
|
||||
let host = new Host(
|
||||
host = new Host(
|
||||
hostConnector,
|
||||
args.config,
|
||||
hostConfig,
|
||||
@@ -227,7 +248,7 @@ async function startHost() {
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
host.shutdown();
|
||||
host!.shutdown();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
@@ -240,11 +261,11 @@ async function startHost() {
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
host.shutdown();
|
||||
host!.shutdown();
|
||||
});
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
host.shutdown();
|
||||
host!.shutdown();
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -255,7 +276,7 @@ async function startHost() {
|
||||
}
|
||||
|
||||
hostConnector.once("connect", () => {
|
||||
logger.add(new lib.LinkTransport({ link: host }));
|
||||
logger.add(new lib.LinkTransport({ link: host! }));
|
||||
});
|
||||
|
||||
await hostConnector.connect();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { bootstrap } from "./host";
|
||||
export { default as BaseHostPlugin } from "./src/BaseHostPlugin";
|
||||
export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin";
|
||||
export { default as Host, HostRouter } from "./src/Host";
|
||||
export { default as Instance } from "./src/Instance";
|
||||
export { default as InstanceConnection } from "./src/InstanceConnection";
|
||||
|
||||
if (module === require.main) {
|
||||
bootstrap();
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"description": "Implementation of Clusterio host server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"main": "dist/index.js",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusteriohost": "./dist/host.js"
|
||||
"clusteriohost": "./dist/node/host.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio"
|
||||
@@ -19,7 +19,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.13",
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"jimp": "^0.22.8",
|
||||
|
||||
@@ -9,7 +9,7 @@ import type Host from "./Host";
|
||||
* Host plugins are subclasses of this class which get instantiated by
|
||||
* the host when it starts up with the plugin enabled in the config. To be
|
||||
* discovered the class must be exported under the name `HostPlugin` in the
|
||||
* module specified by the `hostEntrypoint` in the plugin's info.js file.
|
||||
* module specified by the `hostEntrypoint` in the plugin's `plugin` export.
|
||||
*/
|
||||
export default class BaseHostPlugin {
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ import type Host from "./Host";
|
||||
* the host when it brings up an instance with the plugin enabled in the
|
||||
* config. To be discovered the class must be exported under the name
|
||||
* `InstancePlugin` in the module specified by the `instanceEntrypoint` in
|
||||
* the plugin's info.js file.
|
||||
* the plugin's `plugin` export.
|
||||
*
|
||||
* Instances may be started and stopped many times, and many instances may
|
||||
* be running at the same time, each of which will have their own instance
|
||||
|
||||
+36
-17
@@ -82,7 +82,7 @@ const instanceStartingMessages = new Set([
|
||||
lib.InstanceExportDataRequest.name,
|
||||
]);
|
||||
|
||||
class HostRouter {
|
||||
export class HostRouter {
|
||||
constructor(
|
||||
public host: Host
|
||||
) { }
|
||||
@@ -93,16 +93,22 @@ class HostRouter {
|
||||
* @param origin -
|
||||
* Link the message originated from.
|
||||
* @param message - Message to process.
|
||||
* @param entry - message entry if message is a request or an event.
|
||||
* @param hasFallback - is fallback available
|
||||
* @returns true if the message was handled, false if fallback
|
||||
* is requested
|
||||
*/
|
||||
forwardMessage(origin: lib.Link, message: lib.MessageRoutable, hasFallback: boolean) {
|
||||
forwardMessage(
|
||||
origin: lib.Link,
|
||||
message: lib.MessageRoutable,
|
||||
entry: lib.RequestEntry | lib.EventEntry | undefined,
|
||||
hasFallback: boolean,
|
||||
) {
|
||||
let dst = message.dst;
|
||||
let nextHop: lib.Link | undefined;
|
||||
let msg: string | undefined;
|
||||
if (dst.type === lib.Address.broadcast) {
|
||||
this.broadcastMessage(origin, message);
|
||||
this.broadcastMessage(origin, message, entry as lib.EventEntry);
|
||||
return true;
|
||||
} else if (
|
||||
dst.type === lib.Address.controller
|
||||
@@ -153,27 +159,36 @@ class HostRouter {
|
||||
return true;
|
||||
}
|
||||
|
||||
broadcastMessage(origin: lib.Link, message: lib.MessageRoutable) {
|
||||
broadcastMessage(
|
||||
origin: lib.Link,
|
||||
message: lib.MessageRoutable,
|
||||
entry: lib.EventEntry,
|
||||
) {
|
||||
let dst = message.dst;
|
||||
if (message.type !== "event") {
|
||||
this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`);
|
||||
} else if (dst.id === lib.Address.host || dst.id === lib.Address.instance) {
|
||||
return;
|
||||
}
|
||||
const plugin = entry.Event.plugin;
|
||||
if (dst.id === lib.Address.instance) {
|
||||
for (let instanceConnection of this.host.instanceConnections.values()) {
|
||||
if (instanceConnection !== origin) {
|
||||
instanceConnection.connector.send(message);
|
||||
if (instanceConnection !== origin && (!plugin || instanceConnection.plugins.has(plugin))) {
|
||||
instanceConnection.connector.forward(message);
|
||||
}
|
||||
}
|
||||
if (this.host !== origin) {
|
||||
this.host.connector.send(message);
|
||||
if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) {
|
||||
this.host.connector.forward(message);
|
||||
}
|
||||
} else if (dst.id === lib.Address.control) {
|
||||
if (this.host !== origin) {
|
||||
this.host.connector.send(message);
|
||||
if (!plugin || this.host.serverPlugins.has(plugin)) {
|
||||
this.host.connector.forward(message);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Received control broadcast of ${(message as lib.MessageEvent).name} from master.`);
|
||||
logger.warn(`Received control broadcast of ${(message as lib.MessageEvent).name} from controller.`);
|
||||
}
|
||||
} else {
|
||||
this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`);
|
||||
this.warnUnrouted(message, `Unexpected broadcast target ${dst.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +208,7 @@ class HostRouter {
|
||||
}
|
||||
|
||||
this.host._connectInstance(dst.id).then(instanceConnection => {
|
||||
instanceConnection.connector.send(message);
|
||||
instanceConnection.connector.forward(message);
|
||||
}).catch(err => {
|
||||
logger.error(`Error starting instance:\n${err.stack}`, this.host.instanceLogMeta(dst.id));
|
||||
origin.connector.sendResponseError(
|
||||
@@ -207,7 +222,7 @@ class HostRouter {
|
||||
if (message.type === "request") {
|
||||
nextHop.forwardRequest(message as lib.MessageRequest, origin);
|
||||
} else {
|
||||
nextHop.connector.send(message);
|
||||
nextHop.connector.forward(message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (message.type === "request") {
|
||||
@@ -377,7 +392,10 @@ export default class Host extends lib.Link {
|
||||
|
||||
async loadPlugins() {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)) {
|
||||
if (
|
||||
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|
||||
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -582,8 +600,9 @@ export default class Host extends lib.Link {
|
||||
try {
|
||||
modInfo = await this.modStore.loadMod(mod.name, mod.version);
|
||||
} catch (err: any) {
|
||||
// A broken zip file may have been left in the mods folder
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
logger.error(`Error loading mod ${mod.name} ${mod.version}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (modInfo && (!mod.sha1 || mod.sha1 === modInfo.sha1)) {
|
||||
@@ -1053,7 +1072,7 @@ export default class Host extends lib.Link {
|
||||
if (instanceInfo.config.get("instance.auto_start")) {
|
||||
try {
|
||||
let instanceConnection = await this._connectInstance(instanceId);
|
||||
await instanceConnection.send(new lib.InstanceStartRequest());
|
||||
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
|
||||
} catch (err: any) {
|
||||
logger.error(
|
||||
`Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`,
|
||||
|
||||
@@ -495,7 +495,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
async _loadPlugin(pluginInfo: lib.PluginNodeEnvInfo, host: Host) {
|
||||
let pluginLoadStarted = Date.now();
|
||||
let pluginLoadStartedMs = Date.now();
|
||||
let InstancePluginClass = await lib.loadPluginClass(
|
||||
pluginInfo.name,
|
||||
path.posix.join(pluginInfo.requirePath, pluginInfo.instanceEntrypoint!),
|
||||
@@ -506,7 +506,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
|
||||
this.plugins.set(pluginInfo.name, instancePlugin);
|
||||
await instancePlugin.init();
|
||||
|
||||
this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`);
|
||||
this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`);
|
||||
}
|
||||
|
||||
async _loadStats() {
|
||||
@@ -800,7 +800,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
|
||||
|
||||
// Find stand alone modules to load
|
||||
// XXX for now only the included clusterio module is loaded
|
||||
let modulesDirectory = path.join(__dirname, "..", "..", "modules");
|
||||
let modulesDirectory = path.join(__dirname, "..", "..", "..", "modules");
|
||||
for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (modules.has(entry.name)) {
|
||||
@@ -955,6 +955,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
|
||||
await lib.invokeHook(this.plugins, "onStop");
|
||||
await this.server.stop();
|
||||
await this.sendSaveListUpdate();
|
||||
} else if (
|
||||
this.server._state === "stopping"
|
||||
|| this.server._state === "create"
|
||||
) {
|
||||
await this.server.kill();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-13
@@ -5,7 +5,7 @@ import Jimp from "jimp";
|
||||
import JSZip from "jszip";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as libBuildMod from "@clusterio/lib/dist/build_mod";
|
||||
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod";
|
||||
import type { FactorioServer } from "./server";
|
||||
|
||||
interface Prototype {
|
||||
@@ -18,11 +18,10 @@ interface SimpleIconSpecification {
|
||||
icon_size: number;
|
||||
icon_mipmaps?: number;
|
||||
}
|
||||
type Color = { r: number, g: number, b: number, a: number };
|
||||
interface IconLayer {
|
||||
icon: string;
|
||||
icon_size: number;
|
||||
tint?: Color,
|
||||
tint?: lib.FactorioColor,
|
||||
shift?: [number, number],
|
||||
scale?: number,
|
||||
icon_mipmaps?: number,
|
||||
@@ -62,7 +61,7 @@ async function generateExportMod(server: FactorioServer) {
|
||||
clean: false,
|
||||
build: true,
|
||||
pack: true,
|
||||
sourceDir: path.join(__dirname, "..", "..", "lua", "export"),
|
||||
sourceDir: path.join(__dirname, "..", "..", "..", "lua", "export"),
|
||||
outputDir: server.writePath("mods"),
|
||||
bumpPatch: false,
|
||||
factorioVersion: server.version.replace(/\.\d+$/, ""),
|
||||
@@ -202,15 +201,9 @@ async function loadLayeredIcon(
|
||||
|
||||
iconLayer = iconLayer.clone();
|
||||
|
||||
let tint: Color;
|
||||
let tint: lib.Color;
|
||||
if (layer.tint) {
|
||||
let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1;
|
||||
tint = {
|
||||
r: (layer.tint.r || 0) / divisor,
|
||||
g: (layer.tint.g || 0) / divisor,
|
||||
b: (layer.tint.b || 0) / divisor,
|
||||
a: layer.tint.a || 1,
|
||||
};
|
||||
tint = lib.normalizeColor(layer.tint);
|
||||
} else {
|
||||
tint = { r: 1, b: 1, g: 1, a: 1 };
|
||||
}
|
||||
@@ -252,6 +245,18 @@ async function loadLayeredIcon(
|
||||
return icon;
|
||||
}
|
||||
|
||||
function fixIcons(item: ItemPrototype) {
|
||||
const icons = item.icons;
|
||||
if (typeof icons === "object" && !(icons instanceof Array) && icons !== null) {
|
||||
// It's possible to specify icons as an object with arbitrary keys
|
||||
// and the game will still accept it, cast the value to array if
|
||||
// this is the case.
|
||||
item = { ...item };
|
||||
item.icons = Object.values(icons);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
const itemTypes = new Set([
|
||||
"item",
|
||||
"ammo",
|
||||
@@ -284,7 +289,8 @@ const itemTypes = new Set([
|
||||
function filterItems(prototypes: Prototypes): ItemPrototype[] {
|
||||
return Object.entries(prototypes)
|
||||
.filter(([type, _]) => itemTypes.has(type))
|
||||
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes)) as ItemPrototype[]
|
||||
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
|
||||
.map(fixIcons)
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -427,7 +427,7 @@ export interface FactorioServerOptions {
|
||||
*/
|
||||
maxConcurrentCommands?: number
|
||||
/** Timeout in ms to wait for a response to a shutdown command. */
|
||||
hangTimeout?: number,
|
||||
hangTimeoutMs?: number,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -463,7 +463,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
/** Enable verbose logging */
|
||||
verboseLogging: boolean;
|
||||
/** Timeout in ms to wait for a response to a shutdown command */
|
||||
hangTimeout = 5000;
|
||||
hangTimeoutMs = 5000;
|
||||
|
||||
_factorioDir: string;
|
||||
_writeDir: string;
|
||||
@@ -519,7 +519,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
|
||||
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
|
||||
/** Timeout in ms to wait for a response to a shutdown command */
|
||||
this.hangTimeout = options.hangTimeout ?? 5000;
|
||||
this.hangTimeoutMs = options.hangTimeoutMs ?? 5000;
|
||||
|
||||
if (options.stripPaths) {
|
||||
let charSet = new Set(path.resolve(this.writePath("temp")));
|
||||
@@ -865,7 +865,9 @@ export class FactorioServer extends events.EventEmitter {
|
||||
|
||||
try {
|
||||
let [code, signal] = await events.once(this._server, "exit");
|
||||
void signal;
|
||||
if (signal) {
|
||||
throw new Error(`Factorio exited with signal ${signal}`);
|
||||
}
|
||||
if (code !== 0) {
|
||||
throw new Error(`Factorio exited with status ${code}`);
|
||||
}
|
||||
@@ -1012,7 +1014,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
}
|
||||
this._server!.kill("SIGKILL");
|
||||
}
|
||||
}, this.hangTimeout);
|
||||
}, this.hangTimeoutMs);
|
||||
|
||||
// It's possible the server decides to exit on its own, in which
|
||||
// case the stop operation has to be cancelled.
|
||||
@@ -1092,7 +1094,7 @@ export class FactorioServer extends events.EventEmitter {
|
||||
* process.
|
||||
*/
|
||||
async kill(unexpected = false) {
|
||||
this._check(["running", "stopping"]);
|
||||
this._check(["running", "stopping", "create"]);
|
||||
this._killed = true;
|
||||
if (!unexpected) {
|
||||
this._state = "stopping";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "../lib" },
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
dist/web/build
|
||||
dist/browser
|
||||
@@ -6,6 +6,7 @@ export * from "./src/factorio/exchange_string";
|
||||
export * from "./src/helpers";
|
||||
export * from "./src/ini";
|
||||
export * from "./src/link";
|
||||
export * from "./src/lua_tools";
|
||||
export * from "./src/logging";
|
||||
export * from "./src/permissions";
|
||||
export * from "./src/plugin";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && pnpm run compile_validator",
|
||||
"compile_validator": "node scripts/compile_validator.js"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"main": "dist/node/index.js",
|
||||
"browser": "browser.ts",
|
||||
"keywords": [
|
||||
"factorio"
|
||||
|
||||
@@ -7,9 +7,9 @@ const standaloneCode = require("ajv/dist/standalone").default;
|
||||
/* eslint-disable node/no-sync */
|
||||
|
||||
// Provide an empty file for message_core to load
|
||||
fs.outputFileSync(path.join(__dirname, "../dist/src/data/message_validate.js"), "");
|
||||
fs.outputFileSync(path.join(__dirname, "../dist/node/src/data/message_validate.js"), "");
|
||||
|
||||
const { Message } = require("../dist/src/data/messages_core");
|
||||
const { Message } = require("../dist/node/src/data/messages_core");
|
||||
|
||||
const ajv = new Ajv({
|
||||
allowUnionTypes: true,
|
||||
@@ -28,5 +28,5 @@ function write(output) {
|
||||
`// generated by scripts/compile_validator.js\n// @ts-nocheck\n/* eslint-disable */\n${moduleCode}`
|
||||
);
|
||||
}
|
||||
write("../dist/src/data/message_validate.js");
|
||||
write("../dist/node/src/data/message_validate.js");
|
||||
write("../src/data/message_validate.js");
|
||||
@@ -30,7 +30,7 @@ export default class ExponentialBackoff {
|
||||
reset: number;
|
||||
|
||||
private _exp = 0;
|
||||
private _lastInvocationTime = Date.now();
|
||||
private _lastInvocationTimeMs = Date.now();
|
||||
|
||||
/**
|
||||
* Construct ExponentialBackoff helper
|
||||
@@ -65,9 +65,9 @@ export default class ExponentialBackoff {
|
||||
* @returns time in milliseconds to wait.
|
||||
*/
|
||||
delay() {
|
||||
let invocationTime = Date.now();
|
||||
let interval = (invocationTime - this._lastInvocationTime) / 1000;
|
||||
this._lastInvocationTime = invocationTime;
|
||||
let invocationTimeMs = Date.now();
|
||||
let interval = (invocationTimeMs - this._lastInvocationTimeMs) / 1000;
|
||||
this._lastInvocationTimeMs = invocationTimeMs;
|
||||
|
||||
if (interval > this.reset) {
|
||||
this._exp = 0;
|
||||
|
||||
@@ -56,7 +56,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
await fs.unlink(path.join(this.modsDirectory, file));
|
||||
this.files.delete(file);
|
||||
modInfo.isDeleted = true;
|
||||
modInfo.updatedAt = Date.now();
|
||||
modInfo.updatedAtMs = Date.now();
|
||||
this.emit("change", modInfo);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default class RateLimiter {
|
||||
*/
|
||||
action?: () => void;
|
||||
|
||||
private _lastRun = 0;
|
||||
private _lastRunMs = 0;
|
||||
private _runTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
@@ -60,15 +60,15 @@ export default class RateLimiter {
|
||||
*/
|
||||
activate() {
|
||||
let now = Date.now();
|
||||
if (now < this._lastRun + 1000 / this.maxRate) {
|
||||
if (now < this._lastRunMs + 1000 / this.maxRate) {
|
||||
if (!this._runTimeout && this.action) {
|
||||
this._runTimeout = setTimeout(() => {
|
||||
this._runTimeout = undefined;
|
||||
this._lastRun = Date.now();
|
||||
this._lastRunMs = Date.now();
|
||||
if (this.action) {
|
||||
this.action();
|
||||
}
|
||||
}, this._lastRun + 1000 / this.maxRate - now);
|
||||
}, this._lastRunMs + 1000 / this.maxRate - now);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export default class RateLimiter {
|
||||
this._runTimeout = undefined;
|
||||
}
|
||||
|
||||
this._lastRun = now;
|
||||
this._lastRunMs = now;
|
||||
if (this.action) {
|
||||
this.action();
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@ export class Config<
|
||||
setProp<Field extends keyof Fields & string>(
|
||||
name: Field,
|
||||
prop: string,
|
||||
value: unknown,
|
||||
value?: unknown,
|
||||
location = this.location,
|
||||
) {
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
|
||||
@@ -122,7 +122,7 @@ export default class ModInfo {
|
||||
sha1?: string;
|
||||
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
updatedAt = 0;
|
||||
updatedAtMs = 0;
|
||||
|
||||
/**
|
||||
* True if this mod has been deleted
|
||||
@@ -148,7 +148,7 @@ export default class ModInfo {
|
||||
...this.infoJsonSchema.properties,
|
||||
"size": Type.Optional(Type.Integer()),
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
"updated_at": Type.Optional(Type.Number()),
|
||||
"updated_at_ms": Type.Optional(Type.Number()),
|
||||
"is_deleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ export default class ModInfo {
|
||||
// Additional data
|
||||
if (json.size) { modInfo.size = json.size; }
|
||||
if (json.sha1) { modInfo.sha1 = json.sha1; }
|
||||
if (json.updated_at) { modInfo.updatedAt = json.updated_at; }
|
||||
if (json.updated_at_ms) { modInfo.updatedAtMs = json.updated_at_ms; }
|
||||
if (json.is_deleted) { modInfo.isDeleted = json.is_deleted; }
|
||||
|
||||
return modInfo;
|
||||
@@ -193,7 +193,7 @@ export default class ModInfo {
|
||||
}
|
||||
if (this.size) { json.size = this.size; }
|
||||
if (this.sha1) { json.sha1 = this.sha1; }
|
||||
if (this.updatedAt) { json.updated_at = this.updatedAt; }
|
||||
if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; }
|
||||
if (this.isDeleted) { json.is_deleted = this.isDeleted; }
|
||||
return json;
|
||||
}
|
||||
@@ -229,7 +229,7 @@ export default class ModInfo {
|
||||
|
||||
size: stat.size,
|
||||
sha1: await libHash.hashFile(modPath),
|
||||
updated_at: stat.mtimeMs,
|
||||
updated_at_ms: stat.mtimeMs,
|
||||
is_deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,22 +2,31 @@ import zlib from "zlib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
import * as libSchema from "../schema";
|
||||
import * as libLuaTools from "../lua_tools";
|
||||
|
||||
import ExportManifest from "./ExportManifest";
|
||||
import { integerFactorioVersion } from "./version";
|
||||
import type { Logger } from "../logging";
|
||||
|
||||
|
||||
export const ModSettingColor = Type.Object({
|
||||
r: Type.Number(),
|
||||
g: Type.Number(),
|
||||
b: Type.Number(),
|
||||
a: Type.Number(),
|
||||
});
|
||||
export type ModSettingColor = Static<typeof ModSettingColor>;
|
||||
|
||||
/**
|
||||
* A setting for a mod.
|
||||
*/
|
||||
export interface ModSetting {
|
||||
/** Value of the given mod setting. */
|
||||
value: boolean | number | string;
|
||||
value: boolean | number | string | ModSettingColor;
|
||||
}
|
||||
|
||||
const ModSettingJsonSchema = Type.Object({
|
||||
"value": Type.Union([Type.Boolean(), Type.Number(), Type.String()]),
|
||||
"value": Type.Union([Type.Boolean(), Type.Number(), Type.String(), ModSettingColor]),
|
||||
});
|
||||
|
||||
const ModSettingsJsonSchema = Type.Record(Type.String(), ModSettingJsonSchema);
|
||||
@@ -102,7 +111,7 @@ export default class ModPack {
|
||||
exportManifest?: ExportManifest;
|
||||
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
updatedAt = 0;
|
||||
updatedAtMs = 0;
|
||||
|
||||
/**
|
||||
* True if this mod pack has been deleted from the list of mod packs.
|
||||
@@ -118,7 +127,7 @@ export default class ModPack {
|
||||
clone.mods = this.mods;
|
||||
clone.settings = this.settings;
|
||||
clone.exportManifest = this.exportManifest;
|
||||
clone.updatedAt = this.updatedAt;
|
||||
clone.updatedAtMs = this.updatedAtMs;
|
||||
clone.isDeleted = this.isDeleted;
|
||||
return clone;
|
||||
}
|
||||
@@ -135,7 +144,7 @@ export default class ModPack {
|
||||
"runtime-per-user": ModSettingsJsonSchema,
|
||||
}),
|
||||
"export_manifest": Type.Optional(ExportManifest.jsonSchema),
|
||||
"updated_at": Type.Optional(Type.Number()),
|
||||
"updated_at_ms": Type.Optional(Type.Number()),
|
||||
"is_deleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
@@ -161,7 +170,7 @@ export default class ModPack {
|
||||
};
|
||||
}
|
||||
if (json.export_manifest) { modPack.exportManifest = ExportManifest.fromJSON(json.export_manifest); }
|
||||
if (json.updated_at) { modPack.updatedAt = json.updated_at; }
|
||||
if (json.updated_at_ms) { modPack.updatedAtMs = json.updated_at_ms; }
|
||||
if (json.is_deleted) { modPack.isDeleted = json.is_deleted; }
|
||||
|
||||
if (!modPack.mods.has("base")) {
|
||||
@@ -185,7 +194,7 @@ export default class ModPack {
|
||||
},
|
||||
};
|
||||
if (this.exportManifest) { json.export_manifest = this.exportManifest; }
|
||||
if (this.updatedAt) { json.updated_at = this.updatedAt; }
|
||||
if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; }
|
||||
if (this.isDeleted) { json.is_deleted = this.isDeleted; }
|
||||
return json;
|
||||
}
|
||||
@@ -194,7 +203,7 @@ export default class ModPack {
|
||||
const json = this.toJSON();
|
||||
delete json.id;
|
||||
delete json.export_manifest;
|
||||
delete json.updated_at;
|
||||
delete json.updated_at_ms;
|
||||
delete json.is_deleted;
|
||||
|
||||
// eslint-disable-next-line node/no-sync
|
||||
@@ -335,7 +344,7 @@ export default class ModPack {
|
||||
* @param logger - Logger used to report warnings on.
|
||||
*/
|
||||
fillDefaultSettings(settingPrototypes: Record<string, object>, logger: Logger) {
|
||||
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting"];
|
||||
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
|
||||
let prototypes = Object.entries(settingPrototypes)
|
||||
.filter(([type, _]) => knownTypes.includes(type))
|
||||
.flatMap(([_, settings]) => Object.values(settings))
|
||||
@@ -355,7 +364,11 @@ export default class ModPack {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.settings[settingType].set(prototype.name, { value: prototype.default_value });
|
||||
let value = prototype.default_value;
|
||||
if (prototype.type === "color-setting") {
|
||||
value = libLuaTools.normalizeColor(value);
|
||||
}
|
||||
this.settings[settingType].set(prototype.name, { value });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,30 +37,33 @@ export default class PlayerStats {
|
||||
static jsonSchema = Type.Object({
|
||||
"join_count": Type.Optional(Type.Integer()),
|
||||
"online_time_ms": Type.Optional(Type.Number()),
|
||||
"first_join_at": Type.Optional(Type.Number()),
|
||||
"first_join_at_ms": Type.Optional(Type.Number()),
|
||||
"last_join_at_ms": Type.Optional(Type.Number()),
|
||||
"last_leave_at_ms": Type.Optional(Type.Number()),
|
||||
"last_leave_reason": Type.Optional(Type.String()),
|
||||
// migrate: renamed fields from alpha 13
|
||||
"last_join_at": Type.Optional(Type.Number()),
|
||||
"last_leave_at": Type.Optional(Type.Number()),
|
||||
"last_leave_reason": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
constructor(json: Static<typeof PlayerStats.jsonSchema> = {}) {
|
||||
if (json["join_count"]) {
|
||||
this.joinCount = json["join_count"];
|
||||
}
|
||||
if (json["first_join_at"]) {
|
||||
this.firstJoinAt = new Date(json["first_join_at"]);
|
||||
if (json["first_join_at_ms"]) {
|
||||
this.firstJoinAt = new Date(json["first_join_at_ms"]);
|
||||
}
|
||||
if (json["online_time_ms"]) {
|
||||
this.onlineTimeMs = json["online_time_ms"];
|
||||
}
|
||||
if (json["last_join_at"]) {
|
||||
this.lastJoinAt = new Date(json["last_join_at"]);
|
||||
if (json["last_join_at_ms"] ?? json["last_join_at"]) {
|
||||
this.lastJoinAt = new Date(json["last_join_at_ms"] ?? json["last_join_at"]!);
|
||||
if (!this.firstJoinAt) { // migrate: pre-alpha 14 did not have this field.
|
||||
this.firstJoinAt = this.lastJoinAt;
|
||||
}
|
||||
}
|
||||
if (json["last_leave_at"]) {
|
||||
this.lastLeaveAt = new Date(json["last_leave_at"]);
|
||||
if (json["last_leave_at_ms"] ?? json["last_leave_at"]) {
|
||||
this.lastLeaveAt = new Date(json["last_leave_at_ms"] ?? json["last_leave_at"]!);
|
||||
this.lastLeaveReason = json["last_leave_reason"] || "quit";
|
||||
}
|
||||
}
|
||||
@@ -78,13 +81,13 @@ export default class PlayerStats {
|
||||
json["online_time_ms"] = this.onlineTimeMs;
|
||||
}
|
||||
if (this.firstJoinAt) {
|
||||
json["first_join_at"] = this.firstJoinAt.getTime();
|
||||
json["first_join_at_ms"] = this.firstJoinAt.getTime();
|
||||
}
|
||||
if (this.lastJoinAt) {
|
||||
json["last_join_at"] = this.lastJoinAt.getTime();
|
||||
json["last_join_at_ms"] = this.lastJoinAt.getTime();
|
||||
}
|
||||
if (this.lastLeaveAt) {
|
||||
json["last_leave_at"] = this.lastLeaveAt.getTime();
|
||||
json["last_leave_at_ms"] = this.lastLeaveAt.getTime();
|
||||
json["last_leave_reason"] = this.lastLeaveReason;
|
||||
}
|
||||
return json;
|
||||
|
||||
@@ -19,12 +19,20 @@ export default class Role {
|
||||
permissions: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static remapPermission(permission: string) {
|
||||
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
|
||||
if (permission === "core.instance.save.list.subscribe") {
|
||||
return "core.instance.save.subscribe";
|
||||
}
|
||||
return permission;
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.id,
|
||||
json.name,
|
||||
json.description,
|
||||
new Set(json.permissions),
|
||||
new Set(json.permissions.map(this.remapPermission)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export default class User {
|
||||
/** Reason for being banned. Ignored if isBanned is false. */
|
||||
public banReason = "",
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAt = 0,
|
||||
public updatedAtMs = 0,
|
||||
/** True if this user object has been removed from the cluster. */
|
||||
public isDeleted = false,
|
||||
/** Combined statistics for the player this user account is tied to. */
|
||||
@@ -42,7 +42,7 @@ export default class User {
|
||||
is_banned: Type.Optional(Type.Boolean()),
|
||||
is_whitelisted: Type.Optional(Type.Boolean()),
|
||||
ban_reason: Type.Optional(Type.String()),
|
||||
updated_at: Type.Optional(Type.Number()),
|
||||
updated_at_ms: Type.Optional(Type.Number()),
|
||||
is_deleted: Type.Optional(Type.Boolean()),
|
||||
instance_stats: Type.Optional(
|
||||
Type.Array(
|
||||
@@ -67,7 +67,7 @@ export default class User {
|
||||
json.is_banned,
|
||||
json.is_whitelisted,
|
||||
json.ban_reason,
|
||||
json.updated_at,
|
||||
json.updated_at_ms,
|
||||
json.is_deleted,
|
||||
playerStats,
|
||||
instanceStats,
|
||||
@@ -103,8 +103,8 @@ export default class User {
|
||||
json.ban_reason = this.banReason;
|
||||
}
|
||||
|
||||
if (this.updatedAt) {
|
||||
json.updated_at = this.updatedAt;
|
||||
if (this.updatedAtMs) {
|
||||
json.updated_at_ms = this.updatedAtMs;
|
||||
}
|
||||
|
||||
if (this.isDeleted) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
export { default as ExportManifest } from "./ExportManifest";
|
||||
export { default as ModInfo } from "./ModInfo";
|
||||
export { default as ModPack, ModSetting, ModRecord } from "./ModPack";
|
||||
export { default as ModPack, ModSetting, ModSettingColor, ModRecord } from "./ModPack";
|
||||
export { default as ModuleInfo } from "./ModuleInfo";
|
||||
export { default as Permission } from "./Permission";
|
||||
export { default as PlayerStats } from "./PlayerStats";
|
||||
|
||||
@@ -239,7 +239,7 @@ export class SystemInfo {
|
||||
public diskAvailable: number,
|
||||
public canRestart: boolean,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAt: number,
|
||||
public updatedAtMs: number,
|
||||
public isDeleted: boolean,
|
||||
) { }
|
||||
|
||||
@@ -256,7 +256,7 @@ export class SystemInfo {
|
||||
"diskCapacity": Type.Number(),
|
||||
"diskAvailable": Type.Number(),
|
||||
"canRestart": Type.Boolean(),
|
||||
"updatedAt": Type.Number(),
|
||||
"updatedAtMs": Type.Number(),
|
||||
"isDeleted": Type.Boolean(),
|
||||
});
|
||||
|
||||
@@ -274,7 +274,7 @@ export class SystemInfo {
|
||||
json.diskCapacity,
|
||||
json.diskAvailable,
|
||||
json.canRestart,
|
||||
json.updatedAt,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ export type AddressShorthand =
|
||||
export class Address {
|
||||
/** Controller address */
|
||||
static controller = 0;
|
||||
/** Instance address */
|
||||
static instance = 1;
|
||||
/** Host address */
|
||||
static host = 2;
|
||||
static host = 1;
|
||||
/** Instance address */
|
||||
static instance = 2;
|
||||
/** Control address */
|
||||
static control = 3;
|
||||
/** Broadcast */
|
||||
@@ -87,7 +87,7 @@ export class Address {
|
||||
}
|
||||
|
||||
toString() {
|
||||
const typeMap = ["controller", "instance", "host", "control"];
|
||||
const typeMap = ["controller", "host", "instance", "control"];
|
||||
const type = typeMap[this.type] || this.type;
|
||||
if (this.type === Address.broadcast) {
|
||||
return `[Address ${type}:${typeMap[this.id] || this.id}]`;
|
||||
|
||||
@@ -84,7 +84,7 @@ export class HostDetails {
|
||||
public publicAddress: string = "",
|
||||
public tokenValidAfter: number = 0,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAt = 0,
|
||||
public updatedAtMs = 0,
|
||||
public isDeleted = false,
|
||||
) { }
|
||||
|
||||
@@ -96,7 +96,7 @@ export class HostDetails {
|
||||
"remoteAddress": Type.Optional(Type.String()),
|
||||
"publicAddress": Type.Optional(Type.String()),
|
||||
"tokenValidAfter": Type.Optional(Type.Number()),
|
||||
"updatedAt": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"isDeleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ export class HostDetails {
|
||||
json.remoteAddress,
|
||||
json.publicAddress,
|
||||
json.tokenValidAfter,
|
||||
json.updatedAt,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class InstanceDetails {
|
||||
public gamePort: number | undefined,
|
||||
public status: InstanceStatus,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAt = 0,
|
||||
public updatedAtMs = 0,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -55,7 +55,7 @@ export class InstanceDetails {
|
||||
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
|
||||
"creating_save", "exporting_data", "deleted",
|
||||
]),
|
||||
"updatedAt": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -65,7 +65,7 @@ export class InstanceDetails {
|
||||
json.assignedHost,
|
||||
json.gamePort,
|
||||
json.status,
|
||||
json.updatedAt,
|
||||
json.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ export class InstanceConfigSetPropRequest {
|
||||
"instanceId": Type.Integer(),
|
||||
"field": Type.String(),
|
||||
"prop": Type.String(),
|
||||
"value": Type.Unknown(),
|
||||
"value": Type.Optional(Type.Unknown()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -289,7 +289,7 @@ export class SaveDetails {
|
||||
public loaded: boolean,
|
||||
public loadByDefault: boolean,
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAt: number,
|
||||
public updatedAtMs: number,
|
||||
public isDeleted: boolean,
|
||||
) { }
|
||||
|
||||
@@ -301,7 +301,7 @@ export class SaveDetails {
|
||||
"mtimeMs": Type.Number(),
|
||||
"loaded": Type.Boolean(),
|
||||
"loadByDefault": Type.Boolean(),
|
||||
"updatedAt": Type.Number(),
|
||||
"updatedAtMs": Type.Number(),
|
||||
"isDeleted": Type.Boolean(),
|
||||
});
|
||||
|
||||
@@ -314,7 +314,7 @@ export class SaveDetails {
|
||||
json.mtimeMs,
|
||||
json.loaded,
|
||||
json.loadByDefault,
|
||||
json.updatedAt,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
);
|
||||
}
|
||||
@@ -330,8 +330,8 @@ export class SaveDetails {
|
||||
* @returns true if this save is identical to the provided save
|
||||
*/
|
||||
equals(other: SaveDetails) {
|
||||
// Note that updatedAt is not included in the equality check because
|
||||
// instances send the whole list of saves with updatedAt set to zero
|
||||
// Note that updatedAtMs is not included in the equality check because
|
||||
// instances send the whole list of saves with updatedAtMs set to zero
|
||||
// to the controller whenever any save changes and this equals check
|
||||
// is used to filter whether a save was updated.
|
||||
return (
|
||||
@@ -361,7 +361,7 @@ export class InstanceSaveDetailsUpdatesEvent {
|
||||
static type = "event" as const;
|
||||
static src = ["instance", "host", "controller"] as const;
|
||||
static dst = ["controller", "control"] as const;
|
||||
static permission = "core.instance.save.list.subscribe" as const;
|
||||
static permission = "core.instance.save.subscribe" as const;
|
||||
|
||||
constructor(
|
||||
public updates: SaveDetails[],
|
||||
@@ -385,7 +385,7 @@ export class InstanceSaveDetailsUpdatesEvent {
|
||||
export class InstanceCreateSaveRequest {
|
||||
declare ["constructor"]: typeof InstanceCreateSaveRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.save.create" as const;
|
||||
|
||||
@@ -596,7 +596,7 @@ export class InstancePushSaveRequest {
|
||||
export class InstanceLoadScenarioRequest {
|
||||
declare ["constructor"]: typeof InstanceLoadScenarioRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.load_scenario" as const;
|
||||
|
||||
@@ -622,7 +622,7 @@ export class InstanceLoadScenarioRequest {
|
||||
export class InstanceExportDataRequest {
|
||||
declare ["constructor"]: typeof InstanceExportDataRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.export_data" as const;
|
||||
}
|
||||
@@ -630,7 +630,7 @@ export class InstanceExportDataRequest {
|
||||
export class InstanceExtractPlayersRequest {
|
||||
declare ["constructor"]: typeof InstanceExtractPlayersRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.extract_players" as const;
|
||||
}
|
||||
@@ -638,7 +638,7 @@ export class InstanceExtractPlayersRequest {
|
||||
export class InstanceStopRequest {
|
||||
declare ["constructor"]: typeof InstanceStopRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.stop" as const;
|
||||
}
|
||||
@@ -646,7 +646,7 @@ export class InstanceStopRequest {
|
||||
export class InstanceKillRequest {
|
||||
declare ["constructor"]: typeof InstanceKillRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.kill" as const;
|
||||
}
|
||||
@@ -693,7 +693,7 @@ export class InstanceDeleteInternalRequest {
|
||||
export class InstanceSendRconRequest {
|
||||
declare ["constructor"]: typeof InstanceSendRconRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "instance" as const;
|
||||
static permission = "core.instance.send_rcon" as const;
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ export function basicType(value: unknown) {
|
||||
/**
|
||||
* Asynchronously wait for the given duration
|
||||
*
|
||||
* @param duration - Time to wait for in milliseconds.
|
||||
* @param durationMs - Time to wait for in milliseconds.
|
||||
*/
|
||||
export async function wait(duration: number) {
|
||||
await new Promise(resolve => { setTimeout(resolve, duration); });
|
||||
export async function wait(durationMs: number) {
|
||||
await new Promise(resolve => { setTimeout(resolve, durationMs); });
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,16 @@ export async function wait(duration: number) {
|
||||
* Resolve a promise with a timeout.
|
||||
*
|
||||
* @param {Promise} promise - Promise to wait for.
|
||||
* @param {number} time - Maximum time im milliseconds to wait for.
|
||||
* @param {number} limitMs - Maximum time im milliseconds to wait for.
|
||||
* @param {*=} timeoutResult - Value to return if the operation timed out.
|
||||
*/
|
||||
export async function timeout<T>(promise: Promise<T>, time: number, timeoutResult: T) {
|
||||
export async function timeout<T>(promise: Promise<T>, limitMs: number, timeoutResult: T) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>(resolve => {
|
||||
timer = setTimeout(() => resolve(timeoutResult), time);
|
||||
timer = setTimeout(() => resolve(timeoutResult), limitMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -33,7 +33,30 @@ export abstract class BaseConnector extends events.EventEmitter {
|
||||
this._seq = 1;
|
||||
}
|
||||
|
||||
abstract send(message: libData.Message): void;
|
||||
protected abstract send(message: libData.MessageRoutable): void;
|
||||
|
||||
forward(message: libData.MessageRoutable) {
|
||||
const seq = this._seq;
|
||||
this._seq += 1;
|
||||
// The message type currently can't be inferred like a tagged union
|
||||
// TODO fix typing of message so it can be deduced from .type
|
||||
if (message.type === "request") {
|
||||
const request = message as libData.MessageRequest;
|
||||
this.send(new libData.MessageRequest(seq, request.src, request.dst, request.name, request.data));
|
||||
} else if (message.type === "response") {
|
||||
const response = message as libData.MessageResponse;
|
||||
this.send(new libData.MessageResponse(seq, response.src, response.dst, response.data));
|
||||
} else if (message.type === "responseError") {
|
||||
const error = message as libData.MessageResponseError;
|
||||
this.send(new libData.MessageResponseError(seq, error.src, error.dst, error.data));
|
||||
} else if (message.type === "event") {
|
||||
const event = message as libData.MessageEvent;
|
||||
this.send(new libData.MessageEvent(seq, event.src, event.dst, event.name, event.data));
|
||||
} else {
|
||||
throw new Error(`Cannot forward message type ${(message as any).type}`);
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
|
||||
sendRequest<Req, Res>(
|
||||
request: Request<Req, Res>,
|
||||
@@ -95,7 +118,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
_state: ConnectorState = "closed";
|
||||
_closing = false;
|
||||
_socket: WebSocketClusterio | null = null;
|
||||
_lastHeartbeat: number | null = null;
|
||||
_lastHeartbeatMs: number | null = null;
|
||||
_heartbeatId: ReturnType<typeof setInterval> | null = null;
|
||||
_heartbeatInterval: number | null = null;
|
||||
_lastReceivedSeq = undefined;
|
||||
@@ -105,7 +128,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
this._state = "closed";
|
||||
this._closing = false;
|
||||
assert(this._socket === null);
|
||||
this._lastHeartbeat = null;
|
||||
this._lastHeartbeatMs = null;
|
||||
assert(this._heartbeatId === null);
|
||||
this._heartbeatInterval = null;
|
||||
this._lastReceivedSeq = undefined;
|
||||
@@ -117,7 +140,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
this._state = "connecting";
|
||||
assert(this._closing === false);
|
||||
assert(this._socket);
|
||||
this._lastHeartbeat = null;
|
||||
this._lastHeartbeatMs = null;
|
||||
assert(this._heartbeatId === null);
|
||||
this._heartbeatInterval = null;
|
||||
this._lastReceivedSeq = undefined;
|
||||
@@ -192,7 +215,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
|
||||
_doHeartbeat() {
|
||||
this._check("connected");
|
||||
if (Date.now() - this._lastHeartbeat! > 2000 * this._heartbeatInterval!) {
|
||||
if (Date.now() - this._lastHeartbeatMs! > 2000 * this._heartbeatInterval!) {
|
||||
logger.verbose("Connector | closing after heartbeat timed out");
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
@@ -207,7 +230,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
}
|
||||
|
||||
_processHeartbeat(message: libData.MessageHeartbeat) {
|
||||
this._lastHeartbeat = Date.now();
|
||||
this._lastHeartbeatMs = Date.now();
|
||||
this._dropSendBufferSeq(message.seq);
|
||||
}
|
||||
|
||||
@@ -218,7 +241,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
if (this._heartbeatId) {
|
||||
throw new Error("heartbeat is already running");
|
||||
}
|
||||
this._lastHeartbeat = Date.now();
|
||||
this._lastHeartbeatMs = Date.now();
|
||||
this._heartbeatId = setInterval(() => {
|
||||
this._doHeartbeat();
|
||||
}, this._heartbeatInterval! * 1000);
|
||||
@@ -242,7 +265,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
*
|
||||
* @param message - Message to send.
|
||||
*/
|
||||
send(message: libData.MessageRoutable) {
|
||||
protected send(message: libData.MessageRoutable) {
|
||||
if (!["connected", "resuming"].includes(this._state)) {
|
||||
throw new libErrors.SessionLost("No session");
|
||||
}
|
||||
@@ -295,6 +318,13 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
|
||||
await this.close(1000, "Disconnect");
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify other end that the link is ready to disconnect
|
||||
*/
|
||||
sendDisconnectReady() {
|
||||
this._sendInternal(new libData.MessageDisconnect("ready"));
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the connection is established and active
|
||||
*/
|
||||
@@ -327,7 +357,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
_reconnectId?: ReturnType<typeof setTimeout>;
|
||||
_sessionToken: string | null = null;
|
||||
_sessionTimeout: number | null = null;
|
||||
_startedResuming: number | null = null;
|
||||
_startedResumingMs: number | null = null;
|
||||
_backoff: ExponentialBackoff;
|
||||
|
||||
constructor(
|
||||
@@ -350,7 +380,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
this._reconnectId = undefined;
|
||||
this._sessionToken = null;
|
||||
this._sessionTimeout = null;
|
||||
this._startedResuming = null;
|
||||
this._startedResumingMs = null;
|
||||
super._reset();
|
||||
this.src = undefined as any;
|
||||
}
|
||||
@@ -359,7 +389,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
assert(this._reconnectId === undefined);
|
||||
this._sessionToken = null;
|
||||
this._sessionTimeout = null;
|
||||
this._startedResuming = null;
|
||||
this._startedResumingMs = null;
|
||||
super._invalidate();
|
||||
this.src = undefined as any;
|
||||
}
|
||||
@@ -473,10 +503,10 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
logger.error("Unexpected double call to reconnect");
|
||||
}
|
||||
|
||||
let delay = this._backoff.delay();
|
||||
let delayMs = this._backoff.delay();
|
||||
if (
|
||||
this._state === "resuming"
|
||||
&& this._startedResuming! + this._sessionTimeout! * 1000 < Date.now() + delay
|
||||
&& this._startedResumingMs! + this._sessionTimeout! * 1000 < Date.now() + delayMs
|
||||
) {
|
||||
logger.error("Connector | Session timed out trying to resume");
|
||||
this._reset();
|
||||
@@ -489,12 +519,12 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
}
|
||||
}
|
||||
logger.verbose(
|
||||
`Connector | waiting ${(Math.round(delay / 10) / 100)} seconds for reconnect`
|
||||
`Connector | waiting ${(Math.round(delayMs / 10) / 100)} seconds for reconnect`
|
||||
);
|
||||
this._reconnectId = setTimeout(() => {
|
||||
this._reconnectId = undefined;
|
||||
this._doConnect();
|
||||
}, delay);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
_attachSocketHandlers() {
|
||||
@@ -516,7 +546,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
|
||||
} else {
|
||||
this._state = "resuming";
|
||||
this._startedResuming = Date.now();
|
||||
this._startedResumingMs = Date.now();
|
||||
this.reconnect();
|
||||
this.emit("drop");
|
||||
}
|
||||
@@ -625,7 +655,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
for (let bufferedMessage of this._sendBuffer) {
|
||||
this._sendInternal(bufferedMessage);
|
||||
}
|
||||
this._startedResuming = null;
|
||||
this._startedResumingMs = null;
|
||||
this.emit("resume");
|
||||
|
||||
} else if (type === "invalidate") {
|
||||
|
||||
@@ -72,7 +72,12 @@ export type RequestHandler<Req, Res> = (request: Req, src: libData.Address, dst:
|
||||
export type EventHandler<T> = (event: T, src: libData.Address, dst: libData.Address) => Promise<void>;
|
||||
|
||||
interface Router {
|
||||
forwardMessage(origin: Link, message: MessageRoutable, fallback: boolean): boolean,
|
||||
forwardMessage(
|
||||
origin: Link,
|
||||
message: MessageRoutable,
|
||||
entry: RequestEntry | EventEntry | undefined,
|
||||
fallback: boolean,
|
||||
): boolean,
|
||||
}
|
||||
|
||||
// Some definitions for the terminology used here:
|
||||
@@ -121,15 +126,15 @@ export class Link {
|
||||
});
|
||||
|
||||
connector.on("disconnectPrepare", () => {
|
||||
this.prepareDisconnect().catch(
|
||||
err => {
|
||||
logger.error(`Unexpected error preparing disconnect:\n${err.stack}`);
|
||||
this.prepareDisconnect().finally(() => {
|
||||
// Only WebSocket connectors issue disconnection events.
|
||||
const webSocketConnector = this.connector as WebSocketBaseConnector;
|
||||
if (webSocketConnector.hasSession) {
|
||||
webSocketConnector.sendDisconnectReady();
|
||||
}
|
||||
).finally(
|
||||
() => {
|
||||
this.connector.send(new libData.MessageDisconnect("ready"));
|
||||
},
|
||||
);
|
||||
}).catch(err => {
|
||||
logger.error(`Unexpected error preparing disconnect:\n${err.stack}`);
|
||||
});
|
||||
});
|
||||
|
||||
connector.on("invalidate", () => {
|
||||
@@ -141,17 +146,30 @@ export class Link {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of requests currently waiting for a response on this link
|
||||
*/
|
||||
get pendingRequestCount() {
|
||||
return this._pendingRequests.size + this._forwardedRequests.size;
|
||||
}
|
||||
|
||||
_clearPendingRequests(err: Error & { code?: string }) {
|
||||
for (let pending of this._pendingRequests.values()) {
|
||||
pending.reject(err);
|
||||
}
|
||||
for (let pending of this._forwardedRequests.values()) {
|
||||
assert(pending.origin.connector instanceof WebSocketBaseConnector);
|
||||
if (pending.origin.connector.hasSession) {
|
||||
pending.origin.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code), pending.src, pending.dst
|
||||
);
|
||||
// Drop response if the origin is a WebSocket and the session
|
||||
// has expired. The other end of the link will have sent an
|
||||
// error response to the request in this case.
|
||||
if (
|
||||
pending.origin.connector instanceof WebSocketBaseConnector
|
||||
&& !pending.origin.connector.hasSession
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
pending.origin.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code), pending.src, pending.dst
|
||||
);
|
||||
}
|
||||
this._pendingRequests.clear();
|
||||
this._forwardedRequests.clear();
|
||||
@@ -330,7 +348,7 @@ export class Link {
|
||||
let fallback =
|
||||
message.type === "request" ? this._requestFallbacks.get((entry as RequestEntry).Request) : undefined
|
||||
;
|
||||
if (this.router.forwardMessage(this, message, Boolean(fallback))) {
|
||||
if (this.router.forwardMessage(this, message, entry, Boolean(fallback))) {
|
||||
return;
|
||||
}
|
||||
if (!fallback) {
|
||||
@@ -421,6 +439,7 @@ export class Link {
|
||||
throw new libErrors.InvalidMessage(`Received reply from ${message.src} for message sent to ${pending.dst}`);
|
||||
}
|
||||
|
||||
this._pendingRequests.delete(message.dst.requestId!);
|
||||
try {
|
||||
pending.resolve(pending.request.responseFromJSON(message.data));
|
||||
} catch (err: any) {
|
||||
@@ -437,6 +456,7 @@ export class Link {
|
||||
);
|
||||
}
|
||||
|
||||
this._pendingRequests.delete(message.dst.requestId!);
|
||||
pending.reject(new libErrors.RequestError(message.data.message, message.data.code, message.data.stack));
|
||||
}
|
||||
|
||||
@@ -541,7 +561,7 @@ export class Link {
|
||||
dst: message.dst,
|
||||
};
|
||||
this._forwardedRequests.set(message.src.index(), pending);
|
||||
this.connector.send(message);
|
||||
this.connector.forward(message);
|
||||
}
|
||||
|
||||
sendEvent<T>(event: Event<T>, dst: libData.Address) {
|
||||
|
||||
@@ -75,7 +75,7 @@ export class WebConsoleFormat {
|
||||
transform(info: any, _options: object) {
|
||||
let src = " ";
|
||||
if (info.host_id !== undefined) {
|
||||
src += `s:${info.host_name} - `;
|
||||
src += `h:${info.host_name} - `;
|
||||
}
|
||||
if (info.instance_id !== undefined) {
|
||||
src += `i:${info.instance_name}`;
|
||||
|
||||
@@ -163,7 +163,7 @@ export class TerminalFormat {
|
||||
src += `i:${info.instance_name} `;
|
||||
|
||||
} else if (info.host_name) {
|
||||
src += `s:${info.host_name} `;
|
||||
src += `h:${info.host_name} `;
|
||||
}
|
||||
if (info.plugin) {
|
||||
src += `${info.plugin}: `;
|
||||
|
||||
@@ -3,6 +3,54 @@
|
||||
* @module lib/lua_tools
|
||||
*/
|
||||
|
||||
/**
|
||||
* A colour as it may originate from Factorio Lua code, see
|
||||
* https://lua-api.factorio.com/latest/types/Color.html
|
||||
*/
|
||||
export type FactorioColor = { r?: number, g?: number, b?: number, a?: number } | [number, number, number, number?];
|
||||
|
||||
export type Color = { r: number, g: number, b: number, a: number };
|
||||
|
||||
/**
|
||||
* Normalise a color as it may appear in Factorio Lua code to be an object
|
||||
* with r, g, b, a properties in the range 0 to 1.
|
||||
*
|
||||
* @param color -
|
||||
* A colour that may be specified with left out components or as an
|
||||
* array and have the range 1 to 0 or 255 to 0.
|
||||
* @returns
|
||||
* Normalised colour where all 4 components are present as properties in
|
||||
* the range 0 to 1.
|
||||
*/
|
||||
export function normalizeColor(color: FactorioColor) {
|
||||
let r, g, b, a;
|
||||
if (color instanceof Array) {
|
||||
([r, g, b, a] = color);
|
||||
} else {
|
||||
({ r, g, b, a } = color);
|
||||
}
|
||||
|
||||
r ??= NaN;
|
||||
g ??= NaN;
|
||||
b ??= NaN;
|
||||
a ??= NaN;
|
||||
|
||||
// Note NaN > 1 is false and NaN / 255 is NaN
|
||||
if (r > 1 || g > 1 || b > 1 || a > 1) {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
a /= 255;
|
||||
}
|
||||
|
||||
return {
|
||||
r: Number.isNaN(r) ? 0 : r,
|
||||
g: Number.isNaN(g) ? 0 : g,
|
||||
b: Number.isNaN(b) ? 0 : b,
|
||||
a: Number.isNaN(a) ? 1 : a,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a string for inclusion into a lua string
|
||||
*
|
||||
|
||||
@@ -239,11 +239,11 @@ definePermission({
|
||||
definePermission({
|
||||
name: "core.instance.save.list",
|
||||
title: "List saves",
|
||||
description: "List the saves currently on the instance.",
|
||||
description: "List the saves currently on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.list.subscribe",
|
||||
title: "Subscribe to save list updatess",
|
||||
name: "core.instance.save.subscribe",
|
||||
title: "Subscribe to save updates",
|
||||
description: "Subscribe to be notifed on updates to the list of saves of instances.",
|
||||
});
|
||||
definePermission({
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { Logger } from "./logging";
|
||||
import type { FieldDefinition } from "./config";
|
||||
import type { PlayerStats } from "./data";
|
||||
|
||||
// TODO Add proper typing for plugins
|
||||
/* Used to define the export of info.ts from plugins */
|
||||
/** Used to define the plugin export in plugins */
|
||||
export type PluginDeclaration = {
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -44,9 +43,10 @@ export type PluginNodeEnvInfo = PluginDeclaration & {
|
||||
};
|
||||
|
||||
export type PluginWebpackEnvInfo = PluginDeclaration & {
|
||||
container: any;
|
||||
package: any;
|
||||
container?: any;
|
||||
package?: any;
|
||||
enabled?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,12 +27,18 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
|
||||
let pluginPackage: { name?: string, version: string, main?: string, private?: boolean };
|
||||
|
||||
try {
|
||||
pluginInfo = require(pluginPath).default;
|
||||
pluginInfo = require(pluginPath).plugin;
|
||||
pluginPackage = require(path.posix.join(pluginPath, "package.json"));
|
||||
} catch (err: any) {
|
||||
throw new libErrors.PluginError(pluginName, err);
|
||||
}
|
||||
|
||||
if (typeof pluginInfo !== "object") {
|
||||
throw new libErrors.EnvironmentError(
|
||||
`Expected plugin at ${pluginPath} to export an object named 'plugin' but got ${typeof pluginInfo}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pluginInfo.name !== pluginName) {
|
||||
throw new libErrors.EnvironmentError(
|
||||
`Expected plugin at ${pluginPath} to be named ${pluginName} but got ${pluginInfo.name}`
|
||||
|
||||
@@ -740,10 +740,10 @@ class GaugeChild extends ValueCollectorChild {
|
||||
* seconds from when the timer was started
|
||||
*/
|
||||
startTimer() {
|
||||
let start = process.hrtime.bigint();
|
||||
let startNs = process.hrtime.bigint();
|
||||
return () => {
|
||||
let end = process.hrtime.bigint();
|
||||
this.set(Number(end - start) / 1e9);
|
||||
let endNs = process.hrtime.bigint();
|
||||
this.set(Number(endNs - startNs) / 1e9);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -979,10 +979,10 @@ class SummaryChild {
|
||||
* when the timer was started into the metric.
|
||||
*/
|
||||
startTimer() {
|
||||
let start = process.hrtime.bigint();
|
||||
let startNs = process.hrtime.bigint();
|
||||
return () => {
|
||||
let end = process.hrtime.bigint();
|
||||
this.observe(Number(end - start) / 1e9);
|
||||
let endNs = process.hrtime.bigint();
|
||||
this.observe(Number(endNs - startNs) / 1e9);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1260,10 +1260,10 @@ class HistogramChild {
|
||||
* when the timer was started into the metric.
|
||||
*/
|
||||
startTimer() {
|
||||
let start = process.hrtime.bigint();
|
||||
let startNs = process.hrtime.bigint();
|
||||
return () => {
|
||||
let end = process.hrtime.bigint();
|
||||
this.observe(Number(end - start) / 1e9);
|
||||
let endNs = process.hrtime.bigint();
|
||||
this.observe(Number(endNs - startNs) / 1e9);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +59,19 @@ export async function handlePluginCommand(
|
||||
let pluginInfo: { name: string };
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
pluginInfo = require(pluginPath).default;
|
||||
pluginInfo = require(pluginPath).plugin;
|
||||
} catch (err: any) {
|
||||
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof pluginInfo !== "object") {
|
||||
logger.error("Plugin does not correctly export a 'plugin' object");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginList.has(pluginInfo.name)) {
|
||||
logger.error(`Plugin with the same ${pluginInfo.name} already exists`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import { Link, Event, EventClass, RequestHandler, WebSocketClientConnector, WebSocketBaseConnector } from "./link";
|
||||
import { logger } from "./logging";
|
||||
import { Address, MessageRequest, IControllerUser } from "./data";
|
||||
import isDeepStrictEqual from "./is_deep_strict_equal";
|
||||
|
||||
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
|
||||
export type EventSubscriberCallback<T> = (updates: T[], synced: boolean) => void
|
||||
@@ -33,7 +34,7 @@ export class SubscriptionRequest {
|
||||
constructor(
|
||||
public eventName: string,
|
||||
public subscribe: boolean,
|
||||
public lastRequestTime: number = 0,
|
||||
public lastRequestTimeMs: number = 0,
|
||||
) {
|
||||
if (!Link._eventsByName.has(eventName)) {
|
||||
throw new Error(`Unregistered Event class ${eventName}`);
|
||||
@@ -47,7 +48,7 @@ export class SubscriptionRequest {
|
||||
]);
|
||||
|
||||
toJSON() {
|
||||
return [this.eventName, this.subscribe, this.lastRequestTime];
|
||||
return [this.eventName, this.subscribe, this.lastRequestTimeMs];
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof SubscriptionRequest.jsonSchema>): SubscriptionRequest {
|
||||
@@ -152,7 +153,7 @@ export class SubscriptionController {
|
||||
|
||||
export interface SubscribableValue {
|
||||
id: number | string,
|
||||
updatedAt: number,
|
||||
updatedAtMs: number,
|
||||
isDeleted: boolean,
|
||||
}
|
||||
|
||||
@@ -175,8 +176,8 @@ export class EventSubscriber<
|
||||
/** True if this subscriber is currently synced with the source */
|
||||
synced = false;
|
||||
_snapshot?: readonly [ReadonlyMap<K, Readonly<V>>, boolean];
|
||||
_snapshotLastUpdated = 0;
|
||||
lastResponseTime = -1;
|
||||
_snapshotLastUpdatedMs = 0;
|
||||
lastResponseTimeMs = -1;
|
||||
|
||||
constructor(
|
||||
private Event: EventClass<T>,
|
||||
@@ -203,7 +204,15 @@ export class EventSubscriber<
|
||||
*/
|
||||
async _handle(event: EventSubscribable<T, V>) {
|
||||
for (const value of event.updates) {
|
||||
this.lastResponseTime = Math.max(this.lastResponseTime, value.updatedAt);
|
||||
this.lastResponseTimeMs = Math.max(this.lastResponseTimeMs, value.updatedAtMs);
|
||||
// Warn about updates which changes content but don't update updatedAtMs
|
||||
const existing = this.values.get(value.id as K);
|
||||
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
|
||||
logger.warn(
|
||||
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
|
||||
"updatedAtMs timestamp but differing content"
|
||||
);
|
||||
}
|
||||
if (value.isDeleted) {
|
||||
this.values.delete(value.id as K);
|
||||
} else {
|
||||
@@ -257,8 +266,8 @@ export class EventSubscriber<
|
||||
* @returns tuple of values map snapshot and synced property.
|
||||
*/
|
||||
getSnapshot() {
|
||||
if (this._snapshotLastUpdated !== this.lastResponseTime) {
|
||||
this._snapshotLastUpdated = this.lastResponseTime;
|
||||
if (this._snapshotLastUpdatedMs !== this.lastResponseTimeMs) {
|
||||
this._snapshotLastUpdatedMs = this.lastResponseTimeMs;
|
||||
this._snapshot = [new Map(this.values), this.synced];
|
||||
}
|
||||
return this._snapshot!;
|
||||
@@ -277,10 +286,10 @@ export class EventSubscriber<
|
||||
await this.control.send(new SubscriptionRequest(
|
||||
entry.name,
|
||||
this._callbacks.length > 0,
|
||||
this.lastResponseTime
|
||||
this.lastResponseTimeMs
|
||||
));
|
||||
this.synced = this._callbacks.length > 0;
|
||||
this._snapshotLastUpdated = 0;
|
||||
this._snapshotLastUpdatedMs = 0;
|
||||
for (let callback of this._callbacks) {
|
||||
callback([], this.synced);
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@ export const systemCpuIdleSecondsTotal = new Gauge(
|
||||
},
|
||||
);
|
||||
|
||||
export const systemMemoryCapasityBytes = new Gauge(
|
||||
"system_memory_capasity_bytes",
|
||||
export const systemMemoryCapacityBytes = new Gauge(
|
||||
"system_memory_capacity_bytes",
|
||||
"Total system memory installed",
|
||||
{
|
||||
callback: function(collector) {
|
||||
@@ -87,8 +87,8 @@ export const systemMemoryAvailableBytes = new Gauge(
|
||||
},
|
||||
);
|
||||
|
||||
export const systemDiskCapasityBytes = new Gauge(
|
||||
"system_disk_capasity_bytes",
|
||||
export const systemDiskCapacityBytes = new Gauge(
|
||||
"system_disk_capacity_bytes",
|
||||
"Size of the filesystem of the current working directory Node.js runs on",
|
||||
{
|
||||
callback: async function(collector) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.web.json",
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"include": [
|
||||
"browser.ts",
|
||||
"package.json",
|
||||
@@ -12,6 +12,7 @@
|
||||
"./src/hash.ts",
|
||||
"./src/ini.ts",
|
||||
"./src/link/*.ts",
|
||||
"./src/lua_tools.ts",
|
||||
"./src/logging.ts",
|
||||
"./src/permissions.ts",
|
||||
"./src/prometheus.ts",
|
||||
@@ -25,6 +26,6 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist/web/build",
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"include": ["index.ts", "browser.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"allowJs": true,
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"include": ["index.ts", "browser.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
"allowJs": true,
|
||||
},
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"version": "2.0.0-alpha.17",
|
||||
"browser": "src/index.ts",
|
||||
"main": "dist/src/index.js",
|
||||
"main": "dist/browser/src/index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
@@ -17,12 +17,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.1.4",
|
||||
"@babel/core": "^7.22.9",
|
||||
"@babel/preset-react": "^7.22.5",
|
||||
"@clusterio/lib": "^2.0.0-alpha.13",
|
||||
"@clusterio/lib": "^2.0.0-alpha.17",
|
||||
"@swc/core": "^1.4.0",
|
||||
"antd": "^5.13.0",
|
||||
"assert": "^2.0.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"buffer": "^6.0.3",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
@@ -39,8 +37,8 @@
|
||||
"react-router-dom": "^6.14.2",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"style-loader": "^3.3.3",
|
||||
"swc-loader": "^0.2.6",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"ts-loader": "^9.4.4",
|
||||
"typescript": "^5.1.6",
|
||||
"util": "^0.12.5",
|
||||
"webpack": "^5.88.2",
|
||||
|
||||
@@ -38,7 +38,7 @@ async function loadPluginInfos(): Promise<lib.PluginWebpackEnvInfo[]> {
|
||||
pluginList = [];
|
||||
}
|
||||
|
||||
let pluginInfos = [];
|
||||
let pluginInfos: lib.PluginWebpackEnvInfo[] = [];
|
||||
await __webpack_init_sharing__("default");
|
||||
for (let meta of pluginList) {
|
||||
if (meta.web.error) {
|
||||
@@ -52,13 +52,19 @@ async function loadPluginInfos(): Promise<lib.PluginWebpackEnvInfo[]> {
|
||||
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
|
||||
}
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
let pluginInfo = (await container.get("./info"))().default;
|
||||
let pluginInfo = (await container.get("./"))().plugin;
|
||||
pluginInfo.container = container;
|
||||
pluginInfo.package = (await container.get("./package.json"))();
|
||||
pluginInfo.enabled = meta.enabled;
|
||||
pluginInfos.push(pluginInfo);
|
||||
|
||||
} catch (err: any) {
|
||||
pluginInfos.push({
|
||||
name: meta.name,
|
||||
title: meta.name,
|
||||
enabled: false,
|
||||
error: err.message,
|
||||
});
|
||||
logger.error(`Failed to load plugin info for ${meta.name}`);
|
||||
if (err.stack) {
|
||||
logger.error(err.stack);
|
||||
@@ -79,7 +85,8 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
|
||||
if (pluginInfo.webEntrypoint) {
|
||||
let webModule = (await pluginInfo.container.get(pluginInfo.webEntrypoint))();
|
||||
if (!webModule.WebPlugin) {
|
||||
throw new Error("Plugin webEntrypoint does not export WebPlugin class");
|
||||
pluginInfo.error = "Plugin webEntrypoint does not export WebPlugin class";
|
||||
throw new Error(pluginInfo.error);
|
||||
}
|
||||
WebPluginClass = webModule.WebPlugin;
|
||||
}
|
||||
@@ -89,6 +96,7 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
|
||||
plugins.set(pluginInfo.name, plugin);
|
||||
|
||||
} catch (err: any) {
|
||||
pluginInfo.error = `Error loading plugin: ${err.message}`;
|
||||
logger.error(`Failed to load plugin ${pluginInfo.name}`);
|
||||
if (err.stack) {
|
||||
logger.error(err.stack);
|
||||
@@ -124,7 +132,7 @@ export default async function bootstrap() {
|
||||
|
||||
let wsUrl = new URL(webRoot, document.location.href);
|
||||
let controlConnector = new ControlConnector(wsUrl.href, 120, undefined);
|
||||
let control = new Control(controlConnector);
|
||||
let control = new Control(controlConnector, new Map(pluginInfos.map(p => [p.name, p])));
|
||||
control.plugins = await loadPlugins(pluginInfos, control);
|
||||
control.inputComponents = inputComponentsFromPlugins(control.plugins);
|
||||
|
||||
|
||||
@@ -104,16 +104,16 @@ export default function App(props: AppProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary Component={ErrorCard}>
|
||||
<ConfigProvider
|
||||
theme={{ algorithm: theme.darkAlgorithm }}
|
||||
>
|
||||
<ConfigProvider
|
||||
theme={{ algorithm: theme.darkAlgorithm }}
|
||||
>
|
||||
<ErrorBoundary Component={ErrorCard}>
|
||||
<ControlContext.Provider value={props.control}>
|
||||
<BrowserRouter basename={webRoot}>
|
||||
{page}
|
||||
</BrowserRouter>
|
||||
</ControlContext.Provider>
|
||||
</ConfigProvider>
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export default function ControllerConfigTree() {
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.send(new lib.ControllerConfigSetPropRequest(field, prop, value));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export default function HostConfigTree(props: { id: number, available: boolean }
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value));
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function HostViewPage() {
|
||||
|
||||
let hostButtons = <Space>
|
||||
{
|
||||
account.hasPermission("core.host.revoke_access")
|
||||
account.hasPermission("core.host.revoke_token")
|
||||
&& <Popconfirm
|
||||
title={`Revoke tokens of ${host.name}?`}
|
||||
placement="bottomRight"
|
||||
|
||||
@@ -28,6 +28,8 @@ export default function InstanceConfigTree(props: InstanceConfigTreeProps) {
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.send(new lib.InstanceConfigSetPropRequest(props.id, field, prop, value));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { message, Button, Space, Table } from "antd";
|
||||
import { message, Button, Space, Table, Typography } from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
import type { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
@@ -10,6 +10,7 @@ import { useHosts } from "../model/host";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import StartStopInstanceButton from "./StartStopInstanceButton";
|
||||
import { InstanceDetails } from "@clusterio/lib";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -55,7 +56,12 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
{
|
||||
title: "Assigned Host",
|
||||
key: "assignedHost",
|
||||
render: (_, instance) => hostName(instance.assignedHost),
|
||||
render: (_, instance) => <Link
|
||||
to={`/hosts/${instance.assignedHost}/view`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{hostName(instance.assignedHost)}
|
||||
</Link>,
|
||||
sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)),
|
||||
responsive: ["sm"],
|
||||
},
|
||||
@@ -68,10 +74,10 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
{publicAddress}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined/>}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(publicAddress??"");
|
||||
navigator.clipboard.writeText(publicAddress ?? "");
|
||||
message.success("Copied public address!");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { notifyErrorHandler } from "../util/notify";
|
||||
import { useInstance } from "../model/instance";
|
||||
import { useHost } from "../model/host";
|
||||
import InstanceStatusTag from "./InstanceStatusTag";
|
||||
import Link from "./Link";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -42,7 +43,9 @@ function InstanceDescription(props: InstanceDescriptionProps) {
|
||||
<Descriptions.Item label="Host">
|
||||
{!assigned
|
||||
? <em>Unassigned</em>
|
||||
: host?.name ?? instance.assignedHost
|
||||
: <Link to={`/hosts/${host?.id ?? instance.assignedHost}/view`}>
|
||||
{host?.name ?? instance.assignedHost}
|
||||
</Link>
|
||||
}
|
||||
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
|
||||
id={instance.id}
|
||||
@@ -104,7 +107,7 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
}
|
||||
let instanceButtonsMenuProps: MenuProps = {
|
||||
items: instanceButtonMenuItems,
|
||||
onClick: ({ key }: { key:string }) => {
|
||||
onClick: ({ key }: { key: string }) => {
|
||||
if (key === "export") {
|
||||
setExportingData(true);
|
||||
control.sendTo(
|
||||
@@ -156,9 +159,10 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
|
||||
"core.instance.extract_players",
|
||||
"core.instance.kill",
|
||||
"core.instance.delete",
|
||||
) && <Dropdown placement="bottomRight" trigger={["click"]} menu={instanceButtonsMenuProps}>
|
||||
<Button>More <DownOutlined /></Button>
|
||||
</Dropdown>}
|
||||
)
|
||||
&& <Dropdown placement="bottomRight" trigger={["click"]} menu={instanceButtonsMenuProps}>
|
||||
<Button>More <DownOutlined /></Button>
|
||||
</Dropdown>}
|
||||
</Space>;
|
||||
}
|
||||
|
||||
@@ -180,7 +184,7 @@ export default function InstanceViewPage() {
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<Alert
|
||||
message={"Instance not found" }
|
||||
message={"Instance not found"}
|
||||
showIcon
|
||||
description={<>Instance with id {instanceId} was not found on the controller.</>}
|
||||
type="warning"
|
||||
@@ -198,13 +202,13 @@ export default function InstanceViewPage() {
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader
|
||||
title={instance.name??""}
|
||||
extra={<InstanceButtons instance={instance}/>}
|
||||
title={instance.name ?? ""}
|
||||
extra={<InstanceButtons instance={instance} />}
|
||||
/>
|
||||
<InstanceDescription host={host} instance={instance} />
|
||||
|
||||
{
|
||||
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
|
||||
account.hasAllPermission("core.instance.save.list", "core.instance.save.subscribe")
|
||||
&& <SavesList instance={instance} />
|
||||
}
|
||||
{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Typography } from "antd";
|
||||
import {
|
||||
LinkProps,
|
||||
useHref,
|
||||
useLinkClickHandler,
|
||||
} from "react-router-dom";
|
||||
import { BaseType } from "antd/es/typography/Base";
|
||||
|
||||
const Link = React.forwardRef(
|
||||
(
|
||||
{
|
||||
onClick,
|
||||
replace = false,
|
||||
state,
|
||||
target,
|
||||
to,
|
||||
type,
|
||||
...rest
|
||||
}: LinkProps,
|
||||
ref
|
||||
) => {
|
||||
let href = useHref(to);
|
||||
let handleClick = useLinkClickHandler(to, {
|
||||
replace,
|
||||
state,
|
||||
target,
|
||||
}) as (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
|
||||
return (
|
||||
<Typography.Link
|
||||
{...rest}
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
onClick?.(event as React.MouseEvent<HTMLAnchorElement, MouseEvent>);
|
||||
if (!event.defaultPrevented) {
|
||||
handleClick(event);
|
||||
}
|
||||
}}
|
||||
ref={ref as React.RefObject<HTMLElement> | null}
|
||||
target={target}
|
||||
type={type as BaseType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
export default Link;
|
||||
@@ -14,7 +14,11 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
|
||||
let screens = useBreakpoint();
|
||||
const mod: Partial<lib.ModInfo> & Partial<lib.ModRecord> = props.mod;
|
||||
|
||||
return <Descriptions className="borderless" bordered size="small" column={{ xs: 1, sm: 1, md: 1, lg: 2 }}>
|
||||
return <Descriptions
|
||||
className="borderless"
|
||||
bordered size="small"
|
||||
column={{ xs: 1, sm: 1, md: 1, lg: 2, xl: 2, xxl: 2 }}
|
||||
>
|
||||
{mod.error && <Descriptions.Item
|
||||
label={<><ExclamationCircleOutlined/> Error</>}
|
||||
span={2}
|
||||
@@ -29,10 +33,14 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
|
||||
&& <Descriptions.Item label="Action">{props.actions(props.mod)}</Descriptions.Item>
|
||||
}
|
||||
|
||||
{mod.title && <Descriptions.Item label="Title">{mod.title}</Descriptions.Item>}
|
||||
{mod.version && <Descriptions.Item label="Version" span={mod.title ? 1 : 2}>{mod.version}</Descriptions.Item>}
|
||||
{mod.description && <Descriptions.Item label="Description" span={2}>{mod.description}</Descriptions.Item> }
|
||||
{mod.author && <Descriptions.Item label="Author" span={2}>{mod.author}</Descriptions.Item>}
|
||||
{Boolean(mod.title) && <Descriptions.Item label="Title">{mod.title}</Descriptions.Item>}
|
||||
{Boolean(mod.version)
|
||||
&& <Descriptions.Item label="Version" span={mod.title ? 1 : 2}>{mod.version}</Descriptions.Item>
|
||||
}
|
||||
{Boolean(mod.description)
|
||||
&& <Descriptions.Item label="Description" span={2}>{mod.description}</Descriptions.Item>
|
||||
}
|
||||
{Boolean(mod.author) && <Descriptions.Item label="Author" span={2}>{mod.author}</Descriptions.Item>}
|
||||
|
||||
<Descriptions.Item label="Mod Portal" span={2}>
|
||||
<Typography.Link href={`https://mods.factorio.com/mod/${mod.name}`}>
|
||||
@@ -40,17 +48,17 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
|
||||
</Typography.Link>
|
||||
</Descriptions.Item>
|
||||
|
||||
{mod.contact && <Descriptions.Item label="Contact" span={2}>{mod.contact}</Descriptions.Item>}
|
||||
{mod.homepage && <Descriptions.Item label="Homepage" span={2}>{mod.homepage}</Descriptions.Item>}
|
||||
{Boolean(mod.contact) && <Descriptions.Item label="Contact" span={2}>{mod.contact}</Descriptions.Item>}
|
||||
{Boolean(mod.homepage) && <Descriptions.Item label="Homepage" span={2}>{mod.homepage}</Descriptions.Item>}
|
||||
<Descriptions.Item label="Internal Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
|
||||
{mod.factorioVersion
|
||||
{Boolean(mod.factorioVersion)
|
||||
&& <Descriptions.Item label="Factorio Version">{mod.factorioVersion}</Descriptions.Item>
|
||||
}
|
||||
{mod.filename && <Descriptions.Item label="Filename">{mod.filename}</Descriptions.Item>}
|
||||
{Boolean(mod.filename) && <Descriptions.Item label="Filename">{mod.filename}</Descriptions.Item>}
|
||||
{mod.size !== undefined
|
||||
&& <Descriptions.Item label="Size">{lib.formatBytes(mod.size)}</Descriptions.Item>
|
||||
}
|
||||
{mod.sha1 && <Descriptions.Item label="SHA1" span={2}>{mod.sha1}</Descriptions.Item>}
|
||||
{Boolean(mod.sha1) && <Descriptions.Item label="SHA1" span={2}>{mod.sha1}</Descriptions.Item>}
|
||||
{mod.dependencies && mod.dependencies.length
|
||||
&& <Descriptions.Item label="Dependencies" span={2}>
|
||||
{mod.dependencies
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Button, Card, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
|
||||
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
|
||||
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
|
||||
TablePaginationConfig,
|
||||
} from "antd";
|
||||
@@ -202,7 +202,7 @@ function SearchModsTable(props: SearchModsTableProps) {
|
||||
align: "right",
|
||||
render: (_, result) => <Select
|
||||
size="small"
|
||||
bordered={false}
|
||||
variant="borderless"
|
||||
value={modResultSelectedVersion.get(result.name) || 0}
|
||||
onChange={index => {
|
||||
const newVersions = new Map(modResultSelectedVersion);
|
||||
@@ -404,7 +404,7 @@ function hasChange(changes: ModChange[], field:Partial<ModChange>) {
|
||||
}
|
||||
|
||||
// picks out the value property from settings.
|
||||
function pickValue(map: Map<string, lib.ModSetting>): [string, string|number|boolean][] {
|
||||
function pickValue(map: Map<string, lib.ModSetting>): [string, lib.ModSetting["value"]][] {
|
||||
return [...map].map(([name, value]) => [name, value.value]);
|
||||
}
|
||||
|
||||
@@ -420,6 +420,25 @@ function groupToMap(array: any[], fn: (...args:any) => any) {
|
||||
return map;
|
||||
}
|
||||
|
||||
function InputColor(props: {
|
||||
value: lib.ModSettingColor,
|
||||
onChange: (value: lib.ModSettingColor) => void,
|
||||
}) {
|
||||
const value = props.value;
|
||||
return <ColorPicker
|
||||
defaultFormat="rgb"
|
||||
defaultValue={`rgba(${value.r * 255}, ${value.g * 255}, ${value.b * 255}, ${value.a}`}
|
||||
onChangeComplete={(color) => {
|
||||
const rgb = color.toRgb();
|
||||
props.onChange({
|
||||
r: rgb.r / 255,
|
||||
g: rgb.g / 255,
|
||||
b: rgb.b / 255,
|
||||
a: rgb.a,
|
||||
});
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
type SettingsTableFieldProps = {
|
||||
name: string;
|
||||
type: string;
|
||||
@@ -431,11 +450,15 @@ type SettingsTableFieldProps = {
|
||||
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||
const name = props.name;
|
||||
const isBoolean = props.type === "bool-setting";
|
||||
const isColor = props.type === "color-setting";
|
||||
const isNumber = ["int-setting", "double-setting"].includes(props.type);
|
||||
const isSelect = Boolean(props.allowedValues);
|
||||
let input;
|
||||
if (isBoolean) {
|
||||
input = <Checkbox />;
|
||||
} else if (isColor) {
|
||||
const PartialInputColor = InputColor as () => React.JSX.Element;
|
||||
input = <PartialInputColor />;
|
||||
} else if (isSelect) {
|
||||
const options = Object.values(props.allowedValues).map(value => ({
|
||||
value,
|
||||
@@ -484,7 +507,7 @@ function SettingsTable(props: SettingsTableProps) {
|
||||
.map(mod => [mod.name, mod.title])
|
||||
);
|
||||
|
||||
const types = ["bool-setting", "int-setting", "double-setting", "string-setting"];
|
||||
const types = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
|
||||
let prototypes = Object.entries(props.prototypes)
|
||||
.filter(([type, _]) => types.includes(type))
|
||||
.flatMap<any>(([_, settingPrototypes]: [string, any]) => Object.values(settingPrototypes))
|
||||
@@ -492,7 +515,7 @@ function SettingsTable(props: SettingsTableProps) {
|
||||
|
||||
function controls(
|
||||
scope: "startup"|"runtime-global"|"runtime-per-user",
|
||||
storedFields: [string, string|number|boolean][]
|
||||
storedFields: [string, lib.ModSetting["value"]][]
|
||||
) {
|
||||
let fields = new Map(prototypes
|
||||
.filter(p => p.setting_type === scope)
|
||||
@@ -508,6 +531,8 @@ function SettingsTable(props: SettingsTableProps) {
|
||||
type = "double-setting";
|
||||
} else if (typeof value === "string") {
|
||||
type = "string-setting";
|
||||
} else if (typeof value === "object" && "r" in value) {
|
||||
type = "color-setting";
|
||||
} else {
|
||||
throw Error(`Unhandled setting type ${typeof value}`);
|
||||
}
|
||||
@@ -776,7 +801,7 @@ export default function ModPackViewPage() {
|
||||
title={modPack.name}
|
||||
extra={<Space>
|
||||
<ExportButton modPack={modifiedModPack}/>
|
||||
{account.hasPermission("core.mod-pack.delete") && <Popconfirm
|
||||
{account.hasPermission("core.mod_pack.delete") && <Popconfirm
|
||||
title="Delete mod pack and all of its settings?"
|
||||
placement="bottomRight"
|
||||
okText="Delete"
|
||||
|
||||
@@ -186,8 +186,8 @@ export default function ModsPage() {
|
||||
<SectionHeader
|
||||
title="Mod Packs"
|
||||
extra={<Space>
|
||||
{account.hasPermission("core.mod-pack.create") && <ImportModPackButton />}
|
||||
{account.hasPermission("core.mod-pack.create") && <CreateModPackButton />}
|
||||
{account.hasPermission("core.mod_pack.create") && <ImportModPackButton />}
|
||||
{account.hasPermission("core.mod_pack.create") && <CreateModPackButton />}
|
||||
</Space>}
|
||||
/>
|
||||
<Table
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Layout, Breadcrumb } from "antd";
|
||||
import Link from "./Link";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import notify from "../util/notify";
|
||||
|
||||
export default function PluginViewPage() {
|
||||
let params = useParams();
|
||||
let plugins = useContext(ControlContext).plugins;
|
||||
const control = useContext(ControlContext);
|
||||
let [pluginList, setPluginList] = useState<PluginWebApi[]>();
|
||||
let pluginName = String(params.name);
|
||||
|
||||
@@ -25,8 +25,9 @@ export default function PluginViewPage() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
let plugin = plugins.get(pluginName);
|
||||
let pluginTitle = plugin ? plugin.info.title : pluginName;
|
||||
const plugin = control.plugins.get(pluginName);
|
||||
const pluginInfo = control.pluginInfos.get(pluginName);
|
||||
const pluginTitle = pluginInfo ? pluginInfo.title : pluginName;
|
||||
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
|
||||
if (!pluginList) {
|
||||
return <PageLayout nav={nav}>
|
||||
@@ -43,33 +44,42 @@ export default function PluginViewPage() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
const pluginError = pluginMeta.web.error ?? pluginInfo?.error;
|
||||
if (!plugin) {
|
||||
return <PageLayout nav={nav}>
|
||||
<Descriptions bordered size="small" title={pluginTitle}>
|
||||
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="Loaded">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Loaded in Web UI" span={2}>No</Descriptions.Item>
|
||||
<Descriptions.Item label="Loaded on controller">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Enabled on controller" span={2}>
|
||||
{pluginMeta.enabled ? "Yes" : "No"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Alert
|
||||
{pluginError && <Alert
|
||||
style={{
|
||||
marginTop: "1em",
|
||||
}}
|
||||
message={pluginMeta.web.error || "Error loading web module"}
|
||||
message={pluginError}
|
||||
description={
|
||||
"The web interface was unable to load the webpack module for this plugin. This is "+
|
||||
"usually due to an incorrect or missing webpack build for the plugin. Due to the "+
|
||||
"module not being loaded, the configs and web controls defined by this plugin will "+
|
||||
"module not being loaded, the configs and web controls defined by this plugin may "+
|
||||
"not be available in the web interface."
|
||||
}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
/>}
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<Descriptions bordered size="small" title={pluginTitle}>
|
||||
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="Loaded" span={2}>{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Loaded in Web UI" span={2}>Yes</Descriptions.Item>
|
||||
<Descriptions.Item label="Loaded on controller">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Enabled on controller" span={2}>
|
||||
{pluginMeta.enabled ? "Yes" : "No"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Description" span={3}>{plugin.info.description}</Descriptions.Item>
|
||||
{plugin.package.homepage ? <Descriptions.Item label="Homepage" span={3}>
|
||||
<a href={plugin.package.homepage}>{plugin.package.homepage}</a>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Table } from "antd";
|
||||
import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled";
|
||||
import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled";
|
||||
|
||||
import type { PluginWebApi } from "@clusterio/lib";
|
||||
|
||||
@@ -12,7 +14,7 @@ const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base"
|
||||
|
||||
|
||||
export default function PluginsPage() {
|
||||
let plugins = useContext(ControlContext).plugins;
|
||||
const control = useContext(ControlContext);
|
||||
let navigate = useNavigate();
|
||||
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
|
||||
|
||||
@@ -29,8 +31,8 @@ export default function PluginsPage() {
|
||||
|
||||
let tableContents = [];
|
||||
for (let meta of pluginList) {
|
||||
if (plugins.has(meta.name)) {
|
||||
let plugin = plugins.get(meta.name)!;
|
||||
if (control.plugins.has(meta.name)) {
|
||||
let plugin = control.plugins.get(meta.name)!;
|
||||
tableContents.push({
|
||||
meta,
|
||||
info: plugin.info,
|
||||
@@ -39,6 +41,7 @@ export default function PluginsPage() {
|
||||
} else {
|
||||
tableContents.push({
|
||||
meta,
|
||||
info: control.pluginInfos.get(meta.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -58,8 +61,11 @@ export default function PluginsPage() {
|
||||
title: "Version",
|
||||
key: "version",
|
||||
render: (_, plugin) => {
|
||||
if (!plugin.meta.enabled) {
|
||||
return <><InfoCircleFilled style={{ color: "#1668dc" }} /> Disabled on controller</>;
|
||||
}
|
||||
if (!plugin.package) {
|
||||
return "Error loading module";
|
||||
return <><CloseCircleFilled style={{ color: "#dc4446" }} /> Error loading module</>;
|
||||
}
|
||||
if (plugin.package.version !== plugin.meta.version) {
|
||||
return "Version missmatched";
|
||||
@@ -70,9 +76,8 @@ export default function PluginsPage() {
|
||||
},
|
||||
{
|
||||
title: "Loaded",
|
||||
dataIndex: ["meta", "loaded"],
|
||||
render: loaded => (loaded ? "Yes" : null),
|
||||
sorter: (a, b) => Number(a.meta.loaded) - Number(b.meta.loaded),
|
||||
render: (_, plugin) => (plugin.package ? "Yes" : null),
|
||||
sorter: (a, b) => Number(Boolean(a.package)) - Number(Boolean(b.package)),
|
||||
responsive: ["sm"],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -207,6 +207,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
|
||||
let hostOffline = ["unassigned", "unknown"].includes(props.instance.status!);
|
||||
const saveTable = <Table
|
||||
className="save-list-table"
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
@@ -236,7 +237,6 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
]}
|
||||
dataSource={[...saves.values()]}
|
||||
rowKey={save => save.name}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
columnWidth: 33,
|
||||
expandRowByClick: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import React, { Fragment, useContext, useEffect, useState } from "react";
|
||||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { Dropdown, Layout, Menu, MenuProps } from "antd";
|
||||
import UserOutlined from "@ant-design/icons/UserOutlined";
|
||||
@@ -115,12 +115,12 @@ export default function SiteLayout() {
|
||||
{combinedPages.map(({path, sidebarPath, content}) => <Route
|
||||
path={path}
|
||||
key={path}
|
||||
element={<>
|
||||
element={<Fragment key={path}>
|
||||
<SetSidebar path={sidebarPath ? sidebarPath : path} />
|
||||
<ErrorBoundary Component={ErrorPage}>
|
||||
{content}
|
||||
</ErrorBoundary>
|
||||
</>}
|
||||
</Fragment>}
|
||||
/>)}
|
||||
<Route element={<SetSidebar path={null} />} />
|
||||
</Routes>
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user