mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 07:04:53 +09:00
Merge pull request #782 from clusterio/cursor/implement-factorio-1-1-support-a0fd
Online player list on instance page
This commit is contained in:
4 files changed
+307
-102
No files matched your search
@@ -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() {
|
||||
/>
|
||||
<InstanceDescription host={host} instance={instance} />
|
||||
|
||||
{account.hasPermission("core.user.list") && <OnlinePlayersList instanceId={instanceId} />}
|
||||
|
||||
{
|
||||
account.hasAllPermission("core.instance.save.list", "core.instance.save.subscribe")
|
||||
&& <SavesList instance={instance} />
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4>Online Players ({onlineUsers.length})</h4>
|
||||
<UsersTable instanceId={instanceId} onlyOnline size="small" pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["10", "20", "50", "100", "200"],
|
||||
showTotal: (total: number) => `${total} Users`,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | undefined>(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() {
|
||||
? <Radio.Button value="restore">Restore</Radio.Button> : undefined}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{formAction === "import" ? <UserBulkActionImport {...{setApplyAction, form}}/> : undefined}
|
||||
{formAction === "export" ? <UserBulkActionExport {...{setApplyAction, form}}/> : undefined}
|
||||
{formAction === "restore" ? <UserBulkActionImport restore {...{setApplyAction, form}}/> : undefined}
|
||||
{formAction === "import" ? <UserBulkActionImport {...{ setApplyAction, form }} /> : undefined}
|
||||
{formAction === "export" ? <UserBulkActionExport {...{ setApplyAction, form }} /> : undefined}
|
||||
{formAction === "restore" ? <UserBulkActionImport restore {...{ setApplyAction, form }} /> : undefined}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
@@ -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<InputRef>(null);
|
||||
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<PageHeader
|
||||
@@ -388,87 +377,7 @@ export default function UsersPage() {
|
||||
? <BulkUserActionButton /> : undefined}
|
||||
</Space>}
|
||||
/>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_, user) => <Space>
|
||||
{user.name}
|
||||
<span>
|
||||
{user.isAdmin && <Tag color="gold">Admin</Tag>}
|
||||
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
|
||||
{user.isBanned && <Tag color="red">Banned</Tag>}
|
||||
</span>
|
||||
</Space>,
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
filterIcon: (filtered) => <SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />,
|
||||
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 }) => (
|
||||
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
ref={searchInput}
|
||||
placeholder={"Search username"}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) => setSelectedKeys([e.target.value])}
|
||||
onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })}
|
||||
onSearch={() => confirm({ closeDropdown: true })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
key: "roles",
|
||||
render: (_, user) => (
|
||||
[...user.roleIds]
|
||||
.map(id => <Link key={id} to={`/roles/${id}/view`} onClick={e => e.stopPropagation()}>
|
||||
<Tag>{(roles.get(id) || { name: id }).name}</Tag>
|
||||
</Link>)
|
||||
),
|
||||
},
|
||||
{
|
||||
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`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<UsersTable />
|
||||
<PluginExtra component="UsersPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -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<InputRef>(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) => (
|
||||
<Space>
|
||||
{user.name}
|
||||
<span>
|
||||
{user.isAdmin && <Tag color="gold">Admin</Tag>}
|
||||
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
|
||||
{user.isBanned && <Tag color="red">Banned</Tag>}
|
||||
</span>
|
||||
</Space>
|
||||
),
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name),
|
||||
filterIcon: (filtered: boolean) => (
|
||||
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
|
||||
),
|
||||
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"],
|
||||
}) => (
|
||||
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
ref={searchInput}
|
||||
placeholder={"Search username"}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) => setSelectedKeys([e.target.value])}
|
||||
onSearch={() => confirm({ closeDropdown: false })}
|
||||
onClear={() => {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
confirm({ closeDropdown: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Link key={id} to={`/roles/${id}/view`} onClick={(e) => e.stopPropagation()}>
|
||||
<Tag>{(roles.get(id) || { name: id }).name}</Tag>
|
||||
</Link>
|
||||
))
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// 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 (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey={(user) => user.name}
|
||||
pagination={defaultPagination}
|
||||
size={size}
|
||||
onRow={(user) => ({
|
||||
onClick: (event) => {
|
||||
if ((event.target as HTMLElement).closest("a")) {
|
||||
return;
|
||||
}
|
||||
navigate(`/users/${user.name}/view`);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user