Share player stats with master server

Send the per player stats with the player event updates to the master
server and expose it to the Web UI and ctl interfaces.
This commit is contained in:
Hornwitser committed 2022-07-12 23:03:50 +02:00
1 parent deb6842a4d
commit 0245ccd16c
12 files changed
+400 -34

No files matched your search

+51 -1
View File
@@ -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 <name>", "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 "<deleted>";
}
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));
},
}));
+10
View File
@@ -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,
},
});
+42
View File
@@ -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<number, module:lib/PlayerStats>}
*/
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.
+5
View File
@@ -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,
});
}
+5 -1
View File
@@ -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);
}
+3 -1
View File
@@ -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}`));
@@ -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() {
</Select>
</Form.Item>;
function instanceName(id) {
let instance = instanceList.find(i => i.id === id);
return instance ? instance["name"] : id;
}
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title={userName}
title={<Space>
{userName}
<span>
{user["is_admin"] && <Tag color="gold">Admin</Tag>}
{user["is_whitelisted"] && <Tag>Whitelisted</Tag>}
{user["is_banned"] && <Tag color="red">Banned</Tag>}
</span>
</Space>}
extra={
account.hasPermission("core.user.delete") && <Popconfirm
title={<>
@@ -110,7 +131,23 @@ export default function UserViewPage() {
</Popconfirm>
}
/>
<Form form={form}>
<Form
form={form}
labelCol={{
sm: { span: 6 },
md: { span: 6 },
lg: { span: 4 },
xl: { span: 3 },
xxl: { span: 2 },
}}
wrapperCol={{
sm: { span: 18 },
md: { span: 18 },
lg: { span: 20 },
xl: { span: 21 },
xxl: { span: 22 },
}}
>
<Form.Item
label="Roles"
>
@@ -237,6 +274,58 @@ export default function UserViewPage() {
</Space>
</Form.Item>
</Form>
<SectionHeader title="Player stats" />
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, lg: 3 }}>
<Descriptions.Item label="Total play time">
{user["player_stats"]["online_time_ms"] && formatDuration(user["player_stats"]["online_time_ms"])}
</Descriptions.Item>
<Descriptions.Item label="Total join count">
{user["player_stats"]["join_count"]}
</Descriptions.Item>
<Descriptions.Item label="Last seen">
{formatLastSeen(user)}
</Descriptions.Item>
</Descriptions>
<SectionHeader title="Instance stats" />
<Table
size="small"
columns={[
{
title: "Instance",
key: "instance",
render: ([id]) => 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`);
},
})}
/>
<PluginExtra component="UserViewPage" user={user} />
</PageLayout>;
}
+22 -18
View File
@@ -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 => <Space>
{user["name"]}
<span>
{user["is_admin"] && <Tag color="gold">Admin</Tag>}
{user["is_whitelisted"] && <Tag>Whitelisted</Tag>}
{user["is_banned"] && <Tag color="red">Banned</Tag>}
</span>
</Space>,
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
@@ -90,24 +99,19 @@ export default function UsersPage() {
render: user => user.roles.map(id => <Tag key={id}>{(roles.get(id) || { name: id })["name"]}</Tag>),
},
{
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"],
},
]}
+48 -1
View File
@@ -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 <Tag color="green">Online</Tag>;
}
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);
+33
View File
@@ -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;
}
+30
View File
@@ -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() {
+58 -8
View File
@@ -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",
},
},
},
);
});