diff --git a/packages/ctl/src/commands.js b/packages/ctl/src/commands.js index cfa79189..7c7fa270 100644 --- a/packages/ctl/src/commands.js +++ b/packages/ctl/src/commands.js @@ -941,9 +941,59 @@ roleCommands.add(new libCommand.Command({ const userCommands = new libCommand.CommandTree({ name: "user", alias: ["u"], description: "User management" }); userCommands.add(new libCommand.Command({ - definition: [["list", "l"], "List user in the cluster"], + definition: ["show ", "Show details for one user", (yargs) => { + yargs.positional("name", { decribe: "Name of user to show", type: "string" }); + yargs.options({ + "instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + let response = await libLink.messages.getUser.send(control, { name: args.name }); + delete response["seq"]; + Object.assign(response, response["player_stats"]); + delete response["player_stats"]; + let instanceStats = response["instance_stats"]; + delete response["instance_stats"]; + print(asTable(Object.entries(response).map(([property, value]) => ({ property, value })))); + + if (args.instanceStats) { + let instances = (await libLink.messages.listInstances.send(control)).list; + function instanceName(id) { + let instance = instances.find(i => i.id === id); + if (instance) { + return instance.name; + } + return ""; + } + for (let [id, playerInstanceStats] of instanceStats) { + print(); + print(`Instance ${instanceName(id)} (${id}):`); + print(asTable(Object.entries(playerInstanceStats).map(([property, value]) => ({ property, value })))); + } + } + }, +})); + +userCommands.add(new libCommand.Command({ + definition: [["list", "l"], "List user in the cluster", (yargs) => { + yargs.options({ + "stats": { describe: "include user stats", nargs: 0, type: "boolean", default: false }, + "attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false }, + }); + }], handler: async function(args, control) { let response = await libLink.messages.listUsers.send(control); + for (let user of response.list) { + if (args.stats) { + Object.assign(user, user["player_stats"]); + } + delete user["player_stats"]; + if (!args.attributes) { + delete user["is_admin"]; + delete user["is_whitelisted"]; + delete user["is_banned"]; + } + } print(asTable(response.list)); }, })); diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 1e7050cf..6028c31e 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -3,6 +3,7 @@ const libSchema = require("../schema"); const libErrors = require("../errors"); const { logger } = require("../logging"); +const PlayerStats = require("../PlayerStats"); class MissingLinkHandlerError extends Error { constructor(type, source, target) { @@ -795,6 +796,14 @@ const userProperties = { "is_whitelisted": { type: "boolean" }, "instances": { type: "array", items: { type: "integer" }}, "is_deleted": { type: "boolean" }, + "player_stats": PlayerStats.jsonSchema, + "instance_stats": { + type: "array", + items: { + type: "array", + items: [{ type: "integer" }, PlayerStats.jsonSchema], + }, + }, }; messages.getUser = new Request({ @@ -1325,6 +1334,7 @@ messages.playerEvent = new Event({ "type": { type: "string", enum: ["join", "leave"] }, "name": { type: "string" }, "reason": { type: "string" }, + "stats": PlayerStats.jsonSchema, }, }); diff --git a/packages/lib/users.js b/packages/lib/users.js index 8171a499..c1f72afd 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -11,6 +11,8 @@ const jwt = require("jsonwebtoken"); const libErrors = require("./errors"); +const PlayerStats = require("./PlayerStats"); + /** * Represents a permission that can be granted @@ -196,6 +198,22 @@ class User { } } + /** + * Per instance statistics for the player this user account is tied to. + * @type {Map} + */ + this.instanceStats = new Map( + (serializedUser.instance_stats ? serializedUser.instance_stats : []).map( + ([id, stats]) => [id, new PlayerStats(stats)] + ) + ); + + /** + * Combined statistics for the player this user account is tied to. + * @type {module:lib/PlayerStats} + */ + this.playerStats = this._calculatePlayerStats(); + /** * True if this user object has been removed from the cluster. * @type {boolean} @@ -232,6 +250,10 @@ class User { serialized.ban_reason = this.banReason; } + if (this.instanceStats.size) { + serialized.instance_stats = [...this.instanceStats].map(([id, stats]) => [id, stats.toJSON()]); + } + return serialized; } @@ -291,6 +313,26 @@ class User { User.onlineUsers.delete(this); } } + + recalculatePlayerStats() { + this.playerStats = this._calculatePlayerStats(); + } + + _calculatePlayerStats() { + let playerStats = new PlayerStats(); + for (let instanceStats of this.instanceStats.values()) { + if (!playerStats.lastJoinAt || instanceStats.lastJoinAt > playerStats.lastJoinAt) { + playerStats.lastJoinAt = instanceStats.lastJoinAt; + } + if (!playerStats.lastLeaveAt || instanceStats.lastLeaveAt > playerStats.lastLeaveAt) { + playerStats.lastLeaveAt = instanceStats.lastLeaveAt; + playerStats.lastLeaveReason = instanceStats.lastLeaveReason; + } + playerStats.joinCount += instanceStats.joinCount; + playerStats.onlineTimeMs += instanceStats.onlineTimeMs; + } + return playerStats; + } } /** * Set of users currently online in the cluster. diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index 3b0b4f33..37cc1b6c 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -578,6 +578,8 @@ class ControlConnection extends BaseConnection { is_banned: user.isBanned, is_whitelisted: user.isWhitelisted, instances: [...user.instances], + player_stats: user.playerStats, + instance_stats: [...user.instanceStats], }; } @@ -591,6 +593,7 @@ class ControlConnection extends BaseConnection { is_banned: user.isBanned, is_whitelisted: user.isWhitelisted, instances: [...user.instances], + player_stats: user.playerStats, }); } return { list }; @@ -612,6 +615,8 @@ class ControlConnection extends BaseConnection { is_banned: user.isBanned, is_whitelisted: user.isWhitelisted, instances: [...user.instances], + player_stats: user.playerStats, + instance_stats: [...user.instanceStats], is_deleted: user.isDeleted, }); } diff --git a/packages/master/src/SlaveConnection.js b/packages/master/src/SlaveConnection.js index c2788adb..35564264 100644 --- a/packages/master/src/SlaveConnection.js +++ b/packages/master/src/SlaveConnection.js @@ -3,6 +3,7 @@ const libConfig = require("@clusterio/lib/config"); const libLink = require("@clusterio/lib/link"); const { logger } = require("@clusterio/lib/logging"); const libPlugin = require("@clusterio/lib/plugin"); +const PlayerStats = require("@clusterio/lib/PlayerStats"); const BaseConnection = require("./BaseConnection"); @@ -175,7 +176,7 @@ class SlaveConnection extends BaseConnection { } async playerEventEventHandler(message) { - let { instance_id, name, type } = message.data; + let { instance_id, name, type, stats } = message.data; let user = this._master.userManager.users.get(name); if (!user) { user = this._master.userManager.createUser(name); @@ -186,8 +187,11 @@ class SlaveConnection extends BaseConnection { } else if (type === "leave") { user.notifyLeave(instance_id); } + user.instanceStats.set(instance_id, new PlayerStats(stats)); + user.recalculatePlayerStats(); this._master.userUpdated(user); + delete message.data.stats; let instance = this._master.instances.get(instance_id); await libPlugin.invokeHook(this._master.plugins, "onPlayerEvent", instance, message.data); } diff --git a/packages/slave/src/Instance.js b/packages/slave/src/Instance.js index 58f15554..a3b6fb6a 100644 --- a/packages/slave/src/Instance.js +++ b/packages/slave/src/Instance.js @@ -342,6 +342,7 @@ class Instance extends libLink.Link { instance_id: this.id, type: "join", name, + stats: stats.toJSON(), }; libLink.messages.playerEvent.send(this, event); libPlugin.invokeHook(this.plugins, "onPlayerEvent", event); @@ -363,6 +364,7 @@ class Instance extends libLink.Link { type: "leave", name, reason, + stats: stats.toJSON(), }; libLink.messages.playerEvent.send(this, event); libPlugin.invokeHook(this.plugins, "onPlayerEvent", event); @@ -483,7 +485,7 @@ class Instance extends libLink.Link { pluginInstance.onExit(); } - for (let player of left) { + for (let player of this.playersOnline) { this._recordPlayerLeave(player, "server_quit"); } this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`)); diff --git a/packages/web_ui/src/components/UserViewPage.jsx b/packages/web_ui/src/components/UserViewPage.jsx index 07858a95..81a11e00 100644 --- a/packages/web_ui/src/components/UserViewPage.jsx +++ b/packages/web_ui/src/components/UserViewPage.jsx @@ -1,16 +1,24 @@ import React, { useEffect, useContext, useState } from "react"; import { useHistory, useParams } from "react-router-dom"; -import { Button, Col, Form, Input, PageHeader, Popconfirm, Popover, Row, Tag, Select, Space, Spin, Switch } from "antd"; +import { + Button, Col, Descriptions, Form, Input, PageHeader, Popconfirm, + Popover, Row, Table, Tag, Select, Space, Spin, Switch, +} from "antd"; import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; import { libLink } from "@clusterio/lib"; import { useAccount } from "../model/account"; +import { useInstanceList } from "../model/instance"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; +import SectionHeader from "./SectionHeader"; import { notifyErrorHandler } from "../util/notify"; -import { useUser } from "../model/user"; +import { formatDuration } from "../util/time_format"; +import { formatLastSeen, sortLastSeen, useUser } from "../model/user"; + +const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; export default function UserViewPage() { @@ -21,6 +29,7 @@ export default function UserViewPage() { let account = useAccount(); let control = useContext(ControlContext); + let [instanceList] = useInstanceList(); let [user, updateUser] = useUser(userName); let [roles, setRoles] = useState(null); let [form] = Form.useForm(); @@ -83,10 +92,22 @@ export default function UserViewPage() { ; + function instanceName(id) { + let instance = instanceList.find(i => i.id === id); + return instance ? instance["name"] : id; + } + return + {userName} + + {user["is_admin"] && Admin} + {user["is_whitelisted"] && Whitelisted} + {user["is_banned"] && Banned} + + } extra={ account.hasPermission("core.user.delete") && @@ -110,7 +131,23 @@ export default function UserViewPage() { } /> -
+ @@ -237,6 +274,58 @@ export default function UserViewPage() {
+ + + + {user["player_stats"]["online_time_ms"] && formatDuration(user["player_stats"]["online_time_ms"])} + + + {user["player_stats"]["join_count"]} + + + {formatLastSeen(user)} + + + + instanceName(id), + defaultSortOrder: "ascend", + sorter: (a, b) => strcmp(a[0], b[0]), + }, + { + title: "Play time", + key: "playTime", + render: ([, stats]) => formatDuration(stats["online_time_ms"] || 0), + sorter: (a, b) => (a[1]["online_time_ms"] || 0) - (b[1]["online_time_ms"] || 0), + }, + { + title: "Join count", + key: "joinCoint", + render: ([, stats]) => stats["join_count"] || 0, + sorter: (a, b) => (a[1]["join_count"] || 0) - (b[1]["join_count"] || 0), + responsive: ["sm"], + }, + { + title: "Last seen", + key: "lastSeen", + render: ([id]) => formatLastSeen(user, id), + sorter: (a, b) => sortLastSeen(user, user, a[0], b[0]), + }, + ]} + dataSource={user["instance_stats"]} + pagination={false} + rowKey={([id]) => id} + onRow={([id], rowIndex) => ({ + onClick: event => { + history.push(`/instances/${id}/view`); + }, + })} + /> ; } diff --git a/packages/web_ui/src/components/UsersPage.jsx b/packages/web_ui/src/components/UsersPage.jsx index 9405b8cd..4b32f147 100644 --- a/packages/web_ui/src/components/UsersPage.jsx +++ b/packages/web_ui/src/components/UsersPage.jsx @@ -1,15 +1,16 @@ import React, { useEffect, useContext, useState } from "react"; import { useHistory } from "react-router-dom"; -import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd"; +import { Button, Form, Input, Modal, PageHeader, Space, Table, Tag } from "antd"; import { libLink } from "@clusterio/lib"; import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; +import { formatDuration } from "../util/time_format"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { useUserList } from "../model/user"; +import { formatLastSeen, sortLastSeen, useUserList } from "../model/user"; const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare; @@ -80,7 +81,15 @@ export default function UsersPage() { columns={[ { title: "Name", - dataIndex: "name", + key: "name", + render: user => + {user["name"]} + + {user["is_admin"] && Admin} + {user["is_whitelisted"] && Whitelisted} + {user["is_banned"] && Banned} + + , defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a["name"], b["name"]), }, @@ -90,24 +99,19 @@ export default function UsersPage() { render: user => user.roles.map(id => {(roles.get(id) || { name: id })["name"]}), }, { - title: "Admin", - key: "admin", - render: user => user["is_admin"] && "yes", - sorter: (a, b) => a["is_admin"] - b["is_admin"], + title: "Play time", + key: "playTime", + render: user => user["player_stats"]["online_time_ms"] + && formatDuration(user["player_stats"]["online_time_ms"]), + sorter: (a, b) => (a["player_stats"]["online_time_ms"] || 0) - + (b["player_stats"]["online_time_ms"] || 0), responsive: ["lg"], }, { - title: "Whitelisted", - key: "whitelisted", - render: user => user["is_whitelisted"] && "yes", - sorter: (a, b) => a["is_whitelisted"] - b["is_whitelisted"], - responsive: ["lg"], - }, - { - title: "Banned", - key: "banned", - render: user => user["is_banned"] && "yes", - sorter: (a, b) => a["is_banned"] - b["is_banned"], + title: "Last seen", + key: "lastSeen", + render: user => formatLastSeen(user), + sorter: sortLastSeen, responsive: ["lg"], }, ]} diff --git a/packages/web_ui/src/model/user.jsx b/packages/web_ui/src/model/user.jsx index 6741430a..5dad3ae9 100644 --- a/packages/web_ui/src/model/user.jsx +++ b/packages/web_ui/src/model/user.jsx @@ -1,9 +1,56 @@ -import { useEffect, useContext, useState } from "react"; +import React, { useEffect, useContext, useState } from "react"; +import { Tag } from "antd"; import ControlContext from "../components/ControlContext"; import { libLink, libLogging } from "@clusterio/lib"; const { logger } = libLogging; +function calculateLastSeen(user, instanceId) { + let stats; + if (instanceId === undefined) { + stats = user["player_stats"]; + } else { + let entry = (user["instance_stats"] || []).find(([id]) => id === instanceId); + if (!entry) { + return undefined; + } + stats = entry[1]; + } + if (stats["last_leave_at"] > stats["last_join_at"]) { + return stats["last_leave_at"]; + } + if (stats["last_join_at"]) { + return stats["last_leave_at"]; + } + return undefined; +} + +export function formatLastSeen(user, instanceId = undefined) { + if (user["instances"].some(id => instanceId === undefined || id === instanceId)) { + return Online; + } + let lastSeen = calculateLastSeen(user, instanceId); + if (lastSeen === undefined) { + return undefined; + } + return new Date(lastSeen).toLocaleTimeString(); +} + +export function sortLastSeen(userA, userB, instanceIdA = undefined, instanceIdB = undefined) { + function epoch(user, instanceId) { + return user["instances"].some(id => instanceId === undefined || id === instanceId); + } + + let epochA = epoch(userA, instanceIdA); + let epochB = epoch(userB, instanceIdB); + if (epochA !== epochB) { + return epochA - epochB; + } + + let lastSeenA = calculateLastSeen(userA, instanceIdA) || 0; + let lastSeenB = calculateLastSeen(userB, instanceIdB) || 0; + return lastSeenA - lastSeenB; +} export function useUser(name) { let control = useContext(ControlContext); diff --git a/packages/web_ui/src/util/time_format.jsx b/packages/web_ui/src/util/time_format.jsx new file mode 100644 index 00000000..4f77dac9 --- /dev/null +++ b/packages/web_ui/src/util/time_format.jsx @@ -0,0 +1,33 @@ +const formatSecond = new Intl.NumberFormat(undefined, { style: "unit", unit: "second", unitDisplay: "narrow" }).format; +const formatMinute = new Intl.NumberFormat(undefined, { style: "unit", unit: "minute", unitDisplay: "narrow" }).format; +const formatHour = new Intl.NumberFormat(undefined, { style: "unit", unit: "hour", unitDisplay: "narrow" }).format; +const formatDay = new Intl.NumberFormat(undefined, { style: "unit", unit: "day", unitDisplay: "narrow" }).format; + +/** + * Formats a duration of time in milliseconds to something human readable. + * @param {number} ms - Duration to formatDuration + * @param {object=} options - Options to control the formatting. + * @returns {string} human readable representation of the duration. + */ +export function formatDuration(ms, options = {}) { + let result = ""; + if (ms < 0) { + result += "-"; + ms = -ms; + } + + if (ms > 86400e3) { + result += formatDay(Math.floor(ms / 86400e3)); + ms %= 86400e3; + } + if (ms > 3600e3) { + result += formatHour(Math.floor(ms / 3600e3)); + ms %= 3600e3; + } + if (ms > 60e3) { + result += formatMinute(Math.floor(ms / 60e3)); + ms %= 60e3; + } + result += formatSecond(Math.round(ms / 1e3)); + return result; +} diff --git a/test/lib/users.js b/test/lib/users.js index f9619f91..6dd95a1f 100644 --- a/test/lib/users.js +++ b/test/lib/users.js @@ -85,6 +85,7 @@ describe("lib/users", function() { test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 }); test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true }); test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" }); + test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]}); }); it("should ignore invalid roles", function() { let user = new libUsers.User({ name: "test", roles: [1, 4, 55] }, roles); @@ -115,6 +116,35 @@ describe("lib/users", function() { assert(!libUsers.User.onlineUsers.has(user)); assert.deepEqual(user.instances, new Set()); }); + it("should calculate playerStats", function() { + let user = new libUsers.User({ name: "test", roles: [1], instance_stats: [ + [1, { + join_count: 1, + online_time_ms: 60e3, + last_join_at: new Date("2020-05T12:00Z").getTime(), + last_leave_at: new Date("2020-05T12:01Z").getTime(), + last_leave_reason: "quit", + }], + [2, { + join_count: 1, + online_time_ms: 0, + last_join_at: new Date("2020-05T12:02Z").getTime(), + }], + [3, { + join_count: 2, + online_time_ms: 120e3, + last_join_at: new Date("2020-05T11:00Z").getTime(), + last_leave_at: new Date("2020-05T11:02Z").getTime(), + last_leave_reason: "afk", + }], + ]}, roles); + + assert.equal(user.playerStats.onlineTimeMs, 180e3); + assert.equal(user.playerStats.joinCount, 4); + assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T12:02Z")); + assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T12:01Z")); + assert.equal(user.playerStats.lastLeaveReason, "quit"); + }); describe(".checkPermission()", function() { it("should correctly resolve permissions", function() { diff --git a/test/slave/Instance.js b/test/slave/Instance.js index 142e1cd0..1c3b5808 100644 --- a/test/slave/Instance.js +++ b/test/slave/Instance.js @@ -54,18 +54,28 @@ describe("class Instance", function() { it("should send player_event", function() { instance._recordPlayerJoin("player"); + let stats = instance.playerStats.get("player"); assert.deepEqual( connector.sentMessages[0], { seq: 1, type: "player_event_event", - data: { type: "join", instance_id: instance.id, name: "player" }, + data: { + type: "join", + instance_id: instance.id, + name: "player", + stats: { + join_count: 1, + last_join_at: stats.lastJoinAt.getTime(), + }, + }, }, ); }); - it("should be idempotent", function() { + it("should be idempotent", async function() { instance._recordPlayerJoin("player"); + await wait(10); instance._recordPlayerJoin("player"); assert(instance.playersOnline.has("player"), "player was not added"); assert.equal(connector.sentMessages.length, 1); @@ -73,8 +83,9 @@ describe("class Instance", function() { }); describe("._recordPlayerLeave()", function() { - it("should remove player to playersOnline", function() { + it("should remove player to playersOnline", async function() { instance._recordPlayerJoin("player"); + await wait(10); instance._recordPlayerLeave("player"); assert(!instance.playersOnline.has("player"), "player was not removed"); }); @@ -87,22 +98,38 @@ describe("class Instance", function() { assert(instance.playerStats.get("player").onlineTimeMs > 0, "no onlineTimeMs recorded"); }); - it("should send player_event", function() { + it("should send player_event", async function() { instance._recordPlayerJoin("player"); + await wait(10); instance._recordPlayerLeave("player", "quit"); + let stats = instance.playerStats.get("player"); assert.deepEqual( connector.sentMessages[1], { seq: 2, type: "player_event_event", - data: { type: "leave", instance_id: instance.id, name: "player", reason: "quit" }, + data: { + type: "leave", + instance_id: instance.id, + name: "player", + reason: "quit", + stats: { + join_count: 1, + online_time_ms: stats.onlineTimeMs, + last_join_at: stats.lastJoinAt.getTime(), + last_leave_at: stats.lastLeaveAt.getTime(), + last_leave_reason: "quit", + }, + }, }, ); }); - it("should be idempotent", function() { + it("should be idempotent", async function() { instance._recordPlayerJoin("player"); + await wait(10); instance._recordPlayerLeave("player", "quit"); + await wait(10); instance._recordPlayerLeave("player", "quit"); assert(!instance.playersOnline.has("player"), "player was not removed"); assert.equal(connector.sentMessages.length, 2); @@ -129,12 +156,21 @@ describe("class Instance", function() { ); instance._recordPlayerJoin("player"); await instance._checkOnlinePlayers(); + let stats = instance.playerStats.get("foo"); assert.deepEqual( connector.sentMessages[1], { seq: 2, type: "player_event_event", - data: { type: "join", instance_id: instance.id, name: "foo" }, + data: { + type: "join", + instance_id: instance.id, + name: "foo", + stats: { + join_count: 1, + last_join_at: stats.lastJoinAt.getTime(), + }, + }, }, ); }); @@ -142,13 +178,27 @@ describe("class Instance", function() { it("should remove extra players", async function() { instance.server.rconCommandResults.set("/players online", "Online Players (0):\n"); instance._recordPlayerJoin("player"); + await wait(10); await instance._checkOnlinePlayers(); + let stats = instance.playerStats.get("player"); assert.deepEqual( connector.sentMessages[1], { seq: 2, type: "player_event_event", - data: { type: "leave", instance_id: instance.id, name: "player", reason: "quit" }, + data: { + type: "leave", + instance_id: instance.id, + name: "player", + reason: "quit", + stats: { + join_count: 1, + online_time_ms: stats.onlineTimeMs, + last_join_at: stats.lastJoinAt.getTime(), + last_leave_at: stats.lastLeaveAt.getTime(), + last_leave_reason: "quit", + }, + }, }, ); });