Refactor web interface

Reimplement the pages of the web interface in order to make them
prettier and more easily amended by plugins.
This commit is contained in:
Hornwitser committed 2020-11-06 23:08:43 +01:00
1 parent 0c0626564a
commit f67f56e6de
32 files changed
+1502 -1059

No files matched your search

+1 -3
View File
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import basename from "../basename";
import SiteLayout from "../layout";
import SiteLayout from "./SiteLayout";
import ControlContext from "./ControlContext";
import LoginForm from "./LoginForm";
@@ -21,8 +21,6 @@ export default function App(props) {
}
useEffect(() => {
console.log("Effect called");
function onConnect() {
localStorage.setItem("master_token", token);
setConnected(true);
@@ -0,0 +1,604 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import {
notification, Button, Card, Checkbox, Col, Descriptions, Dropdown, Form, Input, InputNumber, Modal,
Popconfirm, Row, Select, Space, Spin, Tree, Typography,
} from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
const { Title, Paragraph } = Typography;
function useInstance(id) {
let control = useContext(ControlContext);
let [instance, setInstance] = useState({ loading: true });
function updateInstance() {
if (!Number.isInteger(id)) {
setInstance({ missing: true });
return;
}
// XXX optimize by requesting only the instance in question
libLink.messages.listInstances.send(control).then(result => {
let match = result.list.find(i => i.id === id);
if (!match) {
setInstance({ missing: true });
} else {
setInstance({ ...match, present: true });
}
});
}
useEffect(() => {
updateInstance();
}, [id]);
return [instance, updateInstance];
}
function useSlave(id) {
let control = useContext(ControlContext);
let [slave, setSlave] = useState({ loading: true });
function updateSlave() {
if (!Number.isInteger(id)) {
setSlave({ missing: true });
return;
}
// XXX optimize by requesting only the slave in question
libLink.messages.listSlaves.send(control).then(result => {
let match = result.list.find(i => i.id === id);
if (!match) {
setSlave({ missing: true });
} else {
setSlave({ ...match, present: true });
}
});
}
useEffect(() => {
updateSlave();
}, [id]);
return [slave, updateSlave];
}
function formatOutputColored(output, key) {
let time = "";
if (output.format === "seconds") {
time = <span className="factorio-time">{output.time.padStart(8)} </span>;
} else if (output.format === "date") {
time = <span className="factorio-time">{output.time} </span>;
}
let info = "";
if (output.type === "log") {
let level = output.level;
if (level === "Info") {
level = <span className="factorio-info">{level}</span>;
} else if (output.level === "Warning") {
level = <span className="factorio-warning">{level}</span>;
} else if (output.level === "Error") {
level = <span className="factorio-error">{level}</span>;
}
info = <>{level} <span className="factorio-filename">{output.file}: </span></>;
} else if (output.type === "action") {
info = <>[<span className="factorio-action">{output.action}</span>] </>;
}
return <span key={key}>{time}{info}{output.message}<br/></span>;
}
function InstanceConsole(props) {
let control = useContext(ControlContext);
let [lines, setLines] = useState([{ message: "<previous messages are not shown>" }]);
useEffect(() => {
function outputHandler(output) {
setLines(currentLines => currentLines.concat([output]));
}
control.onInstanceOutput(props.id, outputHandler);
return () => {
control.offInstanceOutput(props.id, outputHandler);
};
}, [props.id]);
return <>
<Paragraph code className="instance-console">
{lines.map(formatOutputColored)}
<div className="scroll-anchor" />
</Paragraph>
</>;
}
function InstanceRcon(props) {
let control = useContext(ControlContext);
let [output, setOutput] = useState(null);
let [running, setRunning] = useState(false);
async function sendCommand(command) {
if (!command) {
setOutput(null);
return;
}
setRunning(true);
try {
let result = await libLink.messages.sendRcon.send(control, {
instance_id: props.id,
command: command,
});
setOutput(result.result);
} finally {
setRunning(false);
}
}
return <>
{output && <>
<Title level={5}>Rcon result</Title>
<Paragraph code className="rcon-result">{output}</Paragraph>
</>}
<Input.Search
disabled={props.disabled}
placeholder="Send RCON Command"
enterButton="Send"
onSearch={(command) => sendCommand(command).catch(notifyErrorHandler("Error sending command"))}
loading={running}
/>
</>;
}
function AssignInstanceModal(props) {
let [visible, setVisible] = useState(false);
let [slaves, setSlaves] = useState([]);
let [loading, setLoading] = useState(true);
let [applying, setApplying] = useState(false);
let [form] = Form.useForm();
let control = useContext(ControlContext);
function open() {
setVisible(true);
libLink.messages.listSlaves.send(control).then((result) => {
setSlaves(result.list);
setLoading(false);
});
}
function handleAssign() {
let slaveId = form.getFieldValue("slave");
if (slaveId === undefined) {
setVisible(false);
return;
}
setApplying(true);
libLink.messages.assignInstanceCommand.send(control, {
instance_id: props.id,
slave_id: slaveId,
}).then(() => {
setVisible(false);
if (props.onFinish) {
props.onFinish();
}
}).catch(
notifyErrorHandler("Error assigning instance")
).finally(
() => setApplying(false)
);
}
function handleCancel() {
setVisible(false);
}
return <>
<Button {...props.buttonProps} onClick={open}>
{props.buttonContent || "Assign"}
</Button>
<Modal
title="Assign Instance"
okText="Assign"
visible={visible}
confirmLoading={applying}
onOk={handleAssign}
onCancel={handleCancel}
destroyOnClose
>
<Paragraph style={{ maxWidth: "30em" }}>
Select a Slave to assign this instance to. Assignment
creates the necessary files on the slave to start up
the instance. Note that reassigning an instance from
one slave to another will not move the server save over.
</Paragraph>
<Form form={form} initialValues={{ slave: props.slaveId }}>
<Form.Item name="slave" label="Slave">
<Select loading={loading}>
{slaves.map((slave) => <Select.Option
key={slave["id"]}
value={slave["id"]}
disabled={!slave["connected"]}
>
{slave["name"]}
{!slave["connected"] && " (offline)"}
</Select.Option>)}
</Select>
</Form.Item>
</Form>
</Modal>
</>;
}
function StartStopInstanceButton(props) {
let control = useContext(ControlContext);
let [switching, setSwitching] = useState(false);
function onClick() {
setSwitching(true);
let action;
if (props.instance["status"] === "stopped") {
action = libLink.messages.startInstance.send(
control, { instance_id: props.instance["id"], save: null }
).catch(
notifyErrorHandler("Error starting instance")
);
} else if (["starting", "running"].includes(props.instance["status"])) {
action = libLink.messages.stopInstance.send(
control, { instance_id: props.instance["id"] }
).catch(
notifyErrorHandler("Error stopping instance")
);
} else {
setSwitching(false);
return;
}
action.finally(() => {
setSwitching(false);
if (props.onFinish) {
props.onFinish();
}
});
}
return <Button
{...(props.buttonProps || {})}
loading={switching}
type="primary"
disabled={!["starting", "running", "stopped"].includes(props.instance["status"])}
onClick={onClick}
>
{props.instance["status"] === "stopped" ? "Start" : "Stop"}
</Button>;
}
function InstanceConfigTree(props) {
let control = useContext(ControlContext);
let [config, setConfig] = useState(null);
let [form] = Form.useForm();
let [changedFields, setChangedFields] = useState(new Set());
let [errorFields, setErrorFields] = useState(new Map());
let [applying, setApplying] = useState(false);
async function updateConfig() {
let result = await libLink.messages.getInstanceConfig.send(control, {
instance_id: props.id,
});
let newConfig = new libConfig.InstanceConfig();
await newConfig.load(result.serialized_config);
setConfig(newConfig);
}
useEffect(() => { updateConfig(); }, [props.id]);
function renderInput(group, def) {
if (def.type === "boolean") {
return <Checkbox/>;
}
if (def.type === "string") {
return <Input/>;
}
if (def.type === "number") {
return <InputNumber/>;
}
return `Unknown type ${def.type}`;
}
if (config === null) {
return <Spin/>;
}
let initialValues = {};
let treeData = [];
let propsMap = new Map();
for (let [name, GroupClass] of libConfig.InstanceConfig.groups) {
let group = config.group(name);
let treeNode = {
key: group.name,
title: group.name,
children: [],
};
for (let def of GroupClass.definitions.values()) {
let value = group.get(def.name);
let fieldName = `${group.name}.${def.name}`;
let childNode = {
key: fieldName,
children: [],
};
if (def.type === "object") {
childNode.title = <Form.Item label={def.title || def.name} tooltip={def.description} />;
for (let prop of Object.keys(value)) {
let propPath = `${group.name}.${def.name}.${prop}`;
childNode.children.push({
key: propPath,
title: <Form.Item
name={propPath}
label={`"${prop}"`}
rules={[{ validator: (_, fieldValue) => {
try {
JSON.parse(fieldValue);
} catch (err) {
return Promise.reject(new Error("Must be valid json"));
}
return Promise.resolve();
}}]}
>
<Input className="json-input" />
</Form.Item>,
});
initialValues[propPath] = JSON.stringify(value[prop]);
propsMap.set(propPath, [fieldName, prop]);
}
} else {
childNode.title = <Form.Item
name={fieldName}
label={def.title || def.name}
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
help={errorFields.get(fieldName)}
tooltip={def.description}
valuePropName={def.type === "boolean" ? "checked" : "value"}
>
{renderInput(group, def)}
</Form.Item>;
initialValues[fieldName] = value;
}
treeNode.children.push(childNode);
}
treeData.push(treeNode);
}
function onValuesChange(changedValues) {
let newChangedFields = new Set(changedFields);
let changed = false;
for (let [key, value] of Object.entries(changedValues)) {
if (initialValues[key] === value) {
changed = newChangedFields.delete(key) || changed;
} else {
changed = !newChangedFields.has(key) || changed;
newChangedFields.add(key);
}
}
if (changed) {
setChangedFields(newChangedFields);
}
}
return <>
<Title level={5} style={{ marginTop: 16 }}>
Config
<Space style={{ float: "right" }}>
<Button
type="primary"
size="small"
loading={applying}
onClick={async () => {
setApplying(true);
for (let field of changedFields) {
let value = form.getFieldValue(field);
let request;
if (propsMap.has(field)) {
let [fieldName, prop] = propsMap.get(field);
try {
value = JSON.parse(value);
} catch (err) {
continue;
}
request = libLink.messages.setInstanceConfigProp.send(control, {
instance_id: props.id,
field: fieldName,
prop,
value,
});
} else {
request = libLink.messages.setInstanceConfigField.send(control, {
instance_id: props.id,
field,
value: String(value),
});
}
try {
await request;
setChangedFields(changed => {
let newChanged = new Set(changed);
newChanged.delete(field);
return newChanged;
});
} catch (err) {
setErrorFields(errors => {
let newErrors = new Map(errors);
newErrors.set(field, err.message);
return newErrors;
});
}
}
await updateConfig();
setApplying(false);
if (props.onApply) {
props.onApply();
}
}}
disabled={changedFields.size === 0}
>
Apply
</Button>
<Button
size="small"
onClick={() => {
form.resetFields();
setChangedFields(new Set());
setErrorFields(new Map());
}}
>
Revert
</Button>
</Space>
</Title>
<Card size="small" className="config-container">
<Form
size="small"
form={form}
initialValues={initialValues}
onValuesChange={onValuesChange}
>
<Tree
treeData={treeData}
selectable={false}
defaultExpandAll={true}
filterTreeNode={(node) => changedFields.has(node.key)}
/>
</Form>
</Card>
</>;
}
export default function InstanceViewPage(props) {
let params = useParams();
let instanceId = Number(params.id);
let history = useHistory();
let control = useContext(ControlContext);
let [instance, updateInstance] = useInstance(instanceId);
let [slave] = useSlave(Number(instance["assigned_slave"]));
let [creatingSave, setCreatingSave] = useState(false);
let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }];
if (instance.loading) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (instance.missing) {
return <PageLayout nav={nav}>
<h2>Instance not found</h2>
<p>Instance with id {instanceId} was not found on the master server.</p>
</PageLayout>;
}
let instanceButtons = <Space>
<StartStopInstanceButton
instance={instance}
onFinish={updateInstance}
/>
{instance.status === "stopped" && <Button
loading={creatingSave}
onClick={() => {
setCreatingSave(true);
libLink.messages.createSave.send(
control, { instance_id: instanceId }
).catch(notifyErrorHandler("Error creating save")).finally(() => {
updateInstance();
setCreatingSave(false);
});
}}
>
Create save
</Button>}
<Popconfirm
title="Permanently delete instance and server saves?"
okText="Delete"
placement="bottomRight"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteInstance.send(
control, { instance_id: instanceId }
).then(() => {
history.push("/instances");
}).catch(notifyErrorHandler("Error deleting instance"));
}}
>
<Button
danger
disabled={!["unknown", "unassigned", "stopped"].includes(instance["status"])}
>
<DeleteOutlined />
</Button>
</Popconfirm>
</Space>;
let assigned = instance["assigned_slave"] !== null;
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
title={instance["name"]}
extra={instanceButtons}
>
<Descriptions.Item label="Slave">
{!assigned
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
<AssignInstanceModal
id={instanceId}
slaveId={instance["assigned_slave"]}
buttonProps={{
size: "small",
style: { float: "Right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
onFinish={updateInstance}
/>
</Descriptions.Item>
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
</Descriptions>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<InstanceConsole id={instanceId} />
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
<InstanceConfigTree id={instanceId} onApply={updateInstance} />
</PageLayout>;
}
@@ -0,0 +1,55 @@
import React, { useContext } from "react";
import { useHistory } from "react-router-dom";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function InstancesPage() {
let control = useContext(ControlContext);
let history = useHistory();
async function listInstances() {
let result = await libLink.messages.listInstances.send(control);
return result["list"].map(item => ({
key: item["id"],
"Name": item["name"],
"Assigned Slave": item["assigned_slave"],
"Status": item["status"],
}));
}
return <PageLayout nav={[{ name: "Instances" }]}>
<h2>Instances</h2>
<DataTable
DataFunction={listInstances}
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
}],
insert: async args => {
let instanceConfig = new libConfig.InstanceConfig();
await instanceConfig.init();
console.log("Base config", instanceConfig);
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize();
let response = await libLink.messages.createInstance.send(control, { serialized_config });
console.log("Created instance", response);
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/instances/${record.key}/view`);
},
}),
}}
/>
</PageLayout>;
}
@@ -0,0 +1,18 @@
import React from "react";
import { Link } from "react-router-dom";
import { Layout, Menu, Breadcrumb, Typography } from "antd";
const { Content } = Layout;
export default function PageLayout(props) {
return <>
<Breadcrumb style={{ margin: "16px 0 16px 28px" }}>
{props.nav.map((part, index) => <Breadcrumb.Item key={index}>
{part.path ? <Link to={part.path}>{part.name}</Link> : part.name}
</Breadcrumb.Item>)}
</Breadcrumb>
<Content className="site-layout-content" >
{props.children}
</Content>
</>;
}
@@ -0,0 +1,151 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Form, Input, Popconfirm, Row, Space, Spin } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import libLink from "@clusterio/lib/link";
import libUsers from "@clusterio/lib/users";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
function useRole(id) {
let control = useContext(ControlContext);
let [role, setRole] = useState({ loading: true });
function updateRole() {
// XXX optimize by requesting only the role in question
libLink.messages.listRoles.send(control).then(result => {
let match = result.list.find(u => u.id === id);
if (!match) {
setRole({ missing: true });
} else {
setRole({
...match,
present: true,
});
}
});
}
useEffect(() => {
updateRole();
}, [id]);
return [role, updateRole];
}
export default function RoleViewPage() {
let params = useParams();
let roleId = Number(params.id);
let history = useHistory();
let control = useContext(ControlContext);
let [role, updateRole] = useRole(roleId);
let [edited, setEdited] = useState(false);
let nav = [{ name: "Roles", path: "/roles" }, { name: role["name"] || roleId }];
if (role.loading) {
return <PageLayout nav={nav}>
<h2>{roleId}</h2>
<Spin size="large" />
</PageLayout>;
}
if (role.missing) {
return <PageLayout nav={nav}>
<h2>Role not found</h2>
<p>Role with id {roleId} was not found on the master server.</p>
</PageLayout>;
}
let initialValues = {
name: role["name"],
description: role["description"],
permissions: {
...Object.fromEntries([...libUsers.permissions.values()].map(perm => [
perm.name, role["permissions"].includes(perm.name),
])),
},
};
return <PageLayout nav={nav}>
<Form
initialValues={initialValues}
onValuesChange={() => { setEdited(true); }}
onFinish={values => {
console.log(values);
let newPermissions = [];
for (let [name, value] of Object.entries(values.permissions)) {
if (value) { newPermissions.push(name); }
}
libLink.messages.updateRole.send(control, {
id: roleId,
name: values.name || "",
description: values.description || "",
permissions: newPermissions,
}).then(() => {
setEdited(false);
updateRole();
}).catch(notifyErrorHandler("Error applying changes"));
}}
>
<Row>
<Col flex="auto">
<h2>{role["name"]}</h2>
</Col>
<Col flex="0 0 auto">
<Space>
<Button type={edited ? "primary" : "default"} htmlType="submit">
Apply
</Button>
<Popconfirm
title="Delete role?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteRole.send(
control, { id: roleId }
).then(() => {
history.push("/roles");
}).catch(notifyErrorHandler("Error deleting role"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
</Popconfirm>
</Space>
</Col>
</Row>
<Form.Item name="name" label="Name">
<Input/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input/>
</Form.Item>
<h3>Permissions</h3>
{[...libUsers.permissions.values()].map(({name, title, description}) => (
<Form.Item
name={["permissions", name]}
key={name}
label={title}
tooltip={description}
valuePropName="checked"
labelCol={{ sm: { span: 22, push: 2 }}}
wrapperCol={{ sm: { span: 2, pull: 22 }}}
labelAlign="left"
colon={false}
>
<Checkbox/>
</Form.Item>
))}
</Form>
</PageLayout>;
}
@@ -0,0 +1,51 @@
import React, { useContext } from "react";
import { useHistory } from "react-router-dom";
import libLink from "@clusterio/lib/link";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function UsersPage() {
let control = useContext(ControlContext);
let history = useHistory();
async function listRoles() {
let result = await libLink.messages.listRoles.send(control);
return result["list"].map(item => ({
key: item["id"],
"Name": item["name"],
"Description": item["description"],
}));
}
return <PageLayout nav={[{ name: "Roles" }]}>
<h2>Roles</h2>
<DataTable
DataFunction={listRoles}
AddRecord={{
fields: [
{ dataIndex: "name", title: "Name" },
{ dataIndex: "description", title: "Description" },
],
insert: async args => {
await libLink.messages.createRole.send(control, {
name: args.name,
description: args.description || "",
permissions: [],
});
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/roles/${record.key}/view`);
},
}),
}}
/>
</PageLayout>;
}
@@ -0,0 +1,85 @@
import React, { useEffect, useState } from "react";
import { Switch, Route, useHistory } from "react-router-dom";
import { Layout, Menu } from "antd";
import webUiPackage from "../../package.json";
const { Header, Sider } = Layout;
import SlavesPage from "./SlavesPage";
import InstancesPage from "./InstancesPage";
import InstanceViewPage from "./InstanceViewPage";
import UsersPage from "./UsersPage";
import UserViewPage from "./UserViewPage";
import RolesPage from "./RolesPage";
import RoleViewPage from "./RoleViewPage";
const pages = [
{ path: "/instances/:id/view", sidebarPath: "/instances", content: <InstanceViewPage /> },
{ path: "/users/:name/view", sidebarPath: "/users", content: <UserViewPage /> },
{ path: "/roles/:id/view", sidebarPath: "/roles", content: <RoleViewPage /> },
];
const sidebar = [
{ name: "Slaves", path: "/slaves", content: <SlavesPage /> },
{ name: "Instances", path: "/instances", content: <InstancesPage />},
{ name: "Users", path: "/users", content: <UsersPage /> },
{ name: "Roles", path: "/roles", content: <RolesPage /> },
];
export default function SiteLayout(props) {
let history = useHistory();
let [sidebarPath, setSidebarPath] = useState(null);
function SetSidebar(setSidebarProps) {
useEffect(() => {
setSidebarPath(setSidebarProps.path);
});
return null;
}
return <Layout style={{ minHeight: "100vh" }}>
<Header className="header">
<div className="site-logo" />
<span className="site-name" style={{ top: -10 }}>Clusterio</span>
<span style={{ position: "absolute", top: 10, left: 120, color: "#f00" }}>{ webUiPackage.version }</span>
<Menu theme="dark" mode="horizontal" defaultSelectedKeys={["1"]}>
<Menu.Item key="1">Dashboard</Menu.Item>
</Menu>
</Header>
<Layout>
<Sider
collapsible
collapsedWidth={0}
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
width={250}
className="site-layout-sider"
>
<Menu
mode="inline"
selectedKeys={[sidebarPath]}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => history.push(key)}
>
{sidebar.map(({name, path}) => <Menu.Item key={path}>{name}</Menu.Item>)}
</Menu>
</Sider>
<Layout style={{ padding: "0 24px 24px" }}>
<Switch>
{sidebar.map(({path, content}) => <Route exact path={path} key={path}>
<SetSidebar path={path} />
{content}
</Route>)}
{pages.map(page => <Route exact path={page.path} key={page.path}>
<SetSidebar path={page.sidebarPath} />
{page.content}
</Route>)}
<Route>
<SetSidebar path={null} />
</Route>
</Switch>
</Layout>
</Layout>
</Layout>;
}
@@ -0,0 +1,28 @@
import React, { useContext } from "react";
import libLink from "@clusterio/lib/link";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function SlavesPage() {
let control = useContext(ControlContext);
async function listSlaves() {
let result = await libLink.messages.listSlaves.send(control);
return result["list"].map((item) => ({
key: item["id"],
"Name": item["name"],
"Agent": item["agent"],
"Version": item["version"],
"Connected": item["connected"] && "Yes",
}));
}
return <PageLayout nav={[{ name: "Slaves" }]}>
<h2>Slaves</h2>
<DataTable DataFunction={listSlaves} />
</PageLayout>;
};
@@ -0,0 +1,246 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Form, Input, Popconfirm, Popover, Row, Select, Space, Spin, Switch } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
function useUser(name) {
let control = useContext(ControlContext);
let [user, setUser] = useState({ loading: true });
function updateUser() {
// XXX optimize by requesting only the user in question
libLink.messages.listUsers.send(control).then(result => {
let match = result.list.find(u => u.name === name);
if (!match) {
setUser({ missing: true });
} else {
setUser({ ...match, present: true });
}
});
}
useEffect(() => {
updateUser();
}, [name]);
return [user, updateUser];
}
export default function UserViewPage() {
let params = useParams();
let userName = params.name;
let history = useHistory();
let control = useContext(ControlContext);
let [user, updateUser] = useUser(userName);
let [roles, setRoles] = useState(null);
let [form] = Form.useForm();
let [rolesDirty, setRolesDirty] = useState(false);
let [applyingRoles, setApplyingRoles] = useState(false);
let [rolesError, setRolesError] = useState();
let [banUserPopover, setBanUserPopover] = useState(false);
useEffect(() => {
libLink.messages.listRoles.send(control).then(result => {
setRoles(new Map(result["list"].map(role => [role["id"], role])));
}).catch(() => {
setRoles(new Map());
});
}, []);
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
if (user.loading) {
return <PageLayout nav={nav}>
<h2>{userName}</h2>
<Spin size="large" />
</PageLayout>;
}
if (user.missing) {
return <PageLayout nav={nav}>
<h2>User not found</h2>
<p>User with name {userName} was not found on the master server.</p>
</PageLayout>;
}
return <PageLayout nav={nav}>
<Row>
<Col flex="auto">
<h2>{userName}</h2>
</Col>
<Col flex="0 0 auto">
<Popconfirm
title={<>
Delete user account and all data associated with it?
{(user["is_banned"] && <><br/>This will remove the user ban!</>)}
</>}
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteUser.send(
control, { name: userName }
).then(() => {
history.push("/users");
}).catch(notifyErrorHandler("Error deleting user"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
</Popconfirm>
</Col>
</Row>
<Form form={form}>
<Form.Item
label="Roles"
>
<Row gutter={8}>
<Col flex="auto">
<Form.Item
noStyle
name="roles"
initialValue={user["roles"]}
rules={[
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
]}
>
<Select
onChange={() => setRolesDirty(true)}
loading={!roles}
mode="multiple"
showArrow={true}
filterOption={(inputValue, option) => {
let role = roles.get(option.value);
return role && role["name"].toLowerCase().includes(inputValue.toLowerCase());
}}
>
{roles && [...roles.values()].map(r => <Select.Option
key={r["id"]}
value={r["id"]}
>{r["name"]}</Select.Option>)}
</Select>
</Form.Item>
</Col>
<Col flex="0 0 auto">
<Button
type={"primary"}
disabled={!rolesDirty}
loading={applyingRoles}
onClick={() => {
let newRoles = form.getFieldValue("roles");
setApplyingRoles(true);
libLink.messages.updateUserRoles.send(control, {
name: userName,
roles: newRoles,
}).then(() => {
setRolesDirty(false);
setRolesError();
}).catch(err => {
setRolesError(err.message);
return form.validateFields();
}).finally(() => {
setApplyingRoles(false);
});
}}
>Apply</Button>
</Col>
</Row>
</Form.Item>
<Form.Item label="In-game Admin">
<Switch
checked={user["is_admin"]}
onClick={() => {
libLink.messages.setUserAdmin.send(control, {
name: userName,
admin: !user["is_admin"],
create: false,
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error toggling user admin status"));
}}
/>
</Form.Item>
<Form.Item label="Whitelisted">
<Switch
checked={user["is_whitelisted"]}
onClick={() => {
libLink.messages.setUserWhitelisted.send(control, {
name: userName,
whitelisted: !user["is_whitelisted"],
create: false,
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error toggling user whitelisted status"));
}}
/>
</Form.Item>
<Form.Item label="Banned">
<Space>
{user["is_banned"] ? "Yes" : "No"}
<Popover
title="Ban user"
visible={banUserPopover}
trigger="click"
onVisibleChange={() => {
if (!user["is_banned"]) {
setBanUserPopover(!banUserPopover);
}
}}
content=<Form.Item label="Reason">
<Row gutter={8}>
<Col flex="auto">
<Form.Item noStyle name="reason">
<Input/>
</Form.Item>
</Col>
<Col flex="0 0 auto">
<Button
type="primary"
onClick={() => {
let reason = form.getFieldValue("reason");
libLink.messages.setUserBanned.send(control, {
name: userName,
create: false,
banned: true,
reason,
}).then(() => {
setBanUserPopover(false);
updateUser();
}).catch(notifyErrorHandler("Error banning user"));
}}
>Ban</Button>
</Col>
</Row>
</Form.Item>
>
<Button
type="primary"
size="small"
onClick={() => {
if (user["is_banned"]) {
libLink.messages.setUserBanned.send(control, {
name: userName,
create: false,
banned: false,
reason: "",
}).then(() => {
updateUser();
}).catch(notifyErrorHandler("Error pardoning user"));
}
}}
>{user["is_banned"] ? "Pardon User" : "Ban User"}</Button>
</Popover>
</Space>
</Form.Item>
</Form>
</PageLayout>;
}
@@ -0,0 +1,69 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Spin, Tag } from "antd";
import libLink from "@clusterio/lib/link";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function UsersPage() {
let control = useContext(ControlContext);
let history = useHistory();
let [roles, setRoles] = useState(null);
useEffect(() => {
libLink.messages.listRoles.send(control).then(result => {
setRoles(new Map(result["list"].map(role => [role["id"], role])));
}).catch(() => {
setRoles(new Map());
});
}, []);
async function listUsers() {
let result = await libLink.messages.listUsers.send(control);
return result["list"].map(item => ({
key: item["name"],
"Name": item["name"],
"Roles": item["roles"].map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
"Admin": item["is_admin"] && "Yes",
"Whitelisted": item["is_whitelisted"] && "Yes",
"Banned": item["is_banned"] && "Yes",
}));
}
// DataTable does not make it possible to update the rendering function
if (roles === null) {
return <PageLayout nav={[{ name: "Users" }]}>
<h2>Users</h2>
<Spin/>
</PageLayout>;
}
return <PageLayout nav={[{ name: "Users" }]}>
<h2>Users</h2>
<DataTable
DataFunction={listUsers}
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
}],
insert: async args => {
await libLink.messages.createUser.send(control, { name: args.name });
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/users/${record.key}/view`);
},
}),
}}
/>
</PageLayout>;
}