mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
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:
1 parent
a4893751e9
commit
838170a9ff
92 files changed
+1104
-707
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+12
@@ -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 };
|
||||
@@ -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>
|
||||
+10
-2
@@ -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) {
|
||||
+37
-15
@@ -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);
|
||||
+3
-3
@@ -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);
|
||||
File renamed without changes.
+9
-5
@@ -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
-1
@@ -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>
|
||||
+4
-5
@@ -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>
|
||||
+28
-18
@@ -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}
|
||||
+8
-4
@@ -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);
|
||||
+23
-11
@@ -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);
|
||||
+7
-3
@@ -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;
|
||||
+4
-2
@@ -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>;
|
||||
+19
-15
@@ -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} />
|
||||
+4
-3
@@ -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"));
|
||||
}
|
||||
})
|
||||
+10
-7
@@ -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;
|
||||
}
|
||||
+31
-12
@@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
+5
-3
@@ -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 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 Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
|
||||
{mod.factorioVersion
|
||||
&& <Descriptions.Item label="Factorio 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 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 Name" span={mod.factorioVersion ? 1 : 2}>{mod.name}</Descriptions.Item>
|
||||
{mod.factorioVersion
|
||||
&& <Descriptions.Item label="Factorio 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>;
|
||||
}
|
||||
+170
-88
@@ -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>;
|
||||
|
||||
}
|
||||
+11
-12
@@ -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}
|
||||
+6
-1
@@ -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>}
|
||||
+8
-1
@@ -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;
|
||||
}
|
||||
+7
-5
@@ -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();
|
||||
+7
-5
@@ -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"],
|
||||
},
|
||||
]}
|
||||
+11
-6
@@ -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
|
||||
+5
-6
@@ -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={[
|
||||
+43
-25
@@ -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>)}
|
||||
+5
-1
@@ -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>}
|
||||
+9
-10
@@ -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}
|
||||
+16
-12
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
+17
-17
@@ -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) => ({
|
||||
+8
-8
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
+4
-5
@@ -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);
|
||||
+15
-12
@@ -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() {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -12,7 +12,7 @@ InstanceConfigGroup.define({
|
||||
});
|
||||
InstanceConfigGroup.finalize();
|
||||
|
||||
const info: lib.PluginInfo = {
|
||||
const info: lib.PluginDeclaration = {
|
||||
name: "statistics_exporter",
|
||||
title: "Statistics Exporter",
|
||||
description:
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user