mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Merge pull request #1166 from clusterio/implement-1165-separate-pr
Handle missing, deleted and failed resources the same way on web UI view pages
This commit is contained in:
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