From 2248f23a8d6363371f453223cfd1fadab5462d02 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sat, 23 May 2026 03:51:16 +0100 Subject: [PATCH] Added web UI --- exp_groups/controller.ts | 38 ++- exp_groups/index.ts | 7 + exp_groups/messages.ts | 3 + exp_groups/module/control.lua | 1 + exp_groups/tsconfig.browser.json | 2 +- exp_groups/web/components/AssignmentForm.tsx | 59 ++++ .../web/components/AssignmentsTable.tsx | 65 ++++ exp_groups/web/components/GroupForm.tsx | 41 +++ exp_groups/web/components/GroupViewPage.tsx | 283 ++++++++++++++++++ exp_groups/web/components/GroupsTable.tsx | 48 +++ exp_groups/web/components/RoleMappingForm.tsx | 82 +++++ .../web/components/RoleMappingsTable.tsx | 74 +++++ exp_groups/web/index.tsx | 108 +++++++ exp_groups/webpack.config.js | 5 + 14 files changed, 804 insertions(+), 12 deletions(-) create mode 100644 exp_groups/web/components/AssignmentForm.tsx create mode 100644 exp_groups/web/components/AssignmentsTable.tsx create mode 100644 exp_groups/web/components/GroupForm.tsx create mode 100644 exp_groups/web/components/GroupViewPage.tsx create mode 100644 exp_groups/web/components/GroupsTable.tsx create mode 100644 exp_groups/web/components/RoleMappingForm.tsx create mode 100644 exp_groups/web/components/RoleMappingsTable.tsx diff --git a/exp_groups/controller.ts b/exp_groups/controller.ts index bdc1860b..10bfacd2 100644 --- a/exp_groups/controller.ts +++ b/exp_groups/controller.ts @@ -228,6 +228,7 @@ export class ControllerPlugin extends BaseControllerPlugin { const group = new messages.GroupRecord(id, request.name, request.permissions); this.groups.set(group); + return group; } async handleGroupUpdateRequest(request: messages.GroupUpdateRequest) { @@ -275,6 +276,7 @@ export class ControllerPlugin extends BaseControllerPlugin { const assignment = new messages.AssignmentRecord(name, groupId); this.manualAssignments.set(assignment); + return assignment; } async handleAssignmentUpdateRequest(request: messages.AssignmentUpdateRequest) { @@ -330,24 +332,38 @@ export class ControllerPlugin extends BaseControllerPlugin { id = Math.random() * 2**31 | 0; } - const mapping = new messages.RoleMappingRecord( - id, - new Set(request.roleIds), - request.groupId, - request.priority, - request.enabled, + let priority = request.priority; + const existing = new Set([...this.roleMappings.values()].map(m => m.priority)); + while (existing.has(priority)) { + priority++; + } + + const roleMapping = new messages.RoleMappingRecord( + id, new Set(request.roleIds), request.groupId, priority, request.enabled, ); - this.roleMappings.set(mapping); + this.roleMappings.set(roleMapping); + return roleMapping; } async handleRoleMappingUpdateRequest(request: messages.RoleMappingUpdateRequest) { - const mapping = request.roleMapping; - if (mapping.id === undefined || !this.roleMappings.has(mapping.id)) { - throw new lib.RequestError(`Role mapping with ID ${mapping.id} does not exist`); + const roleMapping = request.roleMapping; + if (roleMapping.id === undefined || !this.roleMappings.has(roleMapping.id)) { + throw new lib.RequestError(`Role mapping with ID ${roleMapping.id} does not exist`); } - this.roleMappings.set(mapping); + const existing = new Set( + [...this.roleMappings.values()] + .filter(m => m.id !== roleMapping.id) + .map(m => m.priority) + ); + + let priority = roleMapping.priority; + while (existing.has(priority)) { + priority++; + } + + this.roleMappings.set(roleMapping); } async handleRoleMappingDeleteRequest(request: messages.RoleMappingDeleteRequest) { diff --git a/exp_groups/index.ts b/exp_groups/index.ts index 2b433065..e16f361b 100644 --- a/exp_groups/index.ts +++ b/exp_groups/index.ts @@ -134,6 +134,7 @@ export const plugin: lib.PluginDeclaration = { messages: [ messages.GroupUpdatedEvent, messages.ManualAssignmentUpdatedEvent, + messages.ResolvedAssignmentUpdatedEvent, messages.RoleMappingUpdatedEvent, messages.GroupCreateRequest, @@ -168,4 +169,10 @@ export const plugin: lib.PluginDeclaration = { controllerEntrypoint: "./dist/node/controller", controllerConfigFields: { }, + + webEntrypoint: "./web", + routes: [ + "/exp_groups", + "/exp_groups/:id/view", + ] }; diff --git a/exp_groups/messages.ts b/exp_groups/messages.ts index 691d8ca3..b0e5e986 100644 --- a/exp_groups/messages.ts +++ b/exp_groups/messages.ts @@ -292,6 +292,7 @@ export class GroupCreateRequest { static src = ["control", "instance"] as const; static dst = "controller" as const; static permission = "exp_groups.group.create" as const; + static Response = GroupRecord; constructor( public name: string, @@ -425,6 +426,7 @@ export class AssignmentCreateRequest { static src = ["control", "instance"] as const; static dst = "controller" as const; static permission = "exp_groups.assignment.create" as const; + static Response = AssignmentRecord; constructor( public name: string, @@ -558,6 +560,7 @@ export class RoleMappingCreateRequest { static src = ["control", "instance"] as const; static dst = "controller" as const; static permission = "exp_groups.role_mapping.create" as const; + static Response = RoleMappingRecord; constructor( public roleIds: number[], diff --git a/exp_groups/module/control.lua b/exp_groups/module/control.lua index 2a854bdb..3a3141e7 100644 --- a/exp_groups/module/control.lua +++ b/exp_groups/module/control.lua @@ -419,6 +419,7 @@ local e = defines.events local events = { [clusterio_api.events.on_server_startup] = on_server_startup, + [e.on_multiplayer_init] = on_server_startup, [e.on_permission_group_added] = on_permission_group_added, [e.on_permission_group_deleted] = on_permission_group_deleted, [e.on_permission_group_edited] = on_permission_group_edited, diff --git a/exp_groups/tsconfig.browser.json b/exp_groups/tsconfig.browser.json index 1ab2704e..1e3889e7 100644 --- a/exp_groups/tsconfig.browser.json +++ b/exp_groups/tsconfig.browser.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.browser.json", - "include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], } diff --git a/exp_groups/web/components/AssignmentForm.tsx b/exp_groups/web/components/AssignmentForm.tsx new file mode 100644 index 00000000..fc280ddb --- /dev/null +++ b/exp_groups/web/components/AssignmentForm.tsx @@ -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 setOpen(false)} + onOk={() => form.submit()} + > + + + + {[...users.values()].map(u => ( + + {u.name} + + ))} + + + + + + {[...groups.values()].map(g => ( + + {g.name} + + ))} + + + + ; +} diff --git a/exp_groups/web/components/AssignmentsTable.tsx b/exp_groups/web/components/AssignmentsTable.tsx new file mode 100644 index 00000000..559f4972 --- /dev/null +++ b/exp_groups/web/components/AssignmentsTable.tsx @@ -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(null); + const [open, setOpen] = useState(false); + + return <> + strcmp(a.name, b.name), + }, + { + title: "Group", + render: (_: any, a: any) => groups.get(a.groupId)?.name ?? a.groupId, + }, + { + title: "Actions", + render: (_: any, record: any) => ( + + {account.hasPermission("exp_groups.assignment.update") && + } onClick={() => { + setEditing(record); + setOpen(true); + }} /> + } + {account.hasPermission("exp_groups.assignment.delete") && + control.send(new messages.AssignmentDeleteRequest(record.name))} + > + } /> + + } + + ), + }, + ]} + dataSource={[...assignments.values()]} + loading={!synced} + pagination={false} + rowKey={(a) => a.name} + /> + + + >; +} diff --git a/exp_groups/web/components/GroupForm.tsx b/exp_groups/web/components/GroupForm.tsx new file mode 100644 index 00000000..acbd62ef --- /dev/null +++ b/exp_groups/web/components/GroupForm.tsx @@ -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 setOpen(false)} onOk={() => form.submit()}> + + + + + + + + + ; +} diff --git a/exp_groups/web/components/GroupViewPage.tsx b/exp_groups/web/components/GroupViewPage.tsx new file mode 100644 index 00000000..2845aa1f --- /dev/null +++ b/exp_groups/web/components/GroupViewPage.tsx @@ -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 = { + "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 | null>(null); + + const [permissionState, setPermissionState] = useState>({}); + const [baselineState, setBaselineState] = useState>({}); + 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(); + + 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 + + ; + } + + if (!group) { + return + + ; + } + + return + + + {edited && ( + + You have unsaved changes + + Revert + Apply + + + )} + + + Name + setName(e.target.value)} /> + + + Permissions + + {!defaultModPack?.exportManifest?.assets?.defines && ( + + )} + + {defaultModPack?.exportManifest?.assets?.defines && !definesJson && } + + {definesJson && <> + setSearch(e.target.value)} + style={{ marginBottom: 16 }} + /> + + + {grouped.map(([groupName, permissions]) => ( + + + + {groupName} + + { + setPermissionState(prev => { + const next = { ...prev }; + for (const p of permissions) next[p] = true; + return next; + }); + }} + > + Select all + + + { + setPermissionState(prev => { + const next = { ...prev }; + for (const p of permissions) next[p] = false; + return next; + }); + }} + > + Clear + + + + + + {permissions.map(p => ( + + setPermissionState(prev => ({ ...prev, [p]: e.target.checked })) + } + > + {p} + + ))} + + + ))} + + >} + ; +} diff --git a/exp_groups/web/components/GroupsTable.tsx b/exp_groups/web/components/GroupsTable.tsx new file mode 100644 index 00000000..08aba64c --- /dev/null +++ b/exp_groups/web/components/GroupsTable.tsx @@ -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 <> + 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`), + })} + /> + + + >; +} \ No newline at end of file diff --git a/exp_groups/web/components/RoleMappingForm.tsx b/exp_groups/web/components/RoleMappingForm.tsx new file mode 100644 index 00000000..d6801f7d --- /dev/null +++ b/exp_groups/web/components/RoleMappingForm.tsx @@ -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 setOpen(false)} + onOk={() => form.submit()} + > + + + + {[...roles.values()].map(r => ( + + {r.name} + + ))} + + + + + + {[...groups.values()].map(g => ( + + {g.name} + + ))} + + + + + + + + + + + + ; +} diff --git a/exp_groups/web/components/RoleMappingsTable.tsx b/exp_groups/web/components/RoleMappingsTable.tsx new file mode 100644 index 00000000..587c6cfa --- /dev/null +++ b/exp_groups/web/components/RoleMappingsTable.tsx @@ -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(null); + const [open, setOpen] = useState(false); + + return <> + [...m.roleIds].map((id: number) => { + const role = roles.get(id); + return {role?.name ?? id}; + }), + }, + { + 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) => ( + + {account.hasPermission("exp_groups.role_mapping.update") && + } onClick={() => { + setEditing(record); + setOpen(true); + }} /> + } + {account.hasPermission("exp_groups.role_mapping.delete") && + control.send(new messages.RoleMappingDeleteRequest(record.id))} + > + } /> + + } + + ), + }, + ]} + dataSource={[...roleMappings.values()].sort((a, b) => b.priority - a.priority)} + loading={!synced} + pagination={false} + rowKey={(m) => m.id} + /> + + + >; +} diff --git a/exp_groups/web/index.tsx b/exp_groups/web/index.tsx index e69de29b..a5214fab 100644 --- a/exp_groups/web/index.tsx +++ b/exp_groups/web/index.tsx @@ -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 + + + {account.hasPermission("exp_groups.group.list") && <> + setGroupOpen(true)}>Create + : undefined + } + /> + + + >} + + {account.hasPermission("exp_groups.role_mapping.list") && <> + setRoleMappingOpen(true)}>Create + : undefined + } + /> + + + >} + + {account.hasPermission("exp_groups.assignment.list") && <> + setAssignmentOpen(true)}>Create + : undefined + } + /> + + + >} + ; +} + +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: , + }, + { + path: "/exp_groups/:id/view", + sidebarPath: "/exp_groups", + permission: "exp_groups.group.get", + content: , + }, + ]; + } + + 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()); + } +} diff --git a/exp_groups/webpack.config.js b/exp_groups/webpack.config.js index 0baa40ac..b1f01165 100644 --- a/exp_groups/webpack.config.js +++ b/exp_groups/webpack.config.js @@ -23,6 +23,11 @@ module.exports = (env = {}) => merge(common(env), { shared: { "@clusterio/lib": { import: false }, "@clusterio/web_ui": { import: false }, + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, + "react-router": { import: false }, + "react-router-dom": { import: false }, }, }), ],