From 7b7389526ba6a4847999539a672e924c370dd43d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Jul 2025 17:41:13 +0000 Subject: [PATCH 1/5] Add online players list component to instance view page Co-authored-by: danielv --- .../src/components/InstanceViewPage.tsx | 3 + .../src/components/OnlinePlayersList.tsx | 119 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 packages/web_ui/src/components/OnlinePlayersList.tsx diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index b2ad9d8d..5d594678 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -18,6 +18,7 @@ import AssignInstanceModal from "./AssignInstanceModal"; import StartStopInstanceButton from "./StartStopInstanceButton"; import LoadScenarioModal from "./LoadScenarioModal"; import SavesList from "./SavesList"; +import OnlinePlayersList from "./OnlinePlayersList"; import { notifyErrorHandler } from "../util/notify"; import { useInstance } from "../model/instance"; import { useHost } from "../model/host"; @@ -225,6 +226,8 @@ export default function InstanceViewPage() { /> + {account.hasPermission("core.user.list") && } + { account.hasAllPermission("core.instance.save.list", "core.instance.save.subscribe") && diff --git a/packages/web_ui/src/components/OnlinePlayersList.tsx b/packages/web_ui/src/components/OnlinePlayersList.tsx new file mode 100644 index 00000000..844c8033 --- /dev/null +++ b/packages/web_ui/src/components/OnlinePlayersList.tsx @@ -0,0 +1,119 @@ +import React from "react"; +import { Table, Tag, Space } from "antd"; +import { ColumnsType } from "antd/es/table"; + +import * as lib from "@clusterio/lib"; + +import { useUsers, formatFirstSeen } from "../model/user"; +import { useRoles } from "../model/roles"; +import { formatDuration } from "../util/time_format"; +import Link from "./Link"; + +type OnlinePlayersListProps = { + instanceId: number; +}; + +export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps) { + const [users] = useUsers(); + const [roles] = useRoles(); + + // Filter users to only show those online on this specific instance + const onlineUsers = [...users.values()].filter(user => user.instances && user.instances.has(instanceId)); + + const columns: ColumnsType = [ + { + title: "Player", + key: "name", + render: (_, user) => ( + + + {user.name} + + + {user.isAdmin && Admin} + + + ), + sorter: (a, b) => a.name.localeCompare(b.name), + defaultSortOrder: "ascend", + }, + { + title: "Roles", + key: "roles", + render: (_, user) => ( + [...user.roleIds] + .map(id => ( + e.stopPropagation()}> + {(roles.get(id) || { name: id }).name} + + )) + ), + }, + { + title: "Play Time", + key: "playTime", + render: (_, user) => { + const instanceStats = user.instanceStats.get(instanceId); + return instanceStats?.onlineTimeMs + ? formatDuration(instanceStats.onlineTimeMs) + : "-"; + }, + sorter: (a, b) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); + }, + }, + { + title: "Join Count", + key: "joinCount", + render: (_, user) => { + const instanceStats = user.instanceStats.get(instanceId); + return instanceStats?.joinCount ?? 0; + }, + sorter: (a, b) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); + }, + }, + { + title: "First Seen", + key: "firstSeen", + render: (_, user) => formatFirstSeen(user, instanceId), + sorter: (a, b) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0; + const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0; + return firstSeenA - firstSeenB; + }, + }, + ]; + + if (onlineUsers.length === 0) { + return null; + } + + return ( +
+

Online Players ({onlineUsers.length})

+ user.name} + pagination={false} + size="small" + onRow={(user) => ({ + onClick: (event) => { + // Don't navigate if clicking on a link + if ((event.target as HTMLElement).closest("a")) { + return; + } + window.location.href = `/users/${user.name}/view`; + }, + })} + /> + + ); +} From e7519975ec60a63768d586088d0ce856d1517c75 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Wed, 23 Jul 2025 14:21:33 +0200 Subject: [PATCH 2/5] Use same component for all user tables --- .../src/components/OnlinePlayersList.tsx | 99 +-------- packages/web_ui/src/components/UsersPage.tsx | 104 +--------- packages/web_ui/src/components/UsersTable.tsx | 194 ++++++++++++++++++ 3 files changed, 203 insertions(+), 194 deletions(-) create mode 100644 packages/web_ui/src/components/UsersTable.tsx diff --git a/packages/web_ui/src/components/OnlinePlayersList.tsx b/packages/web_ui/src/components/OnlinePlayersList.tsx index 844c8033..c35e6984 100644 --- a/packages/web_ui/src/components/OnlinePlayersList.tsx +++ b/packages/web_ui/src/components/OnlinePlayersList.tsx @@ -1,13 +1,7 @@ import React from "react"; -import { Table, Tag, Space } from "antd"; -import { ColumnsType } from "antd/es/table"; -import * as lib from "@clusterio/lib"; - -import { useUsers, formatFirstSeen } from "../model/user"; -import { useRoles } from "../model/roles"; -import { formatDuration } from "../util/time_format"; -import Link from "./Link"; +import { useUsers } from "../model/user"; +import UsersTable from "./UsersTable"; type OnlinePlayersListProps = { instanceId: number; @@ -15,82 +9,10 @@ type OnlinePlayersListProps = { export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps) { const [users] = useUsers(); - const [roles] = useRoles(); // Filter users to only show those online on this specific instance const onlineUsers = [...users.values()].filter(user => user.instances && user.instances.has(instanceId)); - const columns: ColumnsType = [ - { - title: "Player", - key: "name", - render: (_, user) => ( - - - {user.name} - - - {user.isAdmin && Admin} - - - ), - sorter: (a, b) => a.name.localeCompare(b.name), - defaultSortOrder: "ascend", - }, - { - title: "Roles", - key: "roles", - render: (_, user) => ( - [...user.roleIds] - .map(id => ( - e.stopPropagation()}> - {(roles.get(id) || { name: id }).name} - - )) - ), - }, - { - title: "Play Time", - key: "playTime", - render: (_, user) => { - const instanceStats = user.instanceStats.get(instanceId); - return instanceStats?.onlineTimeMs - ? formatDuration(instanceStats.onlineTimeMs) - : "-"; - }, - sorter: (a, b) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); - }, - }, - { - title: "Join Count", - key: "joinCount", - render: (_, user) => { - const instanceStats = user.instanceStats.get(instanceId); - return instanceStats?.joinCount ?? 0; - }, - sorter: (a, b) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); - }, - }, - { - title: "First Seen", - key: "firstSeen", - render: (_, user) => formatFirstSeen(user, instanceId), - sorter: (a, b) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0; - const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0; - return firstSeenA - firstSeenB; - }, - }, - ]; - if (onlineUsers.length === 0) { return null; } @@ -98,22 +20,7 @@ export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps return (

Online Players ({onlineUsers.length})

-
user.name} - pagination={false} - size="small" - onRow={(user) => ({ - onClick: (event) => { - // Don't navigate if clicking on a link - if ((event.target as HTMLElement).closest("a")) { - return; - } - window.location.href = `/users/${user.name}/view`; - }, - })} - /> + ); } diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index 40fafc7d..f35869cd 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -1,31 +1,23 @@ -import React, { useEffect, useContext, useState, useRef } from "react"; +import React, { useContext, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Static } from "@sinclair/typebox"; -import { - Button, Form, FormInstance, Input, InputRef, Modal, - Radio, Space, Table, Tag, Upload, UploadProps, +import { Button, Form, FormInstance, Input, Modal, + Radio, Upload, UploadProps, } from "antd"; -import { - InboxOutlined, SearchOutlined, -} from "@ant-design/icons"; +import { InboxOutlined } from "@ant-design/icons"; import * as lib from "@clusterio/lib"; -import { useRoles } from "../model/roles"; import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; -import { formatDuration } from "../util/time_format"; import { saveJson } from "../util/save_file"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, useUsers } from "../model/user"; -import Link from "./Link"; - -const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; +import UsersTable from "./UsersTable"; function CreateUserButton() { let control = useContext(ControlContext); @@ -374,10 +366,6 @@ function BulkUserActionButton() { export default function UsersPage() { let account = useAccount(); - let navigate = useNavigate(); - let [users] = useUsers(); - const [roles] = useRoles(); - const searchInput = useRef(null); return : undefined} } /> -
- {user.name} - - {user.isAdmin && Admin} - {user.isWhitelisted && Whitelisted} - {user.isBanned && Banned} - - , - defaultSortOrder: "ascend", - sorter: (a, b) => strcmp(a.name, b.name), - filterIcon: (filtered) => , - onFilter: (value, record) => record.name.toLowerCase().includes((value as string).toLowerCase()), - filterDropdownProps: { - onOpenChange: open => open && setTimeout(() => searchInput.current?.select(), 100), - }, - filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => ( -
e.stopPropagation()}> - setSelectedKeys([e.target.value])} - onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })} - onSearch={() => confirm({ closeDropdown: true })} - /> -
- ), - }, - { - title: "Roles", - key: "roles", - render: (_, user) => ( - [...user.roleIds] - .map(id => e.stopPropagation()}> - {(roles.get(id) || { name: id }).name} - ) - ), - }, - { - title: "Online time", - key: "onlineTime", - render: (_, user) => (user.playerStats?.onlineTimeMs - ? formatDuration(user.playerStats.onlineTimeMs) : null), - sorter: (a, b) => (a.playerStats?.onlineTimeMs ?? 0) - - (b.playerStats?.onlineTimeMs ?? 0), - responsive: ["lg"], - }, - { - title: "First seen", - key: "firstSeen", - render: (_, user) => formatFirstSeen(user), - sorter: (a, b) => sortFirstSeen(a, b), - }, - { - title: "Last seen", - key: "lastSeen", - render: (_, user) => formatLastSeen(user), - sorter: (a, b) => sortLastSeen(a, b), - responsive: ["lg"], - }, - ]} - dataSource={[...users.values()]} - pagination={{ - defaultPageSize: 50, - showSizeChanger: true, - pageSizeOptions: ["10", "20", "50", "100", "200"], - showTotal: total => `${total} Users`, - }} - rowKey={user => user.name} - onRow={(user, rowIndex) => ({ - onClick: event => { - navigate(`/users/${user.name}/view`); - }, - })} - /> + ; } diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx new file mode 100644 index 00000000..f0e0d19a --- /dev/null +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -0,0 +1,194 @@ +import React, { useRef } from "react"; +import { Table, Tag, Space, Input, InputRef } from "antd"; +import { SearchOutlined } from "@ant-design/icons"; +import { useNavigate } from "react-router-dom"; + +import * as lib from "@clusterio/lib"; + +import { useRoles } from "../model/roles"; +import { useUsers } from "../model/user"; +import { formatDuration } from "../util/time_format"; +import { + formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, +} from "../model/user"; +import Link from "./Link"; + +export interface UsersTableProps { + /** + * Optional instance id. + * – If provided, instance-specific columns (Play Time, Join Count, First Seen) are shown. + * – If omitted, global player statistics columns (Online Time, First/Last Seen) are shown. + */ + instanceId?: number; + /** Show only players that are currently online. When instanceId is provided players must be online on that instance. */ + onlyOnline?: boolean; + /** Ant Design pagination prop. Pass `false` to disable pagination. */ + pagination?: false | object; + /** Ant Design size prop */ + size?: "small" | "middle" | "large"; +} + +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; + +export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) { + const [roles] = useRoles(); + const navigate = useNavigate(); + const searchInput = useRef(null); + + const [users] = useUsers(); + + let data = [...users.values()]; + + if (onlyOnline) { + if (instanceId !== undefined) { + data = data.filter(u => u.instances && u.instances.has(instanceId)); + } else { + data = data.filter(u => u.instances && u.instances.size > 0); + } + } + + const columns: any[] = [ + { + title: "Name", + key: "name", + render: (_: any, user: lib.User) => ( + + {user.name} + + {user.isAdmin && Admin} + {user.isWhitelisted && Whitelisted} + {user.isBanned && Banned} + + + ), + defaultSortOrder: "ascend", + sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name), + filterIcon: (filtered: boolean) => ( + + ), + onFilter: (value: string | number | boolean, record: lib.User) => + record.name.toLowerCase().includes((value as string).toLowerCase()), + filterDropdownProps: { + onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100), + }, + filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: any) => ( +
e.stopPropagation()}> + setSelectedKeys([e.target.value])} + onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })} + onSearch={() => confirm({ closeDropdown: true })} + /> +
+ ), + }, + { + title: "Roles", + key: "roles", + render: (_: any, user: lib.User) => ( + [...user.roleIds].map((id) => ( + e.stopPropagation()}> + {(roles.get(id) || { name: id }).name} + + )) + ), + }, + ]; + + if (instanceId !== undefined) { + columns.push( + { + title: "Play Time", + key: "playTime", + render: (_: any, user: lib.User) => { + const instanceStats = user.instanceStats.get(instanceId); + return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-"; + }, + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); + }, + }, + { + title: "Join Count", + key: "joinCount", + render: (_: any, user: lib.User) => { + const instanceStats = user.instanceStats.get(instanceId); + return instanceStats?.joinCount ?? 0; + }, + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); + }, + }, + { + title: "First Seen", + key: "firstSeen", + render: (_: any, user: lib.User) => formatFirstSeen(user, instanceId), + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0; + const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0; + return firstSeenA - firstSeenB; + }, + }, + ); + } else { + columns.push( + { + title: "Online time", + key: "onlineTime", + render: (_: any, user: lib.User) => + user.playerStats?.onlineTimeMs ? formatDuration(user.playerStats.onlineTimeMs) : null, + sorter: (a: lib.User, b: lib.User) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0), + responsive: ["lg"], + }, + { + title: "First seen", + key: "firstSeen", + render: (_: any, user: lib.User) => formatFirstSeen(user), + sorter: (a: lib.User, b: lib.User) => sortFirstSeen(a, b), + }, + { + title: "Last seen", + key: "lastSeen", + render: (_: any, user: lib.User) => formatLastSeen(user), + sorter: (a: lib.User, b: lib.User) => sortLastSeen(a, b), + responsive: ["lg"], + }, + ); + } + + const defaultPagination = pagination === undefined + ? { + defaultPageSize: 50, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50", "100", "200"], + showTotal: (total: number) => `${total} Users`, + } + : pagination; + + return ( +
user.name} + pagination={defaultPagination} + size={size} + onRow={(user) => ({ + onClick: (event) => { + if ((event.target as HTMLElement).closest("a")) { + return; + } + navigate(`/users/${user.name}/view`); + }, + })} + /> + ); +} From 20534238648b8454f6856210ff3df009b7e17c73 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Wed, 23 Jul 2025 14:38:09 +0200 Subject: [PATCH 3/5] Add proper filters --- packages/web_ui/src/components/UsersPage.tsx | 15 +- packages/web_ui/src/components/UsersTable.tsx | 395 ++++++++++-------- 2 files changed, 235 insertions(+), 175 deletions(-) diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index f35869cd..aff64468 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -2,8 +2,9 @@ import React, { useContext, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Static } from "@sinclair/typebox"; -import { Button, Form, FormInstance, Input, Modal, - Radio, Upload, UploadProps, +import { + Button, Form, FormInstance, Input, Modal, + Radio, Upload, UploadProps } from "antd"; import { InboxOutlined } from "@ant-design/icons"; @@ -313,7 +314,7 @@ function BulkUserActionButton() { const [formAction, setFormAction] = useState(undefined); const [form] = Form.useForm(); - function onValuesChange({ action } : { action?: string }) { + function onValuesChange({ action }: { action?: string }) { if (action) { setFormAction(action); } @@ -340,7 +341,7 @@ function BulkUserActionButton() { title="Bulk Actions" okText="Apply" open={open} - okButtonProps={{disabled: formAction === undefined}} + okButtonProps={{ disabled: formAction === undefined }} onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} onCancel={() => { setOpen(false); }} destroyOnClose @@ -356,9 +357,9 @@ function BulkUserActionButton() { ? Restore : undefined} - {formAction === "import" ? : undefined} - {formAction === "export" ? : undefined} - {formAction === "restore" ? : undefined} + {formAction === "import" ? : undefined} + {formAction === "export" ? : undefined} + {formAction === "restore" ? : undefined} ; diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index f0e0d19a..bc1f0d1f 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -6,189 +6,248 @@ import { useNavigate } from "react-router-dom"; import * as lib from "@clusterio/lib"; import { useRoles } from "../model/roles"; -import { useUsers } from "../model/user"; import { formatDuration } from "../util/time_format"; import { - formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, + formatFirstSeen, + formatLastSeen, + sortFirstSeen, + sortLastSeen, + useUsers, } from "../model/user"; import Link from "./Link"; export interface UsersTableProps { - /** - * Optional instance id. - * – If provided, instance-specific columns (Play Time, Join Count, First Seen) are shown. - * – If omitted, global player statistics columns (Online Time, First/Last Seen) are shown. - */ - instanceId?: number; - /** Show only players that are currently online. When instanceId is provided players must be online on that instance. */ - onlyOnline?: boolean; - /** Ant Design pagination prop. Pass `false` to disable pagination. */ - pagination?: false | object; - /** Ant Design size prop */ - size?: "small" | "middle" | "large"; + /** + * Optional instance id. + * – If provided, instance-specific columns (Play Time, Join Count, First Seen) are shown. + * – If omitted, global player statistics columns (Online Time, First/Last Seen) are shown. + */ + instanceId?: number; + /** Show only players that are currently online, works with instanceId to show online of a particular instance */ + onlyOnline?: boolean; + /** Ant Design pagination prop. Pass `false` to disable pagination. */ + pagination?: false | object; + /** Ant Design size prop */ + size?: "small" | "middle" | "large"; } const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) { - const [roles] = useRoles(); - const navigate = useNavigate(); - const searchInput = useRef(null); + const [roles] = useRoles(); + const navigate = useNavigate(); + const searchInput = useRef(null); - const [users] = useUsers(); + const [users] = useUsers(); - let data = [...users.values()]; + const data = [...users.values()]; - if (onlyOnline) { - if (instanceId !== undefined) { - data = data.filter(u => u.instances && u.instances.has(instanceId)); - } else { - data = data.filter(u => u.instances && u.instances.size > 0); - } - } + // Determine online predicate based on instanceId + const isUserOnline = (user: lib.User) => { + if (instanceId !== undefined) { + return user.instances && user.instances.has(instanceId); + } + return user.instances && user.instances.size > 0; + }; - const columns: any[] = [ - { - title: "Name", - key: "name", - render: (_: any, user: lib.User) => ( - - {user.name} - - {user.isAdmin && Admin} - {user.isWhitelisted && Whitelisted} - {user.isBanned && Banned} - - - ), - defaultSortOrder: "ascend", - sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name), - filterIcon: (filtered: boolean) => ( - - ), - onFilter: (value: string | number | boolean, record: lib.User) => - record.name.toLowerCase().includes((value as string).toLowerCase()), - filterDropdownProps: { - onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100), - }, - filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: any) => ( -
e.stopPropagation()}> - setSelectedKeys([e.target.value])} - onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })} - onSearch={() => confirm({ closeDropdown: true })} - /> -
- ), - }, - { - title: "Roles", - key: "roles", - render: (_: any, user: lib.User) => ( - [...user.roleIds].map((id) => ( - e.stopPropagation()}> - {(roles.get(id) || { name: id }).name} - - )) - ), - }, - ]; + // Prepare filters for roles column + const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id })); - if (instanceId !== undefined) { - columns.push( - { - title: "Play Time", - key: "playTime", - render: (_: any, user: lib.User) => { - const instanceStats = user.instanceStats.get(instanceId); - return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-"; - }, - sorter: (a: lib.User, b: lib.User) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); - }, - }, - { - title: "Join Count", - key: "joinCount", - render: (_: any, user: lib.User) => { - const instanceStats = user.instanceStats.get(instanceId); - return instanceStats?.joinCount ?? 0; - }, - sorter: (a: lib.User, b: lib.User) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); - }, - }, - { - title: "First Seen", - key: "firstSeen", - render: (_: any, user: lib.User) => formatFirstSeen(user, instanceId), - sorter: (a: lib.User, b: lib.User) => { - const statsA = a.instanceStats.get(instanceId); - const statsB = b.instanceStats.get(instanceId); - const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0; - const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0; - return firstSeenA - firstSeenB; - }, - }, - ); - } else { - columns.push( - { - title: "Online time", - key: "onlineTime", - render: (_: any, user: lib.User) => - user.playerStats?.onlineTimeMs ? formatDuration(user.playerStats.onlineTimeMs) : null, - sorter: (a: lib.User, b: lib.User) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0), - responsive: ["lg"], - }, - { - title: "First seen", - key: "firstSeen", - render: (_: any, user: lib.User) => formatFirstSeen(user), - sorter: (a: lib.User, b: lib.User) => sortFirstSeen(a, b), - }, - { - title: "Last seen", - key: "lastSeen", - render: (_: any, user: lib.User) => formatLastSeen(user), - sorter: (a: lib.User, b: lib.User) => sortLastSeen(a, b), - responsive: ["lg"], - }, - ); - } + const columns: any[] = [ + { + title: "Name", + key: "name", + render: (_: any, user: lib.User) => ( + + {user.name} + + {user.isAdmin && Admin} + {user.isWhitelisted && Whitelisted} + {user.isBanned && Banned} + + + ), + defaultSortOrder: "ascend", + sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name), + filterIcon: (filtered: boolean) => ( + + ), + onFilter: (value: string | number | boolean, record: lib.User) => record.name + .toLowerCase() + .includes((value as string).toLowerCase()), + filterDropdownProps: { + onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100), + }, + filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: any) => ( +
e.stopPropagation()}> + setSelectedKeys([e.target.value])} + onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })} + onSearch={() => confirm({ closeDropdown: true })} + /> +
+ ), + }, + { + title: "Roles", + key: "roles", + filters: roleFilters, + filterMultiple: true, + onFilter: (value: string | number | boolean, record: lib.User) => record.roleIds.has(value as number), + render: (_: any, user: lib.User) => ( + [...user.roleIds].map((id) => ( + e.stopPropagation()}> + {(roles.get(id) || { name: id }).name} + + )) + ), + }, + ]; - const defaultPagination = pagination === undefined - ? { - defaultPageSize: 50, - showSizeChanger: true, - pageSizeOptions: ["10", "20", "50", "100", "200"], - showTotal: (total: number) => `${total} Users`, - } - : pagination; - return ( -
user.name} - pagination={defaultPagination} - size={size} - onRow={(user) => ({ - onClick: (event) => { - if ((event.target as HTMLElement).closest("a")) { - return; - } - navigate(`/users/${user.name}/view`); - }, - })} - /> - ); -} + // Helper to get last seen timestamp + function getLastSeenTimestamp(user: lib.User): number | undefined { + const stats = instanceId !== undefined ? user.instanceStats.get(instanceId) : user.playerStats; + if (!stats) { return undefined; } + if (stats.lastLeaveAt && stats.lastLeaveAt.getTime() > (stats.lastJoinAt?.getTime() ?? 0)) { + return stats.lastLeaveAt.getTime(); + } + if (stats.lastJoinAt) { + return stats.lastJoinAt.getTime(); + } + return undefined; + } + + if (instanceId !== undefined) { + columns.push( + { + title: "Play Time", + key: "playTime", + render: (_: any, user: lib.User) => { + const instanceStats = user.instanceStats.get(instanceId); + return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-"; + }, + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); + }, + }, + { + title: "Join Count", + key: "joinCount", + render: (_: any, user: lib.User) => { + const instanceStats = user.instanceStats.get(instanceId); + return instanceStats?.joinCount ?? 0; + }, + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); + }, + }, + { + title: "First Seen", + key: "firstSeen", + render: (_: any, user: lib.User) => formatFirstSeen(user, instanceId), + sorter: (a: lib.User, b: lib.User) => { + const statsA = a.instanceStats.get(instanceId); + const statsB = b.instanceStats.get(instanceId); + const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0; + const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0; + return firstSeenA - firstSeenB; + }, + }, + ); + } else { + columns.push( + { + title: "Online time", + key: "onlineTime", + render: (_: any, user: lib.User) => (user.playerStats?.onlineTimeMs + ? formatDuration(user.playerStats.onlineTimeMs) + : null), + // eslint-disable-next-line max-len + sorter: (a: lib.User, b: lib.User) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0), + responsive: ["lg"], + }, + { + title: "First seen", + key: "firstSeen", + render: (_: any, user: lib.User) => formatFirstSeen(user), + sorter: (a: lib.User, b: lib.User) => sortFirstSeen(a, b), + }, + ); + } + columns.push( + { + title: "Last seen", + key: "lastSeen", + filterMultiple: false, + defaultFilteredValue: onlyOnline ? ["online"] : undefined, + filters: [ + { text: "Online", value: "online" }, + { text: "24h", value: "24h" }, + { text: "7d", value: "7d" }, + { text: "30d", value: "30d" }, + ], + onFilter: (value: string | number | boolean, record: lib.User) => { + const online = isUserOnline(record); + if (value === "online") { + return online; + } + // Online players should appear in all buckets + if (online) { + return true; + } + const ts = getLastSeenTimestamp(record); + if (!ts) { return false; } + const diff = Date.now() - ts; + switch (value) { + case "24h": + return diff <= 24 * 60 * 60 * 1000; + case "7d": + return diff <= 7 * 24 * 60 * 60 * 1000; + case "30d": + return diff <= 30 * 24 * 60 * 60 * 1000; + default: + return false; + } + }, + sorter: (a: lib.User, b: lib.User) => sortLastSeen(a, b, instanceId, instanceId), + render: (_: any, user: lib.User) => formatLastSeen(user, instanceId), + responsive: ["lg"], + }, + ); + + const defaultPagination = pagination === undefined + ? { + defaultPageSize: 50, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50", "100", "200"], + showTotal: (total: number) => `${total} Users`, + } + : pagination; + + return ( +
user.name} + pagination={defaultPagination} + size={size} + onRow={(user) => ({ + onClick: (event) => { + if ((event.target as HTMLElement).closest("a")) { + return; + } + navigate(`/users/${user.name}/view`); + }, + })} + /> + ); +} From 7b4724845fbd2d71e4d1aa6f0a562cc78da4a4bb Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Thu, 24 Jul 2025 22:36:24 +0200 Subject: [PATCH 4/5] Fix linter error --- packages/web_ui/src/components/UsersPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index aff64468..13164f1b 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -4,7 +4,7 @@ import { Static } from "@sinclair/typebox"; import { Button, Form, FormInstance, Input, Modal, - Radio, Upload, UploadProps + Radio, Upload, UploadProps, } from "antd"; import { InboxOutlined } from "@ant-design/icons"; From 56a26848e6670fb0e6fad5b9b422b4328a304de4 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Thu, 31 Jul 2025 00:11:26 +0200 Subject: [PATCH 5/5] Paginate users on instance page, better search ux --- .../web_ui/src/components/OnlinePlayersList.tsx | 7 ++++++- packages/web_ui/src/components/UsersTable.tsx | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/web_ui/src/components/OnlinePlayersList.tsx b/packages/web_ui/src/components/OnlinePlayersList.tsx index c35e6984..31e5a560 100644 --- a/packages/web_ui/src/components/OnlinePlayersList.tsx +++ b/packages/web_ui/src/components/OnlinePlayersList.tsx @@ -20,7 +20,12 @@ export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps return (

Online Players ({onlineUsers.length})

- + `${total} Users`, + }} />
); } diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index bc1f0d1f..502afc61 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -1,5 +1,6 @@ import React, { useRef } from "react"; import { Table, Tag, Space, Input, InputRef } from "antd"; +import type { FilterDropdownProps } from "antd/es/table/interface"; import { SearchOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; @@ -78,7 +79,12 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, filterDropdownProps: { onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100), }, - filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: any) => ( + filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: { + selectedKeys: string[], + setSelectedKeys: (keys: string[]) => void, + confirm: FilterDropdownProps["confirm"], + clearFilters: FilterDropdownProps["clearFilters"], + }) => (
e.stopPropagation()}> setSelectedKeys([e.target.value])} - onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })} - onSearch={() => confirm({ closeDropdown: true })} + onSearch={() => confirm({ closeDropdown: false })} + onClear={() => { + clearFilters?.({ closeDropdown: false }); + confirm({ closeDropdown: true }); + }} />
),