mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Show the same page for missing, deleted and unloadable resources
Every view page handled a resource that can't be shown its own way, and users without the subscribe permission were left on a spinner forever. Adds useResourceState and a ResourceState component, used by the host, instance, mod pack, icon reference, user, role and plugin pages and exported for plugin pages. The single item hooks now also return the subscription error. Resolves #1165
This commit is contained in:
1 parent
928e03ce02
commit
2e1710d3a9
18 files changed
+233
-112
No files matched your search
@@ -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 <ResourceState state={state} type="Frobber" id={id} nav={nav} listPath="/frobbers" error={error} />;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>{frobber.name}</PageLayout>;
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
@@ -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<keyof typeof lib.levels>("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 <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title={String(hostId)} />
|
||||
<p>Host with id {hostId} was not found on the controller.</p>
|
||||
</PageLayout>;
|
||||
return <ResourceState state={state} type="Host" id={hostId} nav={nav} listPath="/hosts" error={error} />;
|
||||
}
|
||||
|
||||
let hostButtons = <Space wrap>
|
||||
|
||||
@@ -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<Set<string>>(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 <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout nav={nav}>
|
||||
<PageHeader title="Icon Reference" />
|
||||
|
||||
@@ -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<MenuProps>["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 <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<Alert
|
||||
message={"Instance not found"}
|
||||
showIcon
|
||||
description={<>Instance with id {instanceId} was not found on the controller.</>}
|
||||
type="warning"
|
||||
action={
|
||||
<Link to="/instances">Go back to instances list</Link>
|
||||
}
|
||||
/>
|
||||
</PageLayout>;
|
||||
return <ResourceState
|
||||
state={state} type="Instance" id={instanceId} nav={nav} listPath="/instances" error={error}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -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<any, any> = useExportedAsset(modPack, "locale");
|
||||
let [changes, setChanges] = useState<ModChange[]>([]);
|
||||
@@ -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 <PageLayout nav={nav}>
|
||||
<PageHeader title={String(modPackId)} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title="Mod Pack Not Found" />
|
||||
<p>Mod pack with id {modPackId} was not found on the controller.</p>
|
||||
</PageLayout>;
|
||||
return <ResourceState state={state} type="Mod pack" id={modPackId} nav={nav} listPath="/mods" error={error} />;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -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<void>,
|
||||
/** 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<string, ListedPlugins>());
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<PluginWebApi[]>();
|
||||
const [pluginListError, setPluginListError] = useState<Error | null>(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 <PageLayout nav={nav}>
|
||||
<Descriptions bordered size="small" title={pluginTitle} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
if (!pluginMeta && !status) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title="Plugin not found" />
|
||||
<p>Plugin with name {pluginName} was not found on the controller or any host.</p>
|
||||
</PageLayout>;
|
||||
if (state !== "ok") {
|
||||
return <ResourceState
|
||||
state={state} type="Plugin" id={pluginTitle} nav={nav} listPath="/plugins" error={error}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
|
||||
@@ -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<typeof PageLayout>["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 <PageLayout nav={nav}>
|
||||
<PageHeader title={String(id)} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
const results: Record<typeof state, ResultProps> = {
|
||||
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 <PageLayout nav={nav}>
|
||||
<Result
|
||||
{...results[state]}
|
||||
extra={<Link to={props.listPath}>Back to {type.toLowerCase()} list</Link>}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -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<string>();
|
||||
const [descriptionEdit, setDescriptionEdit] = useState<string>();
|
||||
const [permissionEdits, setPermissionEdits] = useState<Record<string, boolean>>({});
|
||||
const [search, setSearch] = useState("");
|
||||
const loadedRoleId = useRef<number>(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 <PageLayout nav={nav}>
|
||||
<PageHeader title={String(roleId)} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title="Role not found" />
|
||||
<p>Role with id {roleId} {
|
||||
loadedRoleId.current === roleId ? "has been deleted" : "was not found on the controller"
|
||||
}.</p>
|
||||
</PageLayout>;
|
||||
return <ResourceState state={state} type="Role" id={roleId} nav={nav} listPath="/roles" error={error} />;
|
||||
}
|
||||
|
||||
const name = nameEdit ?? role.name;
|
||||
|
||||
@@ -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 <PageLayout nav={nav}>
|
||||
<PageHeader title={userName} />
|
||||
<Spin size="large" />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title="User not found" />
|
||||
<p>User with name {userName} was not found on the controller.</p>
|
||||
</PageLayout>;
|
||||
return <ResourceState state={state} type="User" id={userName} nav={nav} listPath="/users" error={error} />;
|
||||
}
|
||||
|
||||
let roleSelector = <Form.Item
|
||||
|
||||
@@ -40,6 +40,7 @@ export { default as PageLayout } from "./components/PageLayout";
|
||||
export { default as SavesList } from "./components/SavesList";
|
||||
export { default as SectionHeader } from "./components/SectionHeader";
|
||||
export { default as ResourceNotes } from "./components/ResourceNotes";
|
||||
export { default as ResourceState } from "./components/ResourceState";
|
||||
|
||||
export { default as Link } from "./components/Link";
|
||||
|
||||
@@ -48,3 +49,4 @@ export { default as FactorioIcon } from "./components/FactorioIcon";
|
||||
export { default as useTableQueryState } from "./util/useTableQueryState";
|
||||
export { default as useColumnSearch } from "./util/useColumnSearch";
|
||||
export { default as useRowNavigation } from "./util/useRowNavigation";
|
||||
export { default as useResourceState, type ResourceStatus } from "./util/useResourceState";
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useContext, useSyncExternalStore } from "react";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
export function useHost(id?: number) {
|
||||
const [hosts, synced] = useHosts();
|
||||
return [id !== undefined ? hosts.get(id) : undefined, synced] as const;
|
||||
const [hosts, synced, error] = useHosts();
|
||||
return [id !== undefined ? hosts.get(id) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useHosts() {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Config, InstanceConfigGetRequest } from "@clusterio/lib";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
export function useInstance(id?: number) {
|
||||
const [instances, synced] = useInstances();
|
||||
return [id !== undefined ? instances.get(id) : undefined, synced] as const;
|
||||
const [instances, synced, error] = useInstances();
|
||||
return [id !== undefined ? instances.get(id) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useInstances() {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { defaultModPackStore } from "../store/default_mod_pack_store";
|
||||
|
||||
|
||||
export function useModPack(id?: number) {
|
||||
const [modPacks, synced] = useModPacks();
|
||||
return [id !== undefined ? modPacks.get(id) : undefined, synced] as const;
|
||||
const [modPacks, synced, error] = useModPacks();
|
||||
return [id !== undefined ? modPacks.get(id) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useModPacks() {
|
||||
|
||||
@@ -12,8 +12,8 @@ export function useRolesOfUser(user?: lib.UserDetails) {
|
||||
}
|
||||
|
||||
export function useRole(id?: number) {
|
||||
const [roles, synced] = useRoles();
|
||||
return [id !== undefined ? roles.get(id) : undefined, synced] as const;
|
||||
const [roles, synced, error] = useRoles();
|
||||
return [id !== undefined ? roles.get(id) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useRoles() {
|
||||
|
||||
@@ -89,8 +89,8 @@ export function sortLastSeen(
|
||||
}
|
||||
|
||||
export function useUser(name?: string) {
|
||||
const [users, synced] = useUsers();
|
||||
return [name !== undefined ? users.get(name.toLowerCase()) : undefined, synced] as const;
|
||||
const [users, synced, error] = useUsers();
|
||||
return [name !== undefined ? users.get(name.toLowerCase()) : undefined, synced, error] as const;
|
||||
}
|
||||
|
||||
export function useUsers() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export type ResourceStatus = "loading" | "ok" | "missing" | "deleted" | "error";
|
||||
|
||||
export function getResourceStatus(
|
||||
exists: boolean,
|
||||
seen: boolean,
|
||||
synced: boolean,
|
||||
error: Error | null,
|
||||
): ResourceStatus {
|
||||
if (exists) {
|
||||
return "ok";
|
||||
}
|
||||
// A retry only clears the error when its response arrives, which can be after the update that synced it
|
||||
if (synced) {
|
||||
return seen ? "deleted" : "missing";
|
||||
}
|
||||
return error ? "error" : "loading";
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of the resource shown on a view page, for use with the ResourceState component
|
||||
*
|
||||
* @param value - The resource, undefined or null if the subscription does not have it.
|
||||
* @param synced - The synced property from the subscription.
|
||||
* @param error - The error property from the subscription.
|
||||
* @param id - Identifies the resource shown, the router reuses the page when navigating between resources.
|
||||
* @returns "ok" if there is a value to show, otherwise why there isn't.
|
||||
*/
|
||||
export default function useResourceState(value: unknown, synced: boolean, error: Error | null, id: number | string) {
|
||||
const seenId = useRef<number | string>(undefined);
|
||||
const exists = value !== undefined && value !== null;
|
||||
if (exists) {
|
||||
seenId.current = id;
|
||||
}
|
||||
return getResourceStatus(exists, seenId.current === id, synced, error);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user