Added web UI

This commit is contained in:
Cooldude2606
2026-05-23 03:51:16 +01:00
parent a521cdd901
commit 2248f23a8d
14 changed files with 804 additions and 12 deletions
@@ -0,0 +1,59 @@
import React, { useContext } from "react";
import { Modal, Form, Select } from "antd";
import { ControlContext, useUsers } from "@clusterio/web_ui";
import * as messages from "../../messages";
export default function AssignmentForm({ open, setOpen, initial }: any) {
const control = useContext(ControlContext);
const plugin = control.plugins.get("exp_groups") as any;
const [groups] = plugin.useGroups();
const [users] = useUsers();
const [form] = Form.useForm();
function submit(values: any) {
if (initial) {
control.send(new messages.AssignmentUpdateRequest(
new messages.AssignmentRecord(values.name, values.groupId)
));
} else {
control.send(new messages.AssignmentCreateRequest(
values.name,
values.groupId,
));
}
setOpen(false);
}
return <Modal
title={initial ? "Edit Assignment" : "Create Assignment"}
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
>
<Form form={form} initialValues={initial} onFinish={submit}>
<Form.Item name="name" label="Player" rules={[{ required: true }]}>
<Select disabled={Boolean(initial)} showSearch>
{[...users.values()].map(u => (
<Select.Option key={u.name} value={u.name}>
{u.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="groupId" label="Group" rules={[{ required: true }]}>
<Select>
{[...groups.values()].map(g => (
<Select.Option key={g.id} value={g.id}>
{g.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>;
}
@@ -0,0 +1,65 @@
import React, { useContext, useState } from "react";
import { Table, Button, Popconfirm, Space } from "antd";
import { EditOutlined, DeleteOutlined } from "@ant-design/icons";
import { ControlContext, useAccount } from "@clusterio/web_ui";
import * as messages from "../../messages";
import AssignmentForm from "./AssignmentForm";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
export default function AssignmentsTable() {
const control = useContext(ControlContext);
const plugin = control.plugins.get("exp_groups") as any;
const account = useAccount();
const [assignments, synced] = plugin.useAssignments();
const [groups] = plugin.useGroups();
const [editing, setEditing] = useState<any>(null);
const [open, setOpen] = useState(false);
return <>
<Table
columns={[
{
title: "Player",
dataIndex: "name",
sorter: (a: any, b: any) => strcmp(a.name, b.name),
},
{
title: "Group",
render: (_: any, a: any) => groups.get(a.groupId)?.name ?? a.groupId,
},
{
title: "Actions",
render: (_: any, record: any) => (
<Space>
{account.hasPermission("exp_groups.assignment.update") &&
<Button icon={<EditOutlined />} onClick={() => {
setEditing(record);
setOpen(true);
}} />
}
{account.hasPermission("exp_groups.assignment.delete") &&
<Popconfirm
title="Delete?"
onConfirm={() => control.send(new messages.AssignmentDeleteRequest(record.name))}
>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
}
</Space>
),
},
]}
dataSource={[...assignments.values()]}
loading={!synced}
pagination={false}
rowKey={(a) => a.name}
/>
<AssignmentForm open={open} setOpen={setOpen} initial={editing} />
</>;
}
+41
View File
@@ -0,0 +1,41 @@
import React, { useContext } from "react";
import { Modal, Form, Input, Switch } from "antd";
import { useNavigate } from "react-router-dom";
import { ControlContext } from "@clusterio/web_ui";
import * as messages from "../../messages";
export default function GroupForm({ open, setOpen, initial }: any) {
const control = useContext(ControlContext);
const [form] = Form.useForm();
const navigate = useNavigate();
async function submit(values: any) {
if (initial) {
control.send(new messages.GroupUpdateRequest(
new messages.GroupRecord(
initial.id, values.name,
new messages.GroupPermissions(Boolean(values.isBlacklist), [])
)
));
} else {
const group = await control.send(new messages.GroupCreateRequest(
values.name, new messages.GroupPermissions(Boolean(values.isBlacklist), [])
));
navigate(`/exp_groups/${group.id}/view`);
}
setOpen(false);
}
return <Modal title="Group" open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()}>
<Form form={form} initialValues={initial} onFinish={submit}>
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="isBlacklist" label="Grant all by default" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>;
}
+283
View File
@@ -0,0 +1,283 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Button, Checkbox, Input, Space, Spin, Alert } from "antd";
import { ControlContext, useAccount, useDefaultModPack } from "@clusterio/web_ui";
import { PageLayout, PageHeader, notifyErrorHandler } from "@clusterio/web_ui";
import * as messages from "../../messages";
const DOMAIN_RULES: Record<string, string[]> = {
"Admin": ["admin", "cheat", "permission", "infinity", "editor", "spawn"],
"Building & Crafting": ["mining", "build", "craft", "deconstruct", "rotate", "entity"],
"Inventory": ["inventory", "stack", "cursor", "slot", "quick_bar", "item", "equipment"],
"Blueprints": ["blueprint", "copy", "paste", "undo", "redo", "upgrade"],
"Trains": ["train", "station", "schedule", "interrupt", "rolling_stock"],
"Circuit Network": ["combinator", "circuit", "signal", "wire", "cable", "speaker", "display_panel"],
"GUI": ["gui", "open"],
"Pins & Alerts": ["pin", "alert", "tag"],
"Logistics": ["logistic", "filter", "request"],
"Space": ["space", "surface", "planet", "rocket"],
"Toggles": ["toggle", "switch", "swap", "set"],
};
function getDomain(name: string) {
const n = name.toLowerCase();
for (const [domain, keywords] of Object.entries(DOMAIN_RULES)) {
if (keywords.some(k => n.includes(k))) return domain;
}
return "Other";
}
export default function GroupViewPage() {
const control = useContext(ControlContext);
const plugin = control.plugins.get("exp_groups") as any;
const account = useAccount();
const { id } = useParams();
const groupId = Number(id);
const [groups, synced] = plugin.useGroups();
const group = groups.get(groupId);
const [name, setName] = useState("");
const defaultModPack = useDefaultModPack();
const [definesJson, setDefinesJson] = useState<Record<string, number> | null>(null);
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
const canUpdate = Boolean(account.hasPermission("exp_groups.group.update"));
// fetch defines
useEffect(() => {
if (!defaultModPack?.exportManifest?.assets?.defines) return;
if (definesJson) return;
const assetName = defaultModPack.exportManifest.assets.defines;
(async () => {
try {
const response = await fetch(`${staticRoot}static/${assetName}`);
const json = await response.json();
setDefinesJson(json.input_action ?? {});
} catch (err) {
console.error("Failed to load defines", err);
}
})();
}, [defaultModPack]);
// initialise permissions
useEffect(() => {
if (!group || !definesJson) return;
if (Object.keys(permissionState).length) return;
const { isBlacklist, permissions } = group.permissions;
const base = Object.fromEntries(
Object.keys(definesJson).map(p => [
p, isBlacklist !== permissions.includes(p)
])
);
setPermissionState(base);
setBaselineState(base);
}, [group, definesJson]);
// sync name
useEffect(() => {
if (group) setName(group.name);
}, [group]);
// derived data
const allPermissions = useMemo(
() => Object.keys(definesJson ?? {}),
[definesJson]
);
const filtered = useMemo(
() => allPermissions.filter(p =>
p.toLowerCase().includes(search.toLowerCase())
),
[allPermissions, search]
);
const grouped = useMemo(() => {
const map = new Map<string, string[]>();
for (const p of filtered) {
const domain = getDomain(p);
if (!map.has(domain)) map.set(domain, []);
map.get(domain)!.push(p);
}
const sorted = [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
for (const [, perms] of sorted) {
perms.sort((a, b) => a.localeCompare(b));
}
return sorted;
}, [filtered]);
const permissionChanged = Object.keys(permissionState).some(
k => permissionState[k] !== baselineState[k]
);
const metaChanged = group ? name !== group.name : false;
const edited = permissionChanged || metaChanged;
function applyChanges() {
if (!group) return;
const whitelist: string[] = [];
const blacklist: string[] = [];
for (const [perm, enabled] of Object.entries(permissionState)) {
if (enabled) whitelist.push(perm);
else blacklist.push(perm);
}
const isBlacklist = blacklist.length < whitelist.length;
const permissions = isBlacklist ? blacklist : whitelist;
control.send(new messages.GroupUpdateRequest(
new messages.GroupRecord(group.id, name, new messages.GroupPermissions(isBlacklist, permissions))
))
.then(() => setBaselineState(permissionState))
.catch(notifyErrorHandler("Error applying changes"));
}
function revertChanges() {
if (!group) return;
setPermissionState(baselineState);
setName(group.name);
}
if (!synced) {
return <PageLayout nav={[{ name: "exp_groups" }, { name: String(groupId) }]}>
<Spin />
</PageLayout>;
}
if (!group) {
return <PageLayout nav={[{ name: "exp_groups" }, { name: String(groupId) }]}>
<PageHeader title="Group not found" />
</PageLayout>;
}
return <PageLayout nav={[{ name: "exp_groups", path: "/exp_groups" }, { name: group.name }]}>
<PageHeader title={group.name} />
{edited && (
<div style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "#2a1912",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
gap: 12,
zIndex: 1000,
}}>
<span>You have unsaved changes</span>
<Space>
<Button onClick={revertChanges}>Revert</Button>
<Button type="primary" onClick={applyChanges}>Apply</Button>
</Space>
</div>
)}
<div style={{ marginBottom: 16, display: "flex", gap: 12 }}>
<label style={{ width: 110 }}>Name</label>
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
</div>
<h3>Permissions</h3>
{!defaultModPack?.exportManifest?.assets?.defines && (
<Alert
type="warning"
message="Missing export"
description="An export of the default mod pack is required to see all available permissions."
showIcon
/>
)}
{defaultModPack?.exportManifest?.assets?.defines && !definesJson && <Spin />}
{definesJson && <>
<Input.Search
placeholder="Search permissions"
allowClear
onChange={e => setSearch(e.target.value)}
style={{ marginBottom: 16 }}
/>
<Space direction="vertical" style={{ width: "100%" }}>
{grouped.map(([groupName, permissions]) => (
<div key={groupName} style={{ border: "1px solid #424242", padding: 12, borderRadius: 6 }}>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<strong>{groupName}</strong>
<Space>
<Button
size="small"
disabled={!canUpdate}
onClick={() => {
setPermissionState(prev => {
const next = { ...prev };
for (const p of permissions) next[p] = true;
return next;
});
}}
>
Select all
</Button>
<Button
size="small"
disabled={!canUpdate}
onClick={() => {
setPermissionState(prev => {
const next = { ...prev };
for (const p of permissions) next[p] = false;
return next;
});
}}
>
Clear
</Button>
</Space>
</Space>
<div style={{
marginTop: 12,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))",
gap: 8,
}}>
{permissions.map(p => (
<Checkbox
key={p}
checked={permissionState[p]}
disabled={!canUpdate}
onChange={e =>
setPermissionState(prev => ({ ...prev, [p]: e.target.checked }))
}
>
{p}
</Checkbox>
))}
</div>
</div>
))}
</Space>
</>}
</PageLayout>;
}
+48
View File
@@ -0,0 +1,48 @@
import React, { useContext, useState } from "react";
import { Table } from "antd";
import { useNavigate } from "react-router-dom";
import { ControlContext, useAccount } from "@clusterio/web_ui";
import GroupForm from "./GroupForm";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
export default function GroupsTable() {
const control = useContext(ControlContext);
const plugin = control.plugins.get("exp_groups") as any;
const navigate = useNavigate();
const [groups, synced] = plugin.useGroups();
const [open, setOpen] = useState(false);
return <>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
sorter: (a: any, b: any) => strcmp(a.name, b.name),
},
{
title: "Type",
render: (_: any, g: any) => g.permissions.isBlacklist ? "Blacklist" : "Whitelist",
},
{
title: "Permissions",
render: (_: any, g: any) => g.permissions.permissions.length,
},
]}
dataSource={[...groups.values()]}
loading={!synced}
pagination={false}
rowKey={(g) => g.id}
onRow={(g) => ({
onClick: () => navigate(`/exp_groups/${g.id}/view`),
})}
/>
<GroupForm open={open} setOpen={setOpen} />
</>;
}
@@ -0,0 +1,82 @@
import React, { useContext } from "react";
import { Modal, Form, Select, InputNumber, Switch } from "antd";
import { ControlContext, useRoles } from "@clusterio/web_ui";
import * as messages from "../../messages";
export default function RoleMappingForm({ open, setOpen, initial }: any) {
const control = useContext(ControlContext);
const plugin = control.plugins.get("exp_groups") as any;
const [groups] = plugin.useGroups();
const [roles] = useRoles();
const [form] = Form.useForm();
function submit(values: any) {
const record = new messages.RoleMappingRecord(
initial?.id,
new Set(values.roleIds),
values.groupId,
values.priority,
values.enabled,
);
if (initial) {
control.send(new messages.RoleMappingUpdateRequest(record));
} else {
control.send(new messages.RoleMappingCreateRequest(
values.roleIds,
values.groupId,
values.priority,
values.enabled,
));
}
setOpen(false);
}
return <Modal
title={initial ? "Edit Role Mapping" : "Create Role Mapping"}
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
>
<Form
form={form}
initialValues={{
...initial,
roleIds: initial ? [...initial.roleIds] : [],
}}
onFinish={submit}
>
<Form.Item name="roleIds" label="Roles" rules={[{ required: true }]}>
<Select mode="multiple">
{[...roles.values()].map(r => (
<Select.Option key={r.id} value={r.id}>
{r.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="groupId" label="Group" rules={[{ required: true }]}>
<Select>
{[...groups.values()].map(g => (
<Select.Option key={g.id} value={g.id}>
{g.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="priority" label="Priority" initialValue={0}>
<InputNumber style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="enabled" label="Enabled" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>;
}
@@ -0,0 +1,74 @@
import React, { useContext, useState } from "react";
import { Table, Button, Popconfirm, Space, Tag } from "antd";
import { EditOutlined, DeleteOutlined } from "@ant-design/icons";
import { ControlContext, useAccount, useRoles } from "@clusterio/web_ui";
import * as messages from "../../messages";
import RoleMappingForm from "./RoleMappingForm";
export default function RoleMappingsTable() {
const control = useContext(ControlContext);
const plugin = control.plugins.get("exp_groups") as any;
const account = useAccount();
const [roleMappings, synced] = plugin.useRoleMappings();
const [groups] = plugin.useGroups();
const [roles] = useRoles();
const [editing, setEditing] = useState<any>(null);
const [open, setOpen] = useState(false);
return <>
<Table
columns={[
{
title: "Priority",
dataIndex: "priority",
},
{
title: "Roles",
render: (_: any, m: any) => [...m.roleIds].map((id: number) => {
const role = roles.get(id);
return <Tag key={id}>{role?.name ?? id}</Tag>;
}),
},
{
title: "Group",
render: (_: any, m: any) => groups.get(m.groupId)?.name ?? m.groupId,
},
{
title: "Enabled",
render: (_: any, m: any) => m.enabled ? "Yes" : "No",
},
{
title: "Actions",
render: (_: any, record: any) => (
<Space>
{account.hasPermission("exp_groups.role_mapping.update") &&
<Button icon={<EditOutlined />} onClick={() => {
setEditing(record);
setOpen(true);
}} />
}
{account.hasPermission("exp_groups.role_mapping.delete") &&
<Popconfirm
title="Delete?"
onConfirm={() => control.send(new messages.RoleMappingDeleteRequest(record.id))}
>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
}
</Space>
),
},
]}
dataSource={[...roleMappings.values()].sort((a, b) => b.priority - a.priority)}
loading={!synced}
pagination={false}
rowKey={(m) => m.id}
/>
<RoleMappingForm open={open} setOpen={setOpen} initial={editing} />
</>;
}
+108
View File
@@ -0,0 +1,108 @@
import React, { useState, useCallback, useSyncExternalStore } from "react";
import { BaseWebPlugin, PageLayout, PageHeader, useAccount, SectionHeader } from "@clusterio/web_ui";
import { Button } from "antd";
import * as messages from "../messages";
import * as lib from "@clusterio/lib";
import GroupsTable from "./components/GroupsTable";
import AssignmentsTable from "./components/AssignmentsTable";
import RoleMappingsTable from "./components/RoleMappingsTable";
import GroupViewPage from "./components/GroupViewPage";
import GroupForm from "./components/GroupForm";
import RoleMappingForm from "./components/RoleMappingForm";
import AssignmentForm from "./components/AssignmentForm";
function ExpGroupsPage() {
const account = useAccount();
const [groupOpen, setGroupOpen] = useState(false);
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [roleMappingOpen, setRoleMappingOpen] = useState(false);
return <PageLayout nav={[{ name: "exp_groups" }]}>
<PageHeader title="Permission Groups" />
{account.hasPermission("exp_groups.group.list") && <>
<SectionHeader
title="Groups"
extra={
account.hasPermission("exp_groups.group.create")
? <Button type="primary" onClick={() => setGroupOpen(true)}>Create</Button>
: undefined
}
/>
<GroupsTable />
<GroupForm open={groupOpen} setOpen={setGroupOpen} />
</>}
{account.hasPermission("exp_groups.role_mapping.list") && <>
<SectionHeader
title="Role Mappings"
extra={
account.hasPermission("exp_groups.role_mapping.create")
? <Button type="primary" onClick={() => setRoleMappingOpen(true)}>Create</Button>
: undefined
}
/>
<RoleMappingsTable />
<RoleMappingForm open={roleMappingOpen} setOpen={setRoleMappingOpen} />
</>}
{account.hasPermission("exp_groups.assignment.list") && <>
<SectionHeader
title="Assignments"
extra={
account.hasPermission("exp_groups.assignment.create")
? <Button type="primary" onClick={() => setAssignmentOpen(true)}>Create</Button>
: undefined
}
/>
<AssignmentsTable />
<AssignmentForm open={assignmentOpen} setOpen={setAssignmentOpen} />
</>}
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
groups = new lib.EventSubscriber(messages.GroupUpdatedEvent, this.control);
assignments = new lib.EventSubscriber(messages.ManualAssignmentUpdatedEvent, this.control);
roleMappings = new lib.EventSubscriber(messages.RoleMappingUpdatedEvent, this.control);
async init() {
this.pages = [
{
path: "/exp_groups",
sidebarName: "Permission Groups",
permission: (account => account.hasAnyPermission(
"exp_groups.group.list",
"exp_groups.assignment.list",
"exp_groups.role_mapping.list",
)),
content: <ExpGroupsPage />,
},
{
path: "/exp_groups/:id/view",
sidebarPath: "/exp_groups",
permission: "exp_groups.group.get",
content: <GroupViewPage />,
},
];
}
useGroups() {
const subscribe = useCallback((cb: () => void) => this.groups.subscribe(cb), []);
return useSyncExternalStore(subscribe, () => this.groups.getSnapshot());
}
useAssignments() {
const subscribe = useCallback((cb: () => void) => this.assignments.subscribe(cb), []);
return useSyncExternalStore(subscribe, () => this.assignments.getSnapshot());
}
useRoleMappings() {
const subscribe = useCallback((cb: () => void) => this.roleMappings.subscribe(cb), []);
return useSyncExternalStore(subscribe, () => this.roleMappings.getSnapshot());
}
}