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`);
+ },
+ })}
+ />
+ );
+}