Refactor EventSubscriber to track its resource

Process updates of the resource EventSubscriber are subscribed to and
have it keep a mapping of id to value syncronised with event provider as
long as there are subscription callbacks registered with it.  The
mapping is kept around after the last callback is unsubscribed so that
it can be efficiently resynced with the resource based on the updatedAt
timestaps of individual elements.

This allows the EventSubscriber to provide the right building blocks to
provide its values to React components via the the useSyncExternalStore
hook.  This in turn greatly simplifies the web_ui models for the
subscribed resources.
This commit is contained in:
Hornwitser committed 2023-12-12 23:40:29 +01:00
1 parent 867bb0c646
commit 8c17adf15b
27 files changed
+410 -713

No files matched your search

+3 -2
View File
@@ -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",
+1 -1
View File
@@ -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.
+5
View File
@@ -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 {
+84 -29
View File
@@ -4,7 +4,7 @@ import { logger } from "./logging";
import { Address, MessageRequest, IControllerUser } from "./data";
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
export type EventSubscriberCallback<T> = (value: T) => void;
export type EventSubscriberCallback<T> = (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<T, V extends SubscribableValue> extends Event<T> {
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<T extends Event<T>, V=T> {
export class EventSubscriber<
T extends EventSubscribable<T, V>,
K extends string | number = T["updates"][number]["id"],
V extends SubscribableValue = T["updates"][number],
> {
_callbacks = new Array<EventSubscriberCallback<V>>();
lastResponse: Event<T> | null = null;
/** Values of the subscribed resource */
values = new Map<K, V>();
/** True if this subscriber is currently synced with the source */
synced = false;
_snapshot?: readonly [ReadonlyMap<K, Readonly<V>>, boolean];
_snapshotLastUpdated = 0;
lastResponseTime = -1;
constructor(
private Event: EventClass<T>,
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<T>) {
this.lastResponse = response;
this.lastResponseTime = Date.now();
const value = this.prehandler ? this.prehandler(response as T) : response as any;
async _handle(event: EventSubscribable<T, V>) {
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<V>) {
subscribe(handler: EventSubscriberCallback<V>) {
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<V>) {
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<T extends Event<T>, 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}`
@@ -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) {
<Select.Option value={"null"}>
<Typography.Text italic>Unassigned</Typography.Text>
</Select.Option>
{hostList.map((host) => <Select.Option
{[...hosts.values()].map((host) => <Select.Option
key={host["id"]}
value={host["id"]}
disabled={!host["connected"]}
+11 -13
View File
@@ -4,13 +4,12 @@ import { useParams } from "react-router-dom";
import * as lib from "@clusterio/lib";
import notify, { notifyErrorHandler } from "../util/notify";
import type { Control } from "../util/websocket";
import ControlContext from "./ControlContext";
import InstanceList from "./InstanceList";
import LogConsole from "./LogConsole";
import { useAccount } from "../model/account";
import { useInstanceList } from "../model/instance";
import { HostState, useHost } from "../model/host";
import { useInstances } from "../model/instance";
import { useHost } from "../model/host";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
@@ -23,17 +22,16 @@ export default function HostViewPage() {
let hostId = Number(params.id);
let control = useContext(ControlContext);
let account = useAccount();
let [instanceList] = useInstanceList();
let [host] = useHost(hostId);
let [instances] = useInstances();
const [host, synced] = useHost(hostId);
const hostInstances = new Map([...instances].filter(([_id, instance]) => 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 <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
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={String(hostId)} />
<p>Host with id {hostId} was not found on the controller.</p>
@@ -83,7 +81,7 @@ export default function HostViewPage() {
</Descriptions>
{account.hasPermission("core.instance.list") && <>
<Title level={5} style={{ marginTop: 16 }}>Instances</Title>
<InstanceList instances={instanceList} size="small" hideAssignedHost />
<InstanceList instances={hostInstances} size="small" hideAssignedHost />
</>}
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
+3 -3
View File
@@ -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 <PageLayout nav={[{ name: "Hosts" }]}>
<PageHeader
@@ -226,7 +226,7 @@ export default function HostsPage() {
sorter: (a, b) => Number(a.connected) - Number(b.connected),
},
]}
dataSource={hostList}
dataSource={[...hosts.values()]}
rowKey={host => host.id}
pagination={false}
onRow={(record, rowIndex) => ({
+17 -18
View File
@@ -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<number, Readonly<InstanceDetails>>;
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<InstanceDetails> = [
@@ -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 <Table
size={props.size || "large"}
columns={columns}
dataSource={props.instances}
dataSource={[...props.instances.values()]}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
@@ -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<lib.HostDetails>;
instance: Readonly<lib.InstanceDetails>;
};
function InstanceDescription(props: InstanceDescriptionProps) {
let account = useAccount();
@@ -42,7 +42,7 @@ function InstanceDescription(props: InstanceDescriptionProps) {
<Descriptions.Item label="Host">
{!assigned
? <em>Unassigned</em>
: host.name || instance.assignedHost
: host?.name ?? instance.assignedHost
}
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instance.id}
@@ -60,7 +60,7 @@ function InstanceDescription(props: InstanceDescriptionProps) {
</Descriptions>;
}
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 <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }];
if (!instance) {
if (!synced) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (instance.missing || instance.status === "deleted") {
return <PageLayout nav={nav}>
<Alert
message={instance.status === "deleted" ? "Instance has been deleted" : "Instance not found" }
message={"Instance not found" }
showIcon
description={<>Instance with id {instanceId} was not found on the controller.</>}
type="warning"
@@ -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 <PageLayout nav={[{ name: "Instances" }]}>
<PageHeader
@@ -70,7 +70,7 @@ export default function InstancesPage() {
extra={<>
{account.hasPermission("core.instance.create") && <CreateInstanceButton />}
{account.hasPermission("core.instance.start")
&& <Button onClick={e => instanceList.forEach(instance => {
&& <Button onClick={e => instances.forEach(instance => {
if (instance.status === "stopped") {
control.sendTo(
{ instanceId: instance.id },
@@ -82,7 +82,7 @@ export default function InstancesPage() {
Start all
</Button>}
{account.hasPermission("core.instance.stop")
&& <Button onClick={e => instanceList.forEach(instance => {
&& <Button onClick={e => instances.forEach(instance => {
if (["starting", "running"].includes(instance.status)) {
control.sendTo(
{ instanceId: instance.id },
@@ -96,7 +96,7 @@ export default function InstancesPage() {
</>}
/>
<InstanceList instances={instanceList} />
<InstanceList instances={instances} />
<PluginExtra component="InstancesPage" />
</PageLayout>;
}
@@ -5,10 +5,9 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import { InstanceState } from "../model/instance";
type LoadScenarioModalProps = {
instance: InstanceState;
instance: lib.InstanceDetails;
};
export default function LoadScenarioModal(props: LoadScenarioModalProps) {
let [open, setOpen] = useState(false);
@@ -55,7 +54,7 @@ export default function LoadScenarioModal(props: LoadScenarioModalProps) {
setLoadingScenario(true);
control.sendTo(
{ instanceId: props.instance.id! },
{ instanceId: props.instance.id },
new lib.InstanceLoadScenarioRequest(scenario, seed, mapGenSettings, mapSettings),
).then(() => {
setOpen(false);
@@ -19,7 +19,7 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { useAccount } from "../model/account";
import { useModPack } from "../model/mod_pack";
import { useModList } from "../model/mods";
import { useMods } from "../model/mods";
import { useClipboard } from "../util/clipboard";
import notify, { notifyErrorHandler } from "../util/notify";
import PageHeader from "./PageHeader";
@@ -245,7 +245,7 @@ type ModsTableProps = {
};
function ModsTable(props: ModsTableProps) {
const [showAddMods, setShowAddMods] = useState(false);
const [modList] = useModList();
const [modsMap] = useMods();
const deletedMods: Map<string, lib.ModRecord|lib.ModInfo> = new Map();
const changedMods: Map<string, lib.ModRecord|lib.ModInfo> = new Map();
@@ -261,10 +261,9 @@ function ModsTable(props: ModsTableProps) {
}
}
const modListMap = new Map(modList.map(mod => [mod.id, mod]));
const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map(
(mod: lib.ModRecord|lib.ModInfo): lib.ModInfo|lib.ModRecord => {
const candidate = modListMap.get(`${mod.name}_${mod.version}`);
const candidate = modsMap.get(`${mod.name}_${mod.version}`);
if (!candidate) {
return {
...mod,
@@ -478,9 +477,9 @@ type SettingsTableProps = {
onRevert: (change: ModChange) => void;
};
function SettingsTable(props: SettingsTableProps) {
const [modList] = useModList();
const [mods] = useMods();
const modsInPack = new Set([...props.modPack.mods.values()].map(mod => `${mod.name}_${mod.version}`));
const modTitles = new Map(modList
const modTitles = new Map([...mods.values()]
.filter(mod => modsInPack.has(mod.id))
.map(mod => [mod.name, mod.title])
);
@@ -609,7 +608,7 @@ function ExportButton(props: { modPack: lib.ModPack }) {
</>;
}
function useExportedAsset(modPack: lib.ModPack, asset: "settings"|"locale"): Map<any, any> | any {
function useExportedAsset(modPack: lib.ModPack | undefined, 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]) {
@@ -689,20 +688,13 @@ export default function ModPackViewPage() {
let modPackId = Number(params.id);
const [form] = Form.useForm();
let [modPack] = useModPack(modPackId) as lib.ModPack[];
const [modPack, synced] = useModPack(modPackId);
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: any;
if (!(modPack instanceof lib.ModPack)) {
// Loading or invalid id
modifiedModPack = modPack;
} else {
modifiedModPack = applyModPackChanges(modPack, changes);
}
const modifiedModPack = modPack ? applyModPackChanges(modPack, changes) : undefined;
useEffect(() => () => { clearTimeout(syncTimeout.current); });
@@ -744,34 +736,35 @@ export default function ModPackViewPage() {
continue;
}
if (["name", "description", "factorioVersion"].includes(field.name[0])) {
if (modifiedModPack[field.name[0]] !== field.value) {
pushChange({ type: field.name[0], value: field.value });
const name = field.name[0] as "name" | "description" | "factorioVersion";
if (modifiedModPack![name] !== field.value) {
pushChange({ type: name, value: field.value });
}
} else if (["startup", "runtime-global", "runtime-per-user"].includes(field.name[0])) {
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;
}
}
if (modifiedModPack.settings[scope].get(settingName).value !== value) {
if (modifiedModPack!.settings[scope].get(settingName)!.value !== value) {
pushChange({ type: "settings.set", scope, name: settingName, value: { value }});
}
}
}
}
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack.name || String(modPackId) }];
if (modifiedModPack.loading) {
return <PageLayout nav={nav}>
<PageHeader title={String(modPackId)} />
<Spin size="large" />
</PageLayout>;
}
let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack?.name ?? String(modPackId) }];
if (!modifiedModPack || !modPack) {
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={String(modPackId)} />
<Spin size="large" />
</PageLayout>;
}
if (modifiedModPack.missing) {
return <PageLayout nav={nav}>
<PageHeader title="Mod Pack Not Found" />
<p>Mod pack with id {modPackId} was not found on the controller.</p>
+6 -6
View File
@@ -8,8 +8,8 @@ import UploadOutlined from "@ant-design/icons/UploadOutlined";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import { useModList } from "../model/mods";
import { useModPackList } from "../model/mod_pack";
import { useMods } from "../model/mods";
import { useModPacks } from "../model/mod_pack";
import { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import PageHeader from "./PageHeader";
@@ -129,8 +129,8 @@ export default function ModsPage() {
let account = useAccount();
let control = useContext(ControlContext);
let navigate = useNavigate();
let [modList] = useModList();
let [modPackList] = useModPackList();
let [mods] = useMods();
let [modPacks] = useModPacks();
function actions(mod: lib.ModInfo) {
return <Space>
@@ -209,7 +209,7 @@ export default function ModsPage() {
render: modPack => modPack.mods.size,
},
]}
dataSource={modPackList}
dataSource={[...modPacks.values()]}
pagination={false}
rowKey={modPack => Number(modPack.id)}
onRow={(modPack, rowIndex) => ({
@@ -263,7 +263,7 @@ export default function ModsPage() {
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={modList}
dataSource={[...mods.values()]}
pagination={false}
rowKey={mod => mod.id}
/>
@@ -4,18 +4,14 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import BaseWebPlugin from "../BaseWebPlugin";
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;
instance?: lib.InstanceDetails;
user?: lib.User;
host?: lib.HostDetails;
role?: lib.Role;
};
export default function PluginExtra(props: PluginExtraProps) {
let control = useContext(ControlContext);
+18 -26
View File
@@ -13,27 +13,19 @@ import PluginExtra from "./PluginExtra";
import { notifyErrorHandler } from "../util/notify";
export type RawRoleState = Partial<lib.Role> & {
loading?: boolean;
missing?: boolean;
present?: boolean;
};
function useRole(id: number): [RawRoleState, () => void] {
function useRole(id: number): [Readonly<lib.Role> | undefined, boolean, () => void] {
let control = useContext(ControlContext);
let [role, setRole] = useState<RawRoleState>({ loading: true });
let [role, setRole] = useState<lib.Role>();
let [synced, setSynced] = useState(false);
function updateRole() {
// XXX optimize by requesting only the role in question
control.send(new lib.RoleListRequest()).then(roles => {
let match = roles.find(u => u.id === id);
if (!match) {
setRole({ missing: true });
} else {
setRole({
...match,
present: true,
});
if (match) {
setRole(match);
}
setSynced(true);
});
}
@@ -41,7 +33,7 @@ function useRole(id: number): [RawRoleState, () => void] {
updateRole();
}, [id]);
return [role, updateRole];
return [role, synced, updateRole];
}
export default function RoleViewPage() {
@@ -52,19 +44,19 @@ export default function RoleViewPage() {
let account = useAccount();
let control = useContext(ControlContext);
let [role, updateRole] = useRole(roleId);
const [role, synced, updateRole] = useRole(roleId);
let [edited, setEdited] = useState(false);
let nav = [{ name: "Roles", path: "/roles" }, { name: role.name || String(roleId) }];
if (role.loading) {
return <PageLayout nav={nav}>
<PageHeader title={String(roleId)} />
<Spin size="large" />
</PageLayout>;
}
let nav = [{ name: "Roles", path: "/roles" }, { name: role?.name ?? String(roleId) }];
if (!role) {
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={String(roleId)} />
<Spin size="large" />
</PageLayout>;
}
if (role.missing) {
return <PageLayout nav={nav}>
<PageHeader title="Role not found" />
<p>Role with id {roleId} was not found on the controller.</p>
@@ -76,7 +68,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),
])),
},
};
@@ -101,7 +93,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
+14 -14
View File
@@ -12,8 +12,8 @@ import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
import { InstanceState, useInstanceList } from "../model/instance";
import { useSaves } from "../model/saves";
import { useInstances } from "../model/instance";
import { useSavesOfInstance } from "../model/saves";
import { notifyErrorHandler } from "../util/notify";
@@ -107,7 +107,7 @@ function TransferModal(props: ModalProps) {
let control = useContext(ControlContext);
let [open, setOpen] = useState(false);
let [form] = Form.useForm();
let [instanceList] = useInstanceList();
let [instances] = useInstances();
return <>
<Button disabled={props.disabled} onClick={() => setOpen(true)}>Transfer</Button>
@@ -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) => <Select.Option
key={instance["id"]}
value={instance["id"]}
title={instance["name"]}
disabled={["unassigned", "unknown"].includes(instance["status"])}
key={instance.id}
value={instance.id}
title={instance.name}
disabled={["unassigned", "unknown"].includes(instance.status)}
>
{instance["name"]}
{instance["status"] === "unassigned" && " (unassigned)"}
{instance["status"] === "unknown" && " (offline)"}
{instance.name}
{instance.status === "unassigned" && " (unassigned)"}
{instance.status === "unknown" && " (offline)"}
</Select.Option>)}
</Select>
</Form.Item>
@@ -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<File[]>([]);
@@ -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={{
@@ -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"}
+14 -14
View File
@@ -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<Map<number, lib.Role>>(new Map());
let [form] = Form.useForm();
let [rolesDirty, setRolesDirty] = useState<boolean>(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 <PageLayout nav={nav}>
<PageHeader title={userName} />
<Spin size="large" />
</PageLayout>;
}
if (!user) {
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={userName} />
<Spin size="large" />
</PageLayout>;
}
if (user.missing) {
return <PageLayout nav={nav}>
<PageHeader title="User not found" />
<p>User with name {userName} was not found on the controller.</p>
@@ -78,7 +79,7 @@ export default function UserViewPage() {
let roleSelector = <Form.Item
noStyle
name="roles"
initialValue={user.roleIds}
initialValue={[...user.roleIds]}
rules={[
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
]}
@@ -100,9 +101,8 @@ export default function UserViewPage() {
</Select>
</Form.Item>;
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 <PageLayout nav={nav}>
+3 -3
View File
@@ -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) => ({
+8 -87
View File
@@ -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<lib.HostDetails> & {
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<HostState>({ 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<lib.HostDetails[]>([]);
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());
}
+8 -86
View File
@@ -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<lib.InstanceDetails> & {
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<InstanceState>({ 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<lib.InstanceDetails[]>([]);
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());
}
+8 -88
View File
@@ -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<lib.ModPack|StateLoadStatus>({ 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<lib.ModPack[]>([]);
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());
}
+9 -47
View File
@@ -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<lib.ModInfo[]>([]);
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());
}
+11 -54
View File
@@ -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<lib.SaveDetails[]>([]);
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());
}
+16 -92
View File
@@ -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 <Tag color="green">Online</Tag>;
}
@@ -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<lib.User> & {
loading?: boolean;
present?: boolean;
missing?: boolean;
};
export function useUser(name: string): [RawUserState, () => void] {
let control = useContext(ControlContext);
let [user, setUser] = useState<RawUserState>({ 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<lib.User[]>([]);
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());
}
+6 -23
View File
@@ -60,29 +60,12 @@ export class Control extends lib.Link {
logHandlers: Map<lib.LogFilter, logHandler[]> = new Map();
/** Updates handled by the subscription service */
hostUpdate = new lib.EventSubscriber<lib.HostUpdatesEvent, lib.HostDetails[]>(
lib.HostUpdatesEvent, this, event => event.updates,
);
instanceUpdate = new lib.EventSubscriber<lib.InstanceDetailsUpdatesEvent, lib.InstanceDetails[]>(
lib.InstanceDetailsUpdatesEvent, this, event => event.updates,
);
saveListUpdate = new lib.EventSubscriber<lib.InstanceSaveDetailsUpdatesEvent, lib.SaveDetails[]>(
lib.InstanceSaveDetailsUpdatesEvent, this, event => event.updates,
);
modPackUpdate = new lib.EventSubscriber<lib.ModPackUpdatesEvent, lib.ModPack[]>(
lib.ModPackUpdatesEvent, this, event => event.updates,
);
modUpdate = new lib.EventSubscriber<lib.ModUpdatesEvent, lib.ModInfo[]>(
lib.ModUpdatesEvent, this, event => event.updates,
);
userUpdate = new lib.EventSubscriber<lib.UserUpdatesEvent, lib.User[]>(
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;
+111 -40
View File
@@ -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");
});
});
});
});