mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 07:24:52 +09:00
Merge pull request #917 from Cooldude2606/feature/user-status-filter
Enhance user filters and improve UI components
This commit is contained in:
8 files changed
+379
-183
No files matched your search
@@ -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 (
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={value => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
options={options}
|
||||
allowClear={props.fieldDefinition.optional}
|
||||
disabled={!synced || props.disabled}
|
||||
loading={!synced}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputInstanceWithAll(props: InputComponentProps) {
|
||||
return <BaseInputInstance {...props} includeAll />;
|
||||
}
|
||||
|
||||
export function InputInstance(props: InputComponentProps) {
|
||||
return <BaseInputInstance {...props} />;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useModPacks } from "../model/mod_pack";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
|
||||
export default function InputModPack(props: InputComponentProps) {
|
||||
const [modPacks] = useModPacks();
|
||||
const [modPacks, synced] = useModPacks();
|
||||
return <Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
@@ -17,6 +17,7 @@ export default function InputModPack(props: InputComponentProps) {
|
||||
value: modPack.id,
|
||||
}))}
|
||||
allowClear={props.fieldDefinition.optional}
|
||||
disabled={props.disabled}
|
||||
disabled={!synced || props.disabled}
|
||||
loading={!synced}
|
||||
/>;
|
||||
}
|
||||
@@ -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 <Select
|
||||
showSearch
|
||||
@@ -17,6 +17,7 @@ export default function InputRole(props: InputComponentProps) {
|
||||
value: role.id,
|
||||
}))}
|
||||
allowClear={props.fieldDefinition.optional}
|
||||
disabled={props.disabled}
|
||||
disabled={!synced || props.disabled}
|
||||
loading={!synced}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useRef } from "react";
|
||||
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";
|
||||
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 Username({
|
||||
user,
|
||||
withStatus = false,
|
||||
} : {
|
||||
user: UserDetails,
|
||||
withStatus: boolean,
|
||||
}) {
|
||||
return (
|
||||
<Space>
|
||||
{user.name}
|
||||
{withStatus && (
|
||||
<span>
|
||||
{user.isAdmin && <Tag color="gold">Admin</Tag>}
|
||||
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
|
||||
{user.isBanned && <Tag color="red">Banned</Tag>}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserFilter(withStatus?: boolean) {
|
||||
const searchInput = useRef<InputRef>(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<UserFilter>) {
|
||||
const encoded = encodeFilter({ ...filter, ...next });
|
||||
if (encoded === "{}") {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
} else {
|
||||
setSelectedKeys([encoded]);
|
||||
}
|
||||
confirm({ closeDropdown: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 8, width: 240 }} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Input.Search
|
||||
ref={searchInput}
|
||||
placeholder="Search username"
|
||||
value={filter.search}
|
||||
allowClear
|
||||
onSearch={() => confirm({ closeDropdown: true })}
|
||||
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
|
||||
onClear={() => {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
confirm({ closeDropdown: true });
|
||||
}}
|
||||
/>
|
||||
|
||||
{withStatus &&<>
|
||||
<Space size={8} align="center">
|
||||
<Segmented
|
||||
size="small"
|
||||
value={triStateToSegmented(filter.admin)}
|
||||
onChange={value => update({ admin: segmentedToTriState(value) })}
|
||||
options={[
|
||||
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
|
||||
{ icon: <MinusOutlined />, value: "any" },
|
||||
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
|
||||
]}
|
||||
/>
|
||||
<Text>Admin</Text>
|
||||
</Space>
|
||||
|
||||
<Space size={8} align="center">
|
||||
<Segmented
|
||||
size="small"
|
||||
value={triStateToSegmented(filter.whitelisted)}
|
||||
onChange={value => update({ whitelisted: segmentedToTriState(value) })}
|
||||
options={[
|
||||
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
|
||||
{ icon: <MinusOutlined />, value: "any" },
|
||||
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
|
||||
]}
|
||||
/>
|
||||
<Text>Whitelist</Text>
|
||||
</Space>
|
||||
|
||||
<Space size={8} align="center">
|
||||
<Segmented
|
||||
size="small"
|
||||
value={triStateToSegmented(filter.banned)}
|
||||
onChange={value => update({ banned: segmentedToTriState(value) })}
|
||||
options={[
|
||||
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
|
||||
{ icon: <MinusOutlined />, value: "any" },
|
||||
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
|
||||
]}
|
||||
/>
|
||||
<Text>Banned</Text>
|
||||
</Space>
|
||||
</>}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterDropdownProps = {
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => searchInput.current?.select(), 100);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { filterDropdown, filterDropdownProps };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import UsersTable from "./UsersTable";
|
||||
import { InputInstanceWithAll } from "./InputInstance";
|
||||
|
||||
function CreateUserButton() {
|
||||
let control = useContext(ControlContext);
|
||||
@@ -366,18 +367,26 @@ function BulkUserActionButton() {
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
let account = useAccount();
|
||||
const account = useAccount();
|
||||
const [instanceId, setInstanceId] = useState<number | null>(null);
|
||||
|
||||
return <PageLayout nav={[{ name: "Users" }]}>
|
||||
<PageHeader
|
||||
title="Users"
|
||||
extra={<Space>
|
||||
{account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
|
||||
{account.hasAllPermission("core.instance.list", "core.instance.subscribe")
|
||||
&& <InputInstanceWithAll
|
||||
value={instanceId}
|
||||
onChange={value => setInstanceId(value as number)}
|
||||
fieldDefinition={{ optional: true } as any}
|
||||
disabled={false}
|
||||
/>}
|
||||
{account.hasPermission("core.user.create") && <CreateUserButton />}
|
||||
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
|
||||
? <BulkUserActionButton /> : undefined}
|
||||
&& <BulkUserActionButton />}
|
||||
</Space>}
|
||||
/>
|
||||
<UsersTable />
|
||||
<UsersTable instanceId={instanceId ?? undefined}/>
|
||||
<PluginExtra component="UsersPage" />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -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 } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -9,20 +8,16 @@ import * as lib from "@clusterio/lib";
|
||||
import { useRoles } from "../model/roles";
|
||||
import { formatDuration } from "../util/time_format";
|
||||
import {
|
||||
formatFirstSeen,
|
||||
formatLastSeen,
|
||||
sortFirstSeen,
|
||||
sortLastSeen,
|
||||
useUsers,
|
||||
calculateFirstSeen, formatFirstSeen, sortFirstSeen,
|
||||
calculateLastSeen, formatLastSeen, sortLastSeen,
|
||||
useUsers, getUserStats, isUserOnline,
|
||||
} from "../model/user";
|
||||
|
||||
import { onFilterUser, Username, useUserFilter } from "./UsersFilters";
|
||||
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.
|
||||
*/
|
||||
/** Optional instance id. If provided, stats will be filtered to that instance only */
|
||||
instanceId?: number;
|
||||
/** Show only players that are currently online, works with instanceId to show online of a particular instance */
|
||||
onlyOnline?: boolean;
|
||||
@@ -35,71 +30,71 @@ export interface UsersTableProps {
|
||||
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 [roles, rolesSynced] = useRoles();
|
||||
const [users, usersSynced] = useUsers();
|
||||
const navigate = useNavigate();
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
|
||||
const [users] = useUsers();
|
||||
const {filterDropdown, filterDropdownProps} = useUserFilter(true);
|
||||
|
||||
const data = [...users.values()];
|
||||
|
||||
// Determine online predicate based on instanceId
|
||||
const isUserOnline = (user: lib.UserDetails) => {
|
||||
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 durationFilters = [
|
||||
{ text: "Online", value: "online" },
|
||||
{ text: "24h", value: "24h" },
|
||||
{ text: "7d", value: "7d" },
|
||||
{ text: "30d", value: "30d" },
|
||||
{ text: "Anytime", value: "any" },
|
||||
];
|
||||
|
||||
function onFilterDuration(
|
||||
value: string,
|
||||
record: lib.UserDetails,
|
||||
calculateDuration: (user: lib.UserDetails, instanceId?: number) => number | undefined,
|
||||
) {
|
||||
const online = isUserOnline(record, instanceId);
|
||||
if (value === "online") {
|
||||
return online;
|
||||
}
|
||||
|
||||
// Online players should appear in all buckets
|
||||
if (online) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ts = calculateDuration(record, instanceId);
|
||||
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;
|
||||
case "any":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const columns: any[] = [
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_: any, user: lib.UserDetails) => (
|
||||
<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>
|
||||
<Username user={user} withStatus />
|
||||
),
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
|
||||
filterIcon: (filtered: boolean) => (
|
||||
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
|
||||
),
|
||||
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"],
|
||||
}) => (
|
||||
<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>
|
||||
),
|
||||
onFilter: onFilterUser,
|
||||
filterDropdownProps,
|
||||
filterDropdown,
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
@@ -117,124 +112,53 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
))
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// Helper to get last seen timestamp
|
||||
function getLastSeenTimestamp(user: lib.UserDetails): 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.UserDetails) => {
|
||||
const instanceStats = user.instanceStats.get(instanceId);
|
||||
return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-";
|
||||
},
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
|
||||
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.UserDetails) => {
|
||||
const instanceStats = user.instanceStats.get(instanceId);
|
||||
return instanceStats?.joinCount ?? 0;
|
||||
},
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
|
||||
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.UserDetails) => formatFirstSeen(user, instanceId),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
|
||||
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.UserDetails) => (user.playerStats?.onlineTimeMs
|
||||
? formatDuration(user.playerStats.onlineTimeMs)
|
||||
: null),
|
||||
// eslint-disable-next-line max-len
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
|
||||
responsive: ["lg"],
|
||||
},
|
||||
{
|
||||
title: "First seen",
|
||||
key: "firstSeen",
|
||||
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b),
|
||||
},
|
||||
);
|
||||
}
|
||||
columns.push(
|
||||
{
|
||||
title: "Last seen",
|
||||
title: "Online Time",
|
||||
key: "onlineTime",
|
||||
render: (_: any, user: lib.UserDetails) => {
|
||||
const userStats = getUserStats(user, instanceId);
|
||||
return userStats?.onlineTimeMs ? formatDuration(userStats.onlineTimeMs) : "-";
|
||||
},
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
|
||||
const statsA = getUserStats(a, instanceId);
|
||||
const statsB = getUserStats(b, instanceId);
|
||||
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Join Count",
|
||||
key: "joinCount",
|
||||
render: (_: any, user: lib.UserDetails) => (
|
||||
getUserStats(user, instanceId)?.joinCount ?? 0
|
||||
),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
|
||||
const statsA = getUserStats(a, instanceId);
|
||||
const statsB = getUserStats(b, instanceId);
|
||||
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "First Seen",
|
||||
key: "firstSeen",
|
||||
filterMultiple: false,
|
||||
filters: durationFilters,
|
||||
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateFirstSeen),
|
||||
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b, instanceId, instanceId),
|
||||
},
|
||||
{
|
||||
title: "Last Seen",
|
||||
key: "lastSeen",
|
||||
filterMultiple: false,
|
||||
filters: durationFilters,
|
||||
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.UserDetails) => {
|
||||
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;
|
||||
}
|
||||
},
|
||||
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateLastSeen),
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId),
|
||||
render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId),
|
||||
// Responsive breaks defaultFilteredValue, see: https://github.com/ant-design/ant-design/issues/32847
|
||||
// responsive: ["lg"],
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
const defaultPagination = pagination === undefined
|
||||
? {
|
||||
@@ -253,6 +177,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
pagination={defaultPagination}
|
||||
size={size}
|
||||
scroll={{ x: "max-content" }}
|
||||
loading={!usersSynced || !rolesSynced}
|
||||
onRow={(user) => ({
|
||||
onClick: (event) => {
|
||||
if ((event.target as HTMLElement).closest("a")) {
|
||||
|
||||
@@ -4,20 +4,27 @@ import ControlContext from "../components/ControlContext";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
function getInstanceStats(user: lib.UserDetails, instanceId?: number) {
|
||||
export function getUserStats(user: lib.UserDetails, instanceId?: number) {
|
||||
if (instanceId === undefined) {
|
||||
return user.playerStats;
|
||||
}
|
||||
return user.instanceStats.get(instanceId);
|
||||
}
|
||||
|
||||
function calculateFirstSeen(user: lib.UserDetails, instanceId?: number) {
|
||||
const stats = getInstanceStats(user, instanceId);
|
||||
export function isUserOnline(user: lib.UserDetails, instanceId?: number) {
|
||||
if (instanceId === undefined) {
|
||||
return user.instances && user.instances.size > 0;
|
||||
}
|
||||
return user.instances && user.instances.has(instanceId);
|
||||
}
|
||||
|
||||
export function calculateFirstSeen(user: lib.UserDetails, instanceId?: number) {
|
||||
const stats = getUserStats(user, instanceId);
|
||||
return stats?.firstJoinAt?.getTime();
|
||||
}
|
||||
|
||||
function calculateLastSeen(user: lib.UserDetails, instanceId?: number) {
|
||||
const stats = getInstanceStats(user, instanceId);
|
||||
export function calculateLastSeen(user: lib.UserDetails, instanceId?: number) {
|
||||
const stats = getUserStats(user, instanceId);
|
||||
if (!stats) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user