From 838170a9ffe4cc2d1abfb789eb908e0077841aad Mon Sep 17 00:00:00 2001 From: Luciole <31817566+Luciole0x@users.noreply.github.com> Date: Sat, 2 Sep 2023 14:51:35 +0200 Subject: [PATCH] Port packages/web_ui to TypeScript (#508) * .jsx to .tsx * controller: tsc --build before webpack-cli * Type Annotation * disabled move into the and * cleanup debug left over * documentation * default ModPack * modPackUpdateHandlers no undefined * PluginInfo changes proposition * PluginInfo changes proposition miss * ModRecord & ModInfo feedback loop at const mods = [ ? * ["connect", "drop", "resume", "close"] as const * .tsx to .ts when possible --- packages/controller/controller.ts | 2 +- packages/controller/package.json | 3 +- packages/controller/src/ControlConnection.ts | 7 +- packages/controller/src/Controller.ts | 2 +- packages/controller/src/HostConnection.ts | 4 +- packages/controller/src/WsServer.ts | 11 +- packages/controller/src/WsServerConnector.ts | 5 - packages/controller/src/routes.ts | 17 +- packages/ctl/src/commands.ts | 4 +- packages/host/host.ts | 2 +- packages/host/src/Host.ts | 4 +- packages/host/src/Instance.ts | 4 +- packages/lib/src/config/classes.ts | 29 +- packages/lib/src/config/definitions.ts | 10 +- packages/lib/src/data/ModPack.ts | 6 +- packages/lib/src/data/index.ts | 2 +- packages/lib/src/data/messages_controller.ts | 16 +- packages/lib/src/data/messages_core.ts | 3 +- packages/lib/src/data/messages_instance.ts | 20 +- packages/lib/src/data/messages_user.ts | 3 +- packages/lib/src/link/connectors.ts | 10 +- packages/lib/src/link/link.ts | 4 +- packages/lib/src/plugin.ts | 66 ++++- packages/lib/src/plugin_loader.ts | 16 +- packages/web_ui/global.d.ts | 12 + packages/web_ui/package.json | 10 +- .../src/{bootstrap.jsx => bootstrap.tsx} | 25 +- .../src/components/{App.jsx => App.tsx} | 23 +- ...tanceModal.jsx => AssignInstanceModal.tsx} | 12 +- ...{BaseConfigTree.jsx => BaseConfigTree.tsx} | 52 +++- .../web_ui/src/components/ControlContext.jsx | 3 - .../web_ui/src/components/ControlContext.tsx | 5 + ...onfigTree.jsx => ControllerConfigTree.tsx} | 6 +- ...{ControllerPage.jsx => ControllerPage.tsx} | 0 ...reateSaveModal.jsx => CreateSaveModal.tsx} | 14 +- .../web_ui/src/components/ErrorBoundary.jsx | 20 -- .../web_ui/src/components/ErrorBoundary.tsx | 27 ++ .../{ErrorPage.jsx => ErrorPage.tsx} | 3 +- .../{HostViewPage.jsx => HostViewPage.tsx} | 9 +- .../{HostsPage.jsx => HostsPage.tsx} | 46 ++-- ...eConfigTree.jsx => InstanceConfigTree.tsx} | 12 +- .../{InstanceList.jsx => InstanceList.tsx} | 34 ++- .../{InstanceRcon.jsx => InstanceRcon.tsx} | 10 +- ...nceStatusTag.jsx => InstanceStatusTag.tsx} | 6 +- ...tanceViewPage.jsx => InstanceViewPage.tsx} | 34 ++- .../{InstancesPage.jsx => InstancesPage.tsx} | 7 +- ...cenarioModal.jsx => LoadScenarioModal.tsx} | 17 +- .../{LogConsole.jsx => LogConsole.tsx} | 43 ++- .../{LoginForm.jsx => LoginForm.tsx} | 8 +- packages/web_ui/src/components/ModDetails.jsx | 54 ---- packages/web_ui/src/components/ModDetails.tsx | 64 +++++ ...odPackViewPage.jsx => ModPackViewPage.tsx} | 258 ++++++++++++------ .../components/{ModsPage.jsx => ModsPage.tsx} | 23 +- .../{PageHeader.jsx => PageHeader.tsx} | 7 +- .../{PageLayout.jsx => PageLayout.tsx} | 9 +- .../web_ui/src/components/PluginExtra.jsx | 17 -- .../web_ui/src/components/PluginExtra.tsx | 31 +++ ...{PluginViewPage.jsx => PluginViewPage.tsx} | 12 +- .../web_ui/src/components/PluginsContext.jsx | 3 - .../{PluginsPage.jsx => PluginsPage.tsx} | 12 +- .../{RoleViewPage.jsx => RoleViewPage.tsx} | 17 +- .../{RolesPage.jsx => RolesPage.tsx} | 11 +- .../{SavesList.jsx => SavesList.tsx} | 68 +++-- .../{SectionHeader.jsx => SectionHeader.tsx} | 6 +- .../{SiteLayout.jsx => SiteLayout.tsx} | 19 +- ...Button.jsx => StartStopInstanceButton.tsx} | 28 +- .../{UserViewPage.jsx => UserViewPage.tsx} | 34 +-- .../{UsersPage.jsx => UsersPage.tsx} | 16 +- packages/web_ui/src/{index.jsx => index.ts} | 15 +- .../src/model/{account.jsx => account.ts} | 28 +- ...export_manifest.jsx => export_manifest.ts} | 9 +- .../web_ui/src/model/{host.jsx => host.ts} | 24 +- .../src/model/{instance.jsx => instance.ts} | 19 +- .../{item_metadata.jsx => item_metadata.ts} | 27 +- .../src/model/{locale.jsx => locale.ts} | 10 +- .../src/model/{mod_pack.jsx => mod_pack.ts} | 15 +- .../web_ui/src/model/{mods.jsx => mods.ts} | 4 +- .../web_ui/src/model/{saves.jsx => saves.ts} | 12 +- .../web_ui/src/model/{user.jsx => user.tsx} | 31 ++- packages/web_ui/src/{pages.jsx => pages.tsx} | 9 +- .../src/util/{clipboard.jsx => clipboard.ts} | 12 +- .../web_ui/src/util/{notify.jsx => notify.ts} | 14 +- .../util/{time_format.jsx => time_format.ts} | 7 +- .../src/util/{websocket.jsx => websocket.ts} | 154 +++++------ packages/web_ui/tsconfig.json | 20 ++ packages/web_ui/webpack.common.js | 9 +- plugins/global_chat/info.ts | 4 +- plugins/inventory_sync/info.ts | 2 +- plugins/player_auth/info.ts | 2 +- plugins/research_sync/info.ts | 4 +- plugins/statistics_exporter/info.ts | 2 +- tsconfig.json | 1 + 92 files changed, 1104 insertions(+), 707 deletions(-) create mode 100644 packages/web_ui/global.d.ts rename packages/web_ui/src/{bootstrap.jsx => bootstrap.tsx} (78%) rename packages/web_ui/src/components/{App.jsx => App.tsx} (83%) rename packages/web_ui/src/components/{AssignInstanceModal.jsx => AssignInstanceModal.tsx} (85%) rename packages/web_ui/src/components/{BaseConfigTree.jsx => BaseConfigTree.tsx} (85%) delete mode 100644 packages/web_ui/src/components/ControlContext.jsx create mode 100644 packages/web_ui/src/components/ControlContext.tsx rename packages/web_ui/src/components/{ControllerConfigTree.jsx => ControllerConfigTree.tsx} (82%) rename packages/web_ui/src/components/{ControllerPage.jsx => ControllerPage.tsx} (100%) rename packages/web_ui/src/components/{CreateSaveModal.jsx => CreateSaveModal.tsx} (94%) delete mode 100644 packages/web_ui/src/components/ErrorBoundary.jsx create mode 100644 packages/web_ui/src/components/ErrorBoundary.tsx rename packages/web_ui/src/components/{ErrorPage.jsx => ErrorPage.tsx} (77%) rename packages/web_ui/src/components/{HostViewPage.jsx => HostViewPage.tsx} (92%) rename packages/web_ui/src/components/{HostsPage.jsx => HostsPage.tsx} (81%) rename packages/web_ui/src/components/{InstanceConfigTree.jsx => InstanceConfigTree.tsx} (73%) rename packages/web_ui/src/components/{InstanceList.jsx => InstanceList.tsx} (72%) rename packages/web_ui/src/components/{InstanceRcon.jsx => InstanceRcon.tsx} (81%) rename packages/web_ui/src/components/{InstanceStatusTag.jsx => InstanceStatusTag.tsx} (74%) rename packages/web_ui/src/components/{InstanceViewPage.jsx => InstanceViewPage.tsx} (89%) rename packages/web_ui/src/components/{InstancesPage.jsx => InstancesPage.tsx} (95%) rename packages/web_ui/src/components/{LoadScenarioModal.jsx => LoadScenarioModal.tsx} (92%) rename packages/web_ui/src/components/{LogConsole.jsx => LogConsole.tsx} (76%) rename packages/web_ui/src/components/{LoginForm.jsx => LoginForm.tsx} (90%) delete mode 100644 packages/web_ui/src/components/ModDetails.jsx create mode 100644 packages/web_ui/src/components/ModDetails.tsx rename packages/web_ui/src/components/{ModPackViewPage.jsx => ModPackViewPage.tsx} (75%) rename packages/web_ui/src/components/{ModsPage.jsx => ModsPage.tsx} (91%) rename packages/web_ui/src/components/{PageHeader.jsx => PageHeader.tsx} (54%) rename packages/web_ui/src/components/{PageLayout.jsx => PageLayout.tsx} (74%) delete mode 100644 packages/web_ui/src/components/PluginExtra.jsx create mode 100644 packages/web_ui/src/components/PluginExtra.tsx rename packages/web_ui/src/components/{PluginViewPage.jsx => PluginViewPage.tsx} (90%) delete mode 100644 packages/web_ui/src/components/PluginsContext.jsx rename packages/web_ui/src/components/{PluginsPage.jsx => PluginsPage.tsx} (82%) rename packages/web_ui/src/components/{RoleViewPage.jsx => RoleViewPage.tsx} (89%) rename packages/web_ui/src/components/{RolesPage.jsx => RolesPage.tsx} (90%) rename packages/web_ui/src/components/{SavesList.jsx => SavesList.tsx} (85%) rename packages/web_ui/src/components/{SectionHeader.jsx => SectionHeader.tsx} (58%) rename packages/web_ui/src/components/{SiteLayout.jsx => SiteLayout.tsx} (87%) rename packages/web_ui/src/components/{StartStopInstanceButton.jsx => StartStopInstanceButton.tsx} (57%) rename packages/web_ui/src/components/{UserViewPage.jsx => UserViewPage.tsx} (90%) rename packages/web_ui/src/components/{UsersPage.jsx => UsersPage.tsx} (85%) rename packages/web_ui/src/{index.jsx => index.ts} (81%) rename packages/web_ui/src/model/{account.jsx => account.ts} (58%) rename packages/web_ui/src/model/{export_manifest.jsx => export_manifest.ts} (67%) rename packages/web_ui/src/model/{host.jsx => host.ts} (66%) rename packages/web_ui/src/model/{instance.jsx => instance.ts} (77%) rename packages/web_ui/src/model/{item_metadata.jsx => item_metadata.ts} (57%) rename packages/web_ui/src/model/{locale.jsx => locale.ts} (56%) rename packages/web_ui/src/model/{mod_pack.jsx => mod_pack.ts} (82%) rename packages/web_ui/src/model/{mods.jsx => mods.ts} (90%) rename packages/web_ui/src/model/{saves.jsx => saves.ts} (59%) rename packages/web_ui/src/model/{user.jsx => user.tsx} (71%) rename packages/web_ui/src/{pages.jsx => pages.tsx} (89%) rename packages/web_ui/src/util/{clipboard.jsx => clipboard.ts} (83%) rename packages/web_ui/src/util/{notify.jsx => notify.ts} (67%) rename packages/web_ui/src/util/{time_format.jsx => time_format.ts} (80%) rename packages/web_ui/src/util/{websocket.jsx => websocket.ts} (74%) create mode 100644 packages/web_ui/tsconfig.json diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index c9c9a8fb..b7be8148 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -194,7 +194,7 @@ interface InitializeParameters { args: ControllerArgs; shouldRun: boolean; clusterLogger: winston.Logger; - pluginInfos: any[] | null; + pluginInfos: lib.PluginNodeEnvInfo[] | null; controllerConfigPath: string | null; controllerConfig: lib.ControllerConfig | null; } diff --git a/packages/controller/package.json b/packages/controller/package.json index b61d3d6c..38fdc352 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -6,8 +6,7 @@ "version": "2.0.0-alpha.13", "main": "dist/controller/controller.js", "scripts": { - "prepare": "webpack-cli --env production && pnpm build", - "build": "tsc --build", + "prepare": "tsc --build && webpack-cli --env production", "test": "echo \"Error: no test specified\" && exit 1" }, "bin": { diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index b43c274c..b87e0255 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -76,13 +76,10 @@ export default class ControlConnection extends BaseConnection { } }); - for (let event of ["connect", "drop", "resume", "close"]) { + for (let event of ["connect", "drop", "resume", "close"] as const) { this.connector.on(event, () => { for (let controllerPlugin of this._controller.plugins.values()) { - controllerPlugin.onControlConnectionEvent( - this, - event as "connect" | "drop" | "resume" | "close" - ); + controllerPlugin.onControlConnectionEvent(this, event); } }); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index e446e995..f69b102b 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -45,7 +45,7 @@ const logSizeGauge = new Gauge( export default class Controller { clusterLogger: winston.Logger; /** Array of plugin info objects for known plugins */ - pluginInfos: lib.PluginInfo[]; + pluginInfos: lib.PluginNodeEnvInfo[]; configPath: string; /** Controller config. */ config: lib.ControllerConfig; diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 013d3c9e..b1f83e69 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -54,11 +54,11 @@ export default class HostConnection extends BaseConnection { plugins: registerData.plugins, }); - for (let event of ["connect", "drop", "resume", "close"]) { + for (let event of ["connect", "drop", "resume", "close"] as const) { // eslint-disable-next-line no-loop-func this.connector.on(event, () => { for (let plugin of this._controller.plugins.values()) { - plugin.onHostConnectionEvent(this, event as "connect"|"drop"|"resume"|"close"); + plugin.onHostConnectionEvent(this, event); } }); } diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index bf57cb79..75b8f73d 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -1,7 +1,6 @@ import type { IncomingMessage } from "http"; import type { Duplex } from "stream"; import type Controller from "./Controller"; -import type { WebSocketClusterio } from "./WsServerConnector"; import jwt from "jsonwebtoken"; import WebSocket, { WebSocketServer } from "ws"; @@ -55,7 +54,7 @@ export default class WsServer { noServer: true, path: "/api/socket", }); - this.wss.on("connection", (socket: WebSocketClusterio, req: IncomingMessage) => this.handleConnection(socket, req)); + this.wss.on("connection", (socket: lib.WebSocketClusterio, req: IncomingMessage) => this.handleConnection(socket, req)); } async stop(): Promise { @@ -98,19 +97,19 @@ export default class WsServer { }); } - handleConnection(socket: WebSocketClusterio, req: IncomingMessage) { + handleConnection(socket: lib.WebSocketClusterio, req: IncomingMessage) { logger.verbose(`WsServer | new connection from ${req.socket.remoteAddress}`); // Track statistics wsConnectionsCounter.inc(); - socket.on("message", (message) => { + socket.on("message", (message: WebSocket.RawData) => { wsMessageCounter.labels("in").inc(); if (!socket.clusterio_ignore_dump) { this.controller.debugEvents.emit("message", { direction: "in", content: message }); } }); let originalSend = socket.send; - socket.send = (...args) => { + socket.send = (...args: any[]) => { wsMessageCounter.labels("out").inc(); if (typeof args[0] === "string" && !socket.clusterio_ignore_dump) { this.controller.debugEvents.emit("message", { direction: "out", content: args[0] }); @@ -120,7 +119,7 @@ export default class WsServer { }; // Start connection handshake. - let loadedPlugins: any = {}; + let loadedPlugins: {[key:string]: string} = {}; for (let [name, plugin] of this.controller.plugins) { loadedPlugins[name] = plugin.info.version; } diff --git a/packages/controller/src/WsServerConnector.ts b/packages/controller/src/WsServerConnector.ts index 578e7568..8ca14782 100644 --- a/packages/controller/src/WsServerConnector.ts +++ b/packages/controller/src/WsServerConnector.ts @@ -5,10 +5,6 @@ import events from "events"; import * as lib from "@clusterio/lib"; const { logger } = lib; -export interface WebSocketClusterio extends WebSocket { - clusterio_ignore_dump?: boolean; -} - /** * Connector for controller connections @@ -18,7 +14,6 @@ export interface WebSocketClusterio extends WebSocket { */ export default class WsServerConnector extends lib.WebSocketBaseConnector { _timeoutId: ReturnType | null = null; - declare _socket: WebSocketClusterio | null; constructor( dst: lib.Address, diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 084b0817..0a9d0e95 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -82,7 +82,7 @@ async function getMetrics(req: Request, res: Response, next: any) { } } } - + // TODO: results seem to mix deserialized and serialized lib.CollectorResult ? // mergeSamples imply samples be [string, [string, number][]] // but lib.defaultRegistry.collect() imply Map> @@ -99,8 +99,21 @@ async function getMetrics(req: Request, res: Response, next: any) { res.send(text); } + +export type PluginWebApi = { + name: string; + version: string; + enabled: boolean; + loaded: boolean; + web: { + main: string; + error?: string; + }; + requirePath: string; +} + function getPlugins(req: Request, res: Response) { - let plugins = []; + let plugins: PluginWebApi[] = []; for (let pluginInfo of req.app.locals.controller.pluginInfos) { let name = pluginInfo.name; let loaded = req.app.locals.controller.plugins.has(name); diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index c4ad00f6..1d8d5d9a 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -325,7 +325,7 @@ instanceConfigCommands.add(new lib.Command({ let instanceId = await lib.resolveInstance(control, args.instance); let response = await control.send(new lib.InstanceConfigGetRequest(instanceId)); - for (let group of (response.config as any).groups) { + for (let group of response.serializedConfig.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -425,7 +425,7 @@ instanceConfigCommands.add(new lib.Command({ } let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0}; let allConfigElements = await serializedConfigToString( - response.config, + response.serializedConfig, lib.InstanceConfig, disallowedList ); diff --git a/packages/host/host.ts b/packages/host/host.ts index 56654d9c..88c2d900 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -31,7 +31,7 @@ export class HostConnector extends lib.WebSocketClientConnector { constructor( public hostConfig: lib.HostConfig, tlsCa: string | undefined, - public pluginInfos: lib.PluginInfo[] + public pluginInfos: lib.PluginNodeEnvInfo[] ) { super( hostConfig.get("host.controller_url"), diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 313fdd9c..835c39b7 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -238,7 +238,7 @@ export default class Host extends lib.Link { * Certificate authority used to validate TLS connections to the controller. */ tlsCa?: string; - pluginInfos: lib.PluginInfo[]; + pluginInfos: lib.PluginNodeEnvInfo[]; config: lib.HostConfig; instanceConnections = new Map(); @@ -260,7 +260,7 @@ export default class Host extends lib.Link { connector: HostConnector, hostConfig: lib.HostConfig, tlsCa: string | undefined, - pluginInfos: lib.PluginInfo[] + pluginInfos: lib.PluginNodeEnvInfo[] ) { super(connector); this.tlsCa = tlsCa; diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 77d223ce..9c3e059f 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -490,7 +490,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`)); } - async _loadPlugin(pluginInfo: lib.PluginInfo, host: Host) { + async _loadPlugin(pluginInfo: lib.PluginNodeEnvInfo, host: Host) { let pluginLoadStarted = Date.now(); let InstancePluginClass = await lib.loadInstancePluginClass(pluginInfo); let instancePlugin = new InstancePluginClass(pluginInfo, this, host); @@ -524,7 +524,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await lib.safeOutputFile(this.path("instance-stats.json"), content); } - async init(pluginInfos: lib.PluginInfo[]) { + async init(pluginInfos: lib.PluginNodeEnvInfo[]) { this.notifyStatus("starting"); try { await this._loadStats(); diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index 6eb33fcb..88614815 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -1,4 +1,5 @@ // Configuration classes +import { Type, Static } from "@sinclair/typebox"; import events from "events"; import isDeepStrictEqual from "../is_deep_strict_equal"; @@ -317,8 +318,34 @@ export class Config extends events.EventEmitter { } } +// TODO: remove after config refactor +export class RawConfig { + constructor( + public serializedConfig: SerializedConfig + ) { } + + static jsonSchema = Type.Object({ + "serializedConfig": Type.Object({ + "groups": Type.Array( + Type.Object({ + "name": Type.String(), + "fields": Type.Record( + Type.String(), + Type.Unknown(), + ), + }), + ), + }), + }); + + static fromJSON(json: Static) { + return new this(json.serializedConfig); + } +} + + type FieldType = "boolean" | "string" | "number" | "object"; -interface FieldDefinition { +export interface FieldDefinition { type: FieldType; name: string; fullName: string; diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index cdfa7d0f..72a5be05 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -1,6 +1,6 @@ // Core definitions for the configuration system import * as classes from "./classes"; -import type { PluginInfo } from "../plugin"; +import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin"; /** @@ -503,14 +503,14 @@ export class ControlConfig extends classes.Config { } ControlConfig.registerGroup(ControlGroup); -function validateGroup(pluginInfo: PluginInfo, groupName: string) { - if (!(pluginInfo[groupName].prototype instanceof classes.PluginConfigGroup)) { +function validateGroup(pluginInfo: PluginNodeEnvInfo|PluginWebpackEnvInfo, groupName: "ControllerConfigGroup"|"InstanceConfigGroup") { + if (!(pluginInfo[groupName]?.prototype instanceof classes.PluginConfigGroup)) { throw new Error( `Expected ${groupName} for ${pluginInfo.name} to be a subclass of PluginConfigGroup` ); } - if (pluginInfo[groupName].groupName !== pluginInfo.name) { + if (pluginInfo[groupName]?.groupName !== pluginInfo.name) { throw new Error( `Expected ${groupName} for ${pluginInfo.name} to be named after the plugin` ); @@ -522,7 +522,7 @@ function validateGroup(pluginInfo: PluginInfo, groupName: string) { * * @param {Array} pluginInfos - Array of plugin info objects. */ -export function registerPluginConfigGroups(pluginInfos: PluginInfo[]) { +export function registerPluginConfigGroups(pluginInfos: PluginNodeEnvInfo[]|PluginWebpackEnvInfo[]) { for (let pluginInfo of pluginInfos) { if (pluginInfo.ControllerConfigGroup) { validateGroup(pluginInfo, "ControllerConfigGroup"); diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index b3194ec4..95d1c31f 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -12,7 +12,7 @@ import type { Logger } from "../logging"; /** * A setting for a mod. */ -interface ModSetting { +export interface ModSetting { /** Value of the given mod setting. */ value: boolean | number | string; } @@ -26,7 +26,7 @@ const ModSettingsJsonSchema = Type.Record(Type.String(), ModSettingJsonSchema); /** * A mod in a mod pack */ -interface ModRecord { +export interface ModRecord { /** name of the mod. */ name: string, /** if mod is to be loaded. */ @@ -35,6 +35,8 @@ interface ModRecord { version: string, /** SHA1 hash of the zip file. */ sha1?: string, + /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */ + error?: "missing"|"bad_checksum", } const ModRecordJsonSchema = Type.Object({ diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index 55d07e21..07fd480b 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -5,7 +5,7 @@ */ export { default as ExportManifest } from "./ExportManifest"; export { default as ModInfo } from "./ModInfo"; -export { default as ModPack } from "./ModPack"; +export { default as ModPack, ModSetting, ModRecord } from "./ModPack"; export { default as ModuleInfo } from "./ModuleInfo"; export * from "./composites"; export * from "./messages_core"; diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 4e2807f7..e39de057 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -1,21 +1,7 @@ import { Type, Static } from "@sinclair/typebox"; import { JsonString, StringEnum } from "./composites"; import { levels } from "../logging"; - -// TODO: remove after config refactor -export class RawConfig { - constructor( - public serializedConfig: object - ) { } - - static jsonSchema = Type.Object({ - "serializedConfig": Type.Object({}), - }); - - static fromJSON(json: Static) { - return new this(json.serializedConfig); - } -} +import { RawConfig } from "../config"; export class ControllerConfigGetRequest { declare ["constructor"]: typeof ControllerConfigGetRequest; diff --git a/packages/lib/src/data/messages_core.ts b/packages/lib/src/data/messages_core.ts index 73ffceea..7704bc17 100644 --- a/packages/lib/src/data/messages_core.ts +++ b/packages/lib/src/data/messages_core.ts @@ -2,6 +2,7 @@ import { TSchema, Type, Static } from "@sinclair/typebox"; import messageValidate from "./message_validate"; import { StringEnum } from "./composites"; +import { AccountRole } from "../plugin"; export type AddressType = "controller" | "host" | "instance" | "control" | "broadcast"; export type AddressShorthand = @@ -288,7 +289,7 @@ export class MessageRegisterControl extends Message { export class AccountDetails { constructor( public name: string, - public roles: { name: string, id: number, permissions: string[] }[], + public roles: AccountRole[], ) { } static jsonSchema = Type.Object({ diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index 9fc2741a..42376ae6 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -1,8 +1,11 @@ import { Type, Static } from "@sinclair/typebox"; import PlayerStats from "../PlayerStats"; import { JsonString, StringEnum, jsonArray } from "./composites"; +import { RawConfig } from "../config"; import type { User } from "../users"; import type { MessageRequest } from "./messages_core"; +import type { SerializedConfig } from "../config"; + export type InstanceStatus = "unknown" | "unassigned" | "stopped" | "starting" | "running" @@ -134,6 +137,8 @@ export class InstanceConfigGetRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.instance.get_config" as const; + // TODO replace with InstanceConfig after config refactor + static Response = RawConfig; constructor( public instanceId: number, @@ -146,21 +151,6 @@ export class InstanceConfigGetRequest { static fromJSON(json: Static) { return new this(json.instanceId); } - - // TODO replace with InstanceConfig after config refactor - static Response = class Response { - constructor( - public config: object, - ) { } - - static jsonSchema = Type.Object({ - "config": Type.Object({}), - }); - - static fromJSON(json: Static) { - return new this(json.config); - } - }; } export class InstanceConfigSetFieldRequest { diff --git a/packages/lib/src/data/messages_user.ts b/packages/lib/src/data/messages_user.ts index a975476b..210254dd 100644 --- a/packages/lib/src/data/messages_user.ts +++ b/packages/lib/src/data/messages_user.ts @@ -1,6 +1,7 @@ import { Type, Static } from "@sinclair/typebox"; import PlayerStats from "../PlayerStats"; import { JsonNumber, jsonArray } from "./composites"; +import { AccountRole } from "../plugin"; export class RawPermission { // TODO refactor into lib/user.Permission constructor( @@ -436,7 +437,7 @@ export class AccountUpdateEvent { static dst = "control" as const; constructor( - public roles?: { name: string, id: number, permissions: string[] }[], + public roles?: AccountRole[], ) { } static jsonSchema = Type.Object({ diff --git a/packages/lib/src/link/connectors.ts b/packages/lib/src/link/connectors.ts index b8960f42..8345d975 100644 --- a/packages/lib/src/link/connectors.ts +++ b/packages/lib/src/link/connectors.ts @@ -82,6 +82,10 @@ export abstract class BaseConnector extends events.EventEmitter { type ConnectorState = "closed" | "connecting" | "connected" | "resuming"; +export type WebSocketClusterio = WebSocket.WebSocket & { + clusterio_ignore_dump?: boolean; +}; + /** * Base connector for links * @@ -91,7 +95,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector { // One of closed, connecting (client only), connected and resuming. _state: ConnectorState = "closed"; _closing = false; - _socket: WebSocket | null = null; + _socket: WebSocketClusterio | null = null; _lastHeartbeat: number | null = null; _heartbeatId: ReturnType | null = null; _heartbeatInterval: number | null = null; @@ -451,12 +455,12 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector { // eslint-disable-next-line node/no-process-env if (process.env.APP_ENV === "browser") { - this._socket = new WebSocket(url); + this._socket = new WebSocket(url) as WebSocketClusterio; } else { let options: { ca?: string } = {}; if (this._tlsCa) { options.ca = this._tlsCa; } - this._socket = new WebSocket(url, options); + this._socket = new WebSocket(url, options) as WebSocketClusterio; } this._attachSocketHandlers(); diff --git a/packages/lib/src/link/link.ts b/packages/lib/src/link/link.ts index 46cc267d..73bb633e 100644 --- a/packages/lib/src/link/link.ts +++ b/packages/lib/src/link/link.ts @@ -6,7 +6,7 @@ import * as libSchema from "../schema"; import { dataClasses } from "./messages"; import { BaseConnector, WebSocketBaseConnector } from "./connectors"; import { strict as assert } from "assert"; -import type { PluginInfo } from "../plugin"; +import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin"; import type { User } from "../users"; import type { AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent } from "../data"; @@ -778,7 +778,7 @@ for (let Class of dataClasses) { Link.register(Class as RequestClass); } -export function registerPluginMessages(pluginInfos: PluginInfo[]) { +export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWebpackEnvInfo[]) { for (let pluginInfo of pluginInfos) { for (let Class of pluginInfo.messages || []) { if (Class.plugin !== pluginInfo.name) { diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 4d2b86a9..67623f35 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -12,7 +12,7 @@ import type { Link } from "./link"; import type { CommandTree } from "./command"; import type { User } from "./users"; import type { MessageRequest, MessageEvent } from "./data"; - +import type { PluginConfigGroup } from "./config"; /** * Conceptual base for controller and instance plugins. @@ -21,11 +21,41 @@ export type BasePlugin = | BaseControllerPlugin | BaseInstancePlugin | BaseControlPlugin - | BaseWebPlugin ; // TODO Add proper typing for plugins -export type PluginInfo = any; +/* Used to define the export of info.ts from plugins */ +export type PluginDeclaration = { + name: string; + title: string; + description?: string; + + instanceEntrypoint?: string; + InstanceConfigGroup?: typeof PluginConfigGroup; + + controllerEntrypoint?: string; + ControllerConfigGroup?: typeof PluginConfigGroup; + + controlEntrypoint?: string; + ControlConfigGroup?: typeof PluginConfigGroup; + + messages?: any[]; + + webEntrypoint?: string; + routes?: string[]; +} +export type PluginNodeEnvInfo = PluginDeclaration & { + requirePath: string; + version: string; + manifest: any; +}; + +export type PluginWebpackEnvInfo = PluginDeclaration & { + container: any; + package: any; + enabled?: boolean; +}; + type Controller = any; type Instance = any; type Host = any; @@ -96,7 +126,7 @@ export class BaseInstancePlugin { /** * The plugin's own info module */ - public info: PluginInfo, + public info: PluginNodeEnvInfo, /** * Instance the plugin started for */ @@ -332,7 +362,7 @@ export class BaseControllerPlugin { logger: Logger; constructor( - public info: PluginInfo, + public info: PluginNodeEnvInfo, public controller: Controller, public metrics: any[], logger: Logger @@ -600,7 +630,7 @@ export class BaseControlPlugin { /** * The plugin's own info module */ - public info: PluginInfo, + public info: PluginNodeEnvInfo, logger: Logger, ) { this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; @@ -647,6 +677,26 @@ export interface PluginLoginForm { Component: React["Component"]; }; +export type AccountRole = { + name: string; + id: number; + permissions: string[]; +}; + +export type UserAccount = { + /** Name of the currently logged in account. */ + name: string; + /** Roles of the corrently logged in account. */ + roles: AccountRole[]; + /** Check if the currently logged in account has the given permission. */ + hasPermission: (permission: string) => boolean | null; + /** Check if the currently logged in account has any of the given permissions. */ + hasAnyPermission: (...permissions: string[]) => boolean | null; + /** Check if the currently logged in account has all of given permissions. */ + hasAllPermission: (...permissions: string[]) => boolean | null; + /** Logs out of the web interface. */ + logOut: () => void; +}; /** * Plugin supplied pages @@ -677,7 +727,7 @@ export interface PluginPage { /** * Permission to access page. function are expected to throw an error if access is deny. */ - permission?: string | ((user: User, message: MessageRequest|MessageEvent) => void); + permission?: string | ((account: UserAccount) => (boolean|null)); }; /** @@ -762,7 +812,7 @@ export class BaseWebPlugin { /** * The plugin's own info module */ - public info: PluginInfo, + public info: PluginWebpackEnvInfo, logger: Logger, ) { this.package = packageData; diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts index 3122a271..6a1a72bf 100644 --- a/packages/lib/src/plugin_loader.ts +++ b/packages/lib/src/plugin_loader.ts @@ -21,9 +21,9 @@ import { logger } from "./logging"; * @returns Array of plugin info modules. */ export async function loadPluginInfos(pluginList: Map) { - let plugins: libPlugin.PluginInfo[] = []; + let plugins: libPlugin.PluginNodeEnvInfo[] = []; for (let [pluginName, pluginPath] of pluginList) { - let pluginInfo: libPlugin.PluginInfo; + let pluginInfo: libPlugin.PluginNodeEnvInfo; let pluginPackage: { version: string, main?: string }; try { @@ -53,12 +53,12 @@ export async function loadPluginInfos(pluginList: Map) { } function loadPluginClass( - entrypointName: string, + entrypointName: "controllerEntrypoint"|"instanceEntrypoint"|"controlEntrypoint", className: string, pluginClass: any, - pluginInfo: libPlugin.PluginInfo + pluginInfo: libPlugin.PluginNodeEnvInfo ) { - let resolvedPath = path.posix.join(pluginInfo.requirePath, pluginInfo[entrypointName]); + let resolvedPath = path.posix.join(pluginInfo.requirePath, pluginInfo[entrypointName]!); let entrypoint = require(resolvedPath); if (!entrypoint[className]) { throw new libErrors.PluginError(pluginInfo.name, @@ -82,7 +82,7 @@ function loadPluginClass( * @returns plugin class */ export async function loadControllerPluginClass( - pluginInfo: libPlugin.PluginInfo + pluginInfo: libPlugin.PluginNodeEnvInfo ): Promise { return loadPluginClass("controllerEntrypoint", "ControllerPlugin", libPlugin.BaseControllerPlugin, pluginInfo); } @@ -96,7 +96,7 @@ export async function loadControllerPluginClass( * @returns plugin class */ export async function loadInstancePluginClass( - pluginInfo: libPlugin.PluginInfo + pluginInfo: libPlugin.PluginNodeEnvInfo ): Promise { return loadPluginClass("instanceEntrypoint", "InstancePlugin", libPlugin.BaseInstancePlugin, pluginInfo); } @@ -110,7 +110,7 @@ export async function loadInstancePluginClass( * @returns plugin class */ export async function loadControlPluginClass( - pluginInfo: libPlugin.PluginInfo + pluginInfo: libPlugin.PluginNodeEnvInfo ): Promise { return loadPluginClass("controlEntrypoint", "ControlPlugin", libPlugin.BaseControlPlugin, pluginInfo); } diff --git a/packages/web_ui/global.d.ts b/packages/web_ui/global.d.ts new file mode 100644 index 00000000..e1a04c39 --- /dev/null +++ b/packages/web_ui/global.d.ts @@ -0,0 +1,12 @@ +declare module '*.png' { + const value: import('react-native').ImageSourcePropType; + export default value; +} + +declare interface Window { + webRoot: string; + staticRoot: string; +} + +declare const __webpack_init_sharing__: (shareScope: string) => Promise; +declare const __webpack_share_scopes__: { default: any }; diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json index 133370b1..93722e7f 100644 --- a/packages/web_ui/package.json +++ b/packages/web_ui/package.json @@ -4,7 +4,7 @@ "author": "Hornwitser", "license": "MIT", "version": "2.0.0-alpha.13", - "browser": "src/index.jsx", + "browser": "src/index.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, @@ -32,12 +32,15 @@ "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", "process": "^0.11.10", + "rc-field-form": "^1.37.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.2", "stream-browserify": "^3.0.0", "style-loader": "^3.3.3", "terser-webpack-plugin": "^5.3.9", + "ts-loader": "^9.4.4", + "typescript": "^5.1.6", "util": "^0.12.5", "webpack": "^5.88.2", "webpack-cli": "^5.1.4", @@ -45,5 +48,10 @@ }, "publishConfig": { "access": "public" + }, + "devDependencies": { + "@types/node": "^20.4.5", + "@types/react": "^18.2.21", + "@types/react-dom": "^18.2.7" } } diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.tsx similarity index 78% rename from packages/web_ui/src/bootstrap.jsx rename to packages/web_ui/src/bootstrap.tsx index 496bae95..01f68715 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -4,13 +4,14 @@ import React from "react"; import { createRoot } from "react-dom/client"; import * as lib from "@clusterio/lib"; +import type { PluginWebApi } from "@clusterio/controller/src/routes"; import App from "./components/App"; import { Control, ControlConnector } from "./util/websocket"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; -async function loadScript(url) { +async function loadScript(url: string) { let script = document.createElement("script"); script.src = url; script.type = "text/javascript"; @@ -25,9 +26,9 @@ async function loadScript(url) { return result; } -async function loadPluginInfos() { - let response = await fetch(`${webRoot}api/plugins`); - let pluginList; +async function loadPluginInfos(): Promise { + let response = await fetch(`${window.webRoot}api/plugins`); + let pluginList: PluginWebApi[]; if (response.ok) { pluginList = await response.json(); @@ -44,8 +45,8 @@ async function loadPluginInfos() { continue; } try { - await loadScript(`${webRoot}${meta.web.main}`); - let container = window[`plugin_${meta.name}`]; + await loadScript(`${window.webRoot}${meta.web.main}`); + let container: any = (window as { [key: string]: any })[`plugin_${meta.name}`]; if (!container) { throw new Error(`Plugin did not expose its container via plugin_${meta.name}`); } @@ -56,7 +57,7 @@ async function loadPluginInfos() { pluginInfo.enabled = meta.enabled; pluginInfos.push(pluginInfo); - } catch (err) { + } catch (err: any) { logger.error(`Failed to load plugin info for ${meta.name}`); if (err.stack) { logger.error(err.stack); @@ -66,7 +67,7 @@ async function loadPluginInfos() { return pluginInfos; } -async function loadPlugins(pluginInfos) { +async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[]) { let plugins = new Map(); for (let pluginInfo of pluginInfos) { if (!pluginInfo.enabled) { @@ -86,7 +87,7 @@ async function loadPlugins(pluginInfos) { await plugin.init(); plugins.set(pluginInfo.name, plugin); - } catch (err) { + } catch (err: any) { logger.error(`Failed to load plugin ${pluginInfo.name}`); if (err.stack) { logger.error(err.stack); @@ -107,10 +108,10 @@ export default async function bootstrap() { lib.finalizeConfigs(); let plugins = await loadPlugins(pluginInfos); - let wsUrl = new URL(window.webRoot, document.location); - let controlConnector = new ControlConnector(wsUrl, 120); + let wsUrl = new URL(window.webRoot, document.location.href); + let controlConnector = new ControlConnector(wsUrl.href, 120, undefined); let control = new Control(controlConnector, plugins); - const root = createRoot(document.getElementById("root")); + const root = createRoot(document.getElementById("root") as HTMLDivElement); root.render(); } diff --git a/packages/web_ui/src/components/App.jsx b/packages/web_ui/src/components/App.tsx similarity index 83% rename from packages/web_ui/src/components/App.jsx rename to packages/web_ui/src/components/App.tsx index 2c573d46..85af41bf 100644 --- a/packages/web_ui/src/components/App.jsx +++ b/packages/web_ui/src/components/App.tsx @@ -1,3 +1,5 @@ +import type { Control } from "../util/websocket"; + import React, { useEffect, useState } from "react"; import { BrowserRouter } from "react-router-dom"; @@ -13,7 +15,10 @@ import { Card, ConfigProvider, Spin, Typography, theme } from "antd"; const { Paragraph } = Typography; -function ErrorCard(props) { +export type ErrorProps = { + error: Error; +}; +function ErrorCard(props: ErrorProps) { return

An unexpected error occured

@@ -22,7 +27,11 @@ function ErrorCard(props) {
; } -export default function App(props) { + +type AppProps = { + control: Control; +}; +export default function App(props: AppProps) { let [connected, setConnected] = useState(false); let [token, setToken] = useState(localStorage.getItem("controller_token") || null); let connector = props.control.connector; @@ -35,7 +44,11 @@ export default function App(props) { useEffect(() => { function onConnect() { - localStorage.setItem("controller_token", token); + if (token === null) { + localStorage.removeItem("controller_token"); + } else { + localStorage.setItem("controller_token", token); + } setConnected(true); } @@ -43,7 +56,7 @@ export default function App(props) { setConnected(false); } - function onError(err) { + function onError(err: any) { logger.error(`Unexpected error in connector:\n${err.stack}`); clearToken(); setConnected(false); @@ -96,7 +109,7 @@ export default function App(props) { theme={{ algorithm: theme.darkAlgorithm }} > - + {page} diff --git a/packages/web_ui/src/components/AssignInstanceModal.jsx b/packages/web_ui/src/components/AssignInstanceModal.tsx similarity index 85% rename from packages/web_ui/src/components/AssignInstanceModal.jsx rename to packages/web_ui/src/components/AssignInstanceModal.tsx index 4fc8109c..06d05be4 100644 --- a/packages/web_ui/src/components/AssignInstanceModal.jsx +++ b/packages/web_ui/src/components/AssignInstanceModal.tsx @@ -1,3 +1,4 @@ +import type { BaseButtonProps } from "antd/es/button/button"; import React, { useContext, useState } from "react"; import { Button, Form, Modal, Select, Typography } from "antd"; @@ -10,7 +11,14 @@ import { useHostList } from "../model/host"; const { Paragraph } = Typography; -export default function AssignInstanceModal(props) { +type AssignInstanceModalProps = { + id?: number; + hostId?: number; + buttonProps: BaseButtonProps & { style: React.CSSProperties }; + buttonContent: string; + onFinish?: () => void; +}; +export default function AssignInstanceModal(props: AssignInstanceModalProps) { let [open, setOpen] = useState(false); let [hostList] = useHostList(); let [applying, setApplying] = useState(false); @@ -30,7 +38,7 @@ export default function AssignInstanceModal(props) { setApplying(true); control.send( - new lib.InstanceAssignRequest(props.id, hostId) + new lib.InstanceAssignRequest(props.id!, hostId) ).then(() => { setOpen(false); if (props.onFinish) { diff --git a/packages/web_ui/src/components/BaseConfigTree.jsx b/packages/web_ui/src/components/BaseConfigTree.tsx similarity index 85% rename from packages/web_ui/src/components/BaseConfigTree.jsx rename to packages/web_ui/src/components/BaseConfigTree.tsx index 68377fff..99c8c69d 100644 --- a/packages/web_ui/src/components/BaseConfigTree.jsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -1,11 +1,13 @@ +import type * as lib from "@clusterio/lib"; + import React, { useEffect, useState } from "react"; import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography } from "antd"; import ReloadOutlined from "@ant-design/icons/ReloadOutlined"; const { Title } = Typography; -function getInitialValues(config, props) { - let initialValues = {}; +function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) { + let initialValues: any = {}; for (let [name, GroupClass] of props.ConfigClass.groups) { let group = config.group(name); for (let def of GroupClass.definitions.values()) { @@ -13,7 +15,7 @@ function getInitialValues(config, props) { continue; } - let value = group.get(def.name); + let value = group.get(def.name) as any; let fieldName = `${group.name}.${def.name}`; if (def.type === "object") { @@ -29,10 +31,18 @@ function getInitialValues(config, props) { return initialValues; } -export default function BaseConfigTree(props) { - let [config, setConfig] = useState(null); +type BaseConfigTreeProps = { + ConfigClass: typeof lib.Config; + retrieveConfig: () => Promise; + setField: (field:string, value:any) => Promise; + setProp: (field:string, prop:string, value:any) => Promise; + id?: number; + onApply?: () => void; +}; +export default function BaseConfigTree(props: BaseConfigTreeProps) { + let [config, setConfig] = useState(null); let [form] = Form.useForm(); - let [changedFields, setChangedFields] = useState(new Set()); + let [changedFields, setChangedFields] = useState>(new Set()); let [errorFields, setErrorFields] = useState(new Map()); let [applying, setApplying] = useState(false); @@ -49,7 +59,7 @@ export default function BaseConfigTree(props) { }); }, [props.id]); - function renderInput(def) { + function renderInput(def: lib.FieldDefinition) { if (def.type === "boolean") { return ; } @@ -67,8 +77,8 @@ export default function BaseConfigTree(props) { return ; } - let initialValues = {}; - function onValuesChange(changedValues) { + let initialValues: any = {}; + function onValuesChange(changedValues: object) { let newChangedFields = new Set(changedFields); let changed = false; for (let [key, value] of Object.entries(changedValues)) { @@ -93,11 +103,23 @@ export default function BaseConfigTree(props) { title="A restart is required for this setting to take effect" >; + + type ChildNode = { + key: string; + children: any[]; + title?: React.ReactElement; + }; + type TreeNode = { + key: string; + title: string; + children: ChildNode[]; + }; + let treeData = []; let propsMap = new Map(); for (let [name, GroupClass] of props.ConfigClass.groups) { let group = config.group(name); - let treeNode = { + let treeNode: TreeNode = { key: group.name, title: group.name, children: [], @@ -108,9 +130,9 @@ export default function BaseConfigTree(props) { continue; } - let value = group.get(def.name); + let value = group.get(def.name) as any; let fieldName = `${group.name}.${def.name}`; - let childNode = { + let childNode: ChildNode = { key: fieldName, children: [], }; @@ -127,7 +149,7 @@ export default function BaseConfigTree(props) { for (let prop of Object.keys(value)) { let propPath = `${group.name}.${def.name}.${prop}`; let restart = Boolean( - def.restartRequiredProps && def.restartRequired ^ restartRequiredProps.has(prop) + def.restartRequiredProps && Number(def.restartRequired) ^ Number(restartRequiredProps.has(prop)) ); childNode.children.push({ key: propPath, @@ -180,7 +202,7 @@ export default function BaseConfigTree(props) { let propValue = form.getFieldValue(`${newPropPath}.value`); if (!Object.prototype.hasOwnProperty.call(value, propName)) { let newConfig = new props.ConfigClass("control"); - await newConfig.load(config.serialize()); + await newConfig.load(config!.serialize()); newConfig.setProp(fieldName, propName, null); setConfig(newConfig); } @@ -252,7 +274,7 @@ export default function BaseConfigTree(props) { return newChanged; }); - } catch (err) { + } catch (err: any) { setErrorFields(errors => { let newErrors = new Map(errors); newErrors.set(field, err.message); diff --git a/packages/web_ui/src/components/ControlContext.jsx b/packages/web_ui/src/components/ControlContext.jsx deleted file mode 100644 index 979faf93..00000000 --- a/packages/web_ui/src/components/ControlContext.jsx +++ /dev/null @@ -1,3 +0,0 @@ -import React from "react"; - -export default React.createContext(); diff --git a/packages/web_ui/src/components/ControlContext.tsx b/packages/web_ui/src/components/ControlContext.tsx new file mode 100644 index 00000000..7e851431 --- /dev/null +++ b/packages/web_ui/src/components/ControlContext.tsx @@ -0,0 +1,5 @@ +import type { Control } from "../util/websocket"; + +import React from "react"; + +export default React.createContext({} as Control); diff --git a/packages/web_ui/src/components/ControllerConfigTree.jsx b/packages/web_ui/src/components/ControllerConfigTree.tsx similarity index 82% rename from packages/web_ui/src/components/ControllerConfigTree.jsx rename to packages/web_ui/src/components/ControllerConfigTree.tsx index b95c71d5..19defb5c 100644 --- a/packages/web_ui/src/components/ControllerConfigTree.jsx +++ b/packages/web_ui/src/components/ControllerConfigTree.tsx @@ -6,7 +6,7 @@ import BaseConfigTree from "./BaseConfigTree"; import ControlContext from "./ControlContext"; -export default function ControllerConfigTree(props) { +export default function ControllerConfigTree() { let control = useContext(ControlContext); async function retrieveConfig() { @@ -14,14 +14,14 @@ export default function ControllerConfigTree(props) { return result.serializedConfig; } - async function setField(field, value) { + async function setField(field: string, value: any) { await control.send(new lib.ControllerConfigSetFieldRequest( field, String(value), )); } - async function setProp(field, prop, value) { + async function setProp(field: string, prop: string, value: any) { if (value) { try { value = JSON.parse(value); diff --git a/packages/web_ui/src/components/ControllerPage.jsx b/packages/web_ui/src/components/ControllerPage.tsx similarity index 100% rename from packages/web_ui/src/components/ControllerPage.jsx rename to packages/web_ui/src/components/ControllerPage.tsx diff --git a/packages/web_ui/src/components/CreateSaveModal.jsx b/packages/web_ui/src/components/CreateSaveModal.tsx similarity index 94% rename from packages/web_ui/src/components/CreateSaveModal.jsx rename to packages/web_ui/src/components/CreateSaveModal.tsx index 100fd4fe..10cf1d7c 100644 --- a/packages/web_ui/src/components/CreateSaveModal.jsx +++ b/packages/web_ui/src/components/CreateSaveModal.tsx @@ -7,7 +7,11 @@ import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; -export default function CreateSaveModal(props) { +type CreateSaveModalProps = { + instance: any; +}; + +export default function CreateSaveModal(props: CreateSaveModalProps) { let [open, setOpen] = useState(false); let [creatingSave, setCreatingSave] = useState(false); let [form] = Form.useForm(); @@ -23,7 +27,7 @@ export default function CreateSaveModal(props) { let result; try { result = lib.readMapExchangeString(values.exchangeString); - } catch (err) { + } catch (err: any) { form.setFields([{ name: "exchangeString", errors: [err.message] }]); return; } @@ -34,7 +38,7 @@ export default function CreateSaveModal(props) { if (values.mapGenSettings && values.mapGenSettings.trim()) { try { mapGenSettings = JSON.parse(values.mapGenSettings); - } catch (err) { + } catch (err: any) { form.setFields([{ name: "mapGenSettings", errors: [err.message] }]); return; } @@ -43,7 +47,7 @@ export default function CreateSaveModal(props) { if (values.mapSettings && values.mapSettings.trim()) { try { mapSettings = JSON.parse(values.mapSettings); - } catch (err) { + } catch (err: any) { form.setFields([{ name: "mapSettings", errors: [err.message] }]); return; } @@ -69,7 +73,7 @@ export default function CreateSaveModal(props) { let result; try { result = lib.readMapExchangeString(exchangeString); - } catch (err) { + } catch (err: any) { form.setFields([{ name: "exchangeString", errors: [err.message] }]); return; } diff --git a/packages/web_ui/src/components/ErrorBoundary.jsx b/packages/web_ui/src/components/ErrorBoundary.jsx deleted file mode 100644 index 4fed84e1..00000000 --- a/packages/web_ui/src/components/ErrorBoundary.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from "react"; - - -export default class ErrorBoundary extends React.Component { - constructor(props) { - super(props); - this.state = { error: null }; - } - - static getDerivedStateFromError(error) { - return { error }; - } - - render() { - if (this.state.error) { - return ; - } - return this.props.children; - } -} diff --git a/packages/web_ui/src/components/ErrorBoundary.tsx b/packages/web_ui/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..6a4a178c --- /dev/null +++ b/packages/web_ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +import type { ErrorProps } from "./App"; + +type ErrorBoundaryProps = { + Component: (props: ErrorProps) => React.JSX.Element; + children?: React.ReactElement; +}; + +type ErrorBoundaryState = { + error: Error | null; +}; + +export default class ErrorBoundary extends React.Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: any) { + return { error }; + } + + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } +} diff --git a/packages/web_ui/src/components/ErrorPage.jsx b/packages/web_ui/src/components/ErrorPage.tsx similarity index 77% rename from packages/web_ui/src/components/ErrorPage.jsx rename to packages/web_ui/src/components/ErrorPage.tsx index 22748c46..8ce7cefb 100644 --- a/packages/web_ui/src/components/ErrorPage.jsx +++ b/packages/web_ui/src/components/ErrorPage.tsx @@ -2,11 +2,12 @@ import React from "react"; import { Typography } from "antd"; import PageLayout from "./PageLayout"; +import type { ErrorProps } from "./App"; const { Paragraph } = Typography; -export default function ErrorPage(props) { +export default function ErrorPage(props: ErrorProps) { return

An unexpected error occured

{props.error.stack} diff --git a/packages/web_ui/src/components/HostViewPage.jsx b/packages/web_ui/src/components/HostViewPage.tsx similarity index 92% rename from packages/web_ui/src/components/HostViewPage.jsx rename to packages/web_ui/src/components/HostViewPage.tsx index 7548be88..5feee10b 100644 --- a/packages/web_ui/src/components/HostViewPage.jsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -13,8 +13,7 @@ import PluginExtra from "./PluginExtra"; const { Title } = Typography; - -export default function HostViewPage(props) { +export default function HostViewPage() { let params = useParams(); let hostId = Number(params.id); let account = useAccount(); @@ -23,20 +22,20 @@ export default function HostViewPage(props) { instanceList = instanceList.filter(instance => instance.assignedHost === hostId); - let nav = [{ name: "Hosts", path: "/hosts" }, { name: host.name || hostId }]; + let nav = [{ name: "Hosts", path: "/hosts" }, { name: host.name || String(hostId) }]; if (host.loading) { return ; } if (host.missing) { return - +

Host with id {hostId} was not found on the controller.

; } return - + {host["name"]} diff --git a/packages/web_ui/src/components/HostsPage.jsx b/packages/web_ui/src/components/HostsPage.tsx similarity index 81% rename from packages/web_ui/src/components/HostsPage.jsx rename to packages/web_ui/src/components/HostsPage.tsx index 636bffb1..29b987c8 100644 --- a/packages/web_ui/src/components/HostsPage.jsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { Button, Form, InputNumber, Modal, Table, Tag, Typography } from "antd"; import CopyOutlined from "@ant-design/icons/lib/icons/CopyOutlined"; +import type { PluginWebApi } from "@clusterio/controller/src/routes"; import * as lib from "@clusterio/lib"; import { useAccount } from "../model/account"; @@ -13,19 +14,19 @@ import PluginExtra from "./PluginExtra"; import { useHostList } from "../model/host"; import notify, { notifyErrorHandler } from "../util/notify"; -const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; -function GenerateHostTokenButton(props) { +function GenerateHostTokenButton() { let control = useContext(ControlContext); let [open, setOpen] = useState(false); - let [token, setToken] = useState(null); - let [hostId, setHostId] = useState(null); + let [token, setToken] = useState(null); + let [hostId, setHostId] = useState(null); let [form] = Form.useForm(); - let [pluginList, setPluginList] = useState([]); + let [pluginList, setPluginList] = useState([]); useEffect(() => { (async () => { - let response = await fetch(`${webRoot}api/plugins`); + let response = await fetch(`${window.webRoot}api/plugins`); if (response.ok) { const plugins = await response.json(); setPluginList(plugins); @@ -36,7 +37,7 @@ function GenerateHostTokenButton(props) { }, []); async function generateToken() { - let id = null; + let id = undefined; let values = form.getFieldsValue(); if (values.hostId) { id = Number.parseInt(values.hostId, 10); @@ -49,7 +50,7 @@ function GenerateHostTokenButton(props) { let newToken = await control.send(new lib.HostGenerateTokenRequest(id)); setToken(newToken); - setHostId(id); + setHostId(id??null); } // Generate a new random @@ -60,6 +61,7 @@ function GenerateHostTokenButton(props) { }, [open]); // Only install plugins that aren't filesystem paths. Npm modules have max 1 forward slash in their name. + //@ts-ignore TODO::: this doesn't filter anything, `x.split("/") <= 1` is always = true. repair or shouldn't filter ? const pluginString = pluginList.map(p => `"${p.requirePath}"`).filter(x => x.split("/") <= 1).join(" "); return <>