diff --git a/.eslintrc.js b/.eslintrc.js index b316b1ed..0764886b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -350,8 +350,9 @@ module.exports = { "node/exports-style": "off", "node/file-extension-in-import": "off", "node/global-require": "off", - "node/handle-callback-err": "error", - "node/no-callback-literal": "error", + // Node.js callback passing style of (err, value) is not used in this project + "node/handle-callback-err": "off", + "node/no-callback-literal": "off", "node/no-deprecated-api": "error", "node/no-exports-assign": "error", "node/no-extraneous-import": "off", diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index e413cf32..f5761137 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -58,7 +58,7 @@ export default class ModPack { /** * Id of this mod pack */ - id?: number; + id: number = Math.random() * 2**31 | 0; /** * Name of this mod pack. diff --git a/packages/lib/src/data/User.ts b/packages/lib/src/data/User.ts index 604194e8..42a99a34 100644 --- a/packages/lib/src/data/User.ts +++ b/packages/lib/src/data/User.ts @@ -143,6 +143,11 @@ export default class User { } return playerStats; } + + /** Name of the user, used by event subscriptions */ + get id() { + return this.name; + } } export interface IControllerUser extends User { diff --git a/packages/lib/src/subscriptions.ts b/packages/lib/src/subscriptions.ts index 0cd427cc..abf237cd 100644 --- a/packages/lib/src/subscriptions.ts +++ b/packages/lib/src/subscriptions.ts @@ -4,7 +4,7 @@ import { logger } from "./logging"; import { Address, MessageRequest, IControllerUser } from "./data"; export type SubscriptionRequestHandler = RequestHandler | null>; -export type EventSubscriberCallback = (value: T) => void; +export type EventSubscriberCallback = (updates: T[], synced: boolean) => void /** * A subscription request sent by a subscriber, this updates what events the subscriber will be sent @@ -150,68 +150,118 @@ export class SubscriptionController { } } +export interface SubscribableValue { + id: number | string, + updatedAt: number, + isDeleted: boolean, +} + +export interface EventSubscribable extends Event { + updates: V[], +} + /** - * A class component to allow subscribing and unsubscribing to/from an event + * Component for subscribing to and tracking updates of a remote resource * Multiple handlers can be subscribed at the same time */ -export class EventSubscriber, V=T> { +export class EventSubscriber< + T extends EventSubscribable, + K extends string | number = T["updates"][number]["id"], + V extends SubscribableValue = T["updates"][number], +> { _callbacks = new Array>(); - lastResponse: Event | null = null; + /** Values of the subscribed resource */ + values = new Map(); + /** True if this subscriber is currently synced with the source */ + synced = false; + _snapshot?: readonly [ReadonlyMap>, boolean]; + _snapshotLastUpdated = 0; lastResponseTime = -1; constructor( private Event: EventClass, public control: Link, - private prehandler?: (e: T) => V, ) { control.handle(Event, this._handle.bind(this)); control.connector.on("connect", () => { this._updateSubscription(); }); + control.connector.on("close", () => { + if (this.synced) { + this.synced = false; + for (let callback of this._callbacks) { + callback([], this.synced); + } + } + }); } /** * Handle incoming events and distribute it to the correct callbacks - * @param response - event from subscribed resource + * @param event - event from subscribed resource * @internal */ - async _handle(response: Event) { - this.lastResponse = response; - this.lastResponseTime = Date.now(); - const value = this.prehandler ? this.prehandler(response as T) : response as any; + async _handle(event: EventSubscribable) { + for (const value of event.updates) { + this.lastResponseTime = Math.max(this.lastResponseTime, value.updatedAt); + if (value.isDeleted) { + this.values.delete(value.id as K); + } else { + this.values.set(value.id as K, value); + } + } for (let callback of this._callbacks) { - callback(value); + callback(event.updates, this.synced); } } /** * Subscribe to receive all event notifications * @param handler - - * callback invoked whenever the subscribed resource changes. + * callback invoked whenever the subscribed resource changes or the + * synced property changes, in which case the updates will be empty. + * @returns function that will unsubscribe from notifications */ - async subscribe(handler: EventSubscriberCallback) { + subscribe(handler: EventSubscriberCallback) { this._callbacks.push(handler); - await this._updateSubscription(); + if (this._callbacks.length === 1) { + this._updateSubscription(); + } + return () => { + // During a page transition the components currently rendered + // are unmounted and then the components for the new page is + // mounted. This means that if a resource is used by both pages + // it is first unsubscribed by the unmounted component causing + // the callbacks count to go to zero and a subscription update + // to be sent, and then subscribed by the mounted component + // causing another subscription update to be sent. + + // By delaying the unsubscription here the subscription happens + // before the unsubscription, thus preventing the redundant + // updates from being sent out. + setImmediate(() => { + let index = this._callbacks.lastIndexOf(handler); + if (index === -1) { + return; + } + this._callbacks.splice(index, 1); + if (this._callbacks.length === 0) { + this._updateSubscription(); + } + }); + }; } /** - * Unsubscribe from receiving all event notifications, does not effect - * the subscriptions of other handlers. - * - * @param handler - A callback previously passed to {@link subscribe} - * - * @throws {Error} - * If your handler is not subscribed, does not accept anonymous - * functions + * Obtain a snapshot of the current state of the tracked resource + * @returns tuple of values map snapshot and synced property. */ - async unsubscribe(handler: EventSubscriberCallback) { - let index = this._callbacks.lastIndexOf(handler); - if (index === -1) { - throw new Error("handler is not registered"); + getSnapshot() { + if (this._snapshotLastUpdated !== this.lastResponseTime) { + this._snapshotLastUpdated = this.lastResponseTime; + this._snapshot = [new Map(this.values), this.synced]; } - - this._callbacks.splice(index, 1); - await this._updateSubscription(); + return this._snapshot!; } /** @@ -229,6 +279,11 @@ export class EventSubscriber, V=T> { this._callbacks.length > 0, this.lastResponseTime )); + this.synced = this._callbacks.length > 0; + this._snapshotLastUpdated = 0; + for (let callback of this._callbacks) { + callback([], this.synced); + } } catch (err: any) { logger.error( `Unexpected error updating ${entry.name} subscription:\n${err.stack}` diff --git a/packages/web_ui/src/components/AssignInstanceModal.tsx b/packages/web_ui/src/components/AssignInstanceModal.tsx index b033469f..f6525522 100644 --- a/packages/web_ui/src/components/AssignInstanceModal.tsx +++ b/packages/web_ui/src/components/AssignInstanceModal.tsx @@ -6,7 +6,7 @@ import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; -import { useHostList } from "../model/host"; +import { useHosts } from "../model/host"; const { Paragraph } = Typography; @@ -20,7 +20,7 @@ type AssignInstanceModalProps = { }; export default function AssignInstanceModal(props: AssignInstanceModalProps) { let [open, setOpen] = useState(false); - let [hostList] = useHostList(); + let [hosts] = useHosts(); let [applying, setApplying] = useState(false); let [form] = Form.useForm(); let control = useContext(ControlContext); @@ -80,7 +80,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) { Unassigned - {hostList.map((host) => instance.assignedHost === hostId)); - instanceList = instanceList.filter(instance => instance.assignedHost === hostId); + let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }]; + if (!host) { + if (!synced) { + return ; + } - let nav = [{ name: "Hosts", path: "/hosts" }, { name: host.name || String(hostId) }]; - if (host.loading) { - return ; - } - - if (host.missing) { return

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

@@ -83,7 +81,7 @@ export default function HostViewPage() { {account.hasPermission("core.instance.list") && <> Instances - + } {account.hasPermission("core.log.follow") && <> Console diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 5d59c4af..cbec5dc2 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -10,7 +10,7 @@ import ControlContext from "./ControlContext"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { useHostList } from "../model/host"; +import { useHosts } from "../model/host"; import notify, { notifyErrorHandler } from "../util/notify"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -184,7 +184,7 @@ function CopyButton({ text, message }: { text:string, message:string }) { export default function HostsPage() { let account = useAccount(); let navigate = useNavigate(); - let [hostList] = useHostList(); + let [hosts] = useHosts(); return Number(a.connected) - Number(b.connected), }, ]} - dataSource={hostList} + dataSource={[...hosts.values()]} rowKey={host => host.id} pagination={false} onRow={(record, rowIndex) => ({ diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index feec3335..09868175 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -6,7 +6,7 @@ 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 { useHosts } from "../model/host"; import InstanceStatusTag from "./InstanceStatusTag"; import StartStopInstanceButton from "./StartStopInstanceButton"; import { InstanceDetails } from "@clusterio/lib"; @@ -14,7 +14,7 @@ import { InstanceDetails } from "@clusterio/lib"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; type InstanceListProps = { - instances: InstanceDetails[]; + instances: ReadonlyMap>; size?: SizeType; hideAssignedHost?: boolean; }; @@ -22,28 +22,27 @@ type InstanceListProps = { export default function InstanceList(props: InstanceListProps) { let account = useAccount(); let navigate = useNavigate(); - let [hostList] = useHostList(); + let [hosts] = useHosts(); - function hostName(hostId?: number|null) { - if (hostId === null ||hostId === undefined) { + function hostName(hostId?: number) { + if (hostId === undefined) { return ""; } - let host = hostList.find(s => s.id === hostId); - if (host) { - return host.name; - } - return String(hostId); + return hosts.get(hostId)?.name ?? String(hostId); } function instancePublicAddress(instance: InstanceDetails) { - let host = hostList.find(s => s.id === instance.assignedHost); - if (host && host.publicAddress) { - if (instance.gamePort) { - return `${host.publicAddress}:${instance.gamePort}`; - } + if (instance.assignedHost === undefined) { + return ""; + } + let host = hosts.get(instance.assignedHost); + if (!host || !host.publicAddress) { + return ""; + } + if (instance.gamePort === undefined) { return host.publicAddress; } - return null; + return `${host.publicAddress}:${instance.gamePort}`; } let columns: ColumnsType = [ @@ -78,7 +77,7 @@ export default function InstanceList(props: InstanceListProps) { /> : ""; }, - sorter: (a, b) => strcmp(instancePublicAddress(a)||"", instancePublicAddress(b)||""), + sorter: (a, b) => strcmp(instancePublicAddress(a), instancePublicAddress(b)), responsive: ["lg"], }, { @@ -109,7 +108,7 @@ export default function InstanceList(props: InstanceListProps) { return instance["id"]} pagination={false} onRow={(record, rowIndex) => ({ diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index e8c72614..13258761 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -20,15 +20,15 @@ import StartStopInstanceButton from "./StartStopInstanceButton"; import LoadScenarioModal from "./LoadScenarioModal"; import SavesList from "./SavesList"; import { notifyErrorHandler } from "../util/notify"; -import { InstanceState, useInstance } from "../model/instance"; -import { HostState, useHost } from "../model/host"; +import { useInstance } from "../model/instance"; +import { useHost } from "../model/host"; import InstanceStatusTag from "./InstanceStatusTag"; const { Title } = Typography; type InstanceDescriptionProps = { - host: HostState; - instance: InstanceState; + host?: Readonly; + instance: Readonly; }; function InstanceDescription(props: InstanceDescriptionProps) { let account = useAccount(); @@ -42,7 +42,7 @@ function InstanceDescription(props: InstanceDescriptionProps) { {!assigned ? Unassigned - : host.name || instance.assignedHost + : host?.name ?? instance.assignedHost } {account.hasPermission("core.instance.assign") && ; } -function InstanceButtons(props: { instance: InstanceState }) { +function InstanceButtons(props: { instance: lib.InstanceDetails }) { let account = useAccount(); let control = useContext(ControlContext); let navigate = useNavigate(); @@ -169,18 +169,18 @@ export default function InstanceViewPage() { let navigate = useNavigate(); let account = useAccount(); - let [instance] = useInstance(instanceId); - let [host] = useHost(Number(instance.assignedHost)); + let [instance, synced] = useInstance(instanceId); + let [host] = useHost(instance?.assignedHost); - let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }]; - if (instance.loading) { - return ; - } + let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }]; + if (!instance) { + if (!synced) { + return ; + } - if (instance.missing || instance.status === "deleted") { return Instance with id {instanceId} was not found on the controller.} type="warning" diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx index eba88f67..351d6fad 100644 --- a/packages/web_ui/src/components/InstancesPage.tsx +++ b/packages/web_ui/src/components/InstancesPage.tsx @@ -9,7 +9,7 @@ import ControlContext from "./ControlContext"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { useInstanceList } from "../model/instance"; +import { useInstances } from "../model/instance"; import InstanceList from "./InstanceList"; import { notifyErrorHandler } from "../util/notify"; @@ -62,7 +62,7 @@ function CreateInstanceButton() { export default function InstancesPage() { let control = useContext(ControlContext); let account = useAccount(); - let [instanceList] = useInstanceList(); + let [instances] = useInstances(); return {account.hasPermission("core.instance.create") && } {account.hasPermission("core.instance.start") - && } {account.hasPermission("core.instance.stop") - && } {account.hasPermission("core.role.delete") && @@ -153,17 +153,17 @@ function TransferModal(props: ModalProps) { (option?.title.toLowerCase().indexOf(input.toLowerCase())??-1) >= 0 )} > - {instanceList.filter( + {[...instances.values()].filter( instance => instance["id"] !== props.instanceId ).map((instance) => - {instance["name"]} - {instance["status"] === "unassigned" && " (unassigned)"} - {instance["status"] === "unknown" && " (offline)"} + {instance.name} + {instance.status === "unassigned" && " (unassigned)"} + {instance.status === "unknown" && " (offline)"} )} @@ -198,10 +198,10 @@ type File = { status: string; } -export default function SavesList(props: { instance: InstanceState }) { +export default function SavesList(props: { instance: lib.InstanceDetails }) { let account = useAccount(); let control = useContext(ControlContext); - let saves = useSaves(props.instance.id); + let [saves] = useSavesOfInstance(props.instance.id); let [starting, setStarting] = useState(false); let [uploadingFiles, setUploadingFiles] = useState([]); @@ -234,7 +234,7 @@ export default function SavesList(props: { instance: InstanceState }) { defaultSortOrder: "descend", }, ]} - dataSource={saves} + dataSource={[...saves.values()]} rowKey={save => save.name} pagination={false} expandable={{ diff --git a/packages/web_ui/src/components/StartStopInstanceButton.tsx b/packages/web_ui/src/components/StartStopInstanceButton.tsx index 2920721a..2948675a 100644 --- a/packages/web_ui/src/components/StartStopInstanceButton.tsx +++ b/packages/web_ui/src/components/StartStopInstanceButton.tsx @@ -5,11 +5,10 @@ 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"; type StartStopInstanceButtonProps = { - instance: InstanceState; + instance: lib.InstanceDetails; onFinish?: () => void; buttonProps?: ButtonProps; }; @@ -23,15 +22,15 @@ export default function StartStopInstanceButton(props: StartStopInstanceButtonPr let action; 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") @@ -54,7 +53,7 @@ export default function StartStopInstanceButton(props: StartStopInstanceButtonPr {...(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"} diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 5d41a4ac..07620da2 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -9,7 +9,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; import * as lib from "@clusterio/lib"; import { useAccount } from "../model/account"; -import { useInstanceList } from "../model/instance"; +import { useInstances } from "../model/instance"; import ControlContext from "./ControlContext"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; @@ -33,8 +33,8 @@ export default function UserViewPage() { let account = useAccount(); let control = useContext(ControlContext); - let [instanceList] = useInstanceList(); - let [user] = useUser(userName); + let [instances] = useInstances(); + const [user, synced] = useUser(userName); let [roles, setRoles] = useState>(new Map()); let [form] = Form.useForm(); let [rolesDirty, setRolesDirty] = useState(false); @@ -54,6 +54,7 @@ export default function UserViewPage() { if ( account.hasPermission("core.user.set_banned") && !banReasonDirty + && user && user.banReason !== form.getFieldValue("banReason") ) { form.setFieldsValue({ "banReason": user.banReason }); @@ -61,14 +62,14 @@ export default function UserViewPage() { }, [account, form, user, banReasonDirty]); let nav = [{ name: "Users", path: "/users" }, { name: userName }]; - if (user.loading) { - return - - - ; - } + if (!user) { + if (!synced) { + return + + + ; + } - if (user.missing) { return

User with name {userName} was not found on the controller.

@@ -78,7 +79,7 @@ export default function UserViewPage() { let roleSelector = (rolesError ? Promise.reject(rolesError) : Promise.resolve()) }, ]} @@ -100,9 +101,8 @@ export default function UserViewPage() { ; - function instanceName(id: number): string { - let instance = instanceList.find(i => i.id === id); - return instance ? instance.name : String(id); + function instanceName(id: number) { + return instances.get(id)?.name ?? String(id); } return diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index e0966187..b7469d40 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -11,7 +11,7 @@ import { formatDuration } from "../util/time_format"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { formatLastSeen, sortLastSeen, useUserList } from "../model/user"; +import { formatLastSeen, sortLastSeen, useUsers } from "../model/user"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -60,7 +60,7 @@ export default function UsersPage() { let account = useAccount(); let control = useContext(ControlContext); let navigate = useNavigate(); - let [userList] = useUserList(); + let [users] = useUsers(); let [roles, setRoles] = useState(new Map()); @@ -117,7 +117,7 @@ export default function UsersPage() { responsive: ["lg"], }, ]} - dataSource={userList} + dataSource={[...users.values()]} pagination={false} rowKey={user => user.name} onRow={(user, rowIndex) => ({ diff --git a/packages/web_ui/src/model/host.ts b/packages/web_ui/src/model/host.ts index ec067f14..c6e28ff3 100644 --- a/packages/web_ui/src/model/host.ts +++ b/packages/web_ui/src/model/host.ts @@ -1,92 +1,13 @@ -import { useEffect, useContext, useState } from "react"; +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -import * as lib from "@clusterio/lib"; - - -export type HostState = Partial & { - loading?: boolean, - present?: boolean, - missing?: boolean, +export function useHost(id?: number) { + const [hosts, synced] = useHosts(); + return [id !== undefined ? hosts.get(id) : undefined, synced] as const; } -export function useHost(id: number): [ HostState, () => void ] { - let control = useContext(ControlContext); - let [host, setHost] = useState({ loading: true }); - - function updateHost() { - // XXX optimize by requesting only the host in question - control.send( - new lib.HostListRequest() - ).then((hosts: lib.HostDetails[]) => { - let match = hosts.find(i => i.id === id); - if (!match) { - setHost({ missing: true }); - } else { - setHost({ ...match, present: true }); - } - }); - } - - useEffect(() => { - if (!Number.isInteger(id)) { - setHost({ missing: true }); - return undefined; - } - updateHost(); - - function updateHandler(newHosts: lib.HostDetails[]) { - const newHost = newHosts.find(h => h.id === id); - if (newHost) { - setHost({ ...newHost, loading: false, missing: false, present: true }); - } - } - - control.hostUpdate.subscribe(updateHandler); - return () => { - control.hostUpdate.unsubscribe(updateHandler); - }; - }, [id]); - - return [host, updateHost]; -} - -export function useHostList() { - let control = useContext(ControlContext); - let [hostList, setHostList] = useState([]); - - function updateHostList() { - control.send( - new lib.HostListRequest() - ).then((hosts: lib.HostDetails[]) => { - setHostList(hosts); - }); - } - - useEffect(() => { - updateHostList(); - - function updateHandler(newHosts: lib.HostDetails[]) { - setHostList(oldList => { - let newList = oldList.concat(); - for (const newHost of newHosts) { - let index = newList.findIndex(s => s.id === newHost.id); - if (index !== -1) { - newList[index] = newHost; - } else { - newList.push(newHost); - } - } - return newList; - }); - } - - control.hostUpdate.subscribe(updateHandler); - return () => { - control.hostUpdate.unsubscribe(updateHandler); - }; - }, []); - - - return [hostList]; +export function useHosts() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.hosts.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.hosts.getSnapshot()); } diff --git a/packages/web_ui/src/model/instance.ts b/packages/web_ui/src/model/instance.ts index bd50b3a4..41bb8b99 100644 --- a/packages/web_ui/src/model/instance.ts +++ b/packages/web_ui/src/model/instance.ts @@ -1,91 +1,13 @@ -import { useEffect, useContext, useState } from "react"; +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -import * as lib from "@clusterio/lib"; - -const { logger } = lib; - -export type InstanceState = Partial & { - loading?: boolean, - present?: boolean, - missing?: boolean, +export function useInstance(id?: number) { + const [instances, synced] = useInstances(); + return [id !== undefined ? instances.get(id) : undefined, synced] as const; } -export function useInstance(id: number): [InstanceState, ()=>void] { - let control = useContext(ControlContext); - let [instance, setInstance] = useState({ loading: true }); - - function updateInstance() { - control.send(new lib.InstanceDetailsGetRequest(id)).then(updatedInstance => { - setInstance({ ...updatedInstance, present: true }); - }).catch((err: any) => { - logger.error(`Failed to get instance: ${err}`); - setInstance({ missing: true }); - }); - } - - useEffect(() => { - if (!Number.isInteger(id)) { - setInstance({ missing: true }); - return undefined; - } - updateInstance(); - - function updateHandler(newInstances: lib.InstanceDetails[]) { - const newInstance = newInstances.find(i => i.id === id); - if (newInstance) { - setInstance({ ...newInstance, present: true }); - } - } - - control.instanceUpdate.subscribe(updateHandler); - return () => { - control.instanceUpdate.unsubscribe(updateHandler); - }; - }, [id]); - - return [instance, updateInstance]; -} - -export function useInstanceList() { - let control = useContext(ControlContext); - let [instanceList, setInstanceList] = useState([]); - - function updateInstanceList() { - control.send(new lib.InstanceDetailsListRequest()).then(instances => { - setInstanceList(instances); - }).catch(err => { - logger.error(`Failed to list instances:\n${err}`); - }); - } - - useEffect(() => { - updateInstanceList(); - - function updateHandler(newInstances: lib.InstanceDetails[]) { - setInstanceList(oldList => { - let newList = oldList.concat(); - for (const newInstance of newInstances) { - let index = newList.findIndex(s => s.id === newInstance.id); - if (newInstance.status !== "deleted") { - if (index !== -1) { - newList[index] = newInstance; - } else { - newList.push(newInstance); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.instanceUpdate.subscribe(updateHandler); - return () => { - control.instanceUpdate.unsubscribe(updateHandler); - }; - }, []); - - return [instanceList]; +export function useInstances() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.instances.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.instances.getSnapshot()); } diff --git a/packages/web_ui/src/model/mod_pack.ts b/packages/web_ui/src/model/mod_pack.ts index 6b65b36b..927a9a75 100644 --- a/packages/web_ui/src/model/mod_pack.ts +++ b/packages/web_ui/src/model/mod_pack.ts @@ -1,93 +1,13 @@ -import { useContext, useEffect, useState } from "react"; - -import * as lib from "@clusterio/lib"; - +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -const { logger } = lib; - - -export type StateLoadStatus = { - loading?: boolean; - present?: boolean; - missing?: boolean; -}; - -export function useModPack(id: number) { - let control = useContext(ControlContext); - let [modPack, setModPack] = useState({ loading: true }); - - function updateModPack() { - control.send(new lib.ModPackGetRequest(id)).then(updatedModPack => { - setModPack(updatedModPack); - }).catch(err => { - logger.error(`Failed to get mod pack: ${err}`); - setModPack({ missing: true }); - }); - } - - useEffect(() => { - if (typeof id !== "number") { - setModPack({ missing: true }); - return undefined; - } - updateModPack(); - - function updateHandler(modPacks: lib.ModPack[]) { - const newModPack = modPacks.find(m => m.id === id); - if (newModPack) { - setModPack(newModPack); - } - } - control.modPackUpdate.subscribe(updateHandler); - return () => { - control.modPackUpdate.unsubscribe(updateHandler); - }; - }, [id]); - - return [modPack]; +export function useModPack(id?: number) { + const [modPacks, synced] = useModPacks(); + return [id !== undefined ? modPacks.get(id) : undefined, synced] as const; } -export function useModPackList() { - let control = useContext(ControlContext); - let [modPackList, setModPackList] = useState([]); - - function updateModPackList() { - control.send(new lib.ModPackListRequest()).then(modPacks => { - setModPackList(modPacks); - }).catch(err => { - logger.error(`Failed to list mod packs:\n${err}`); - }); - } - - useEffect(() => { - updateModPackList(); - - function updateHandler(newModPacks: lib.ModPack[]) { - setModPackList(oldList => { - let newList = oldList.concat(); - for (const newModPack of newModPacks) { - let index = newList.findIndex(u => u.id === newModPack.id); - if (!newModPack.isDeleted) { - if (index !== -1) { - newList[index] = newModPack; - } else { - newList.push(newModPack); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.modPackUpdate.subscribe(updateHandler); - return () => { - control.modPackUpdate.unsubscribe(updateHandler); - }; - }, []); - - return [modPackList]; - +export function useModPacks() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.modPacks.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.modPacks.getSnapshot()); } diff --git a/packages/web_ui/src/model/mods.ts b/packages/web_ui/src/model/mods.ts index 6bbdfc16..4c618d38 100644 --- a/packages/web_ui/src/model/mods.ts +++ b/packages/web_ui/src/model/mods.ts @@ -1,51 +1,13 @@ -import { useContext, useEffect, useState } from "react"; - -import * as lib from "@clusterio/lib"; - +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -const { logger } = lib; - -export function useModList() { - let control = useContext(ControlContext); - let [modList, setModList] = useState([]); - - function updateModList() { - control.send(new lib.ModListRequest()).then(mods => { - setModList(mods); - }).catch(err => { - logger.error(`Failed to list mods:\n${err}`); - }); - } - - useEffect(() => { - updateModList(); - - function updateHandler(newMods: lib.ModInfo[]) { - setModList(oldList => { - let newList = oldList.concat(); - for (const newMod of newMods) { - let index = newList.findIndex(mod => mod.name === newMod.name && mod.version === newMod.version); - if (!newMod.isDeleted) { - if (index !== -1) { - newList[index] = newMod; - } else { - newList.push(newMod); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.modUpdate.subscribe(updateHandler); - return () => { - control.modUpdate.unsubscribe(updateHandler); - }; - }, []); - - return [modList]; +export function useMod(id?: string) { + const [mods, synced] = useMods(); + return [id !== undefined ? mods.get(id) : undefined, synced] as const; } +export function useMods() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.mods.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.mods.getSnapshot()); +} diff --git a/packages/web_ui/src/model/saves.ts b/packages/web_ui/src/model/saves.ts index 6e7296e8..f92b5ce5 100644 --- a/packages/web_ui/src/model/saves.ts +++ b/packages/web_ui/src/model/saves.ts @@ -1,57 +1,14 @@ -import { useEffect, useContext, useState } from "react"; +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -import * as lib from "@clusterio/lib"; - -const { logger } = lib; - -export function useSaves(instanceId?: number): lib.SaveDetails[] { - let control = useContext(ControlContext); - let [saves, setSaves] = useState([]); - - function updateSaves() { - control.sendTo("controller", new lib.InstanceSaveDetailsListRequest()).then(updatedSaves => { - setSaves(updatedSaves.filter(save => save.instanceId === instanceId)); - }).catch(err => { - logger.error(`Failed to list instance saves: ${err}`); - setSaves([]); - }); - } - - useEffect(() => { - if (!Number.isInteger(instanceId)) { - setSaves([]); - return undefined; - } - updateSaves(); - - function updateHandler(updates: lib.SaveDetails[]) { - setSaves(oldList => { - let newList = oldList.concat(); - for (const newSave of updates) { - if (newSave.instanceId !== instanceId) { - continue; - } - let index = newList.findIndex(s => s.id === newSave.id); - if (!newSave.isDeleted) { - if (index !== -1) { - newList[index] = newSave; - } else { - newList.push(newSave); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.saveListUpdate.subscribe(updateHandler); - return () => { - control.saveListUpdate.unsubscribe(updateHandler); - }; - }, [instanceId]); - - return saves; +export function useSavesOfInstance(instanceId?: number) { + const [saves, synced] = useSaves(); + const savesOfInstance = new Map([...saves].filter(([_id, save]) => save.instanceId === instanceId)); + return [savesOfInstance, synced] as const; +} + +export function useSaves() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.saves.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.saves.getSnapshot()); } diff --git a/packages/web_ui/src/model/user.tsx b/packages/web_ui/src/model/user.tsx index 9ad9e745..f0ff2b7e 100644 --- a/packages/web_ui/src/model/user.tsx +++ b/packages/web_ui/src/model/user.tsx @@ -1,31 +1,29 @@ -import React, { useEffect, useContext, useState } from "react"; +import React, { useCallback, useContext, useSyncExternalStore } from "react"; import { Tag } from "antd"; import ControlContext from "../components/ControlContext"; import * as lib from "@clusterio/lib"; -const { logger } = lib; - -function calculateLastSeen(user: RawUserState, instanceId?: number) { +function calculateLastSeen(user: lib.User, instanceId?: number) { let stats; if (instanceId === undefined) { stats = user.playerStats; } else { - stats = (user.instanceStats || new Map()).get(instanceId); + stats = user.instanceStats.get(instanceId); if (!stats) { return undefined; } } - if (stats.lastLeaveAt > stats.lastJoinAt) { - return stats.lastLeaveAt; + if ((stats.lastLeaveAt?.getTime() ?? 0) > (stats.lastJoinAt?.getTime() ?? 0)) { + return stats.lastLeaveAt?.getTime(); } if (stats.lastJoinAt) { - return stats.lastLeaveAt; + return stats.lastLeaveAt?.getTime(); } return undefined; } -export function formatLastSeen(user: RawUserState, instanceId?: number) { +export function formatLastSeen(user: lib.User, instanceId?: number) { if (user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId)) { return Online; } @@ -36,8 +34,8 @@ export function formatLastSeen(user: RawUserState, instanceId?: number) { return new Date(lastSeen).toLocaleString(); } -export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA?: number, instanceIdB?: number) { - function epoch(user:RawUserState, instanceId?: number) { +export function sortLastSeen(userA: lib.User, userB: lib.User, instanceIdA?: number, instanceIdB?: number) { + function epoch(user: lib.User, instanceId?: number) { return user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId); } @@ -52,87 +50,13 @@ export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA return lastSeenA - lastSeenB; } -export type RawUserState = Partial & { - loading?: boolean; - present?: boolean; - missing?: boolean; -}; - -export function useUser(name: string): [RawUserState, () => void] { - let control = useContext(ControlContext); - let [user, setUser] = useState({ loading: true }); - - function updateUser() { - control.send(new lib.UserGetRequest(name)).then(updatedUser => { - setUser({ ...updatedUser, present: true }); - }).catch(err => { - logger.error(`Failed to get user: ${err}`); - setUser({ missing: true }); - }); - } - - useEffect(() => { - if (typeof name !== "string") { - setUser({ missing: true }); - return undefined; - } - updateUser(); - - function updateHandler(newUsers: lib.User[]) { - const newUser = newUsers.find(u => u.name === name); - if (newUser) { - setUser({ ...newUser, present: true }); - } - } - - control.userUpdate.subscribe(updateHandler); - return () => { - control.userUpdate.unsubscribe(updateHandler); - }; - }, [name]); - - return [user, updateUser]; +export function useUser(name?: string) { + const [users, synced] = useUsers(); + return [name !== undefined ? users.get(name) : undefined, synced] as const; } -export function useUserList() { - let control = useContext(ControlContext); - let [userList, setUserList] = useState([]); - - function updateUserList() { - control.send(new lib.UserListRequest()).then(users => { - setUserList(users); - }).catch(err => { - logger.error(`Failed to list users:\n${err}`); - }); - } - - useEffect(() => { - updateUserList(); - - function updateHandler(newUsers: lib.User[]) { - setUserList(oldList => { - let newList = oldList.concat(); - for (const newUser of newUsers) { - let index = newList.findIndex(u => u.name === newUser.name); - if (!newUser.isDeleted) { - if (index !== -1) { - newList[index] = newUser; - } else { - newList.push(newUser); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.userUpdate.subscribe(updateHandler); - return () => { - control.userUpdate.unsubscribe(updateHandler); - }; - }, []); - - return [userList]; +export function useUsers() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.users.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.users.getSnapshot()); } diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index cb7f4320..06f796e9 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -60,29 +60,12 @@ export class Control extends lib.Link { logHandlers: Map = new Map(); /** Updates handled by the subscription service */ - hostUpdate = new lib.EventSubscriber( - lib.HostUpdatesEvent, this, event => event.updates, - ); - - instanceUpdate = new lib.EventSubscriber( - lib.InstanceDetailsUpdatesEvent, this, event => event.updates, - ); - - saveListUpdate = new lib.EventSubscriber( - lib.InstanceSaveDetailsUpdatesEvent, this, event => event.updates, - ); - - modPackUpdate = new lib.EventSubscriber( - lib.ModPackUpdatesEvent, this, event => event.updates, - ); - - modUpdate = new lib.EventSubscriber( - lib.ModUpdatesEvent, this, event => event.updates, - ); - - userUpdate = new lib.EventSubscriber( - lib.UserUpdatesEvent, this, event => event.updates, - ); + hosts = new lib.EventSubscriber(lib.HostUpdatesEvent, this); + instances = new lib.EventSubscriber(lib.InstanceDetailsUpdatesEvent, this); + saves = new lib.EventSubscriber(lib.InstanceSaveDetailsUpdatesEvent, this); + modPacks = new lib.EventSubscriber(lib.ModPackUpdatesEvent, this); + mods = new lib.EventSubscriber(lib.ModUpdatesEvent, this); + users = new lib.EventSubscriber(lib.UserUpdatesEvent, this); declare connector: ControlConnector; diff --git a/test/lib/subscriptions.js b/test/lib/subscriptions.js index ec46a846..5196b9b0 100644 --- a/test/lib/subscriptions.js +++ b/test/lib/subscriptions.js @@ -19,6 +19,9 @@ describe("lib/subscriptions", function() { static src = ["controller", "control"]; static dst = ["controller", "control"]; static permission = null; + constructor(updates = []) { + this.updates = updates; + } } lib.Link.register(RegisteredEvent); @@ -275,7 +278,7 @@ describe("lib/subscriptions", function() { assertNoEvent(0); }); it("should accept a respond with an event replay when returned by the handler", async function() { - subscriptions.handle(StringPermissionEvent, undefined, async function() { + subscriptions.handle(StringPermissionEvent, async function() { return new StringPermissionEvent(); }); @@ -283,7 +286,6 @@ describe("lib/subscriptions", function() { await subscriptions.handleRequest( getLink(0), request, connectorSetupDate[0].src, connectorSetupDate[0].dst ); - subscriptions.broadcast(new StringPermissionEvent()); await onceConnectorSend(0); assertLastEvent(0, StringPermissionEvent); @@ -293,6 +295,18 @@ describe("lib/subscriptions", function() { const response = eventEntry.eventFromJSON(lastMessage.data); assert.deepEqual(response, new StringPermissionEvent()); }); + it("should accept a respond with null when returned by the handler", async function() { + subscriptions.handle(StringPermissionEvent, async function() { + return null; + }); + + const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true); + await subscriptions.handleRequest( + getLink(0), request, connectorSetupDate[0].src, connectorSetupDate[0].dst + ); + await new Promise(resolve => setImmediate(resolve)); + assertNoEvent(0); + }); }); }); @@ -314,6 +328,10 @@ describe("lib/subscriptions", function() { assert.deepEqual(lastMessage.data, request); } + function assertNoMessageSent() { + assert.equal(mockControl.connector.sentMessages.length, 0, "messages were sent"); + } + describe("constructor()", function() { it("should not accept unregistered events", function() { assert.throws( @@ -324,54 +342,75 @@ describe("lib/subscriptions", function() { it("should handle the provided event", function() { assert.equal(mockControl._eventHandlers.has(RegisteredEvent), true); }); - it("should call and use the return of a pre-handler, if provided", function() { - let calledWith = null; - function prehandler() { return "PreHandlerReturn"; } - const eventSubscriber = new lib.EventSubscriber(StringPermissionEvent, mockControl, prehandler); - eventSubscriber.subscribe(function(value) { calledWith = value; }); - eventSubscriber._handle(new StringPermissionEvent()); - assert.equal(calledWith, "PreHandlerReturn"); - }); }); describe("subscribe()", function() { - it("should allow subscriptions to an event", function() { + it("should allow subscriptions to an event", async function() { let calledWith = null; let calledWithTwo = null; - const event = new RegisteredEvent(); - registeredEvent.subscribe(function(value) { calledWith = value; }); - registeredEvent.subscribe(function(value) { calledWithTwo = value; }); - registeredEvent._handle(event); - assert.deepEqual(calledWith, event); - assert.deepEqual(calledWithTwo, event); + const item = { id: 1, updatedAt: 1, isDeleted: false }; + const event = new RegisteredEvent([item]); + registeredEvent.subscribe(function(updates, synced) { calledWith = [updates, synced]; }); + registeredEvent.subscribe(function(updates, synced) { calledWithTwo = [updates, synced]; }); + await registeredEvent._handle(event); + assert.deepEqual(calledWith, [[item], false]); + assert.deepEqual(calledWithTwo, [[item], false]); + }); + it("should allow unsubscribing from an event", async function() { + let called = false; + let event = new RegisteredEvent(); + function callback() { called = true; } + const unsubscribe = registeredEvent.subscribe(callback); + await registeredEvent._handle(event); + assert.deepEqual(called, true); + + called = false; + event = new RegisteredEvent(); + unsubscribe(); + // Wait for unsubscribe to happen. + await new Promise(resolve => setImmediate(resolve)); + await registeredEvent._handle(event); + assert.equal(called, false); + }); + it("should not throw error on incorrectly unsubscribing multiple times", async function() { + const unsubscribe = registeredEvent.subscribe(() => {}); + unsubscribe(); + unsubscribe(); + // Wait for unsubscribe to happen. + await new Promise(resolve => setImmediate(resolve)); }); }); - describe("unsubscribe()", function() { - it("should allow unsubscribing from an event", function() { - let calledWith = null; - let event = new RegisteredEvent(); - function callback(value) { calledWith = value; } - registeredEvent.subscribe(callback); - registeredEvent._handle(event); - assert.deepEqual(calledWith, event); - - calledWith = null; - event = new RegisteredEvent(); - registeredEvent.unsubscribe(callback); - registeredEvent._handle(event); - assert.equal(calledWith, null); - }); - it("should throw an error if the handler was not subscribed", function() { - function callback(value) { calledWith = value; } - assert.rejects( - () => registeredEvent.unsubscribe(callback), - new Error("handler is not registered") - ); + describe("getSnapshot()", function() { + it("should give an immutable copy of the tracked values", async function() { + const update1 = [{ id: 1, updatedAt: 1, isDeleted: false }]; + const update2 = [{ id: 1, updatedAt: 2, isDeleted: false }, { id: 2, updatedAt: 3, isDeleted: false }]; + const update3 = [{ id: 1, updatedAt: 4, isDeleted: true }]; + const snap0 = registeredEvent.getSnapshot(); + assert.deepEqual(snap0, [new Map(), false]); + await registeredEvent._handle(new RegisteredEvent(update1)); + const snap1 = registeredEvent.getSnapshot(); + assert.deepEqual(snap1, [new Map([[1, update1[0]]]), false]); + await registeredEvent._handle(new RegisteredEvent(update2)); + const snap2 = registeredEvent.getSnapshot(); + assert.deepEqual(snap2, [new Map([[1, update2[0]], [2, update2[1]]]), false]); + await registeredEvent._handle(new RegisteredEvent(update3)); + const snap3 = registeredEvent.getSnapshot(); + assert.deepEqual(snap3, [new Map([[2, update2[1]]]), false]); + // validate previous snapshots were not modified + assert.deepEqual(snap2, [new Map([[1, update2[0]], [2, update2[1]]]), false]); + assert.deepEqual(snap1, [new Map([[1, update1[0]]]), false]); + assert.deepEqual(snap0, [new Map(), false]); }); }); describe("_updateSubscription()", function() { + it("should do nothing if not connected", async function() { + registeredEvent.control.connector.connected = false; + registeredEvent.subscribe(() => true); + await new Promise(resolve => setImmediate(resolve)); + assertNoMessageSent(); + }); it("should correctly request a subscription", async function() { const expected = new lib.SubscriptionRequest(RegisteredEvent.name, true, -1); registeredEvent.subscribe(() => true); @@ -381,12 +420,44 @@ describe("lib/subscriptions", function() { it("should correctly request no subscriptions", async function() { const expected = new lib.SubscriptionRequest(RegisteredEvent.name, false, -1); function callback() {} - registeredEvent.subscribe(callback); + const unsubscribe = registeredEvent.subscribe(callback); await events.once(mockControl.connector, "send"); - registeredEvent.unsubscribe(callback); + unsubscribe(); await events.once(mockControl.connector, "send"); assertLastRequest(expected); }); + it("should set synced and invoke callbacks when request is responded to", async function() { + let calledWith; + registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; }); + const [msg] = await events.once(mockControl.connector, "send"); + assert(!registeredEvent.synced, "synced was set before response"); + mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src)); + await new Promise(resolve => setImmediate(resolve)); + assert(calledWith !== undefined, "callback not called"); + assert.deepEqual(calledWith, [[], true]); + assert(registeredEvent.synced, "synced was not set"); + }); + it("should be called con connector connect event", async function() { + const expected = new lib.SubscriptionRequest(RegisteredEvent.name, true, -1); + registeredEvent._callbacks.push(() => {}); + registeredEvent.control.connector.emit("connect"); + await events.once(mockControl.connector, "send"); + assertLastRequest(expected); + }); + it("should revert synced and invoke callbacks on connector close event", async function() { + let calledWith; + registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; }); + const [msg] = await events.once(mockControl.connector, "send"); + mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src)); + await new Promise(resolve => setImmediate(resolve)); + + assert(registeredEvent.synced, "synced was not set before close"); + calledWith = undefined; + registeredEvent.control.connector.emit("close"); + assert(calledWith !== undefined, "callback not called"); + assert.deepEqual(calledWith, [[], false]); + assert(!registeredEvent.synced, "synced was not reset"); + }); }); }); });