Port packages/web_ui to TypeScript (#508)

* .jsx to .tsx

* controller: tsc --build before webpack-cli

* Type Annotation

* disabled move into the <Input/> and <Checkbox/>

* 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
This commit is contained in:
Luciole authored and GitHub committed 2023-09-02 14:51:35 +02:00
1 parent a4893751e9
commit 838170a9ff
92 files changed
+1104 -707

No files matched your search

+1 -1
View File
@@ -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;
}
+1 -2
View File
@@ -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": {
+2 -5
View File
@@ -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);
}
});
}
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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);
}
});
}
+5 -6
View File
@@ -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<void> {
@@ -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;
}
@@ -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<typeof setTimeout> | null = null;
declare _socket: WebSocketClusterio | null;
constructor(
dst: lib.Address,
+15 -2
View File
@@ -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<string, Map<string, number>>
@@ -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);
+2 -2
View File
@@ -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
);
+1 -1
View File
@@ -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"),
+2 -2
View File
@@ -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<number, InstanceConnection>();
@@ -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;
+2 -2
View File
@@ -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();
+28 -1
View File
@@ -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<typeof this.jsonSchema>) {
return new this(json.serializedConfig);
}
}
type FieldType = "boolean" | "string" | "number" | "object";
interface FieldDefinition {
export interface FieldDefinition {
type: FieldType;
name: string;
fullName: string;
+5 -5
View File
@@ -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<Object>} 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");
+4 -2
View File
@@ -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({
+1 -1
View File
@@ -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";
+1 -15
View File
@@ -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<typeof this.jsonSchema>) {
return new this(json.serializedConfig);
}
}
import { RawConfig } from "../config";
export class ControllerConfigGetRequest {
declare ["constructor"]: typeof ControllerConfigGetRequest;
+2 -1
View File
@@ -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({
+5 -15
View File
@@ -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<typeof this.jsonSchema>) {
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<typeof this.jsonSchema>) {
return new this(json.config);
}
};
}
export class InstanceConfigSetFieldRequest {
+2 -1
View File
@@ -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({
+7 -3
View File
@@ -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<typeof setInterval> | 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();
+2 -2
View File
@@ -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<unknown, unknown>);
}
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) {
+58 -8
View File
@@ -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;
+8 -8
View File
@@ -21,9 +21,9 @@ import { logger } from "./logging";
* @returns Array of plugin info modules.
*/
export async function loadPluginInfos(pluginList: Map<string, string>) {
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<string, string>) {
}
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<typeof libPlugin.BaseControllerPlugin> {
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<typeof libPlugin.BaseInstancePlugin> {
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<typeof libPlugin.BaseControlPlugin> {
return loadPluginClass("controlEntrypoint", "ControlPlugin", libPlugin.BaseControlPlugin, pluginInfo);
}
+12
View File
@@ -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<void>;
declare const __webpack_share_scopes__: { default: any };
+9 -1
View File
@@ -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"
}
}
@@ -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<lib.PluginWebpackEnvInfo[]> {
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(<App control={control}/>);
}
@@ -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 <div className="login-container">
<Card>
<h1>An unexpected error occured</h1>
@@ -22,7 +27,11 @@ function ErrorCard(props) {
</div>;
}
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 }}
>
<ControlContext.Provider value={props.control}>
<BrowserRouter basename={webRoot}>
<BrowserRouter basename={window.webRoot}>
{page}
</BrowserRouter>
</ControlContext.Provider>
@@ -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) {
@@ -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<lib.SerializedConfig>;
setField: (field:string, value:any) => Promise<void>;
setProp: (field:string, prop:string, value:any) => Promise<void>;
id?: number;
onApply?: () => void;
};
export default function BaseConfigTree(props: BaseConfigTreeProps) {
let [config, setConfig] = useState<lib.Config|null>(null);
let [form] = Form.useForm();
let [changedFields, setChangedFields] = useState(new Set());
let [changedFields, setChangedFields] = useState<Set<string>>(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 <Checkbox/>;
}
@@ -67,8 +77,8 @@ export default function BaseConfigTree(props) {
return <Spin/>;
}
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"
><ReloadOutlined/></Tooltip>;
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);
@@ -1,3 +0,0 @@
import React from "react";
export default React.createContext();
@@ -0,0 +1,5 @@
import type { Control } from "../util/websocket";
import React from "react";
export default React.createContext<Control>({} as Control);
@@ -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);
@@ -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;
}
@@ -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 <this.props.Component error={this.state.error} />;
}
return this.props.children;
}
}
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: any) {
return { error };
}
render() {
if (this.state.error) {
return <this.props.Component error={this.state.error} />;
}
return this.props.children;
}
}
@@ -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 <PageLayout nav={[{ name: "Error" }]}>
<h2>An unexpected error occured</h2>
<Paragraph code className="error-traceback">{props.error.stack}</Paragraph>
@@ -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 <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (host.missing) {
return <PageLayout nav={nav}>
<PageHeader title={hostId} />
<PageHeader title={String(hostId)} />
<p>Host with id {hostId} was not found on the controller.</p>
</PageLayout>;
}
return <PageLayout nav={nav}>
<PageHeader title={host.name || hostId} />
<PageHeader title={host.name || String(hostId)} />
<Descriptions bordered size="small" column={{ xs: 1, sm: 2, xl: 4 }}>
<Descriptions.Item label="Name">{host["name"]}</Descriptions.Item>
@@ -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<string|null>(null);
let [hostId, setHostId] = useState<number|null>(null);
let [form] = Form.useForm();
let [pluginList, setPluginList] = useState([]);
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
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 <>
<Button
@@ -138,10 +140,11 @@ function GenerateHostTokenButton(props) {
</>;
}
function CopyButton({ text, message }) {
function CopyButton({ text, message }: { text:string, message:string }) {
let [clipboardPermision, setClipboardPermission] = useState("granted");
useEffect(() => {
(async () => {
//@ts-ignore https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/write
let result = await navigator.permissions.query({ name: "clipboard-write" });
// result.state is "granted", "denied" or "prompt"
setClipboardPermission(result.state);
@@ -152,13 +155,20 @@ function CopyButton({ text, message }) {
}, []);
async function checkClipboardPermission() {
let result = await navigator.permissions.query({ name: "clipboard-write" });
// result.state is "granted", "denied" or "prompt"
setClipboardPermission(result.state);
result.onchange = function () {
try {
//@ts-ignore https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/write
let result = await navigator.permissions.query({ name: "clipboard-write" });
// result.state is "granted", "denied" or "prompt"
setClipboardPermission(result.state);
};
return result.state === "granted";
result.onchange = function () {
setClipboardPermission(result.state);
};
return result.state === "granted";
} catch (err: any) {
//If it fail because "clipboard-write" is not supported.
return err.name === "TypeError";
}
}
return <Button
@@ -184,7 +194,7 @@ export default function HostsPage() {
return <PageLayout nav={[{ name: "Hosts" }]}>
<PageHeader
title="Hosts"
extra={account.hasPermission("core.host.generate_token") && <GenerateHostTokenButton />}
extra={account.hasPermission("core.host.generate_token") && <GenerateHostTokenButton /> || undefined}
/>
<Table
columns={[
@@ -208,7 +218,7 @@ export default function HostsPage() {
{
title: "Public address",
dataIndex: "publicAddress",
sorter: (a, b) => strcmp(a.publicAddress, b.publicAddress),
sorter: (a, b) => strcmp(a.publicAddress??"", b.publicAddress??""),
},
{
title: "Connected",
@@ -218,7 +228,7 @@ export default function HostsPage() {
>
{host.connected ? "Connected" : "Disconnected"}
</Tag>,
sorter: (a, b) => a.connected - b.connected,
sorter: (a, b) => Number(a.connected) - Number(b.connected),
},
]}
dataSource={hostList}
@@ -6,19 +6,23 @@ import BaseConfigTree from "./BaseConfigTree";
import ControlContext from "./ControlContext";
export default function InstanceConfigTree(props) {
type InstanceConfigTreeProps = {
id: number;
};
export default function InstanceConfigTree(props: InstanceConfigTreeProps) {
let control = useContext(ControlContext);
async function retrieveConfig() {
let result = await control.send(new lib.InstanceConfigGetRequest(props.id));
return result.config;
return result.serializedConfig;
}
async function setField(field, value) {
async function setField(field: string, value: any) {
await control.send(new lib.InstanceConfigSetFieldRequest(props.id, field, value));
}
async function setProp(field, prop, value) {
async function setProp(field: string, prop: string, value: any) {
if (value) {
try {
value = JSON.parse(value);
@@ -2,21 +2,30 @@ import React from "react";
import { useNavigate } from "react-router-dom";
import { message, Button, Space, Table } 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";
import { useAccount } from "../model/account";
import { useHostList } from "../model/host";
import InstanceStatusTag from "./InstanceStatusTag";
import StartStopInstanceButton from "./StartStopInstanceButton";
import { InstanceDetails } from "@clusterio/lib";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
export default function InstanceList(props) {
type InstanceListProps = {
instances: InstanceDetails[];
size?: SizeType;
hideAssignedHost?: boolean;
};
export default function InstanceList(props: InstanceListProps) {
let account = useAccount();
let navigate = useNavigate();
let [hostList] = useHostList();
function hostName(hostId) {
if (hostId === null) {
function hostName(hostId?: number|null) {
if (hostId === null ||hostId === undefined) {
return "";
}
let host = hostList.find(s => s.id === hostId);
@@ -26,7 +35,7 @@ export default function InstanceList(props) {
return String(hostId);
}
function instancePublicAddress(instance) {
function instancePublicAddress(instance: InstanceDetails) {
let host = hostList.find(s => s.id === instance.assignedHost);
if (host && host.publicAddress) {
if (instance.gamePort) {
@@ -37,7 +46,7 @@ export default function InstanceList(props) {
return null;
}
let columns = [
let columns: ColumnsType<InstanceDetails> = [
{
title: "Name",
dataIndex: "name",
@@ -63,13 +72,13 @@ export default function InstanceList(props) {
icon={<CopyOutlined/>}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(publicAddress);
navigator.clipboard.writeText(publicAddress??"");
message.success("Copied public address!");
}}
/>
</> : "";
},
sorter: (a, b) => strcmp(hostPublicAddress(a.assignedHost), hostPublicAddress(b.assignedHost)),
sorter: (a, b) => strcmp(instancePublicAddress(a)||"", instancePublicAddress(b)||""),
responsive: ["lg"],
},
{
@@ -78,7 +87,10 @@ export default function InstanceList(props) {
render: instance => <InstanceStatusTag status={instance["status"]} />,
sorter: (a, b) => strcmp(a["status"], b["status"]),
},
...(account.hasAnyPermission("core.instance.start", "core.instance.stop") ? [{
];
if (account.hasAnyPermission("core.instance.start", "core.instance.stop")) {
columns.push({
key: "action",
render: instance => <StartStopInstanceButton
buttonProps={{ size: "small" }}
@@ -87,8 +99,8 @@ export default function InstanceList(props) {
responsive: ["sm"],
align: "right",
width: 100,
}] : []),
];
})
}
if (props.hideAssignedHost) {
columns.splice(1, 1);
@@ -9,12 +9,16 @@ import { notifyErrorHandler } from "../util/notify";
const { Title, Paragraph } = Typography;
export default function InstanceRcon(props) {
type InstanceRconProps = {
id: number;
disabled: boolean;
};
export default function InstanceRcon(props: InstanceRconProps) {
let control = useContext(ControlContext);
let [output, setOutput] = useState(null);
let [output, setOutput] = useState<string|null>(null);
let [running, setRunning] = useState(false);
async function sendCommand(command) {
async function sendCommand(command: string) {
if (!command) {
setOutput(null);
return;
@@ -13,8 +13,10 @@ export const statusColors = {
deleted: "#262626",
};
export default function InstanceStatusTag(props) {
type InstanceStatusTag = {
status: keyof typeof statusColors;
};
export default function InstanceStatusTag(props: InstanceStatusTag) {
return <Tag color={statusColors[props.status]}>
{props.status.replace("_", " ")}
</Tag>;
@@ -1,6 +1,7 @@
import React, { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Descriptions, Dropdown, Menu, Modal, Space, Spin, Typography } from "antd";
import { Alert, Button, Descriptions, Dropdown, Menu, MenuProps, Modal, Space, Spin, Typography } from "antd";
import type { ItemType } from 'antd/es/menu/hooks/useItems';
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import DownOutlined from "@ant-design/icons/DownOutlined";
@@ -19,14 +20,17 @@ import StartStopInstanceButton from "./StartStopInstanceButton";
import LoadScenarioModal from "./LoadScenarioModal";
import SavesList from "./SavesList";
import { notifyErrorHandler } from "../util/notify";
import { useInstance } from "../model/instance";
import { useHost } from "../model/host";
import { InstanceState, useInstance } from "../model/instance";
import { HostState, useHost } from "../model/host";
import InstanceStatusTag from "./InstanceStatusTag";
const { Title } = Typography;
function InstanceDescription(props) {
type InstanceDescriptionProps = {
host: HostState;
instance: InstanceState;
};
function InstanceDescription(props: InstanceDescriptionProps) {
let account = useAccount();
const { host, instance } = props;
@@ -45,18 +49,18 @@ function InstanceDescription(props) {
hostId={instance.assignedHost}
buttonProps={{
size: "small",
style: { float: "Right" },
style: { float: "right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance.status),
disabled: !["unknown", "unassigned", "stopped"].includes(instance.status!),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance.status} /></Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance.status!} /></Descriptions.Item>
</Descriptions>;
}
export default function InstanceViewPage(props) {
export default function InstanceViewPage() {
let params = useParams();
let instanceId = Number(params.id);
@@ -93,7 +97,7 @@ export default function InstanceViewPage(props) {
</PageLayout>;
}
let instanceButtonMenuItems = [];
let instanceButtonMenuItems: ItemType[] = [];
if (account.hasPermission("core.instance.export_data")) {
instanceButtonMenuItems.push({
disabled: exportingData || instance.status !== "stopped",
@@ -110,7 +114,7 @@ export default function InstanceViewPage(props) {
}
if (account.hasPermission("core.instance.kill")) {
instanceButtonMenuItems.push({
disabled: ["unknown", "unassigned", "stopped"].includes(instance.status),
disabled: ["unknown", "unassigned", "stopped"].includes(instance.status!),
key: "kill",
label: "Kill process",
});
@@ -120,16 +124,16 @@ export default function InstanceViewPage(props) {
instanceButtonMenuItems.push({ type: "divider" });
}
instanceButtonMenuItems.push({
disabled: !["unknown", "unassigned", "stopped"].includes(instance.status),
disabled: !["unknown", "unassigned", "stopped"].includes(instance.status!),
danger: true,
key: "delete",
icon: <DeleteOutlined />,
label: "Delete",
});
}
let instanceButtonsMenuProps = {
let instanceButtonsMenuProps: MenuProps = {
items: instanceButtonMenuItems,
onClick: ({ key }) => {
onClick: ({ key }: { key:string }) => {
if (key === "export") {
setExportingData(true);
control.sendTo(
@@ -188,7 +192,7 @@ export default function InstanceViewPage(props) {
return <PageLayout nav={nav}>
<PageHeader
title={instance.name}
title={instance.name??""}
extra={instanceButtons}
/>
<InstanceDescription host={host} instance={instance} />
@@ -13,7 +13,7 @@ import { useInstanceList } from "../model/instance";
import InstanceList from "./InstanceList";
import { notifyErrorHandler } from "../util/notify";
function CreateInstanceButton(props) {
function CreateInstanceButton() {
let control = useContext(ControlContext);
let navigate = useNavigate();
let [open, setOpen] = useState(false);
@@ -72,8 +72,9 @@ export default function InstancesPage() {
{account.hasPermission("core.instance.start")
&& <Button onClick={e => instanceList.forEach(instance => {
if (instance.status === "stopped") {
control.send(
new lib.InstanceStartRequest(undefined), { instanceId: instance.id }
control.sendTo(
{ instanceId: instance.id },
new lib.InstanceStartRequest(undefined),
).catch(notifyErrorHandler("Error starting instance"));
}
})
@@ -5,9 +5,12 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import { InstanceState } from "../model/instance";
export default function LoadScenarioModal(props) {
type LoadScenarioModalProps = {
instance: InstanceState;
};
export default function LoadScenarioModal(props: LoadScenarioModalProps) {
let [open, setOpen] = useState(false);
let [loadingScenario, setLoadingScenario] = useState(false);
let [form] = Form.useForm();
@@ -23,7 +26,7 @@ export default function LoadScenarioModal(props) {
let result;
try {
result = lib.readMapExchangeString(values.exchangeString);
} catch (err) {
} catch (err: any) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
}
@@ -34,7 +37,7 @@ export default function LoadScenarioModal(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 +46,7 @@ export default function LoadScenarioModal(props) {
if (values.mapSettings && values.mapSettings.trim()) {
try {
mapSettings = JSON.parse(values.mapGenSettings);
} catch (err) {
} catch (err: any) {
form.setFields([{ name: "mapSettings", errors: [err.message] }]);
return;
}
@@ -52,7 +55,7 @@ export default function LoadScenarioModal(props) {
setLoadingScenario(true);
control.sendTo(
{ instanceId: props.instance.id },
{ instanceId: props.instance.id! },
new lib.InstanceLoadScenarioRequest(scenario, seed, mapGenSettings, mapSettings),
).then(() => {
setOpen(false);
@@ -68,7 +71,7 @@ export default function LoadScenarioModal(props) {
let result;
try {
result = lib.readMapExchangeString(exchangeString);
} catch (err) {
} catch (err: any) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
}
@@ -1,4 +1,4 @@
import React, { useEffect, useContext, useRef, useState } from "react";
import React, { useEffect, useContext, useRef, useState, ReactElement } from "react";
import { Typography } from "antd";
import * as lib from "@clusterio/lib";
@@ -8,18 +8,27 @@ import ControlContext from "./ControlContext";
const { Paragraph } = Typography;
type Parsed = {
format: string;
type: string;
level: string;
time: string;
file: string;
action: string;
message: string;
};
function formatParsedOutput(parsed, key) {
let time = "";
function formatParsedOutput(parsed: Parsed, key: number) {
let time: ReactElement|string = "";
if (parsed.format === "seconds") {
time = <span className="factorio-time">{parsed.time.padStart(8)} </span>;
} else if (parsed.format === "date") {
time = <span className="factorio-time">{parsed.time} </span>;
}
let info = "";
let info: ReactElement|string = "";
if (parsed.type === "log") {
let level = parsed.level;
let level: ReactElement|string = parsed.level;
if (level === "Script") {
level = <span className="factorio-script">{level}</span>;
} else if (level === "Verbose") {
@@ -41,7 +50,12 @@ function formatParsedOutput(parsed, key) {
return <span key={key}>{time}{info}{parsed.message}<br/></span>;
}
function formatLog(info, key) {
type Info = {
level: string;
message: string;
parsed?: Parsed;
};
function formatLog(info: Info, key: number): ReactElement {
if (info.level === "server" && info.parsed) {
return formatParsedOutput(info.parsed, key);
}
@@ -49,12 +63,18 @@ function formatLog(info, key) {
return <span key={key}>[{level}] {info.message}<br/></span>;
}
export default function LogConsole(props) {
type LogConsoleProps = {
all?: boolean;
controller?: boolean;
hosts?: number[];
instances?: number[];
};
export default function LogConsole(props: LogConsoleProps) {
let account = useAccount();
let control = useContext(ControlContext);
let anchor = useRef();
let anchor = useRef<any>();
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
let [lines, setLines] = useState([]);
let [lines, setLines] = useState<ReactElement[]>([]);
useEffect(() => {
let logFilter = {
@@ -79,7 +99,7 @@ export default function LogConsole(props) {
400,
"desc",
)).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info, -index - 1)).reverse());
setPastLines(result.log.map((info, index) => formatLog(info as Info, -index - 1)).reverse());
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
@@ -87,7 +107,7 @@ export default function LogConsole(props) {
setPastLines([]);
}
function logHandler(info) {
function logHandler(info: Info) {
setLines(currentLines => currentLines.concat(
[formatLog(info, currentLines.length)]
));
@@ -107,4 +127,3 @@ export default function LogConsole(props) {
</Paragraph>
</>;
}
@@ -7,8 +7,10 @@ import ControlContext from "./ControlContext";
const { Paragraph } = Typography;
function TokenAuth(props) {
type SetTokenProps = {
setToken: (token: string) => void;
};
function TokenAuth(props: SetTokenProps) {
return <>
<Paragraph>If you have generated an authentication token from the controller, use that here.</Paragraph>
<Form
@@ -32,7 +34,7 @@ function TokenAuth(props) {
</>;
}
export default function LoginForm(props) {
export default function LoginForm(props: SetTokenProps) {
let plugins = useContext(ControlContext).plugins;
let loginForms = [];
@@ -1,54 +0,0 @@
import React, { Fragment } from "react";
import { Descriptions, Grid, Typography } from "antd";
import ExclamationCircleOutlined from "@ant-design/icons/ExclamationCircleOutlined";
import * as lib from "@clusterio/lib";
const { useBreakpoint } = Grid;
export default function ModDetails(props) {
let screens = useBreakpoint();
const mod = props.mod;
return <Descriptions className="borderless" bordered size="small" column={{ xs: 1, sm: 1, md: 1, lg: 2 }}>
{mod.error && <Descriptions.Item label={<><ExclamationCircleOutlined/> Error</>} span={2}>
{mod.error === "missing" && "The given version of this mod is missing from the controller storage."}
{mod.error === "bad_checksum"
&& "Checksum of the mod in the controller storage does not match the checksum provided " +
"by this mod pack."
}
</Descriptions.Item>}
{props.actions && !screens.lg
&& <Descriptions.Item label="Action">{props.actions(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>}
<Descriptions.Item label="Mod&nbsp;Portal" span={2}>
<Typography.Link href={`https://mods.factorio.com/mod/${mod.name}`}>
{`https://mods.factorio.com/mod/${mod.name}`}
</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>}
<Descriptions.Item label="Internal&nbsp;Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
{mod.factorioVersion
&& <Descriptions.Item label="Factorio&nbsp;Version">{mod.factorioVersion}</Descriptions.Item>
}
{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>}
{mod.dependencies && mod.dependencies.length
&& <Descriptions.Item label="Dependencies" span={2}>
{mod.dependencies
.filter(e => !e.startsWith("(?)"))
.map((e, i) => <Fragment key={i}>{e}<br/></Fragment>)
}
</Descriptions.Item>
}
</Descriptions>;
}
@@ -0,0 +1,64 @@
import React, { Fragment } from "react";
import { Descriptions, Grid, Typography } from "antd";
import ExclamationCircleOutlined from "@ant-design/icons/ExclamationCircleOutlined";
import * as lib from "@clusterio/lib";
const { useBreakpoint } = Grid;
type ModDetailsProps = {
mod: lib.ModInfo|lib.ModRecord;
actions: (mod: lib.ModInfo|lib.ModRecord) => React.JSX.Element;
};
export default function ModDetails(props: ModDetailsProps) {
let screens = useBreakpoint();
const mod = props.mod;
return <Descriptions className="borderless" bordered size="small" column={{ xs: 1, sm: 1, md: 1, lg: 2 }}>
{!(mod instanceof lib.ModInfo) && mod.error && <Descriptions.Item label={<><ExclamationCircleOutlined/> Error</>} span={2}>
{mod.error === "missing" && "The given version of this mod is missing from the controller storage."}
{mod.error === "bad_checksum"
&& "Checksum of the mod in the controller storage does not match the checksum provided " +
"by this mod pack."
}
</Descriptions.Item>}
{props.actions && !screens.lg
&& <Descriptions.Item label="Action">{props.actions(mod)}</Descriptions.Item>
}
{ (mod instanceof lib.ModInfo) && <>
{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>}
</>}
<Descriptions.Item label="Mod&nbsp;Portal" span={2}>
<Typography.Link href={`https://mods.factorio.com/mod/${mod.name}`}>
{`https://mods.factorio.com/mod/${mod.name}`}
</Typography.Link>
</Descriptions.Item>
{ (mod instanceof lib.ModInfo) && <>
{mod.contact && <Descriptions.Item label="Contact" span={2}>{mod.contact}</Descriptions.Item>}
{mod.homepage && <Descriptions.Item label="Homepage" span={2}>{mod.homepage}</Descriptions.Item>}
<Descriptions.Item label="Internal&nbsp;Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
{mod.factorioVersion
&& <Descriptions.Item label="Factorio&nbsp;Version">{mod.factorioVersion}</Descriptions.Item>
}
{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>}
{mod.dependencies && mod.dependencies.length
&& <Descriptions.Item label="Dependencies" span={2}>
{mod.dependencies
.filter(e => !e.startsWith("(?)"))
.map((e, i) => <Fragment key={i}>{e}<br/></Fragment>)
}
</Descriptions.Item>
}
</>}
</Descriptions>;
}
@@ -3,7 +3,11 @@ import { useNavigate, useParams } from "react-router-dom";
import {
Button, Card, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
TablePaginationConfig,
} from "antd";
import { FieldData } from "rc-field-form/lib/interface";
import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface";
import ExportOutlined from "@ant-design/icons/ExportOutlined";
import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined";
import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
@@ -25,18 +29,48 @@ import ModDetails from "./ModDetails";
const { logger } = lib;
const { Paragraph, Text } = Typography;
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
function SearchModsTable(props) {
type ModChange =
{
type: "name" | "description" | "factorioVersion",
name?: never,
scope?: never,
value: string,
} | {
type: "settings.set" | "settings.delete",
name: string,
scope: "startup" | "runtime-global" | "runtime-per-user",
value: lib.ModSetting,
} | {
type: "mods.set" | "mods.delete",
name: string,
scope?: never,
value: lib.ModRecord | lib.ModInfo,
}
;
type ModResult = {
name: string;
versions: lib.ModInfo[];
};
type SearchModsTableProps = {
modPack: lib.ModPack;
changes: ModChange[];
onChange: (change: ModChange) => void;
};
function SearchModsTable(props: SearchModsTableProps) {
const control = useContext(ControlContext);
const [searchText, setSearchText] = useState("");
const [modResultSort, setModResultSort] = useState("title");
const [modResultSortOrder, setModResultSortOrder] = useState("asc");
const [modResults, setModResults] = useState([]);
const [modResultPage, setModResultPage] = useState(1);
const [modResultPageSize, setModResultPageSize] = useState(10);
const [modResultCount, setModResultCount] = useState(2);
const [modResultSelectedVersion, setModResultSelectedVersion] = useState(new Map());
const [searchText, setSearchText] = useState<string>("");
const [modResultSort, setModResultSort] = useState<string|undefined>("title");
const [modResultSortOrder, setModResultSortOrder] = useState<string|undefined>("asc");
const [modResults, setModResults] = useState<ModResult[]>([]);
const [modResultPage, setModResultPage] = useState<number>(1);
const [modResultPageSize, setModResultPageSize] = useState<number>(10);
const [modResultCount, setModResultCount] = useState<number>(2);
const [modResultSelectedVersion, setModResultSelectedVersion] = useState<Map<string,number>>(new Map());
const factorioVersion = props.modPack.factorioVersion.split(".").slice(0, 2).join(".");
@@ -57,7 +91,7 @@ function SearchModsTable(props) {
// of || 0 on modResultSelectedVersion.get() calls. Remove when updating React.
setModResults(response.results);
setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0])));
setModResultCount(response.result_count);
setModResultCount(response.resultCount);
});
return () => {
@@ -65,20 +99,29 @@ function SearchModsTable(props) {
};
}, [searchText, factorioVersion, modResultPageSize, modResultPage, modResultSort, modResultSortOrder]);
function modResultPageChanged(page, pageSize) {
function modResultPageChanged(page: number, pageSize: number) {
setModResultPage(page);
setModResultPageSize(pageSize);
}
function modResultsTableChanged(pagination, filters, sorters, extra) {
function modResultsTableChanged(
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<ModResult>|SorterResult<ModResult>[],
extra: TableCurrentDataSource<ModResult>,
) {
if (extra.action === "sort") {
if (sorters.order) {
if (sorter instanceof Array) {
sorter = sorter[0];
}
if (sorter.order) {
const mapping = {
ascend: "asc",
descend: "desc",
};
setModResultSort(sorters.columnKey);
setModResultSortOrder(mapping[sorters.order]);
setModResultSort(String(sorter.columnKey));
setModResultSortOrder(mapping[sorter.order]);
} else {
setModResultSort(undefined);
setModResultSortOrder(undefined);
@@ -86,9 +129,9 @@ function SearchModsTable(props) {
}
}
function actions(mod) {
function actions(mod: lib.ModInfo|lib.ModRecord) {
return <Space>
{(!props.modPack.mods.has(mod.name) || props.modPack.mods.get(mod.name).version !== mod.version)
{(!props.modPack.mods.has(mod.name) || props.modPack.mods.get(mod.name)?.version !== mod.version)
&& <Typography.Link
onClick={() => {
props.onChange({
@@ -106,7 +149,7 @@ function SearchModsTable(props) {
props.onChange({
type: "mods.delete",
name: mod.name,
value: { name: mod.name, version: mod.version, sha1: mod.sha1 },
value: { name: mod.name, enabled: false, version: mod.version, sha1: mod.sha1 },
});
}}
>remove</Typography.Link>
@@ -142,14 +185,14 @@ function SearchModsTable(props) {
{
title: "Title",
key: "title",
render: results => results.versions[0].title,
render: (result:ModResult) => result.versions[0].title,
defaultSortOrder: "ascend",
sorter: true,
},
{
title: "Author",
key: "author",
render: results => results.versions[0].author,
render: (result:ModResult) => result.versions[0].author,
responsive: ["lg"],
sorter: true,
},
@@ -157,7 +200,7 @@ function SearchModsTable(props) {
title: "Version",
key: "version",
align: "right",
render: result => <Select
render: (result:ModResult) => <Select
size="small"
bordered={false}
value={modResultSelectedVersion.get(result.name) || 0}
@@ -173,7 +216,7 @@ function SearchModsTable(props) {
title: "Action",
key: "action",
responsive: ["lg"],
render: result => actions(result.versions[modResultSelectedVersion.get(result.name) || 0]),
render: (result:ModResult) => actions(result.versions[modResultSelectedVersion.get(result.name) || 0]),
},
]}
expandable={{
@@ -192,34 +235,44 @@ function SearchModsTable(props) {
</>;
}
function ModsTable(props) {
type ModsTableProps = {
modPack: lib.ModPack;
changes: ModChange[];
onChange: (change: ModChange) => void;
onRevert: (change: ModChange) => void;
};
function ModsTable(props: ModsTableProps) {
const [showAddMods, setShowAddMods] = useState(false);
const [modList] = useModList();
const deletedMods = new Map();
const changedMods = new Map();
const deletedMods: Map<string, lib.ModRecord|lib.ModInfo> = new Map();
const changedMods: Map<string, lib.ModRecord|lib.ModInfo> = new Map();
for (let change of props.changes) {
if (props.modPack.mods.has(change.name)) {
if (change.type === "mods.set") {
changedMods.set(change.name, change.value);
if (change.name) {
if (props.modPack.mods.has(change.name)) {
if (change.type === "mods.set") {
changedMods.set(change.name, change.value);
}
} else if (change.type === "mods.delete") {
deletedMods.set(change.name, change.value);
}
} else if (change.type === "mods.delete") {
deletedMods.set(change.name, change.value);
}
}
const modListMap = new Map(modList.map(mod => [`${mod.name}_${mod.version}`, mod]));
const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map(
mod => {
(mod: lib.ModRecord|lib.ModInfo): lib.ModInfo|lib.ModRecord => {
const candidate = modListMap.get(`${mod.name}_${mod.version}`);
if (!candidate) {
return {
...mod,
enabled: (mod as any).enabled ?? false,
error: "missing",
};
} else if (mod.sha1 && candidate.sha1 !== mod.sha1) {
return {
...mod,
enabled: (mod as any).enabled ?? false,
error: "bad_checksum",
};
}
@@ -227,7 +280,7 @@ function ModsTable(props) {
}
);
function actions(mod) {
function actions(mod: lib.ModRecord|lib.ModInfo) {
return <Space>
{!deletedMods.has(mod.name) && <Typography.Link
type="danger"
@@ -241,7 +294,7 @@ function ModsTable(props) {
>remove</Typography.Link>}
{(deletedMods.has(mod.name) || changedMods.has(mod.name)) && <Typography.Link
onClick={() => {
props.onRevert({
props.onRevert && props.onRevert({
type: deletedMods.has(mod.name) ? "mods.delete" : "mods.set",
name: mod.name,
value: mod,
@@ -275,16 +328,16 @@ function ModsTable(props) {
return { disabled: deletedMods.has(mod.name) };
},
onChange(selectedRowKeys, selectedRows) {
function setEnabled(modNames, enabled) {
function setEnabled(modNames: string[], enabled: boolean) {
for (let name of modNames) {
props.onChange({
type: "mods.set",
name,
value: { ...props.modPack.mods.get(name), enabled },
value: { ...props.modPack.mods.get(name)!, enabled },
});
}
}
const added = selectedRowKeys.filter(key => !props.modPack.mods.get(key).enabled);
const added = selectedRowKeys.filter(key => !props.modPack.mods.get(key as string)?.enabled) as string[];
const removed = [...props.modPack.mods.values()].filter(
mod => mod.enabled && selectedRowKeys.indexOf(mod.name) === -1
).map(mod => mod.name);
@@ -341,16 +394,18 @@ function ModsTable(props) {
}
// Returns true if a change record with the given fields exist
function hasChange(changes, field) {
return changes.some(change => Object.entries(field).every(([key, value]) => change[key] === value));
function hasChange(changes: ModChange[], field:Partial<ModChange>) {
return changes
.some(change => Object.entries(field)
.every(([key, value]) => (change as any)[key] === value));
}
// picks out the value property from settings.
function pickValue(map) {
function pickValue(map: Map<string, lib.ModSetting>): [string, string|number|boolean][] {
return [...map].map(([name, value]) => [name, value.value]);
}
function groupToMap(array, fn) {
function groupToMap(array: any[], fn: (...args:any) => any) {
const map = new Map();
for (let index = 0; index < array.length; index++) {
let key = fn(array[index], index, array);
@@ -362,7 +417,15 @@ function groupToMap(array, fn) {
return map;
}
const SettingsTableField = memo((props) => {
type SettingsTableFieldProps = {
name: string;
type: string;
scope: string;
allowedValues: any;
locale: Map<string, any>;
changed: boolean;
}
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
const name = props.name;
const isBoolean = props.type === "bool-setting";
const isNumber = ["int-setting", "double-setting"].includes(props.type);
@@ -388,20 +451,29 @@ const SettingsTableField = memo((props) => {
wrapperCol={{ span: 16 }}
valuePropName={isBoolean ? "checked" : "value"}
rules={[...isNumber ? [{
validator(rule, value) {
validator(rule: any, value: any) {
if (Number.isNaN(Number(value))) {
return Promise.reject(new Error("Must be a number"));
}
return Promise.resolve();
},
}] : []]}
className={ props.changed && "changed" }
className={ props.changed && "changed" || "" }
>
{input}
</Form.Item>;
});
function SettingsTable(props) {
type SettingsTableProps = {
modPack: lib.ModPack;
prototypes: any;
locale: any;
changes: ModChange[];
onChange: (change: ModChange) => void;
onRevert: (change: ModChange) => void;
};
function SettingsTable(props: SettingsTableProps) {
const [modList] = useModList();
const modsInPack = new Set([...props.modPack.mods.values()].map(mod => `${mod.name}_${mod.version}`));
const modTitles = new Map(modList
@@ -412,10 +484,13 @@ function SettingsTable(props) {
const types = ["bool-setting", "int-setting", "double-setting", "string-setting"];
let prototypes = Object.entries(props.prototypes)
.filter(([type, _]) => types.includes(type))
.flatMap(([_, settingPrototypes]) => Object.values(settingPrototypes))
.flatMap<any>(([_, settingPrototypes]: [string, any]) => Object.values(settingPrototypes))
;
function controls(scope, storedFields) {
function controls(
scope: "startup"|"runtime-global"|"runtime-per-user",
storedFields: [string, string|number|boolean][]
) {
let fields = new Map(prototypes
.filter(p => p.setting_type === scope)
.sort((a, b) => strcmp(a.mod, b.mod) || strcmp(a.order || "", b.order || "") || strcmp(a.name, b.name))
@@ -473,10 +548,10 @@ function SettingsTable(props) {
</>;
}
function CopyButton(props) {
function CopyButton(props: { content: string }) {
let clipboard = useClipboard();
let [copiedVisible, setCopiedVisible] = useState(false);
let copiedTimeout = useRef();
let [copiedVisible, setCopiedVisible] = useState<boolean|undefined>(false);
let copiedTimeout = useRef<ReturnType<typeof setTimeout>|undefined>();
useEffect(() => () => {
clearTimeout(copiedTimeout.current);
@@ -493,7 +568,7 @@ function CopyButton(props) {
copiedTimeout.current = setTimeout(() => {
setCopiedVisible(false);
}, 3000);
} catch (err) {
} catch (err: any) {
logger.error(`Writing to clipboard failed:\n${err.stack}`);
notify("Unable to copy to clipboard", "error", "Use ordinary select and copy instead.");
}
@@ -501,13 +576,13 @@ function CopyButton(props) {
</Tooltip>;
}
function ExportButton(props) {
let [open, setOpen] = useState(false);
function ExportButton(props: { modPack: lib.ModPack }) {
let [open, setOpen] = useState<boolean>(false);
function close() {
setOpen(false);
}
let exportString;
let exportString = "";
if (open) { exportString = props.modPack.toModPackString(); }
return <>
@@ -530,10 +605,10 @@ function ExportButton(props) {
</>;
}
function useExportedAsset(modPack, asset) {
let [assetData, setAssetData] = useState(asset === "locale" ? new Map() : {});
let assetFilename;
if (modPack instanceof lib.ModPack && modPack.exportManifest && modPack.exportManifest.assets[asset]) {
function useExportedAsset(modPack: lib.ModPack, asset: "settings"|"locale"): Map<any, any> | any {
let [assetData, setAssetData] = useState<Map<any, any>|any>(asset === "locale" ? new Map() : {});
let assetFilename: string | undefined;
if (modPack instanceof lib.ModPack && modPack.exportManifest?.assets[asset]) {
assetFilename = modPack.exportManifest.assets[asset];
}
useEffect(() => {
@@ -542,7 +617,7 @@ function useExportedAsset(modPack, asset) {
return;
}
let response = await fetch(`${staticRoot}static/${assetFilename}`);
let response = await fetch(`${window.staticRoot}static/${assetFilename}`);
if (response.ok) {
let data = await response.json();
setAssetData(asset === "locale" ? new Map(data) : data);
@@ -557,7 +632,7 @@ function useExportedAsset(modPack, asset) {
return assetData;
}
function applyModPackChanges(modPack, changes) {
function applyModPackChanges(modPack: lib.ModPack, changes: ModChange[]) {
let modifiedModPack = modPack.shallowClone();
for (let change of changes) {
// Only modify fields that change to reduce re-renders
@@ -566,6 +641,7 @@ function applyModPackChanges(modPack, changes) {
}
if (
["settings.set", "settings.delete"].includes(change.type)
&& change.scope
&& modifiedModPack.settings[change.scope] === modPack.settings[change.scope]
) {
if (modifiedModPack.settings === modPack.settings) {
@@ -581,13 +657,19 @@ function applyModPackChanges(modPack, changes) {
} else if (change.type === "factorioVersion") {
modifiedModPack.factorioVersion = change.value;
} else if (change.type === "mods.set") {
modifiedModPack.mods.set(change.name, change.value);
if (change.name) {
modifiedModPack.mods.set(change.name, change.value as lib.ModRecord);
}
} else if (change.type === "mods.delete") {
modifiedModPack.mods.delete(change.name);
modifiedModPack.mods.delete(change.name!);
} else if (change.type === "settings.set") {
modifiedModPack.settings[change.scope].set(change.name, change.value);
if (change.scope && change.name) {
modifiedModPack.settings[change.scope].set(change.name, change.value);
}
} else if (change.type === "settings.delete") {
modifiedModPack.settings[change.scope].delete(change.name);
if (change.scope && change.name) {
modifiedModPack.settings[change.scope].delete(change.name);
}
} else {
throw new Error(`Unknown change type ${change.type}`);
}
@@ -603,13 +685,13 @@ export default function ModPackViewPage() {
let modPackId = Number(params.id);
const [form] = Form.useForm();
let [modPack] = useModPack(modPackId);
let prototypes = useExportedAsset(modPack, "settings");
let locale = useExportedAsset(modPack, "locale");
let [changes, setChanges] = useState([]);
let syncTimeout = useRef();
let [modPack] = useModPack(modPackId) as lib.ModPack[];
let prototypes: any = useExportedAsset(modPack, "settings");
let locale: Map<any, any> = useExportedAsset(modPack, "locale");
let [changes, setChanges] = useState<ModChange[]>([]);
let syncTimeout = useRef<ReturnType<typeof setTimeout>|undefined>();
let modifiedModPack;
let modifiedModPack: any;
if (!(modPack instanceof lib.ModPack)) {
// Loading or invalid id
modifiedModPack = modPack;
@@ -620,7 +702,7 @@ export default function ModPackViewPage() {
useEffect(() => () => { clearTimeout(syncTimeout.current); });
const pushChange = useCallback((change) => {
const pushChange = useCallback((change: ModChange) => {
setChanges(oldChanges => {
const newChanges = [...oldChanges];
if (
@@ -637,7 +719,7 @@ export default function ModPackViewPage() {
});
}, []);
const revertChange = useCallback((change) => {
const revertChange = useCallback((change: ModChange) => {
setChanges(oldChanges => {
const newChanges = [...oldChanges];
const index = newChanges.findLastIndex(reference => (
@@ -652,20 +734,19 @@ export default function ModPackViewPage() {
});
}, []);
function syncPack(fields) {
function syncPack(fields: FieldData[]) {
for (let field of fields) {
if (!field.touched || field.validating || field.errors.length) {
if (!field.touched || field.validating || (field.errors?.length??false)) {
continue;
}
if (["name", "description", "factorioVersion"].includes(field.name[0])) {
if (modifiedModPack[field.name[0]] !== field.value) {
pushChange({ type: field.name[0], value: field.value });
}
} else if (["startup", "runtime-global", "runtime-per-user"].includes(field.name[0])) {
let [scope, settingName] = field.name;
let [scope, settingName]: ["startup"|"runtime-global"|"runtime-per-user", string] = field.name;
let value = field.value;
if (typeof modPack.settings[scope].get(settingName).value === "number") {
if (typeof modPack.settings[scope].get(settingName)?.value === "number") {
value = Number(value);
if (Number.isNaN(value)) {
continue;
@@ -678,10 +759,10 @@ export default function ModPackViewPage() {
}
}
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack.name || modPackId }];
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack.name || String(modPackId) }];
if (modifiedModPack.loading) {
return <PageLayout nav={nav}>
<PageHeader title={modPackId} />
<PageHeader title={String(modPackId)} />
<Spin size="large" />
</PageLayout>;
}
@@ -704,11 +785,13 @@ export default function ModPackViewPage() {
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(
new lib.ModPackDeleteRequest(modPack.id)
).then(() => {
navigate("/mods");
}).catch(notifyErrorHandler("Error deleting mod pack"));
if (modPack.id !== undefined) {
control.send(
new lib.ModPackDeleteRequest(modPack.id)
).then(() => {
navigate("/mods");
}).catch(notifyErrorHandler("Error deleting mod pack"));
}
}}
>
<Button danger icon={<DeleteOutlined />}>Delete</Button>
@@ -739,7 +822,7 @@ export default function ModPackViewPage() {
<Descriptions.Item span={3} label="Name">
<Form.Item noStyle name="name">
<Input
className={hasChange(changes, { type: "name" }) && "changed"}
className={hasChange(changes, { type: "name" }) && "changed" || ""}
/>
</Form.Item>
</Descriptions.Item>
@@ -747,7 +830,7 @@ export default function ModPackViewPage() {
<Form.Item noStyle name="description">
<Input.TextArea
autoSize={{ minRows: 2 }}
className={hasChange(changes, { type: "description" }) && "changed"}
className={hasChange(changes, { type: "description" }) && "changed" || ""}
/>
</Form.Item>
</Descriptions.Item>
@@ -764,7 +847,7 @@ export default function ModPackViewPage() {
}]}
>
<Input
className={hasChange(changes, { type: "factorioVersion" }) && "changed"}
className={hasChange(changes, { type: "factorioVersion" }) && "changed" || ""}
/>
</Form.Item>
</Descriptions.Item>
@@ -812,5 +895,4 @@ export default function ModPackViewPage() {
</Row>
</div>
</PageLayout>;
}
@@ -18,8 +18,7 @@ import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
function ImportModPackButton() {
let control = useContext(ControlContext);
@@ -94,11 +93,11 @@ function CreateModPackButton() {
} catch {
return; // Validation failed
}
const modPack = lib.ModPack.fromJSON({});
const modPack = lib.ModPack.fromJSON({} as any);
modPack.name = values.name;
modPack.factorioVersion = values.factorioVersion;
if (values.description) { modPack.description = values.description; }
await control.send(new lib.ModPackCreateRequest(modPack.toJSON()));
await control.send(new lib.ModPackCreateRequest(modPack));
navigate(`/mods/mod-packs/${modPack.id}/view`);
})().catch(notifyErrorHandler("Error creating mod pack"));
}}
@@ -133,17 +132,17 @@ export default function ModsPage() {
let [modList] = useModList();
let [modPackList] = useModPackList();
function actions(mod) {
function actions(mod: lib.ModInfo|lib.ModRecord) {
return <Space>
{account.hasPermission("core.mod.download")
&& <Typography.Link
onClick={() => {
control.send(
new lib.ModDownloadRequest(mod.name, mod.version)
).then(streamId => {
let url = new URL(webRoot, document.location);
).then((streamId: string) => {
let url = new URL(window.webRoot, document.location.origin);
url.pathname += `api/stream/${streamId}`;
document.location = url;
document.location = url.href;
}).catch(
notifyErrorHandler("Error downloading save")
);
@@ -174,9 +173,9 @@ export default function ModsPage() {
accept=".zip"
multiple
headers={{
"X-Access-Token": control.connector.token,
"X-Access-Token": control.connector.token||"",
}}
action={`${webRoot}api/upload-mod`}
action={`${window.webRoot}api/upload-mod`}
>
<Button icon={<UploadOutlined/>}>Upload</Button>
</Upload>;
@@ -212,7 +211,7 @@ export default function ModsPage() {
]}
dataSource={modPackList}
pagination={false}
rowKey={modPack => modPack.id}
rowKey={modPack => Number(modPack.id)}
onRow={(modPack, rowIndex) => ({
onClick: event => {
navigate(`/mods/mod-packs/${modPack.id}/view`);
@@ -261,7 +260,7 @@ export default function ModsPage() {
},
]}
expandable={{
expandedRowRender: mod => <ModDetails mod={mod} actions={actions} />,
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={modList}
@@ -1,6 +1,11 @@
import React from "react";
export default function PageHeader(props) {
type PageHeaderProps = {
title: string|React.ReactElement;
extra?: React.JSX.Element;
};
export default function PageHeader(props: PageHeaderProps): React.JSX.Element {
return <div className="page-header">
<h2 className="page-header-title">{props.title}</h2>
{props.extra && <div className="page-header-extra">{props.extra}</div>}
@@ -4,7 +4,14 @@ import { Layout, Breadcrumb } from "antd";
const { Content } = Layout;
export default function PageLayout(props) {
type PageLayoutProps = {
nav: {
path?: string;
name: string;
}[];
children?: any;
};
export default function PageLayout(props: PageLayoutProps) {
return <>
<Breadcrumb className="site-breadcrumb">
{props.nav.map((part, index) => <Breadcrumb.Item key={index}>
@@ -1,17 +0,0 @@
import React, { useContext } from "react";
import ControlContext from "./ControlContext";
export default function PluginExtra(params) {
let control = useContext(ControlContext);
let components = [];
for (let [name, plugin] of control.plugins) {
if (Object.prototype.hasOwnProperty.call(plugin.componentExtra, params.component)) {
let ComponentExtra = plugin.componentExtra[params.component];
components.push(<ComponentExtra key={name} plugin={plugin} {...params}/>);
}
}
return components;
}
@@ -0,0 +1,31 @@
import React, { useContext } from "react";
import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { RawUserState } from "../model/user";
import { HostState } from "../model/host";
import { InstanceState } from "../model/instance";
import { RawRoleState } from "./RoleViewPage";
type PluginExtraProps = {
component: string;
instance?: InstanceState;
user?: RawUserState;
host?: HostState;
role?: RawRoleState;
};
export default function PluginExtra(props: PluginExtraProps) {
let control = useContext(ControlContext);
let components = [];
for (let [name, plugin] of control.plugins) {
if (Object.prototype.hasOwnProperty.call(plugin.componentExtra, props.component)) {
type ComponentExtra = {[key: string]: React.ComponentType<PluginExtraProps & {plugin:lib.BaseWebPlugin}>};
let ComponentExtra = (plugin.componentExtra as ComponentExtra)[props.component];
components.push(<ComponentExtra key={name} plugin={plugin} {...props}/>);
}
}
return components;
}
@@ -2,19 +2,21 @@ import React, { useEffect, useContext, useState } from "react";
import { useParams } from "react-router-dom";
import { Alert, Descriptions, Spin } from "antd";
import type { PluginWebApi } from "@clusterio/controller/src/routes";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import notify from "../util/notify";
export default function PluginViewPage() {
let params = useParams();
let plugins = useContext(ControlContext).plugins;
let [pluginList, setPluginList] = useState(null);
let pluginName = params.name;
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
let pluginName = String(params.name);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
let response = await fetch(`${window.webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
@@ -28,7 +30,7 @@ export default function PluginViewPage() {
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
if (!pluginList) {
return <PageLayout nav={nav}>
<Descriptions borderd size="small" title={pluginTitle} />
<Descriptions bordered size="small" title={pluginTitle} />
<Spin size="large" />
</PageLayout>;
}
@@ -1,3 +0,0 @@
import React from "react";
export default React.createContext();
@@ -2,21 +2,23 @@ import React, { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Table } from "antd";
import type { PluginWebApi } from "@clusterio/controller/src/routes";
import notify from "../util/notify";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
export default function PluginsPage() {
let plugins = useContext(ControlContext).plugins;
let navigate = useNavigate();
let [pluginList, setPluginList] = useState([]);
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
let response = await fetch(`${window.webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
@@ -28,7 +30,7 @@ export default function PluginsPage() {
let tableContents = [];
for (let meta of pluginList) {
if (plugins.has(meta.name)) {
let plugin = plugins.get(meta.name);
let plugin = plugins.get(meta.name)!;
tableContents.push({
meta,
info: plugin.info,
@@ -70,7 +72,7 @@ export default function PluginsPage() {
title: "Loaded",
dataIndex: ["meta", "loaded"],
render: loaded => (loaded ? "Yes" : null),
sorter: (a, b) => a.loaded - b.loaded,
sorter: (a, b) => Number(a.meta.loaded) - Number(b.meta.loaded),
responsive: ["sm"],
},
]}
@@ -13,9 +13,14 @@ import PluginExtra from "./PluginExtra";
import { notifyErrorHandler } from "../util/notify";
function useRole(id) {
export type RawRoleState = Partial<lib.RawRole> & {
loading?: Boolean;
missing?: Boolean;
present?: Boolean;
};
function useRole(id: number): [RawRoleState, () => void] {
let control = useContext(ControlContext);
let [role, setRole] = useState({ loading: true });
let [role, setRole] = useState<RawRoleState>({ loading: true });
function updateRole() {
// XXX optimize by requesting only the role in question
@@ -51,10 +56,10 @@ export default function RoleViewPage() {
let [edited, setEdited] = useState(false);
let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }];
let nav = [{ name: "Roles", path: "/roles" }, { name: role.name || String(roleId) }];
if (role.loading) {
return <PageLayout nav={nav}>
<PageHeader title={roleId} />
<PageHeader title={String(roleId)} />
<Spin size="large" />
</PageLayout>;
}
@@ -71,7 +76,7 @@ export default function RoleViewPage() {
description: role["description"],
permissions: {
...Object.fromEntries([...lib.permissions.values()].map(perm => [
perm.name, role["permissions"].includes(perm.name),
perm.name, role.permissions!.includes(perm.name),
])),
},
};
@@ -96,7 +101,7 @@ export default function RoleViewPage() {
}}
>
<PageHeader
title={role["name"]}
title={role.name!}
extra={<>
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
{account.hasPermission("core.role.delete") && <Popconfirm
@@ -11,8 +11,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
function CreateRoleButton() {
let control = useContext(ControlContext);
@@ -27,11 +26,11 @@ function CreateRoleButton() {
return;
}
let result = await control.send(
let idRole = await control.send(
new lib.RoleCreateRequest(values.roleName, values.description || "", [])
);
setOpen(false);
navigate(`/roles/${result.id}/view`);
navigate(`/roles/${idRole}/view`);
}
return <>
@@ -64,7 +63,7 @@ export default function RolesPage() {
let control = useContext(ControlContext);
let navigate = useNavigate();
let [roles, setRoles] = useState([]);
let [roles, setRoles] = useState<lib.RawRole[]>([]);
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
@@ -75,7 +74,7 @@ export default function RolesPage() {
return <PageLayout nav={[{ name: "Roles" }]}>
<PageHeader
title="Roles"
extra={account.hasPermission("core.role.create") && <CreateRoleButton />}
extra={account.hasPermission("core.role.create") && <CreateRoleButton /> || undefined}
/>
<Table
columns={[
@@ -12,12 +12,19 @@ import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
import { useInstanceList } from "../model/instance";
import { InstanceState, useInstanceList } from "../model/instance";
import { useSaves } from "../model/saves";
import { notifyErrorHandler } from "../util/notify";
function RenameModal(props) {
type ModalProps = {
disabled: boolean;
save: lib.SaveDetails;
instanceId: number;
}
function RenameModal(props: ModalProps) {
let control = useContext(ControlContext);
let [open, setOpen] = useState(false);
let [form] = Form.useForm();
@@ -56,7 +63,7 @@ function RenameModal(props) {
</>;
}
function CopyModal(props) {
function CopyModal(props: ModalProps) {
let control = useContext(ControlContext);
let [open, setOpen] = useState(false);
let [form] = Form.useForm();
@@ -95,7 +102,8 @@ function CopyModal(props) {
</>;
}
function TransferModal(props) {
function TransferModal(props: ModalProps) {
let account = useAccount();
let control = useContext(ControlContext);
let [open, setOpen] = useState(false);
@@ -142,7 +150,7 @@ function TransferModal(props) {
<Select
autoFocus
showSearch
filterOption={(input, option) => option.title.toLowerCase().indexOf(input.toLowerCase()) >= 0}
filterOption={(input, option) => (option?.title.toLowerCase().indexOf(input.toLowerCase())??-1) >= 0}
>
{instanceList.filter(
instance => instance["id"] !== props.instanceId
@@ -161,32 +169,42 @@ function TransferModal(props) {
<Form.Item
name="transferredName"
label="Transferred name"
disabled={!account.hasAnyPermission("core.instance.save.rename", "core.instance.save.copy")}
>
<Input />
<Input
disabled={!account.hasAnyPermission("core.instance.save.rename", "core.instance.save.copy")}
/>
</Form.Item>
<Form.Item
name="copy"
valuePropName="checked"
label="Copy"
tooltip="Copy instead of moving the save to the new instance."
disabled={!account.hasPermission("core.instance.save.copy")}
>
<Checkbox />
<Checkbox
disabled={!account.hasPermission("core.instance.save.copy")}
/>
</Form.Item>
</Form>
</Modal>
</>;
}
export default function SavesList(props) {
type File = {
uid: string;
name: string;
percent: number;
status: string;
}
export default function SavesList(props: { instance: InstanceState }) {
let account = useAccount();
let control = useContext(ControlContext);
let saves = useSaves(props.instance.id);
let [starting, setStarting] = useState(false);
let [uploadingFiles, setUploadingFiles] = useState([]);
let [uploadingFiles, setUploadingFiles] = useState<File[]>([]);
let hostOffline = ["unassigned", "unknown"].includes(props.instance.status);
let hostOffline = ["unassigned", "unknown"].includes(props.instance.status!);
const saveTable = <Table
size="small"
columns={[
@@ -228,7 +246,7 @@ export default function SavesList(props) {
onClick={() => {
setStarting(true);
control.sendTo(
{ instanceId: props.instance.id },
{ instanceId: props.instance.id! },
new lib.InstanceStartRequest(save.name),
).catch(
notifyErrorHandler("Error loading save")
@@ -238,27 +256,27 @@ export default function SavesList(props) {
}}
>Load save</Button>}
{account.hasPermission("core.instance.save.rename") && <RenameModal
disabled={hostOffline} instanceId={props.instance.id} save={save}
disabled={hostOffline} instanceId={props.instance.id!} save={save}
/>}
{account.hasPermission("core.instance.save.copy") && <CopyModal
disabled={hostOffline} instanceId={props.instance.id} save={save}
disabled={hostOffline} instanceId={props.instance.id!} save={save}
/>}
{account.hasPermission("core.instance.save.download") && <Button
disabled={hostOffline}
onClick={() => {
control.send(
new lib.InstanceDownloadSaveRequest(props.instance.id, save.name)
new lib.InstanceDownloadSaveRequest(props.instance.id!, save.name)
).then(streamId => {
let url = new URL(webRoot, document.location);
let url = new URL(window.webRoot, document.location.href);
url.pathname += `api/stream/${streamId}`;
document.location = url;
document.location.assign(url);
}).catch(
notifyErrorHandler("Error downloading save")
);
}}
>Download</Button>}
{account.hasPermission("core.instance.save.transfer") && <TransferModal
disabled={hostOffline} instanceId={props.instance.id} save={save}
disabled={hostOffline} instanceId={props.instance.id!} save={save}
/>}
{account.hasPermission("core.instance.save.delete") && <Popconfirm
title="Permanently delete save?"
@@ -267,7 +285,7 @@ export default function SavesList(props) {
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(
new lib.InstanceDeleteSaveRequest(props.instance.id, save.name)
new lib.InstanceDeleteSaveRequest(props.instance.id!, save.name)
).catch(notifyErrorHandler("Error deleting save"));
}}
>
@@ -277,7 +295,7 @@ export default function SavesList(props) {
}}
/>;
function onChange(changeEvent) {
function onChange(changeEvent: any) {
if (["done", "error"].includes(changeEvent.file.status)) {
if (changeEvent.file.status === "error") {
notifyErrorHandler("Error uploading file")(changeEvent.file.error);
@@ -292,7 +310,7 @@ export default function SavesList(props) {
return;
}
let file = {
let file: File = {
uid: changeEvent.file.uid,
name: changeEvent.file.name,
percent: changeEvent.file.percent,
@@ -314,13 +332,13 @@ export default function SavesList(props) {
name: "file",
accept: ".zip",
headers: {
"X-Access-Token": control.connector.token,
"X-Access-Token": control.connector.token || "",
},
data: {
instance_id: props.instance.id,
},
showUploadList: false,
action: `${webRoot}api/upload-save`,
action: `${window.webRoot}api/upload-save`,
onChange,
};
@@ -343,7 +361,7 @@ export default function SavesList(props) {
{file.name}
<Progress
percent={file.percent}
format={percent => `${Math.floor(percent)}%`}
format={percent => `${Math.floor(percent||0)}%`}
status={file.status === "error" ? "exception" : "normal"}
/>
</List.Item>)}
@@ -1,6 +1,10 @@
import React from "react";
export default function SectionHeader(props) {
type SectionHeader = {
title: string;
extra?: React.JSX.Element;
};
export default function SectionHeader(props: SectionHeader): React.JSX.Element {
return <div className="section-header">
<div className="section-header-title">{props.title}</div>
{props.extra && <div className="section-header-extra">{props.extra}</div>}
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Dropdown, Layout, Menu } from "antd";
import { Dropdown, Layout, Menu, MenuProps } from "antd";
import UserOutlined from "@ant-design/icons/UserOutlined";
import webUiPackage from "../../package.json";
@@ -12,22 +12,21 @@ import { pages } from "../pages";
const { Header, Sider } = Layout;
export default function SiteLayout(props) {
export default function SiteLayout() {
let navigate = useNavigate();
let [currentSidebarPath, setCurrentSidebarPath] = useState(null);
let [currentSidebarPath, setCurrentSidebarPath] = useState<string|null>(null);
let account = useAccount();
let plugins = useContext(ControlContext).plugins;
function SetSidebar(setSidebarProps) {
function SetSidebar(props: { path: string|null }) {
useEffect(() => {
setCurrentSidebarPath(setSidebarProps.path);
setCurrentSidebarPath(props.path);
});
return null;
}
let accountMenuProps = {
onClick: ({ key }) => {
let accountMenuProps: MenuProps = {
onClick: ({ key }: {key:string}) => {
if (key === "user") {
navigate(`/users/${account.name}/view`);
} else if (key === "logOut") {
@@ -87,7 +86,7 @@ export default function SiteLayout(props) {
<Dropdown
className="account-dropdown-header"
placement="bottomRight"
trigger="click"
trigger={["click"]}
menu={accountMenuProps}
>
<UserOutlined/>
@@ -105,7 +104,7 @@ export default function SiteLayout(props) {
<Menu
mode="inline"
defaultOpenKeys={[...menuGroups.keys()]}
selectedKeys={[currentSidebarPath]}
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => navigate(key)}
items={menuItems}
@@ -5,27 +5,33 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import type { InstanceState } from "../model/instance";
import { ButtonProps } from "antd/es/button";
export default function StartStopInstanceButton(props) {
type StartStopInstanceButtonProps = {
instance: InstanceState;
onFinish?: () => void;
buttonProps?: ButtonProps;
};
export default function StartStopInstanceButton(props: StartStopInstanceButtonProps) {
let control = useContext(ControlContext);
let [switching, setSwitching] = useState(false);
let [switching, setSwitching] = useState<boolean>(false);
function onClick(event) {
const onClick: React.MouseEventHandler<HTMLElement> = function(event) {
event.stopPropagation();
setSwitching(true);
let action;
if (props.instance["status"] === "stopped") {
if (props.instance.status === "stopped") {
action = control.sendTo(
{ instanceId: props.instance["id"] },
{ instanceId: props.instance.id! },
new lib.InstanceStartRequest(undefined),
).catch(
notifyErrorHandler("Error starting instance")
);
} else if (["starting", "running"].includes(props.instance["status"])) {
} else if (["starting", "running"].includes(props.instance.status!)) {
action = control.sendTo(
{ instanceId: props.instance["id"] },
{ instanceId: props.instance.id! },
new lib.InstanceStopRequest(),
).catch(
notifyErrorHandler("Error stopping instance")
@@ -48,11 +54,9 @@ export default function StartStopInstanceButton(props) {
{...(props.buttonProps || {})}
loading={switching}
type="primary"
disabled={!["starting", "running", "stopped"].includes(props.instance["status"])}
disabled={!["starting", "running", "stopped"].includes(props.instance.status!)}
onClick={onClick}
>
{props.instance["status"] === "stopped" ? "Start" : "Stop"}
{props.instance.status === "stopped" ? "Start" : "Stop"}
</Button>;
}
@@ -19,7 +19,7 @@ import notify, { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import { formatLastSeen, sortLastSeen, useUser } from "../model/user";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
// The user page has a lot of optional elements, that does not make it
@@ -27,7 +27,7 @@ const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "ba
// eslint-disable-next-line complexity
export default function UserViewPage() {
let params = useParams();
let userName = params.name;
let userName = params.name as string;
let navigate = useNavigate();
@@ -35,12 +35,12 @@ export default function UserViewPage() {
let control = useContext(ControlContext);
let [instanceList] = useInstanceList();
let [user] = useUser(userName);
let [roles, setRoles] = useState(null);
let [roles, setRoles] = useState<Map<number, lib.RawRole>>(new Map());
let [form] = Form.useForm();
let [rolesDirty, setRolesDirty] = useState(false);
let [banReasonDirty, setBanReasonDirty] = useState(false);
let [applyingRoles, setApplyingRoles] = useState(false);
let [rolesError, setRolesError] = useState();
let [rolesDirty, setRolesDirty] = useState<boolean>(false);
let [banReasonDirty, setBanReasonDirty] = useState<boolean>(false);
let [applyingRoles, setApplyingRoles] = useState<boolean>(false);
let [rolesError, setRolesError] = useState<string|undefined>();
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
@@ -88,9 +88,9 @@ export default function UserViewPage() {
loading={!roles}
mode="multiple"
showArrow={true}
filterOption={(inputValue, option) => {
filterOption={(inputValue:string, option: any): boolean => {
let role = roles.get(option.value);
return role && role.name.toLowerCase().includes(inputValue.toLowerCase());
return role?.name.toLowerCase().includes(inputValue.toLowerCase()) ?? false;
}}
>
{roles && [...roles.values()].map(r => <Select.Option
@@ -100,9 +100,9 @@ export default function UserViewPage() {
</Select>
</Form.Item>;
function instanceName(id) {
function instanceName(id: number): string {
let instance = instanceList.find(i => i.id === id);
return instance ? instance.name : id;
return instance ? instance.name : String(id);
}
return <PageLayout nav={nav}>
@@ -181,7 +181,7 @@ export default function UserViewPage() {
<Col flex="auto">
{account.hasPermission("core.user.update_roles")
? roleSelector
: [...user.roles].map(id => <Tag key={id}>{
: [...user.roles!].map(id => <Tag key={id}>{
roles ? (roles.get(id) || { name: id }).name : id
}</Tag>)
}
@@ -198,7 +198,7 @@ export default function UserViewPage() {
new lib.UserUpdateRolesRequest(userName, newRoles)
).then(() => {
setRolesDirty(false);
setRolesError();
setRolesError(undefined);
}).catch(err => {
setRolesError(err.message);
return form.validateFields();
@@ -288,10 +288,10 @@ export default function UserViewPage() {
<SectionHeader title="Player stats" />
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, lg: 3 }}>
<Descriptions.Item label="Total online time">
{formatDuration(user.playerStats.onlineTimeMs || 0)}
{formatDuration(user.playerStats?.onlineTimeMs ?? 0)}
</Descriptions.Item>
<Descriptions.Item label="Total join count">
{user.playerStats.joinCount || 0}
{user.playerStats?.joinCount ?? 0}
</Descriptions.Item>
<Descriptions.Item label="Last seen">
{formatLastSeen(user) || " "}
@@ -306,7 +306,7 @@ export default function UserViewPage() {
key: "instance",
render: ([id]) => instanceName(id),
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a[0], b[0]),
sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])),
},
{
title: "Online time",
@@ -328,7 +328,7 @@ export default function UserViewPage() {
sorter: (a, b) => sortLastSeen(user, user, a[0], b[0]),
},
]}
dataSource={[...user.instanceStats.entries()]}
dataSource={[...(user.instanceStats || []).entries()]}
pagination={false}
rowKey={([id]) => id}
onRow={([id], rowIndex) => ({
@@ -13,7 +13,7 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { formatLastSeen, sortLastSeen, useUserList } from "../model/user";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
function CreateUserButton() {
@@ -75,14 +75,14 @@ export default function UsersPage() {
return <PageLayout nav={[{ name: "Users" }]}>
<PageHeader
title="Users"
extra={account.hasPermission("core.user.create") && <CreateUserButton />}
extra={account.hasPermission("core.user.create") && <CreateUserButton /> || undefined}
/>
<Table
columns={[
{
title: "Name",
key: "name",
render: user => <Space>
render: (user: lib.RawUser) => <Space>
{user.name}
<span>
{user.isAdmin && <Tag color="gold">Admin</Tag>}
@@ -96,21 +96,21 @@ export default function UsersPage() {
{
title: "Roles",
key: "roles",
render: user => user.roles.map(id => <Tag key={id}>{(roles.get(id) || { name: id }).name}</Tag>),
render: (user: lib.RawUser) => user.roles.map(id => <Tag key={id}>{(roles.get(id) || { name: id }).name}</Tag>),
},
{
title: "Online time",
key: "onlineTime",
render: user => (user.playerStats.onlineTimeMs
render: (user: lib.RawUser) => (user.playerStats?.onlineTimeMs
? formatDuration(user.playerStats.onlineTimeMs) : null),
sorter: (a, b) => (a.playerStats.onlineTimeMs || 0) -
(b.playerStats.onlineTimeMs || 0),
sorter: (a, b) => (a.playerStats?.onlineTimeMs ?? 0) -
(b.playerStats?.onlineTimeMs ?? 0),
responsive: ["lg"],
},
{
title: "Last seen",
key: "lastSeen",
render: user => formatLastSeen(user),
render: (user: lib.RawUser) => formatLastSeen(user),
sorter: (a, b) => sortLastSeen(a, b),
responsive: ["lg"],
},
@@ -1,12 +1,11 @@
export { default as bootstrap } from "./bootstrap.jsx";
export { default as notify, notifyErrorHandler } from "./util/notify.jsx";
export * from "./model/instance.jsx";
export * from "./model/host.jsx";
export * from "./model/locale.jsx";
export * from "./model/item_metadata.jsx";
export * from "./model/account.jsx";
export { default as bootstrap } from "./bootstrap";
export { default as notify, notifyErrorHandler } from "./util/notify";
export * from "./model/instance";
export * from "./model/host";
export * from "./model/locale";
export * from "./model/item_metadata";
export * from "./model/account";
export { default as ControlContext } from "./components/ControlContext";
@@ -1,24 +1,17 @@
import { useContext, useEffect, useState } from "react";
import * as lib from "@clusterio/lib";
import { notifyErrorHandler } from "../util/notify";
import ControlContext from "../components/ControlContext";
/**
* @typedef {Object} Account
* @property {?string} name - Name of the currently logged in account.
* @property {?Array<object>} roles - Roles of the corrently logged in account.
* @property {function(string)} hasPermission -
* Check if the currently logged in account has the given permission.
* @property {function()} logOut - Logs out of the web interface.
*/
export function useAccount() {
export function useAccount(): lib.UserAccount {
let control = useContext(ControlContext);
let [name, setName] = useState(control.accountName);
let [roles, setRoles] = useState(control.accountRoles);
let [name, setName] = useState<string>(control.accountName || "");
let [roles, setRoles] = useState<any[]>(control.accountRoles || []);
useEffect(() => {
function onAccountUpdate(account) {
function onAccountUpdate(account: lib.AccountDetails) {
setName(account.name);
setRoles(account.roles);
}
@@ -28,7 +21,7 @@ export function useAccount() {
};
}, [control]);
function hasPermission(permission) {
function hasPermission(permission: string): boolean {
for (let role of roles) {
if (role.permissions.includes("core.admin") || role.permissions.includes(permission)) {
return true;
@@ -40,13 +33,13 @@ export function useAccount() {
return {
name,
roles,
hasPermission(permission) {
hasPermission(permission: string): boolean | null {
if (!roles) {
return null;
}
return hasPermission(permission);
},
hasAnyPermission(...permissions) {
hasAnyPermission(...permissions: string[]): boolean | null {
if (!roles) {
return null;
}
@@ -57,7 +50,7 @@ export function useAccount() {
}
return false;
},
hasAllPermission(...permissions) {
hasAllPermission(...permissions: string[]): boolean | null {
if (!roles) {
return null;
}
@@ -70,7 +63,8 @@ export function useAccount() {
},
logOut() {
control.loggingOut = true;
control.shutdown(notifyErrorHandler("Error logging out"));
notifyErrorHandler("Error logging out");
control.shutdown();
},
};
}
@@ -3,12 +3,11 @@ import { useEffect, useContext, useState } from "react";
import * as lib from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
let exportManifestCache = null;
let emptyCache = {};
export function useExportManifest() {
let exportManifestCache: lib.ExportManifest|null = null;
let emptyCache: any = {};
export function useExportManifest(): lib.ExportManifest|null {
const control = useContext(ControlContext);
let [exportManifest, setExportManifest] = useState(exportManifestCache || emptyCache);
let [exportManifest, setExportManifest] = useState<lib.ExportManifest|null>(exportManifestCache || emptyCache);
useEffect(() => {
async function load() {
let modPack = await control.send(new lib.ModPackGetDefaultRequest());
@@ -4,13 +4,20 @@ import ControlContext from "../components/ControlContext";
import * as lib from "@clusterio/lib";
export function useHost(id) {
export type HostState = Partial<lib.HostDetails> & {
loading?: boolean,
present?: boolean,
missing?: boolean,
}
export function useHost(id: number): [ HostState, () => void ] {
let control = useContext(ControlContext);
let [host, setHost] = useState({ loading: true });
let [host, setHost] = useState<HostState>({ loading: true });
function updateHost() {
// XXX optimize by requesting only the host in question
control.send(new lib.HostListRequest()).then(hosts => {
control.send(new lib.HostListRequest())
.then((hosts: lib.HostDetails[]) => {
let match = hosts.find(i => i.id === id);
if (!match) {
setHost({ missing: true });
@@ -27,8 +34,8 @@ export function useHost(id) {
}
updateHost();
function updateHandler(newHost) {
setHost({ ...newHost, present: true });
function updateHandler(newHost: lib.HostDetails) {
setHost({ ...newHost, loading:false, missing:false, present:true });
}
control.onHostUpdate(id, updateHandler);
@@ -42,10 +49,11 @@ export function useHost(id) {
export function useHostList() {
let control = useContext(ControlContext);
let [hostList, setHostList] = useState([]);
let [hostList, setHostList] = useState<lib.HostDetails[]>([]);
function updateHostList() {
control.send(new lib.HostListRequest()).then(hosts => {
control.send(new lib.HostListRequest())
.then((hosts: lib.HostDetails[]) => {
setHostList(hosts);
});
}
@@ -53,7 +61,7 @@ export function useHostList() {
useEffect(() => {
updateHostList();
function updateHandler(newHost) {
function updateHandler(newHost: lib.HostDetails) {
setHostList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(s => s.id === newHost.id);
@@ -5,18 +5,23 @@ import * as lib from "@clusterio/lib";
const { logger } = lib;
export function useInstance(id) {
export type InstanceState = Partial<lib.InstanceDetails> & {
loading?: boolean,
present?: boolean,
missing?: boolean,
}
export function useInstance(id: number): [InstanceState, ()=>void] {
let control = useContext(ControlContext);
let [instance, setInstance] = useState({ loading: true });
let [instance, setInstance] = useState<InstanceState>({ loading: true });
function updateInstance() {
control.send(new lib.InstanceDetailsGetRequest(id)).then(updatedInstance => {
setInstance({ ...updatedInstance, present: true });
}).catch(err => {
}).catch((err: any) => {
logger.error(`Failed to get instance: ${err}`);
setInstance({ missing: true });
});
}
useEffect(() => {
@@ -26,7 +31,7 @@ export function useInstance(id) {
}
updateInstance();
function updateHandler(newInstance) {
function updateHandler(newInstance: lib.InstanceDetails) {
setInstance({ ...newInstance, present: true });
}
@@ -41,7 +46,7 @@ export function useInstance(id) {
export function useInstanceList() {
let control = useContext(ControlContext);
let [instanceList, setInstanceList] = useState([]);
let [instanceList, setInstanceList] = useState<lib.InstanceDetails[]>([]);
function updateInstanceList() {
control.send(new lib.InstanceDetailsListRequest()).then(instances => {
@@ -54,7 +59,7 @@ export function useInstanceList() {
useEffect(() => {
updateInstanceList();
function updateHandler(newInstance) {
function updateHandler(newInstance: lib.InstanceDetails) {
setInstanceList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(s => s.id === newInstance.id);
@@ -3,10 +3,15 @@ import { useEffect, useState } from "react";
import { useExportManifest } from "./export_manifest";
let itemMetadataCache = null;
type Metadata = {
x: number;
y: number;
size: number;
};
let itemMetadataCache: Map<string, Metadata> | null = null;
export function useItemMetadata() {
let exportManifest = useExportManifest();
let [itemMetadata, setItemMetadata] = useState(itemMetadataCache || new Map());
let [itemMetadata, setItemMetadata] = useState<Map<string, Metadata>>(itemMetadataCache || new Map());
useEffect(() => {
async function load() {
if (
@@ -17,22 +22,21 @@ export function useItemMetadata() {
) {
return;
}
let response = await fetch(`${staticRoot}static/${exportManifest.assets["item-metadata"]}`);
let response = await fetch(`${window.staticRoot}static/${exportManifest.assets["item-metadata"]}`);
if (response.ok) {
let data = await response.json();
itemMetadataCache = new Map(data);
let style = document.createElement("style");
style.type = "text/css";
document.head.appendChild(style);
for (let [name, meta] of itemMetadataCache) {
style.sheet.insertRule(
style.sheet!.insertRule(
`.item-${CSS.escape(name)} {
background-image: url("${staticRoot}static/${exportManifest.assets["item-spritesheet"]}");
background-repeat: no-repeat;
background-position: -${meta.x}px -${meta.y}px;
height: ${meta.size}px;
width: ${meta.size}px;
}`
background-image: url("${window.staticRoot}static/${exportManifest.assets["item-spritesheet"]}");
background-repeat: no-repeat;
background-position: -${meta.x}px -${meta.y}px;
height: ${meta.size}px;
width: ${meta.size}px;
}`
);
}
setItemMetadata(itemMetadataCache);
@@ -46,4 +50,3 @@ export function useItemMetadata() {
return itemMetadata;
}
@@ -3,16 +3,16 @@ import React, { useEffect, useState } from "react";
import { useExportManifest } from "./export_manifest";
let localeCache = null;
export function useLocale() {
let localeCache: Map<string, string>|null = null;
export function useLocale(): Map<string, string> {
let exportManifest = useExportManifest();
let [locale, setLocale] = useState(localeCache || new Map());
let [locale, setLocale] = useState<Map<string, string>>(localeCache || new Map());
useEffect(() => {
async function load() {
if (!exportManifest || !exportManifest.assets || !exportManifest.assets["locale"]) {
if (!exportManifest?.assets?.["locale"]) {
return;
}
let response = await fetch(`${staticRoot}static/${exportManifest.assets["locale"]}`);
let response = await fetch(`${window.staticRoot}static/${exportManifest.assets["locale"]}`);
if (response.ok) {
let data = await response.json();
localeCache = new Map(data);
@@ -6,9 +6,16 @@ import ControlContext from "../components/ControlContext";
const { logger } = lib;
export function useModPack(id) {
export type StateLoadStatus = {
loading?: boolean;
present?: boolean;
missing?: boolean;
};
export function useModPack(id: number) {
let control = useContext(ControlContext);
let [modPack, setModPack] = useState({ loading: true });
let [modPack, setModPack] = useState<lib.ModPack|StateLoadStatus>({ loading: true });
function updateModPack() {
control.send(new lib.ModPackGetRequest(id)).then(updatedModPack => {
@@ -37,7 +44,7 @@ export function useModPack(id) {
export function useModPackList() {
let control = useContext(ControlContext);
let [modPackList, setModPackList] = useState([]);
let [modPackList, setModPackList] = useState<lib.ModPack[]>([]);
function updateModPackList() {
control.send(new lib.ModPackListRequest()).then(modPacks => {
@@ -50,7 +57,7 @@ export function useModPackList() {
useEffect(() => {
updateModPackList();
function updateHandler(newModPack) {
function updateHandler(newModPack: lib.ModPack) {
setModPackList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(u => u.id === newModPack.id);
@@ -8,7 +8,7 @@ const { logger } = lib;
export function useModList() {
let control = useContext(ControlContext);
let [modList, setModList] = useState([]);
let [modList, setModList] = useState<lib.ModInfo[]>([]);
function updateModList() {
control.send(new lib.ModListRequest()).then(mods => {
@@ -21,7 +21,7 @@ export function useModList() {
useEffect(() => {
updateModList();
function updateHandler(newMod) {
function updateHandler(newMod: lib.ModInfo) {
setModList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(mod => mod.name === newMod.name && mod.version === newMod.version);
@@ -5,12 +5,12 @@ import * as lib from "@clusterio/lib";
const { logger } = lib;
export function useSaves(instanceId) {
export function useSaves(instanceId?: number): lib.SaveDetails[] {
let control = useContext(ControlContext);
let [saves, setSaves] = useState([]);
let [saves, setSaves] = useState<lib.SaveDetails[]>([]);
function updateSaves() {
control.sendTo({ instanceId }, new lib.InstanceListSavesRequest()).then(updatedSaves => {
control.sendTo({ instanceId:instanceId! }, new lib.InstanceListSavesRequest()).then(updatedSaves => {
setSaves(updatedSaves);
}).catch(err => {
logger.error(`Failed to list instance saves: ${err}`);
@@ -25,13 +25,13 @@ export function useSaves(instanceId) {
}
updateSaves();
function updateHandler(data) {
function updateHandler(data: lib.InstanceSaveListUpdateEvent) {
setSaves(data.saves);
}
control.onSaveListUpdate(instanceId, updateHandler);
control.onSaveListUpdate(instanceId!, updateHandler);
return () => {
control.offSaveListUpdate(instanceId, updateHandler);
control.offSaveListUpdate(instanceId!, updateHandler);
};
}, [instanceId]);
@@ -6,7 +6,7 @@ import * as lib from "@clusterio/lib";
const { logger } = lib;
function calculateLastSeen(user, instanceId) {
function calculateLastSeen(user: RawUserState, instanceId?: number) {
let stats;
if (instanceId === undefined) {
stats = user.playerStats;
@@ -25,8 +25,8 @@ function calculateLastSeen(user, instanceId) {
return undefined;
}
export function formatLastSeen(user, instanceId = undefined) {
if (user.instances.some(id => instanceId === undefined || id === instanceId)) {
export function formatLastSeen(user: RawUserState, instanceId?: number) {
if (user.instances && user.instances.some(id => instanceId === undefined || id === instanceId)) {
return <Tag color="green">Online</Tag>;
}
let lastSeen = calculateLastSeen(user, instanceId);
@@ -36,15 +36,15 @@ export function formatLastSeen(user, instanceId = undefined) {
return new Date(lastSeen).toLocaleString();
}
export function sortLastSeen(userA, userB, instanceIdA = undefined, instanceIdB = undefined) {
function epoch(user, instanceId) {
return user.instances.some(id => instanceId === undefined || id === instanceId);
export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA?: number, instanceIdB?: number) {
function epoch(user:RawUserState, instanceId?: number) {
return user.instances && user.instances.some(id => instanceId === undefined || id === instanceId);
}
let epochA = epoch(userA, instanceIdA);
let epochB = epoch(userB, instanceIdB);
if (epochA !== epochB) {
return epochA - epochB;
return Number(epochA) - Number(epochB);
}
let lastSeenA = calculateLastSeen(userA, instanceIdA) || 0;
@@ -52,9 +52,15 @@ export function sortLastSeen(userA, userB, instanceIdA = undefined, instanceIdB
return lastSeenA - lastSeenB;
}
export function useUser(name) {
export type RawUserState = Partial<lib.RawUser> & {
loading?: Boolean;
present?: Boolean;
missing?: Boolean;
};
export function useUser(name: string): [RawUserState, () => void] {
let control = useContext(ControlContext);
let [user, setUser] = useState({ loading: true });
let [user, setUser] = useState<RawUserState>({ loading: true });
function updateUser() {
control.send(new lib.UserGetRequest(name)).then(updatedUser => {
@@ -63,7 +69,6 @@ export function useUser(name) {
logger.error(`Failed to get user: ${err}`);
setUser({ missing: true });
});
}
useEffect(() => {
@@ -73,7 +78,7 @@ export function useUser(name) {
}
updateUser();
function updateHandler(newUser) {
function updateHandler(newUser: lib.RawUser) {
setUser({ ...newUser, present: true });
}
@@ -88,7 +93,7 @@ export function useUser(name) {
export function useUserList() {
let control = useContext(ControlContext);
let [userList, setUserList] = useState([]);
let [userList, setUserList] = useState<lib.RawUser[]>([]);
function updateUserList() {
control.send(new lib.UserListRequest()).then(users => {
@@ -101,7 +106,7 @@ export function useUserList() {
useEffect(() => {
updateUserList();
function updateHandler(newUser) {
function updateHandler(newUser: lib.RawUser) {
setUserList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(u => u.name === newUser.name);
@@ -1,4 +1,6 @@
import React from "react";
import React, { ReactElement } from "react";
import type * as lib from "@clusterio/lib";
import ControllerPage from "./components/ControllerPage";
import HostsPage from "./components/HostsPage";
@@ -14,8 +16,7 @@ import RoleViewPage from "./components/RoleViewPage";
import PluginsPage from "./components/PluginsPage";
import PluginViewPage from "./components/PluginViewPage";
export const pages = [
export const pages: lib.PluginPage[] = [
{
path: "/controller",
sidebarName: "Controller",
@@ -46,7 +47,7 @@ export const pages = [
{
path: "/mods",
sidebarName: "Mods",
permission: account => account.hasAnyPermission("core.mod.list", "core.mod-pack.list"),
permission: (account:lib.UserAccount) => account.hasAnyPermission("core.mod.list", "core.mod-pack.list"),
content: <ModsPage />,
},
{
@@ -2,9 +2,9 @@ import { useEffect, useState } from "react";
import { EventEmitter } from "events";
class Permission extends EventEmitter {
status;
status!: { state: string };
constructor(name) {
constructor(name: PermissionName) {
super();
navigator.permissions.query({ name }).then(
status => {
@@ -30,8 +30,8 @@ class Permission extends EventEmitter {
}
}
let readPermission = new Permission("clipboard-read");
let writePermission = new Permission("clipboard-write");
let readPermission = new Permission("clipboard-read" as PermissionName);
let writePermission = new Permission("clipboard-write" as PermissionName);
// Wrapper for navigator.clipboard suitable for use in React
export function useClipboard() {
@@ -52,8 +52,8 @@ export function useClipboard() {
read: async () => await navigator.clipboard.read(),
readText: async () => await navigator.clipboard.readText(),
writePermissionState: writePermissionState,
write: async (data) => await navigator.clipboard.write(data),
writeText: async (text) => await navigator.clipboard.writeText(text),
write: async (data: ClipboardItems) => await navigator.clipboard.write(data),
writeText: async (text: string) => await navigator.clipboard.writeText(text),
// Give a possible reason for why the clipboard is not available
deniedReason: () => {
@@ -2,7 +2,11 @@ import { notification } from "antd";
import { logger } from "@clusterio/lib";
export default function notify(message, type = "info", description = undefined) {
export default function notify(
message: string | Error,
type: "success"|"info"|"warning"|"error" = "info",
description?: string,
) {
notification[type]({
message: typeof message === "string" ? message : "ERROR: See console",
description,
@@ -18,13 +22,13 @@ export default function notify(message, type = "info", description = undefined)
*
* Shows the error occuring in a notification card.
*
* @param {string} message -
* @param message -
* User facing message to give context to the error. Should say which
* operation failed.
* @returns {function(err)} function showing the error passed to it.
* @returns function showing the error passed to it.
*/
export function notifyErrorHandler(message) {
return function handler(err) {
export function notifyErrorHandler(message: string): (err:Error) => void {
return function handler(err: Error) {
logger.error(err.stack);
notification.error({
message,
@@ -5,11 +5,10 @@ const formatDay = new Intl.NumberFormat(undefined, { style: "unit", unit: "day",
/**
* Formats a duration of time in milliseconds to something human readable.
* @param {number} ms - Duration to formatDuration
* @param {object=} options - Options to control the formatting.
* @returns {string} human readable representation of the duration.
* @param ms - Duration to formatDuration
* @returns human readable representation of the duration.
*/
export function formatDuration(ms, options = {}) {
export function formatDuration(ms: number): string {
let result = "";
if (ms < 0) {
result += "-";
@@ -3,15 +3,21 @@ import packageJson from "../../package.json";
const { logFilter, logger } = lib;
type accountHandler = (account: lib.AccountDetails) => void;
type hostHandler = (hostDetails: lib.HostDetails) => void;
type instanceHandler = (instanceDetails: lib.InstanceDetails) => void;
type saveListHandler = (saveListEvent: lib.InstanceSaveListUpdateEvent) => void;
type modPackHandler = (modPack: lib.ModPack) => void;
type modInfoHandler = (modInfo: lib.ModInfo) => void;
type userHandler = (rawUser: lib.RawUser) => void;
type logHandler = (info: { level:string, message:string }) => void;
/**
* Connector for control connection to controller
* @private
*/
export class ControlConnector extends lib.WebSocketClientConnector {
constructor(url, maxReconnectDelay) {
super(url, maxReconnectDelay);
this.token = null;
}
token: string | null = null;
register() {
if (!this.token) {
@@ -38,45 +44,33 @@ export class ControlConnector extends lib.WebSocketClientConnector {
* @static
*/
export class Control extends lib.Link {
constructor(connector, plugins) {
/** Flag indicating the connection is in the process of logging out. */
loggingOut: boolean = false;
/** Name of the account this control link is connected as. */
accountName: string | null = null;
/** Roles of the account this control link is connected as. */
accountRoles: lib.AccountRole[] | null = null;
accountUpdateHandlers: Function[] = [];
hostUpdateHandlers: Map<number|null, hostHandler[]> = new Map();
instanceUpdateHandlers: Map<number|null, instanceHandler[]> = new Map();
saveListUpdateHandlers: Map<number|null, saveListHandler[]> = new Map();
modPackUpdateHandlers: Map<number|null, modPackHandler[]> = new Map();
modUpdateHandlers: Map<string|null, modInfoHandler[]> = new Map();
userUpdateHandlers: Map<string|null, userHandler[]> = new Map();
logHandlers: Map<lib.LogFilter, logHandler[]> = new Map();
declare connector: ControlConnector;
constructor(
connector: ControlConnector,
public plugins: Map<string, lib.BaseWebPlugin>,
) {
super(connector);
/**
* Mapping of plugin names to their instance for loaded plugins.
* @type {Map<string, module:lib.BaseWebPlugin>}
*/
this.plugins = plugins;
for (let plugin of plugins.values()) {
plugin.control = this;
}
/**
* Flag indicating the connection is in the process of logging out.
* @type {boolean}
*/
this.loggingOut = false;
/**
* Name of the account this control link is connected as.
* @type {?string}
*/
this.accountName = null;
/**
* Roles of the account this control link is connected as.
* @type {?Array<object>}
*/
this.accountRoles = null;
this.accountUpdateHandlers = [];
this.hostUpdateHandlers = new Map();
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
this.modPackUpdateHandlers = new Map();
this.modUpdateHandlers = new Map();
this.userUpdateHandlers = new Map();
this.logHandlers = new Map();
this.connector.on("connect", data => {
this.accountName = data.account.name;
this.accountRoles = data.account.roles;
@@ -110,7 +104,7 @@ export class Control extends lib.Link {
this.emitAccountUpdate();
});
for (let event of ["connect", "drop", "resume", "close"]) {
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => {
for (let plugin of this.plugins.values()) {
plugin.onControllerConnectionEvent(event);
@@ -129,8 +123,8 @@ export class Control extends lib.Link {
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
}
async handleAccountUpdateEvent(event) {
this.accountRoles = event.roles;
async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) {
this.accountRoles = event.roles??null;
this.emitAccountUpdate();
}
@@ -143,11 +137,11 @@ export class Control extends lib.Link {
}
}
onAccountUpdate(handler) {
onAccountUpdate(handler: accountHandler) {
this.accountUpdateHandlers.push(handler);
}
offAccountUpdate(handler) {
offAccountUpdate(handler: accountHandler) {
let index = this.accountUpdateHandlers.lastIndexOf(handler);
if (index === -1) {
throw new Error("Given handler is not registered for account update");
@@ -155,8 +149,8 @@ export class Control extends lib.Link {
this.accountUpdateHandlers.splice(index, 1);
}
async handleHostUpdateEvent(event) {
let handlers = [].concat(
async handleHostUpdateEvent(event: lib.HostUpdateEvent) {
let handlers: Function[] = ([] as Function[]).concat(
this.hostUpdateHandlers.get(null) || [],
this.hostUpdateHandlers.get(event.update.id) || [],
);
@@ -165,7 +159,7 @@ export class Control extends lib.Link {
}
}
async onHostUpdate(id, handler) {
async onHostUpdate(id: number|null, handler: hostHandler) {
if (id !== null && !Number.isInteger(id)) {
throw new Error("Invalid host id");
}
@@ -183,7 +177,7 @@ export class Control extends lib.Link {
}
}
async offHostUpdate(id, handler) {
async offHostUpdate(id: number|null, handler: hostHandler) {
let handlers = this.hostUpdateHandlers.get(id);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for host ${id} exists`);
@@ -208,12 +202,12 @@ export class Control extends lib.Link {
await this.send(new lib.HostSetSubscriptionsRequest(
this.hostUpdateHandlers.has(null),
[...this.hostUpdateHandlers.keys()].filter(e => e !== null),
[...this.hostUpdateHandlers.keys()].filter(e => e !== null) as number[],
));
}
async handleInstanceDetailsUpdateEvent(event) {
let handlers = [].concat(
async handleInstanceDetailsUpdateEvent(event: lib.InstanceDetailsUpdateEvent) {
let handlers = ([] as Function[]).concat(
this.instanceUpdateHandlers.get(null) || [],
this.instanceUpdateHandlers.get(event.details.id) || [],
);
@@ -222,7 +216,7 @@ export class Control extends lib.Link {
}
};
async onInstanceUpdate(id, handler) {
async onInstanceUpdate(id: number|null, handler: instanceHandler) {
if (id !== null && !Number.isInteger(id)) {
throw new Error("Invalid instance id");
}
@@ -240,7 +234,7 @@ export class Control extends lib.Link {
}
}
async offInstanceUpdate(id, handler) {
async offInstanceUpdate(id: number|null, handler: instanceHandler) {
let handlers = this.instanceUpdateHandlers.get(id);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for instance ${id} exist`);
@@ -265,18 +259,18 @@ export class Control extends lib.Link {
await this.send(new lib.InstanceDetailsSetSubscriptionsRequest(
this.instanceUpdateHandlers.has(null),
[...this.instanceUpdateHandlers.keys()].filter(e => e !== null),
[...this.instanceUpdateHandlers.keys()].filter(e => e !== null) as number[],
));
}
async handleInstanceSaveListUpdateEvent(event) {
async handleInstanceSaveListUpdateEvent(event: lib.InstanceSaveListUpdateEvent) {
let handlers = this.saveListUpdateHandlers.get(event.instanceId);
for (let handler of handlers || []) {
handler(event);
}
};
async onSaveListUpdate(id, handler) {
async onSaveListUpdate(id: number, handler: saveListHandler) {
if (!Number.isInteger(id)) {
throw new Error("Invalid instance id");
}
@@ -294,7 +288,7 @@ export class Control extends lib.Link {
}
}
async offSaveListUpdate(id, handler) {
async offSaveListUpdate(id: number, handler: saveListHandler) {
let handlers = this.saveListUpdateHandlers.get(id);
if (!handlers) {
throw new Error(`No handlers for instance ${id} exists`);
@@ -319,22 +313,23 @@ export class Control extends lib.Link {
await this.send(new lib.InstanceSetSaveListSubscriptionsRequest(
false,
[...this.saveListUpdateHandlers.keys()],
[...this.saveListUpdateHandlers.keys()].filter(e => e !== null) as number[],
));
}
async handleModPackUpdateEvent(event) {
async handleModPackUpdateEvent(event: lib.ModPackUpdateEvent) {
let modPack = event.modPack;
let handlers = [].concat(
let handlers = ([] as modPackHandler[]).concat(
this.modPackUpdateHandlers.get(null) || [],
this.modPackUpdateHandlers.get(modPack.id) || [],
this.modPackUpdateHandlers.get(modPack.id??null) || [],
);
for (let handler of handlers) {
handler(modPack);
}
}
async onModPackUpdate(id, handler) {
async onModPackUpdate(id: number|null, handler: modPackHandler) {
if (id !== null && typeof id !== "number") {
throw new Error("Invalid mod pack id");
}
@@ -352,7 +347,7 @@ export class Control extends lib.Link {
}
}
async offModPackUpdate(id, handler) {
async offModPackUpdate(id: number|null, handler: modPackHandler) {
let handlers = this.modPackUpdateHandlers.get(id);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for mod pack ${id} exist`);
@@ -377,13 +372,14 @@ export class Control extends lib.Link {
await this.send(new lib.ModPackSetSubscriptionsRequest(
this.modPackUpdateHandlers.has(null),
[...this.modPackUpdateHandlers.keys()].filter(k => k !== null),
[...this.modPackUpdateHandlers.keys()]
.filter(k => k !== null && k !== undefined) as number[],
));
}
async handleModUpdateEvent(event) {
async handleModUpdateEvent(event: lib.ModUpdateEvent) {
let mod = event.mod;
let handlers = [].concat(
let handlers = ([] as modInfoHandler[]).concat(
this.modUpdateHandlers.get(null) || [],
this.modUpdateHandlers.get(mod.name) || []
);
@@ -392,7 +388,7 @@ export class Control extends lib.Link {
}
}
async onModUpdate(name, handler) {
async onModUpdate(name: string|null, handler: modInfoHandler) {
if (name !== null && typeof name !== "string") {
throw new Error("Invalid mod name");
}
@@ -410,7 +406,7 @@ export class Control extends lib.Link {
}
}
async offModUpdate(name, handler) {
async offModUpdate(name: string|null, handler: modInfoHandler) {
let handlers = this.modUpdateHandlers.get(name);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for mod ${name} exist`);
@@ -435,12 +431,12 @@ export class Control extends lib.Link {
await this.send(new lib.ModSetSubscriptionsRequest(
this.modUpdateHandlers.has(null),
[...this.modUpdateHandlers.keys()].filter(k => k !== null),
[...this.modUpdateHandlers.keys()].filter(k => k !== null) as string[],
));
}
async handleUserUpdateEvent(event) {
let handlers = [].concat(
async handleUserUpdateEvent(event: lib.UserUpdateEvent) {
let handlers = ([] as userHandler[]).concat(
this.userUpdateHandlers.get(null) || [],
this.userUpdateHandlers.get(event.user.name) || [],
);
@@ -449,7 +445,7 @@ export class Control extends lib.Link {
}
};
async onUserUpdate(name, handler) {
async onUserUpdate(name: string|null, handler: userHandler) {
if (name !== null && typeof name !== "string") {
throw new Error("Invalid user name");
}
@@ -467,7 +463,7 @@ export class Control extends lib.Link {
}
}
async offUserUpdate(name, handler) {
async offUserUpdate(name: string|null, handler: userHandler) {
let handlers = this.userUpdateHandlers.get(name);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for user ${name} exist`);
@@ -492,11 +488,11 @@ export class Control extends lib.Link {
await this.send(new lib.UserSetSubscriptionsRequest(
this.userUpdateHandlers.has(null),
[...this.userUpdateHandlers.keys()].filter(e => e !== null),
[...this.userUpdateHandlers.keys()].filter(e => e !== null) as string[],
));
}
async handleLogMessageEvent(event) {
async handleLogMessageEvent(event: lib.LogMessageEvent) {
let info = event.info;
for (let [filter, handlers] of this.logHandlers) {
@@ -508,7 +504,7 @@ export class Control extends lib.Link {
}
}
async onLog(filter, handler) {
async onLog(filter: lib.LogFilter, handler: logHandler) {
let handlers = this.logHandlers.get(filter);
if (!handlers) {
handlers = [];
@@ -522,7 +518,7 @@ export class Control extends lib.Link {
}
}
async offLog(filter, handler) {
async offLog(filter: lib.LogFilter, handler: logHandler) {
let handlers = this.logHandlers.get(filter);
if (!handlers || !handlers.length) {
throw new Error("No handlers for the given filter exists");
@@ -547,8 +543,8 @@ export class Control extends lib.Link {
let all = false;
let controller = false;
let hostIds = [];
let instanceIds = [];
let hostIds: number[] = [];
let instanceIds: number[] = [];
for (let filter of this.logHandlers.keys()) {
if (filter.all) { all = true; }
@@ -568,9 +564,9 @@ export class Control extends lib.Link {
await this.send(new lib.LogSetSubscriptionsRequest(all, controller, hostIds, instanceIds, undefined));
}
async handleDebugWsMessageEvent(message) {
async handleDebugWsMessageEvent(message: lib.DebugWsMessageEvent) {
// eslint-disable-next-line no-console
console.log("WS", message.data.direction, message.data.content);
console.log("WS", message.direction, message.content);
}
async shutdown() {
+20
View File
@@ -0,0 +1,20 @@
{
"include": [ "src/**/*.tsx", "src/**/*.ts", "package.json", "global.d.ts" ],
"compilerOptions": {
"jsx": "react",
"lib": ["dom", "es2023"],
"module": "commonjs",
"target": "es2022",
"rootDir": "./",
"outDir": "dist",
"composite": true,
"sourceMap": true,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmitOnError": true
}
}
+8 -1
View File
@@ -73,6 +73,13 @@ module.exports = (env = {}) => ({
fullySpecified: false,
},
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: {
loader: require.resolve("ts-loader"),
},
},
{
test: /\.jsx$/,
exclude: /node_modules/,
@@ -90,7 +97,7 @@ module.exports = (env = {}) => ({
],
},
resolve: {
extensions: [".js", ".json", ".jsx"],
extensions: [".js", ".ts", ".json", ".jsx", ".tsx"],
fallback: {
"crypto": false,
+2 -2
View File
@@ -1,8 +1,8 @@
import type { PluginInfo } from "@clusterio/lib";
import type * as lib from "@clusterio/lib";
import { ChatEvent } from "./messages";
const info: PluginInfo = {
const info: lib.PluginDeclaration = {
name: "global_chat",
title: "Global Chat",
description: "Forwards chat between instances.",
+1 -1
View File
@@ -40,7 +40,7 @@ lib.definePermission({
grantByDefault: true,
});
const info: lib.PluginInfo = {
const info: lib.PluginDeclaration = {
name: "inventory_sync",
title: "Inventory sync",
description: "Synchronizes players inventories between instances",
+1 -1
View File
@@ -20,7 +20,7 @@ ControllerConfigGroup.define({
});
ControllerConfigGroup.finalize();
const info: lib.PluginInfo = {
const info: lib.PluginDeclaration = {
name: "player_auth",
title: "Player Auth",
description: "Provides authentication to the cluster via logging into a Factorio server.",
+2 -2
View File
@@ -1,8 +1,8 @@
import type { PluginInfo } from "@clusterio/lib";
import type * as lib from "@clusterio/lib";
import * as messages from "./messages";
const info: PluginInfo = {
const info: lib.PluginDeclaration = {
name: "research_sync",
title: "Research Sync",
description: "Synchronises technology research progress between instances.",
+1 -1
View File
@@ -12,7 +12,7 @@ InstanceConfigGroup.define({
});
InstanceConfigGroup.finalize();
const info: lib.PluginInfo = {
const info: lib.PluginDeclaration = {
name: "statistics_exporter",
title: "Statistics Exporter",
description:
+1
View File
@@ -5,6 +5,7 @@
{ "path": "./packages/controller" },
{ "path": "./packages/host" },
{ "path": "./packages/ctl" },
{ "path": "./packages/web_ui" },
{ "path": "./plugins/global_chat" },
{ "path": "./plugins/inventory_sync" },
{ "path": "./plugins/player_auth" },