diff --git a/plugins/subspace_storage/info.js b/plugins/subspace_storage/info.js index 52534a65..84c7d511 100644 --- a/plugins/subspace_storage/info.js +++ b/plugins/subspace_storage/info.js @@ -1,6 +1,7 @@ "use strict"; const libLink = require("@clusterio/lib/link"); const libConfig = require("@clusterio/lib/config"); +const libUsers = require("@clusterio/lib/users"); class MasterConfigGroup extends libConfig.PluginConfigGroup {} MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; @@ -41,6 +42,13 @@ InstanceConfigGroup.define({ }); InstanceConfigGroup.finalize(); +libUsers.definePermission({ + name: "subspace_storage.storage.view", + title: "View Subspace Storage", + description: "View the items and fluids stored in the shared subspace.", + grantByDefault: true, +}); + // JSON schema for subspace storage items const items = { type: "array", @@ -65,6 +73,9 @@ module.exports = { masterEntrypoint: "master", MasterConfigGroup, + webEntrypoint: "./web", + routes: ["/storage"], + messages: { // XXX this should be a request to be reliable place: new libLink.Event({ @@ -90,7 +101,8 @@ module.exports = { }), getStorage: new libLink.Request({ type: "subspace_storage:get_storage", - links: ["instance-slave", "slave-master"], + links: ["instance-slave", "slave-master", "control-master"], + permission: "subspace_storage.storage.view", forwardTo: "master", responseProperties: { "items": items, @@ -98,11 +110,20 @@ module.exports = { }), updateStorage: new libLink.Event({ type: "subspace_storage:update_storage", - links: ["master-slave", "slave-instance"], + links: ["master-slave", "slave-instance", "master-control"], broadcastTo: "instance", eventProperties: { "items": items, }, }), + + setStorageSubscription: new libLink.Request({ + type: "subspace_storage:set_storage_subscription", + links: ["control-master"], + permission: "subspace_storage.storage.view", + requestProperties: { + "storage": { type: "boolean" }, + }, + }), }, }; diff --git a/plugins/subspace_storage/master.js b/plugins/subspace_storage/master.js index 6cf6e538..0472a379 100644 --- a/plugins/subspace_storage/master.js +++ b/plugins/subspace_storage/master.js @@ -72,6 +72,8 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin { } }, 1000); + this.subscribedControlLinks = new Set(); + routes.addApiRoutes(this.master.app, this.items, this.metrics.endpointHitCounter); } @@ -90,6 +92,9 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin { itemsToUpdate = [...itemsToUpdate.entries()]; this.broadcastEventToSlaves(this.info.messages.updateStorage, { items: itemsToUpdate }); + for (let link of this.subscribedControlLinks) { + this.info.messages.updateStorage.send(link, { items: itemsToUpdate }); + } this.itemsLastUpdate = new Map(this.items._items.entries()); } @@ -181,6 +186,20 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin { }; } + async setStorageSubscriptionRequestHandler(message, request, link) { + if (message.data.storage) { + this.subscribedControlLinks.add(link); + } else { + this.subscribedControlLinks.delete(link); + } + } + + onControlConnectionEvent(connection, event) { + if (event === "close") { + this.subscribedControlLinks.delete(connection); + } + } + onMetrics() { if (this.items) { for (let [key, count] of this.items._items) { diff --git a/plugins/subspace_storage/web/index.css b/plugins/subspace_storage/web/index.css new file mode 100644 index 00000000..e0e730c8 --- /dev/null +++ b/plugins/subspace_storage/web/index.css @@ -0,0 +1,5 @@ +.factorio-icon { + display: inline-block; + vertical-align: middle; + margin-right: 0.5em; +} diff --git a/plugins/subspace_storage/web/index.jsx b/plugins/subspace_storage/web/index.jsx index e69de29b..3336ac13 100644 --- a/plugins/subspace_storage/web/index.jsx +++ b/plugins/subspace_storage/web/index.jsx @@ -0,0 +1,184 @@ +import React, { useContext, useEffect, useState } from "react"; +import { Input, Table, Typography } from "antd"; + +import libPlugin from "@clusterio/lib/plugin"; +import { notifyErrorHandler, useItemMetadata, useLocale, PageLayout, ControlContext } from "@clusterio/web_ui"; +import info from "../info"; + +import "./index.css"; + +const { Paragraph } = Typography; + + +function useStorage(control) { + let plugin = control.plugins.get("subspace_storage"); + let [storage, setStorage] = useState([...plugin.storage]); + + useEffect(() => { + function update() { + setStorage([...plugin.storage]); + } + + plugin.onUpdate(update); + return () => { + plugin.offUpdate(update); + }; + }, []); + return storage; +} + +function StoragePage() { + let control = useContext(ControlContext); + let locale = useLocale(); + let itemMetadata = useItemMetadata(); + let storage = useStorage(control); + let [filter, setFilter] = useState(null); + + function getLocaleName(itemName) { + let localeName = itemName; + let meta = itemMetadata.get(itemName); + if (meta && meta.localized_name) { + // TODO: implement the locale to name conversion. + localeName = locale.get(meta.localized_name[0]); + } else { + for (let section of ["item-name", "entity-name", "fluid-name", "equipment-name"]) { + let name = locale.get(`${section}.${itemName}`); + if (name) { + localeName = name; + break; + } + } + } + + return localeName; + } + + let numberFormat = new Intl.NumberFormat("en-US"); + + return +

Storage

+ + { + let search = event.target.value.trim(); + if (!search) { + setFilter(null); + return; + } + search = search.replace(/(^| )(\w)/g, "$1\\b$2"); + search = search.replace(/ +/g, ".*"); + let filterExpr = new RegExp(search, "i"); + setFilter(() => (item => { + let name = getLocaleName(item[0]); + return filterExpr.test(name) || filterExpr.test(item[0]); + })); + }} + /> + + { + let aName = getLocaleName(a[0]); + let bName = getLocaleName(b[0]); + if (aName < bName) { return -1; } + if (aName > bName) { return 1; } + return 0; + }, + render: item => { + let localeName = getLocaleName(item[0]); + let hasMeta = itemMetadata.get(item[0]); + + return <> + + {localeName} + ; + }, + }, + { + title: "Quantity", + key: "quantity", + align: "right", + defaultSortOrder: "descend", + sorter: (a, b) => a[1] - b[1], + render: item => numberFormat.format(item[1]), + }, + ]} + dataSource={filter ? storage.filter(filter) : storage} + rowKey={item => item[0]} + pagination={false} + /> + ; +} + +export class WebPlugin extends libPlugin.BaseWebPlugin { + async init() { + this.pages = [ + { path: "/storage", sidebarName: "Storage", content: }, + ]; + + this.storage = new Map(); + this.callbacks = []; + } + + onMasterConnectionEvent(event) { + if (event === "connect") { + this.updateSubscription(); + } + } + + async updateStorageEventHandler(message) { + this.updateStorage(message.data.items); + } + + onUpdate(callback) { + this.callbacks.push(callback); + if (this.callbacks.length) { + this.updateSubscription(); + } + } + + offUpdate(callback) { + let index = this.callbacks.lastIndexOf(callback); + if (index === -1) { + throw new Error("callback is not registered"); + } + + this.callbacks.splice(index, 1); + if (!this.callbacks.length) { + this.updateSubscription(); + } + } + + updateStorage(items) { + for (let [name, amount] of items) { + this.storage.set(name, amount); + } + for (let callback of this.callbacks) { + callback(); + } + } + + updateSubscription() { + if (!this.control.connector.connected) { + return; + } + + info.messages.setStorageSubscription.send( + this.control, { storage: Boolean(this.callbacks.length) } + ).catch(notifyErrorHandler("Error subscribing to storage")); + + if (this.callbacks.length) { + info.messages.getStorage.send(this.control).then( + result => { + this.updateStorage(result.items); + } + ).catch(notifyErrorHandler("Error updating storage")); + } else { + this.storage.clear(); + } + } +} diff --git a/plugins/subspace_storage/webpack.config.js b/plugins/subspace_storage/webpack.config.js index 8305920b..491a9c31 100644 --- a/plugins/subspace_storage/webpack.config.js +++ b/plugins/subspace_storage/webpack.config.js @@ -21,12 +21,14 @@ module.exports = (env = {}) => merge(common(env), { exposes: { "./info": "./info.js", "./package.json": "./package.json", + "./web": "./web/index.jsx", }, shared: { "@clusterio/lib/config": { import: false }, "@clusterio/lib/link": { import: false }, + "@clusterio/lib/plugin": { import: false }, + "@clusterio/lib/users": { import: false }, "@clusterio/web_ui": { import: false }, - "ajv": { import: false }, "antd": { import: false }, "react": { import: false }, "react-dom": { import: false },