From 1d0b6b9a627272dfc83f498f5745fbab05ec7423 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:47:33 +0100 Subject: [PATCH 1/6] Add user status filters --- .../web_ui/src/components/UsersFilters.tsx | 157 ++++++++++++++++++ packages/web_ui/src/components/UsersTable.tsx | 41 +---- packages/web_ui/src/style.css | 27 +++ 3 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 packages/web_ui/src/components/UsersFilters.tsx diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx new file mode 100644 index 00000000..34295d18 --- /dev/null +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -0,0 +1,157 @@ +import React, { useRef } from "react"; +import { InputRef, Input, Space, Segmented, Button, Typography } from "antd"; +import type { FilterDropdownProps } from "antd/es/table/interface"; +import { CheckOutlined, CloseOutlined, MinusOutlined } from "@ant-design/icons"; +import { UserDetails } from "@clusterio/lib"; +const { Text } = Typography; + +export interface UserFilter { + search?: string; + admin?: boolean; + whitelisted?: boolean; + banned?: boolean; +} + +function encodeFilter(value: UserFilter): string { + return JSON.stringify(value); +} + +function decodeFilter(value: string): UserFilter { + try { + return JSON.parse(value); + } catch { + return {}; + } +} + +function triStateToSegmented(value?: boolean) { + if (value === true) { return "yes"; } + if (value === false) { return "no"; } + return "any"; +} + +function segmentedToTriState(value: string): boolean | undefined { + if (value === "yes") { return true; } + if (value === "no") { return false; } + return undefined; +} + +function matchesTriState(value: boolean, filter?: boolean) { + return filter === undefined || value === filter; +} + +export function onFilterUser( + value: string | number | boolean, + record: UserDetails +) { + const filter = decodeFilter(String(value)); + + // Name filter + if (filter.search) { + if (!record.name.toLowerCase().includes(filter.search.toLowerCase())) { + return false; + } + } + + // Status filter + return matchesTriState(record.isAdmin, filter.admin) + && matchesTriState(record.isWhitelisted, filter.whitelisted) + && matchesTriState(record.isBanned, filter.banned); +} + +export function useUserFilter() { + const searchInput = useRef(null); + + function filterDropdown({ + selectedKeys, + setSelectedKeys, + confirm, + clearFilters, + }: { + selectedKeys: string[]; + setSelectedKeys: (keys: string[]) => void; + confirm: FilterDropdownProps["confirm"]; + clearFilters: FilterDropdownProps["clearFilters"]; + }) { + const filter: UserFilter = selectedKeys[0] + ? decodeFilter(selectedKeys[0]) + : {}; + + function update(next: Partial, search?: boolean) { + const updated = { ...filter, ...next }; + setSelectedKeys([encodeFilter(updated)]); + confirm({ closeDropdown: false }); + } + + return ( +
e.stopPropagation()}> + + update({ search: e.target.value })} + onSearch={() => confirm({ closeDropdown: true })} + onClear={() => { + clearFilters?.({ closeDropdown: false }); + confirm({ closeDropdown: true }); + }} + /> + + + update({ admin: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Admin + + + + update({ whitelisted: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Whitelist + + + + update({ banned: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Banned + + +
+ ); + } + + const filterDropdownProps = { + onOpenChange: (open: boolean) => { + if (open) { + setTimeout(() => searchInput.current?.select(), 100); + } + }, + }; + + return { filterDropdown, filterDropdownProps }; +} diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index ec39a5d3..15b31f97 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -1,6 +1,5 @@ -import React, { useRef } from "react"; -import { Table, Tag, Space, Input, InputRef } from "antd"; -import type { FilterDropdownProps } from "antd/es/table/interface"; +import React from "react"; +import { Table, Tag, Space } from "antd"; import { SearchOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; @@ -15,6 +14,8 @@ import { sortLastSeen, useUsers, } from "../model/user"; + +import { onFilterUser, useUserFilter } from "./UsersFilters"; import Link from "./Link"; export interface UsersTableProps { @@ -37,10 +38,10 @@ const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) { const [roles] = useRoles(); const navigate = useNavigate(); - const searchInput = useRef(null); + + const {filterDropdown, filterDropdownProps} = useUserFilter(); const [users] = useUsers(); - const data = [...users.values()]; // Determine online predicate based on instanceId @@ -73,33 +74,9 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, filterIcon: (filtered: boolean) => ( ), - onFilter: (value: string | number | boolean, record: lib.UserDetails) => record.name - .toLowerCase() - .includes((value as string).toLowerCase()), - filterDropdownProps: { - onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100), - }, - filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: { - selectedKeys: string[], - setSelectedKeys: (keys: string[]) => void, - confirm: FilterDropdownProps["confirm"], - clearFilters: FilterDropdownProps["clearFilters"], - }) => ( -
e.stopPropagation()}> - setSelectedKeys([e.target.value])} - onSearch={() => confirm({ closeDropdown: false })} - onClear={() => { - clearFilters?.({ closeDropdown: false }); - confirm({ closeDropdown: true }); - }} - /> -
- ), + onFilter: onFilterUser, + filterDropdownProps, + filterDropdown, }, { title: "Roles", diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 59be1d2b..b1f53739 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -409,3 +409,30 @@ tr.ant-table-row.deleted { border-radius: 8px; max-width: 384px; } + +/** + * Tri-state segmented control: + * represents include, exclude, or no filter + */ +.seg-yes { + color: #389e0d; +} +.seg-no { + color: #cf1322; +} + +.ant-segmented-item-selected.seg-yes { + background-color: #389e0d !important; + color: white !important; +} +.ant-segmented-item-selected.seg-no { + background-color: #cf1322 !important; + color: white !important; +} + +.seg-yes:hover { + background-color: #306317; +} +.seg-no:hover { + background-color: #7a2e2f; +} From 6914645c19e5d56bcb71fa6ce20ee944246f735b Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:58:27 +0100 Subject: [PATCH 2/6] Make Username reusable and fix active icon issue --- .../web_ui/src/components/UsersFilters.tsx | 117 +++++++++++------- packages/web_ui/src/components/UsersTable.tsx | 15 +-- 2 files changed, 76 insertions(+), 56 deletions(-) diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx index 34295d18..264fa60d 100644 --- a/packages/web_ui/src/components/UsersFilters.tsx +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -1,5 +1,5 @@ import React, { useRef } from "react"; -import { InputRef, Input, Space, Segmented, Button, Typography } from "antd"; +import { InputRef, Input, Space, Segmented, Button, Typography, Tag } from "antd"; import type { FilterDropdownProps } from "antd/es/table/interface"; import { CheckOutlined, CloseOutlined, MinusOutlined } from "@ant-design/icons"; import { UserDetails } from "@clusterio/lib"; @@ -59,7 +59,28 @@ export function onFilterUser( && matchesTriState(record.isBanned, filter.banned); } -export function useUserFilter() { +export function Username({ + user, + withStatus = false, +} : { + user: UserDetails, + withStatus: boolean, +}) { + return ( + + {user.name} + {withStatus && ( + + {user.isAdmin && Admin} + {user.isWhitelisted && Whitelisted} + {user.isBanned && Banned} + + )} + + ); +} + +export function useUserFilter(withStatus?: boolean) { const searchInput = useRef(null); function filterDropdown({ @@ -77,9 +98,13 @@ export function useUserFilter() { ? decodeFilter(selectedKeys[0]) : {}; - function update(next: Partial, search?: boolean) { - const updated = { ...filter, ...next }; - setSelectedKeys([encodeFilter(updated)]); + function update(next: Partial) { + const encoded = encodeFilter({ ...filter, ...next }); + if (encoded === "{}") { + clearFilters?.({ closeDropdown: false }); + } else { + setSelectedKeys([encoded]); + } confirm({ closeDropdown: false }); } @@ -91,55 +116,57 @@ export function useUserFilter() { placeholder="Search username" value={filter.search} allowClear - onChange={(e) => update({ search: e.target.value })} onSearch={() => confirm({ closeDropdown: true })} + onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })} onClear={() => { clearFilters?.({ closeDropdown: false }); confirm({ closeDropdown: true }); }} /> - - update({ admin: segmentedToTriState(value) })} - options={[ - { icon: , value: "no", className: "seg-no" }, - { icon: , value: "any" }, - { icon: , value: "yes", className: "seg-yes" }, - ]} - /> - Admin - + {withStatus &&<> + + update({ admin: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Admin + - - update({ whitelisted: segmentedToTriState(value) })} - options={[ - { icon: , value: "no", className: "seg-no" }, - { icon: , value: "any" }, - { icon: , value: "yes", className: "seg-yes" }, - ]} - /> - Whitelist - + + update({ whitelisted: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Whitelist + - - update({ banned: segmentedToTriState(value) })} - options={[ - { icon: , value: "no", className: "seg-no" }, - { icon: , value: "any" }, - { icon: , value: "yes", className: "seg-yes" }, - ]} - /> - Banned - + + update({ banned: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Banned + + } ); diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index 15b31f97..a553bc6f 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Table, Tag, Space } from "antd"; +import { Table, Tag } from "antd"; import { SearchOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; @@ -15,7 +15,7 @@ import { useUsers, } from "../model/user"; -import { onFilterUser, useUserFilter } from "./UsersFilters"; +import { onFilterUser, Username, useUserFilter } from "./UsersFilters"; import Link from "./Link"; export interface UsersTableProps { @@ -39,7 +39,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, const [roles] = useRoles(); const navigate = useNavigate(); - const {filterDropdown, filterDropdownProps} = useUserFilter(); + const {filterDropdown, filterDropdownProps} = useUserFilter(true); const [users] = useUsers(); const data = [...users.values()]; @@ -60,14 +60,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, title: "Name", key: "name", render: (_: any, user: lib.UserDetails) => ( - - {user.name} - - {user.isAdmin && Admin} - {user.isWhitelisted && Whitelisted} - {user.isBanned && Banned} - - + ), defaultSortOrder: "ascend", sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name), From 680ade59759899bcc494c8391bf898a1f0aa022d Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:44:13 +0100 Subject: [PATCH 3/6] Add instance filter to users page --- .../web_ui/src/components/InputInstance.tsx | 42 +++++++++++++++++++ packages/web_ui/src/components/UsersPage.tsx | 17 ++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 packages/web_ui/src/components/InputInstance.tsx diff --git a/packages/web_ui/src/components/InputInstance.tsx b/packages/web_ui/src/components/InputInstance.tsx new file mode 100644 index 00000000..6564311c --- /dev/null +++ b/packages/web_ui/src/components/InputInstance.tsx @@ -0,0 +1,42 @@ +import { Select } from "antd"; + +import { InputComponentProps } from "../BaseWebPlugin"; +import { useInstances } from "../model/instance"; + +interface BaseInputInstanceProps extends InputComponentProps { + includeAll?: boolean; +} + +function BaseInputInstance(props: BaseInputInstanceProps) { + const [instances, synced] = useInstances(); + + const options = [ + ...(props.includeAll ? [{ label: "All instances", value: null }] : []), + ...[...instances.values()].map(instance => ({ + label: instance.name ?? `Instance ${instance.id}`, + value: instance.id, + })), + ]; + + return ( + ; } diff --git a/packages/web_ui/src/components/InputRole.tsx b/packages/web_ui/src/components/InputRole.tsx index e8039cda..fec189a8 100644 --- a/packages/web_ui/src/components/InputRole.tsx +++ b/packages/web_ui/src/components/InputRole.tsx @@ -4,7 +4,7 @@ import { InputComponentProps } from "../BaseWebPlugin"; import { useRoles } from "../model/roles"; export default function InputRole(props: InputComponentProps) { - const [roles] = useRoles(); + const [roles, synced] = useRoles(); return