diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index d2a2a82f..fd571c0e 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -16,6 +16,7 @@ Each entrypoint of a plugin exports a function that attaches handlers to hooks c - [Defining Link Messages](#defining-link-messages) - [Sending Link Messages](#sending-link-messages) - [Handling connection events](#handling-connection-events) +- [Web Pages for a Single Resource](#web-pages-for-a-single-resource) - [Collecting Statistics](#collecting-statistics) - [Adding Custom Commands to clusterioctl](#adding-custom-commands-to-clusterioctl) @@ -842,6 +843,34 @@ This means that if your plugin sends a lot of events or requests, they can end u To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised. +## Web Pages for a Single Resource + +A web page showing one resource from a subscription has more to handle than the resource itself. +It can still be loading, it might not exist, it can be deleted while the page is open, and the subscription is rejected if the user lacks the permission for it. +`useResourceState` and `ResourceState` from `@clusterio/web_ui` show these the same way as the pages for hosts, instances and the other built in resources. + +```tsx +function FrobberPage() { + const id = Number(useParams().id); + const [frobbers, synced, error] = useSyncExternalStore(subscribe, () => frobberSubscriber.getSnapshot()); + const frobber = frobbers.get(id); + const state = useResourceState(frobber, synced, error, id); + + const nav = [{ name: "Frobbers", path: "/frobbers" }, { name: frobber?.name ?? String(id) }]; + if (!frobber) { + return ; + } + + return {frobber.name}; +} +``` + +`useResourceState` returns `"ok"` when there is a value and otherwise `"loading"`, `"missing"`, `"deleted"` or `"error"`. +It tells deleted from missing by remembering if it has seen a value for that id. +`ResourceState` renders the whole page for the states without a value, with a link back to the page at `listPath`. +The `useHost`, `useInstance`, `useUser`, `useRole` and `useModPack` hooks return `[value, synced, error]` so they can be used the same way. + + ## Collecting Statistics Clusterio comes with its own Prometheus client implementation, one part due to Not Invented Here and another part due to collectors in prom-client being difficult to get to work nicely with collecting data from plugins optionally loaded at runtime on different computers. diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index a7b1d6fc..2328fce7 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -1,5 +1,5 @@ import React, { useContext, useState } from "react"; -import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd"; +import { Descriptions, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd"; import { ExclamationCircleOutlined } from "@ant-design/icons"; import { useNavigate, useParams } from "react-router-dom"; @@ -17,11 +17,13 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import { MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed, MetricDiskUsed, MetricDiskRatio, MetricRelativeDate, } from "./system_metrics"; import { useSystems } from "../model/system"; +import useResourceState from "../util/useResourceState"; import { hasNpmButtonPermission, NpmButton } from "./NpmButton"; import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton"; @@ -132,21 +134,15 @@ export default function HostViewPage() { let [instances] = useInstances(); const [systems] = useSystems(); const system = systems.get(hostId); - const [host, synced] = useHost(hostId); + const [host, synced, error] = useHost(hostId); + const state = useResourceState(host, synced, error, hostId); const [maxLevel, setMaxLevel] = useState("info"); const canShowNpm = hasNpmButtonPermission(false); const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId)); let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }]; if (!host) { - if (!synced) { - return ; - } - - return - -

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

-
; + return ; } let hostButtons = diff --git a/packages/web_ui/src/components/IconReferencePage.tsx b/packages/web_ui/src/components/IconReferencePage.tsx index de5263b0..2a8f58dc 100644 --- a/packages/web_ui/src/components/IconReferencePage.tsx +++ b/packages/web_ui/src/components/IconReferencePage.tsx @@ -8,6 +8,8 @@ import FactorioIcon from "./FactorioIcon"; import { useParams } from "react-router-dom"; import { useModPack } from "../model/mod_pack"; import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store"; +import useResourceState from "../util/useResourceState"; +import ResourceState from "./ResourceState"; const { Text } = Typography; @@ -39,7 +41,8 @@ export default function IconReferencePage() { const [filter, setFilter] = useState(""); const [expandedGroups, setExpandedGroups] = useState>(new Set()); - const [modPack] = useModPack(modPackId); + const [modPack, synced, error] = useModPack(modPackId); + const state = useResourceState(modPack, synced, error, modPackId); const manifest = modPack?.exportManifest; const prototypes = useExportPrototypeMetadata(modPack); const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]); @@ -100,6 +103,10 @@ export default function IconReferencePage() { { name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` }, { name: "Icon Reference" }, ]; + if (!modPack) { + return ; + } + return ( diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index ab52b69a..ce12ef8d 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -1,7 +1,7 @@ import React, { useContext, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { - Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Spin, Tooltip, Typography, + Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Segmented, Space, Tooltip, Typography, message, } from "antd"; import CopyOutlined from "@ant-design/icons/CopyOutlined"; @@ -16,6 +16,7 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import InstanceConfigTree from "./InstanceConfigTree"; import LogConsole, { SelectMaxLogLevel } from "./LogConsole"; import InstanceRcon from "./InstanceRcon"; @@ -31,6 +32,7 @@ import InstanceStatusTag from "./InstanceStatusTag"; import Link from "./Link"; import { instancePublicAddress } from "../util/instance"; import useLocalStorage from "../util/useLocalStorage"; +import useResourceState from "../util/useResourceState"; import { MetricRelativeDate } from "./system_metrics"; type MenuItem = Required["items"][number]; @@ -208,26 +210,15 @@ export default function InstanceViewPage() { let navigate = useNavigate(); let account = useAccount(); - let [instance, synced] = useInstance(instanceId); + let [instance, synced, error] = useInstance(instanceId); + const state = useResourceState(instance, synced, error, instanceId); let [host] = useHost(instance?.assignedHost); let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }]; if (!instance) { - if (!synced) { - return ; - } - - return - Instance with id {instanceId} was not found on the controller.} - type="warning" - action={ - Go back to instances list - } - /> - ; + return ; } return diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 575b3361..f0216254 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination, - Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip, + Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Switch, Modal, Tooltip, TablePaginationConfig, Alert, notification, FormProps, } from "antd"; @@ -22,10 +22,12 @@ import { useModPack } from "../model/mod_pack"; import { useMods } from "../model/mods"; import { useClipboard } from "../util/clipboard"; import notify, { notifyErrorHandler } from "../util/notify"; +import useResourceState from "../util/useResourceState"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import SectionHeader from "./SectionHeader"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import ModDetails from "./ModDetails"; import { InputPartialVersion } from "./InputVersion"; import Link from "./Link"; @@ -1183,7 +1185,8 @@ export default function ModPackViewPage() { let modPackId = Number(params.id); const [form] = Form.useForm(); - const [modPack, synced] = useModPack(modPackId); + const [modPack, synced, error] = useModPack(modPackId); + const state = useResourceState(modPack, synced, error, modPackId); let prototypes: any = useExportedAsset(modPack, "settings"); let locale: Map = useExportedAsset(modPack, "locale"); let [changes, setChanges] = useState([]); @@ -1253,17 +1256,7 @@ export default function ModPackViewPage() { let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack?.name ?? String(modPackId) }]; if (!modifiedModPack || !modPack) { - if (!synced) { - return - - - ; - } - - return - -

Mod pack with id {modPackId} was not found on the controller.

-
; + return ; } return diff --git a/packages/web_ui/src/components/PluginClusterStatus.tsx b/packages/web_ui/src/components/PluginClusterStatus.tsx index 8c17234f..570c1f1b 100644 --- a/packages/web_ui/src/components/PluginClusterStatus.tsx +++ b/packages/web_ui/src/components/PluginClusterStatus.tsx @@ -35,6 +35,8 @@ type MachinePluginsState = { unknown: Machine[], /** Fetch the plugin lists of connected machines now, instead of waiting for the next system metrics */ refresh: (addresses?: lib.AddressShorthand[]) => Promise, + /** Why machines is not available, if it's not just still loading */ + error: Error | null, }; /** @@ -44,7 +46,7 @@ type MachinePluginsState = { export function useMachinePlugins(): MachinePluginsState { const control = useContext(ControlContext); const account = useAccount(); - const [hosts, hostsSynced] = useHosts(); + const [hosts, hostsSynced, hostsError] = useHosts(); const [systems, systemsSynced] = useSystems(); // Lists fetched directly, used until the system metrics of the machine update const [listed, setListed] = useState(new Map()); @@ -88,8 +90,12 @@ export function useMachinePlugins(): MachinePluginsState { } }, [canList, hostsSynced, connectedKey]); - if (!canList || !hostsSynced || (!systemsSynced && !listed.size)) { - return { machines: undefined, unknown: [], refresh }; + if (!canList) { + const error = new lib.PermissionError("Missing permission core.plugin.list"); + return { machines: undefined, unknown: [], refresh, error }; + } + if (!hostsSynced || (!systemsSynced && !listed.size)) { + return { machines: undefined, unknown: [], refresh, error: hostsError }; } const machines: MachinePlugins[] = []; const unknown: Machine[] = []; @@ -105,7 +111,7 @@ export function useMachinePlugins(): MachinePluginsState { unknown.push(machine); } } - return { machines, unknown, refresh }; + return { machines, unknown, refresh, error: null }; } /** Latest version published on npm of each package */ diff --git a/packages/web_ui/src/components/PluginViewPage.tsx b/packages/web_ui/src/components/PluginViewPage.tsx index 2a7e6c8e..6903f258 100644 --- a/packages/web_ui/src/components/PluginViewPage.tsx +++ b/packages/web_ui/src/components/PluginViewPage.tsx @@ -1,15 +1,15 @@ import React, { useEffect, useContext, useState } from "react"; import { useParams } from "react-router-dom"; -import { Alert, Descriptions, Spin } from "antd"; +import { Alert, Descriptions } from "antd"; import * as lib from "@clusterio/lib"; import type { PluginWebApi } from "@clusterio/lib"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; -import PageHeader from "./PageHeader"; +import ResourceState from "./ResourceState"; import SectionHeader from "./SectionHeader"; -import notify from "../util/notify"; +import useResourceState from "../util/useResourceState"; import { usePluginInstaller } from "./PluginInstall"; import { PluginMachinesTable, PluginUpdateButton, useLatestVersions, useMachinePlugins } from "./PluginClusterStatus"; import { PluginStatus, computePluginStatuses, pluginStatusKey } from "../util/plugin_status"; @@ -89,18 +89,18 @@ export default function PluginViewPage() { let params = useParams(); const control = useContext(ControlContext); let [pluginList, setPluginList] = useState(); + const [pluginListError, setPluginListError] = useState(null); let pluginName = String(params.name); - const { machines, unknown, refresh } = useMachinePlugins(); + const { machines, unknown, refresh, error: machinesError } = useMachinePlugins(); useEffect(() => { (async () => { let response = await fetch(`${webRoot}api/plugins`); - if (response.ok) { - setPluginList(await response.json()); - } else { - notify("Failed to load plugin list"); + if (!response.ok) { + throw new Error(`Failed to load plugin list: ${response.status} ${response.statusText}`); } - })(); + setPluginList(await response.json()); + })().catch(setPluginListError); }, []); const pluginMeta = pluginList?.find(p => p.name === pluginName); @@ -110,19 +110,16 @@ export default function PluginViewPage() { : undefined; const pluginInfo = control.pluginInfos.get(pluginName); const pluginTitle = pluginInfo?.title ?? status?.title ?? pluginName; + // Machines are only needed for plugins not on the controller, which isn't known until the list has loaded + const error = pluginListError ?? (pluginList ? machinesError : null); + const state = useResourceState( + pluginList && (pluginMeta ?? status), Boolean(pluginList && machines), error, pluginName + ); let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }]; - if (!pluginList || (!pluginMeta && !machines)) { - return - - - ; - } - - if (!pluginMeta && !status) { - return - -

Plugin with name {pluginName} was not found on the controller or any host.

-
; + if (state !== "ok") { + return ; } return diff --git a/packages/web_ui/src/components/ResourceState.tsx b/packages/web_ui/src/components/ResourceState.tsx new file mode 100644 index 00000000..d2587704 --- /dev/null +++ b/packages/web_ui/src/components/ResourceState.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Result, ResultProps, Spin } from "antd"; + +import Link from "./Link"; +import PageHeader from "./PageHeader"; +import PageLayout from "./PageLayout"; +import type { ResourceStatus } from "../util/useResourceState"; + +type ResourceStateProps = { + /** Status from useResourceState */ + state: ResourceStatus; + /** Capitalised name of the kind of resource, e.g. "Mod pack" */ + type: string; + /** Id or name of the resource */ + id: number | string; + nav: React.ComponentProps["nav"]; + /** Page listing this kind of resource */ + listPath: string; + /** Error from the subscription */ + error: Error | null; +}; + +/** Whole page to render in place of a view page when its resource can't be shown */ +export default function ResourceState(props: ResourceStateProps) { + const { state, type, id, nav } = props; + if (state === "ok") { + return null; + } + + if (state === "loading") { + return + + + ; + } + + const results: Record = { + missing: { status: "warning", title: `${type} not found`, subTitle: `${type} ${id} was not found.` }, + deleted: { status: "warning", title: `${type} ${id} has been deleted` }, + error: { status: "error", title: `Could not load ${type.toLowerCase()}`, subTitle: props.error?.message }, + }; + + return + Back to {type.toLowerCase()} list} + /> + ; +} diff --git a/packages/web_ui/src/components/RoleViewPage.tsx b/packages/web_ui/src/components/RoleViewPage.tsx index 5e61df85..5a7d8ba4 100644 --- a/packages/web_ui/src/components/RoleViewPage.tsx +++ b/packages/web_ui/src/components/RoleViewPage.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useContext, useState, useMemo, useRef } from "react"; +import React, { useEffect, useContext, useState, useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Spin, Tooltip } from "antd"; +import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Tooltip } from "antd"; import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons"; import * as lib from "@clusterio/lib"; @@ -12,7 +12,9 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import { notifyErrorHandler } from "../util/notify"; +import useResourceState from "../util/useResourceState"; /** Splits a permission name into parts, removing the last part */ @@ -162,14 +164,14 @@ export default function RoleViewPage() { const navigate = useNavigate(); const account = useAccount(); const control = useContext(ControlContext); - const [role, synced] = useRole(roleId); + const [role, synced, error] = useRole(roleId); + const state = useResourceState(role, synced, error, roleId); // Local edits on top of the live role const [nameEdit, setNameEdit] = useState(); const [descriptionEdit, setDescriptionEdit] = useState(); const [permissionEdits, setPermissionEdits] = useState>({}); const [search, setSearch] = useState(""); - const loadedRoleId = useRef(undefined); // Hidden below lg, where the site sider leaves too little room; undefined until measured const screens = Grid.useBreakpoint(); const showGroupList = screens.lg !== false; @@ -203,27 +205,9 @@ export default function RoleViewPage() { setPermissionEdits({}); }, [roleId]); - if (role) { - loadedRoleId.current = roleId; - } - - // If no role then display either the loading screen or the not found screen if (!role) { const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }]; - - if (!synced) { - return - - - ; - } - - return - -

Role with id {roleId} { - loadedRoleId.current === roleId ? "has been deleted" : "was not found on the controller" - }.

-
; + return ; } const name = nameEdit ?? role.name; diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 02e9733b..5e3581c0 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useContext, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Button, Col, Descriptions, Form, Input, Popconfirm, - Row, Table, Tag, Select, Space, Spin, Switch, + Row, Table, Tag, Select, Space, Switch, } from "antd"; import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; @@ -18,7 +18,9 @@ import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import SectionHeader from "./SectionHeader"; import ResourceNotes from "./ResourceNotes"; +import ResourceState from "./ResourceState"; import useRowNavigation from "../util/useRowNavigation"; +import useResourceState from "../util/useResourceState"; import Link from "./Link"; import notify, { notifyErrorHandler } from "../util/notify"; import { formatDuration } from "../util/time_format"; @@ -40,7 +42,8 @@ export default function UserViewPage() { let account = useAccount(); let control = useContext(ControlContext); let [instances] = useInstances(); - const [user, synced] = useUser(userName); + const [user, synced, error] = useUser(userName); + const state = useResourceState(user, synced, error, userName); let [roles, rolesSynced] = useRoles(); let [form] = Form.useForm(); const statsTable = useTableQueryState<[number, lib.PlayerStats]>({ @@ -78,17 +81,7 @@ export default function UserViewPage() { let nav = [{ name: "Users", path: "/users" }, { name: userName }]; if (!user) { - if (!synced) { - return - - - ; - } - - return - -

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

-
; + return ; } let roleSelector = (undefined); + const exists = value !== undefined && value !== null; + if (exists) { + seenId.current = id; + } + return getResourceStatus(exists, seenId.current === id, synced, error); +} diff --git a/test/web_ui/useResourceState.js b/test/web_ui/useResourceState.js new file mode 100644 index 00000000..50666001 --- /dev/null +++ b/test/web_ui/useResourceState.js @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; + +import { getResourceStatus } from "../../packages/web_ui/src/util/useResourceState.ts"; + +describe("web_ui/util/useResourceState", function() { + describe("getResourceStatus()", function() { + const error = new Error("Permission denied"); + + it("should be ok when the resource exists", function() { + assert.equal(getResourceStatus(true, true, true, null), "ok"); + }); + it("should keep showing a resource after the subscription stopped syncing", function() { + assert.equal(getResourceStatus(true, true, false, null), "ok"); + assert.equal(getResourceStatus(true, true, false, error), "ok"); + }); + it("should be loading until the subscription has synced", function() { + assert.equal(getResourceStatus(false, false, false, null), "loading"); + }); + it("should be loading while resyncing a resource that was seen", function() { + assert.equal(getResourceStatus(false, true, false, null), "loading"); + }); + it("should be missing when synced without having seen the resource", function() { + assert.equal(getResourceStatus(false, false, true, null), "missing"); + }); + it("should be deleted when synced after having seen the resource", function() { + assert.equal(getResourceStatus(false, true, true, null), "deleted"); + }); + it("should be error when the subscription failed", function() { + assert.equal(getResourceStatus(false, false, false, error), "error"); + assert.equal(getResourceStatus(false, true, false, error), "error"); + }); + it("should ignore the error of an earlier attempt once synced", function() { + assert.equal(getResourceStatus(false, false, true, error), "missing"); + assert.equal(getResourceStatus(false, true, true, error), "deleted"); + }); + }); +});