diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index cf1d76dc..ec1b28d6 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 InstanceControlButton, { InstanceControlButtonPermissions } from "./InstanceControlButton"; 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"; @@ -211,6 +212,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..31e5a560 --- /dev/null +++ b/packages/web_ui/src/components/OnlinePlayersList.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import { useUsers } from "../model/user"; +import UsersTable from "./UsersTable"; + +type OnlinePlayersListProps = { + instanceId: number; +}; + +export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps) { + const [users] = useUsers(); + + // Filter users to only show those online on this specific instance + const onlineUsers = [...users.values()].filter(user => user.instances && user.instances.has(instanceId)); + + if (onlineUsers.length === 0) { + return null; + } + + return ( +
+

Online Players ({onlineUsers.length})

+ `${total} Users`, + }} /> +
+ ); +} diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index 6f77f409..ac7e238d 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -1,31 +1,24 @@ -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, + 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); @@ -321,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); } @@ -348,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 @@ -364,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} ; @@ -374,10 +367,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..502afc61 --- /dev/null +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -0,0 +1,262 @@ +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"; + +import * as lib from "@clusterio/lib"; + +import { useRoles } from "../model/roles"; +import { formatDuration } from "../util/time_format"; +import { + 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, 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 [users] = useUsers(); + + const data = [...users.values()]; + + // 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; + }; + + // Prepare filters for roles column + const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id })); + + 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 }: { + 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 }); + }} + /> +
+ ), + }, + { + 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} + + )) + ), + }, + ]; + + + // 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`); + }, + })} + /> + ); +}