Merge pull request #512 from bbassie/feature/reports-watch-list

Add a watch list and read state to exp_reports
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-25 22:12:53 +00:00
commit c61dcaecb5
14 files changed
+899 -46

No files matched your search

+16 -6
View File
@@ -2,7 +2,9 @@
Lets players report each other in game or from the web interface. Reports are kept on the controller in `database/exp_reports/reports.json`, so a report made on one instance can be seen and cleared from any other.
Each report has the reported player, the player who made it, a reason, the instance it was made on and the time. A player can only report the same player once. Reports can't be edited, only deleted. Nothing is stored in the Lua state, every command sends a request to the controller and the answer is printed once it arrives.
Each report has the reported player, the player who made it, a reason, the instance it was made on and the time. A player can only report the same player once. Reports can't be edited, only marked as read or unread from the web interface, and deleted. Nothing is stored in the Lua state, every command sends a request to the controller and the answer is printed once it arrives.
The plugin also keeps a watch list in `database/exp_reports/watch_list.json`. When a player on it joins any instance, every admin online on every instance is told who joined where and why they are watched, and the join is posted to the webhooks. Names on the watch list are case insensitive, like Factorio and Clusterio user names. With `exp_reports.watch_unread_reports` set, players with unread reports are treated as if they were on the watch list, and their unread reports are listed with the join.
## Config
@@ -10,10 +12,12 @@ All fields are on the controller.
| Field | Default | Description |
|---|---|---|
| `exp_reports.discord_webhook_url` | | Discord channel webhook which new reports are posted to as an embed. |
| `exp_reports.json_webhook_url` | | URL which new reports are posted to as JSON. |
| `exp_reports.discord_webhook_url` | | Discord channel webhook which new reports and watched player joins are posted to as an embed. |
| `exp_reports.json_webhook_url` | | URL which new reports and watched player joins are posted to as JSON. |
| `exp_reports.watch_unread_reports` | `false` | Treat players with unread reports as if they were on the watch list. |
| `exp_reports.announce_to_all_instances` | `true` | Tell admins on every instance when a watched player joins. When off only admins on the instance they joined are told. |
Only new reports are posted, deletions are not. The JSON webhook gets `{ "type": "report_created", "report": { ... } }` with the report in the same form as `reports.json`. A webhook that fails is logged as a warning and the report is still made.
Only new reports and watched player joins are posted. Deleting or reading a report and editing the watch list are not. The JSON webhook gets `{ "type": "report_created", "report": { ... } }` with the report in the same form as `reports.json`, or `{ "type": "watched_player_joined", "player_name", "instance_name", "watch_list_entry", "unread_reports" }`. `watch_list_entry` is left out when the player is only watched for unread reports, and `unread_reports` is empty unless `exp_reports.watch_unread_reports` is set. Unread reports are newest first. The Discord embed lists the three newest and counts the rest in its footer. A webhook that fails is logged as a warning and the report is still made.
## Permissions
@@ -24,6 +28,10 @@ Only new reports are posted, deletions are not. The JSON webhook gets `{ "type":
| `exp_reports.report.subscribe` | granted | Receive updates when reports are made or deleted. |
| `exp_reports.report.create` | not granted | Report a player from the web interface. |
| `exp_reports.report.delete` | not granted | Delete reports from the web interface. |
| `exp_reports.report.mark_read` | not granted | Mark reports as read or unread. |
| `exp_reports.watch_list.subscribe` | granted | See the watch list and receive updates to it. |
| `exp_reports.watch_list.add` | not granted | Add a player to the watch list. |
| `exp_reports.watch_list.remove` | not granted | Remove a player from the watch list. |
These only apply to the web interface and control connections. In game, players with the `exp_scenario.bypass.reports` role permission can't be reported.
@@ -39,7 +47,9 @@ When a report is made, admins on that instance see who made it and the reason. O
## Web
The Reports page lists every report. The player, reporter and reason columns are searchable, and the instance column can be filtered. Reports made from the web interface show "Web UI" as the instance. Users with `exp_reports.report.create` get a Create button, and users with `exp_reports.report.delete` get a Delete button on each row. A report made from the web interface is made by the logged in user.
The Reports page lists every report. The player, reporter and reason columns are searchable, and the instance column can be filtered. Reports made from the web interface show "Web UI" as the instance. The status column shows whether a report has been read and can be filtered. Users with `exp_reports.report.create` get a Create button. Each row has buttons for the permissions the user has: Mark read or Mark unread with `exp_reports.report.mark_read`, Add to watch list with `exp_reports.watch_list.add`, and Delete with `exp_reports.report.delete`. Add to watch list opens the add form with the player and the report's reason filled in, and is disabled once the player is on the watch list. A report made from the web interface is made by the logged in user.
Below the reports is the watch list, with the player, reason, who added them and when. Users with `exp_reports.watch_list.add` get an Add button, and users with `exp_reports.watch_list.remove` get a Delete button on each row. A reason is required. Players watched only for unread reports are not listed here.
## Lua
@@ -58,4 +68,4 @@ The functions only send the request, the outcome is printed to `player`. The eve
## Node
`ReportCreateRequest`, `ReportListRequest`, `ReportGetRequest` and `ReportDeleteRequest` can be sent from instances and control connections. Web plugins and control connections can subscribe to `ReportUpdatedEvent` with the `exp_reports.report.subscribe` permission.
`ReportCreateRequest`, `ReportListRequest`, `ReportGetRequest` and `ReportDeleteRequest` can be sent from instances and control connections. `ReportSetReadRequest`, `WatchListAddRequest` and `WatchListRemoveRequest` can only be sent from control connections. Web plugins and control connections can subscribe to `ReportUpdatedEvent` with the `exp_reports.report.subscribe` permission, and to `WatchListUpdatedEvent` with `exp_reports.watch_list.subscribe`. The controller sends `WatchedPlayerJoinedEvent` to every instance when a watched player joins.
+131 -11
View File
@@ -1,8 +1,13 @@
import type { ControllerPluginContext } from "@clusterio/controller";
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
import * as path from "node:path";
/** Reports saved before the read state was added used updated_at_ms as the creation time. */
function migrateReports(rawJson: unknown[]) {
return rawJson.map((report: any) => ({ created_at_ms: report.updated_at_ms ?? 0, ...report }));
}
export default async function (context: ControllerPluginContext) {
const { controller, logger, plugin } = context;
const databaseDirectory = controller.config.get("controller.database_directory");
@@ -10,6 +15,13 @@ export default async function (context: ControllerPluginContext) {
...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "exp_reports", "reports.json"),
messages.ReportRecord.fromJSON.bind(messages.ReportRecord),
migrateReports,
).bootstrap()
);
const watchList = new lib.SubscribableDatastore(
...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "exp_reports", "watch_list.json"),
messages.WatchListRecord.fromJSON.bind(messages.WatchListRecord),
).bootstrap()
);
@@ -19,15 +31,20 @@ export default async function (context: ControllerPluginContext) {
return playerName === undefined ? all : all.filter(report => report.playerName === playerName);
}
/** Name of the user behind a control connection. */
function controlUserName(src: lib.Address) {
return controller.wsServer.controlConnections.get(src.id)!.user.name;
}
/** Post a new report to the configured webhooks, failures are logged rather than failing the report. */
function sendWebhooks(report: messages.ReportRecord) {
function sendReportWebhooks(report: messages.ReportRecord) {
const discordUrl = controller.config.get("exp_reports.discord_webhook_url");
if (discordUrl) {
postWebhook(discordUrl, {
embeds: [{
title: "Player reported",
color: 0xffcc00,
timestamp: new Date(report.updatedAtMs).toISOString(),
timestamp: new Date(report.createdAtMs).toISOString(),
fields: [
{ name: "Player", value: report.playerName, inline: true },
{ name: "By", value: report.byPlayerName, inline: true },
@@ -44,6 +61,37 @@ export default async function (context: ControllerPluginContext) {
}
}
/** Post a watched player joining to the configured webhooks. */
function sendJoinWebhooks(event: messages.WatchedPlayerJoinedEvent) {
const discordUrl = controller.config.get("exp_reports.discord_webhook_url");
if (discordUrl) {
const entry = event.watchListEntry;
postWebhook(discordUrl, {
embeds: [{
title: "Watched player joined",
color: 0xff6600,
timestamp: new Date().toISOString(),
fields: [
{ name: "Player", value: event.playerName, inline: true },
{ name: "Instance", value: event.instanceName, inline: true },
...entry ? [{ name: `Watch list, added by ${entry.byPlayerName}`, value: entry.reason }] : [],
...event.unreadReports.slice(0, 3).map(report => ({
name: `Unread report by ${report.byPlayerName}`, value: report.reason,
})),
],
...event.unreadReports.length > 3 ? {
footer: { text: `+${event.unreadReports.length - 3} more unread reports` },
} : {},
}],
});
}
const jsonUrl = controller.config.get("exp_reports.json_webhook_url");
if (jsonUrl) {
postWebhook(jsonUrl, { type: "watched_player_joined", ...event.toJSON() });
}
}
async function postWebhook(url: string, body: unknown) {
try {
const response = await fetch(url, {
@@ -59,18 +107,51 @@ export default async function (context: ControllerPluginContext) {
}
}
/** Tell the instances and the webhooks when a watched player joins, names are case insensitive. */
function playerJoined(instance: InstanceRecord, playerName: string) {
const key = playerName.toLowerCase();
const watchListEntry = watchList.get(key);
const unreadReports = controller.config.get("exp_reports.watch_unread_reports")
? [...reports.values()]
.filter(report => !report.isRead && report.playerName.toLowerCase() === key)
.sort((a, b) => b.createdAtMs - a.createdAtMs)
: [];
if (!watchListEntry && !unreadReports.length) {
return;
}
const event = new messages.WatchedPlayerJoinedEvent(
playerName, instance.config.get("instance.name"), watchListEntry, unreadReports,
);
if (controller.config.get("exp_reports.announce_to_all_instances")) {
controller.sendTo("allInstances", event);
} else {
controller.sendTo({ instanceId: instance.id }, event);
}
sendJoinWebhooks(event);
}
controller.subscriptions.handle(messages.ReportUpdatedEvent, async request => {
const updates = [...reports.values()].filter(report => report.updatedAtMs > request.lastRequestTimeMs);
return updates.length ? new messages.ReportUpdatedEvent(updates) : null;
});
// Reports are immutable, so any update which is not a deletion is a new report
controller.subscriptions.handle(messages.WatchListUpdatedEvent, async request => {
const updates = [...watchList.values()].filter(entry => entry.updatedAtMs > request.lastRequestTimeMs);
return updates.length ? new messages.WatchListUpdatedEvent(updates) : null;
});
reports.on("update", updates => {
controller.subscriptions.broadcast(new messages.ReportUpdatedEvent(updates));
for (const report of updates) {
if (!report.isDeleted) {
sendWebhooks(report);
}
});
watchList.on("update", updates => {
controller.subscriptions.broadcast(new messages.WatchListUpdatedEvent(updates));
});
controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
if (event.type === "join") {
playerJoined(instance, event.name);
}
});
@@ -90,7 +171,7 @@ export default async function (context: ControllerPluginContext) {
let byPlayerName = request.byPlayerName;
let instanceName = "";
if (src.type === lib.Address.control) {
byPlayerName = controller.wsServer.controlConnections.get(src.id)!.user.name;
byPlayerName = controlUserName(src);
} else {
const instance = controller.instances.get(src.id);
instanceName = instance ? instance.config.get("instance.name") : String(src.id);
@@ -111,8 +192,11 @@ export default async function (context: ControllerPluginContext) {
id = Math.random() * 2 ** 31 | 0;
}
const report = new messages.ReportRecord(id, request.playerName, byPlayerName, request.reason.trim(), instanceName);
const report = new messages.ReportRecord(
id, request.playerName, byPlayerName, request.reason.trim(), instanceName, Date.now(),
);
reports.set(report);
sendReportWebhooks(report);
return report;
});
@@ -124,7 +208,43 @@ export default async function (context: ControllerPluginContext) {
reports.delete(report);
});
controller.hooks.shutdown.attach(plugin.name, async () => {
controller.handle(messages.ReportSetReadRequest, async (request: messages.ReportSetReadRequest) => {
const report = reports.getMutable(request.id);
if (!report) {
throw new lib.RequestError(`Report with ID ${request.id} does not exist`);
}
report.isRead = request.isRead;
reports.set(report);
});
controller.handle(messages.WatchListAddRequest, async (request: messages.WatchListAddRequest, src: lib.Address) => {
const playerName = request.playerName.trim();
if (!playerName) {
throw new lib.RequestError("A player name is required");
}
if (!request.reason.trim()) {
throw new lib.RequestError("A reason is required");
}
const existing = watchList.get(playerName.toLowerCase());
if (existing) {
throw new lib.RequestError(`${existing.playerName} is already on the watch list`);
}
const entry = new messages.WatchListRecord(playerName, request.reason.trim(), controlUserName(src), Date.now());
watchList.set(entry);
return entry;
});
controller.handle(messages.WatchListRemoveRequest, async (request: messages.WatchListRemoveRequest) => {
const entry = watchList.getMutable(request.playerName.toLowerCase());
if (!entry) {
throw new lib.RequestError(`${request.playerName} is not on the watch list`);
}
watchList.delete(entry);
});
controller.hooks.save.attach(plugin.name, async () => {
await reports.save();
await watchList.save();
});
}
+50 -2
View File
@@ -5,6 +5,8 @@ declare module "@clusterio/lib" {
export interface ControllerConfigFields {
"exp_reports.discord_webhook_url": string | null;
"exp_reports.json_webhook_url": string | null;
"exp_reports.watch_unread_reports": boolean;
"exp_reports.announce_to_all_instances": boolean;
}
export interface Permissions {
"exp_reports.report.get": never;
@@ -12,6 +14,10 @@ declare module "@clusterio/lib" {
"exp_reports.report.subscribe": never;
"exp_reports.report.create": never;
"exp_reports.report.delete": never;
"exp_reports.report.mark_read": never;
"exp_reports.watch_list.subscribe": never;
"exp_reports.watch_list.add": never;
"exp_reports.watch_list.remove": never;
}
}
@@ -27,11 +33,17 @@ export const plugin: lib.PluginDeclaration = {
messages: [
messages.ReportUpdatedEvent,
messages.WatchListUpdatedEvent,
messages.WatchedPlayerJoinedEvent,
messages.ReportListRequest,
messages.ReportGetRequest,
messages.ReportCreateRequest,
messages.ReportDeleteRequest,
messages.ReportSetReadRequest,
messages.WatchListAddRequest,
messages.WatchListRemoveRequest,
],
permissions: [
@@ -65,6 +77,30 @@ export const plugin: lib.PluginDeclaration = {
description: "Delete reports made against a player.",
grantByDefault: false,
},
{
name: "exp_reports.report.mark_read",
title: "Mark Reports Read",
description: "Mark reports as read or unread.",
grantByDefault: false,
},
{
name: "exp_reports.watch_list.subscribe",
title: "Subscribe to Watch List Updates",
description: "See the watch list and receive updates when players are added or removed.",
grantByDefault: true,
},
{
name: "exp_reports.watch_list.add",
title: "Add to Watch List",
description: "Add a player to the watch list.",
grantByDefault: false,
},
{
name: "exp_reports.watch_list.remove",
title: "Remove from Watch List",
description: "Remove a player from the watch list.",
grantByDefault: false,
},
],
instanceEntrypoint: "./dist/node/instance.js",
@@ -73,16 +109,28 @@ export const plugin: lib.PluginDeclaration = {
controllerConfigFields: {
"exp_reports.discord_webhook_url": {
title: "Discord Webhook URL",
description: "Discord channel webhook which new reports are posted to as an embed.",
description: "Discord channel webhook which new reports and watched player joins are posted to as an embed.",
type: "string",
optional: true,
},
"exp_reports.json_webhook_url": {
title: "JSON Webhook URL",
description: "URL which new reports are posted to as JSON.",
description: "URL which new reports and watched player joins are posted to as JSON.",
type: "string",
optional: true,
},
"exp_reports.watch_unread_reports": {
title: "Watch Unread Reports",
description: "Treat players with unread reports as if they were on the watch list.",
type: "boolean",
initialValue: false,
},
"exp_reports.announce_to_all_instances": {
title: "Announce to All Instances",
description: "Tell admins on every instance when a watched player joins, instead of only the instance they joined.",
type: "boolean",
initialValue: true,
},
},
webEntrypoint: "./web",
+4
View File
@@ -94,4 +94,8 @@ export default async function (context: InstancePluginContext) {
await luaSend("receive_error", { caller: event.caller, message: err.message });
}
});
instance.handle(messages.WatchedPlayerJoinedEvent, async (event: messages.WatchedPlayerJoinedEvent) => {
await luaSend("receive_watched_joined", event.toJSON());
});
}
+232 -2
View File
@@ -8,8 +8,8 @@ import { Type, Static } from "@sinclair/typebox";
/**
* A report made against a player.
*
* Reports are immutable, so until it is deleted updatedAtMs is when the
* report was made.
* Only the read state of a report can change, so createdAtMs is kept apart
* from updatedAtMs.
*/
export class ReportRecord {
constructor(
@@ -19,6 +19,8 @@ export class ReportRecord {
public reason: string,
/** Name of the instance the report was made on, empty when made from the web ui. */
public instanceName: string,
public createdAtMs: number = 0,
public isRead: boolean = false,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
@@ -29,6 +31,8 @@ export class ReportRecord {
by_player_name: Type.String(),
reason: Type.String(),
instance_name: Type.String(),
created_at_ms: Type.Number(),
is_read: Type.Optional(Type.Boolean()),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
@@ -40,8 +44,13 @@ export class ReportRecord {
by_player_name: this.byPlayerName,
reason: this.reason,
instance_name: this.instanceName,
created_at_ms: this.createdAtMs,
};
if (this.isRead) {
json.is_read = true;
}
if (this.updatedAtMs) {
json.updated_at_ms = this.updatedAtMs;
}
@@ -60,6 +69,65 @@ export class ReportRecord {
json.by_player_name,
json.reason,
json.instance_name,
json.created_at_ms,
json.is_read ?? false,
json.updated_at_ms ?? 0,
json.is_deleted ?? false,
);
}
}
/** A player whose joins are announced to admins and posted to the webhooks. */
export class WatchListRecord {
constructor(
public playerName: string,
public reason: string,
/** User who added the player, from the web ui or a report. */
public byPlayerName: string,
public createdAtMs: number = 0,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
/** Player names are case insensitive in Factorio and Clusterio. */
get id() {
return this.playerName.toLowerCase();
}
static jsonSchema = Type.Object({
player_name: Type.String(),
reason: Type.String(),
by_player_name: Type.String(),
created_at_ms: Type.Number(),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
toJSON() {
const json: Static<typeof WatchListRecord.jsonSchema> = {
player_name: this.playerName,
reason: this.reason,
by_player_name: this.byPlayerName,
created_at_ms: this.createdAtMs,
};
if (this.updatedAtMs) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted) {
json.is_deleted = true;
}
return json;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.player_name,
json.reason,
json.by_player_name,
json.created_at_ms,
json.updated_at_ms ?? 0,
json.is_deleted ?? false,
);
@@ -95,6 +163,31 @@ export class ReportUpdatedEvent {
}
}
export class WatchListUpdatedEvent {
declare ["constructor"]: typeof WatchListUpdatedEvent;
static plugin = "exp_reports" as const;
static type = "event" as const;
static src = "controller" as const;
static dst = "control" as const;
static permission = "exp_reports.watch_list.subscribe" as const;
constructor(
public updates: WatchListRecord[],
) {}
static jsonSchema = Type.Object({
updates: Type.Array(WatchListRecord.jsonSchema),
});
toJSON() {
return { updates: this.updates.map(entry => entry.toJSON()) };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(entry => WatchListRecord.fromJSON(entry)));
}
}
/*
Report requests
*/
@@ -220,3 +313,140 @@ export class ReportDeleteRequest {
return new this(json.id);
}
}
export class ReportSetReadRequest {
declare ["constructor"]: typeof ReportSetReadRequest;
static plugin = "exp_reports" as const;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "exp_reports.report.mark_read" as const;
constructor(
public id: number,
public isRead: boolean,
) {}
static jsonSchema = Type.Object({
id: Type.Integer(),
is_read: Type.Boolean(),
});
toJSON() {
return { id: this.id, is_read: this.isRead };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id, json.is_read);
}
}
/*
Watch list requests
*/
/** Add a player to the watch list in the name of the web user. */
export class WatchListAddRequest {
declare ["constructor"]: typeof WatchListAddRequest;
static plugin = "exp_reports" as const;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "exp_reports.watch_list.add" as const;
static Response = WatchListRecord;
constructor(
public playerName: string,
public reason: string,
) {}
static jsonSchema = Type.Object({
player_name: Type.String(),
reason: Type.String(),
});
toJSON() {
return { player_name: this.playerName, reason: this.reason };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.player_name, json.reason);
}
}
export class WatchListRemoveRequest {
declare ["constructor"]: typeof WatchListRemoveRequest;
static plugin = "exp_reports" as const;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "exp_reports.watch_list.remove" as const;
constructor(
public playerName: string,
) {}
static jsonSchema = Type.Object({
player_name: Type.String(),
});
toJSON() {
return { player_name: this.playerName };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.player_name);
}
}
/**
* A watched player joined an instance, sent to every instance so admins
* everywhere see it, or only to the one they joined when
* exp_reports.announce_to_all_instances is off.
*
* The player is watched because of their watch list entry, their unread
* reports when exp_reports.watch_unread_reports is set, or both. Unread
* reports are newest first.
*/
export class WatchedPlayerJoinedEvent {
declare ["constructor"]: typeof WatchedPlayerJoinedEvent;
static plugin = "exp_reports" as const;
static type = "event" as const;
static src = "controller" as const;
static dst = "instance" as const;
constructor(
public playerName: string,
public instanceName: string,
public watchListEntry: WatchListRecord | undefined,
public unreadReports: ReportRecord[],
) {}
static jsonSchema = Type.Object({
player_name: Type.String(),
instance_name: Type.String(),
watch_list_entry: Type.Optional(WatchListRecord.jsonSchema),
unread_reports: Type.Array(ReportRecord.jsonSchema),
});
toJSON() {
const json: Static<typeof WatchedPlayerJoinedEvent.jsonSchema> = {
player_name: this.playerName,
instance_name: this.instanceName,
unread_reports: this.unreadReports.map(report => report.toJSON()),
};
if (this.watchListEntry) {
json.watch_list_entry = this.watchListEntry.toJSON();
}
return json;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.player_name,
json.instance_name,
json.watch_list_entry ? WatchListRecord.fromJSON(json.watch_list_entry) : undefined,
json.unread_reports.map(report => ReportRecord.fromJSON(report)),
);
}
}
+26
View File
@@ -40,8 +40,16 @@ local ExpReports = {
--- @field by_player_name string
--- @field reason string
--- @field instance_name string
--- @field created_at_ms number
--- @field is_read boolean?
--- @field updated_at_ms number
--- @class ExpReports.WatchListEntry
--- @field player_name string
--- @field reason string
--- @field by_player_name string
--- @field created_at_ms number
local error_settings = { color = ExpUtil.color.orange_red, sound_path = "utility/wire_pickup" }
--- Report a player, the outcome is printed once the controller answers
@@ -194,6 +202,24 @@ function ExpReports.receive_deleted(payload)
})
end
--- A watched player joined an instance, which may be another one, admins are told why they are watched
--- @param payload { player_name: string, instance_name: string, watch_list_entry: ExpReports.WatchListEntry?, unread_reports: ExpReports.Report[] }
function ExpReports.receive_watched_joined(payload)
local player_name = format_player_name(payload.player_name)
local entry = payload.watch_list_entry
for _, player in pairs(game.connected_players) do
if player.admin then
player.print{ "exp-reports.watched-joined", player_name, payload.instance_name }
if entry then
player.print{ "exp-reports.watched-entry", format_player_name(entry.by_player_name), entry.reason }
end
for _, report in ipairs(payload.unread_reports) do
player.print{ "exp-reports.watched-report", format_player_name(report.by_player_name), report.reason }
end
end
end
end
--- The controller refused a request, the message is printed to whoever asked
--- @param payload { caller: string, message: string }
function ExpReports.receive_error(payload)
+3
View File
@@ -8,6 +8,9 @@ list-all-entry=__1__: __2__ __plural_for_parameter__2__{1=report|rest=reports}__
list-all-none=No players have reports against them.
deleted=__1__ had __2__ __plural_for_parameter__2__{1=report|rest=reports}__ removed by __3__.
deleted-none=__1__ has no reports against them.
watched-joined=__1__ joined __2__ and is being watched.
watched-entry=Watch list, added by __1__: __2__
watched-report=Unread report by __1__: __2__
[exp-reports_commands]
description-create=Reports a player and notifies moderators
+154 -7
View File
@@ -25,9 +25,9 @@ const fromUnknownInstance = lib.Address.fromShorthand({ instanceId: 9 });
const fromControl = lib.Address.fromShorthand({ controlId: 7 });
/** Run the plugin on a real controller, which is side effect free while not started, returns the handlers it registered. */
async function startPlugin(t2, { config = {} } = {}) {
async function startPlugin(t2, { config = {}, files = {} } = {}) {
const controllerConfig = new lib.ControllerConfig("controller", {
"controller.database_directory": t2.testdir(),
"controller.database_directory": t2.testdir(files),
...config,
});
const controller = new Controller(logger, [], controllerConfig);
@@ -35,11 +35,13 @@ async function startPlugin(t2, { config = {} } = {}) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.set("instance.id", 1);
instanceConfig.set("instance.name", "EXP");
controller.instances.records.set(new InstanceRecord(instanceConfig, "running"));
const instanceRecord = new InstanceRecord(instanceConfig, "running");
controller.instances.records.set(instanceRecord);
controller.wsServer.controlConnections.set(7, { user: { name: "admin" } });
// Spies which record and then defer to the real behaviour
const state = { broadcasts: [], posts: [], warnings: [] };
const state = { broadcasts: [], posts: [], warnings: [], sent: [] };
controller.sendTo = (dst, message) => state.sent.push({ dst, message });
const broadcast = controller.subscriptions.broadcast.bind(controller.subscriptions);
controller.subscriptions.broadcast = event => {
state.broadcasts.push(event);
@@ -78,7 +80,14 @@ async function startPlugin(t2, { config = {} } = {}) {
list: playerName => request(messages.ReportListRequest, playerName),
get: id => request(messages.ReportGetRequest, id),
remove: id => request(messages.ReportDeleteRequest, id),
setRead: (id, isRead) => request(messages.ReportSetReadRequest, id, isRead),
subscription: lastRequestTimeMs => handlers.get(messages.ReportUpdatedEvent)({ lastRequestTimeMs }),
watch: (playerName, reason) => handlers.get(messages.WatchListAddRequest)(
new messages.WatchListAddRequest(playerName, reason), fromControl,
),
unwatch: playerName => request(messages.WatchListRemoveRequest, playerName),
watchSubscription: lastRequestTimeMs => handlers.get(messages.WatchListUpdatedEvent)({ lastRequestTimeMs }),
join: name => controller.hooks.playerEvent.invoke(instanceRecord, { type: "join", name, stats: {} }),
};
}
@@ -89,10 +98,10 @@ t.test("entrypoint", async t2 => {
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
const controller = new Controller(logger, [], controllerConfig);
await entrypoint({ controller, logger, plugin: { name: "exp_reports" }, metrics: undefined });
for (const hook of ["shutdown"]) {
for (const hook of ["playerEvent", "save"]) {
t2.ok([...controller.hooks[hook].attached].includes("exp_reports"), `attached to ${hook}`);
}
await controller.hooks.shutdown.invoke();
await controller.hooks.save.invoke();
});
t.test("controller plugin", t2 => {
@@ -104,7 +113,8 @@ t.test("controller plugin", t2 => {
t3.strictSame(report.byPlayerName, "alice", "the reporter comes from the request");
t3.strictSame(report.reason, "griefing", "the reason is trimmed");
t3.strictSame(report.instanceName, "EXP", "the instance is named");
t3.ok(report.updatedAtMs > 0, "the report is timestamped");
t3.ok(report.createdAtMs > 0, "the report is timestamped");
t3.strictSame(report.isRead, false, "new reports are unread");
t3.strictSame(await list(), [report], "the report is stored");
const unknown = await create("carol", "spam", "alice", fromUnknownInstance);
@@ -175,6 +185,143 @@ t.test("controller plugin", t2 => {
);
});
t2.test("ReportSetReadRequest marks a report read or unread without changing when it was made", async t3 => {
const { create, get, setRead, state } = await startPlugin(t3, { config: {
"exp_reports.json_webhook_url": "https://json.example/hook",
} });
const report = await create("bob", "griefing", "alice");
const createdAtMs = report.createdAtMs;
state.posts.length = 0;
state.broadcasts.length = 0;
await setRead(report.id, true);
t3.strictSame((await get(report.id)).isRead, true);
t3.strictSame((await get(report.id)).createdAtMs, createdAtMs, "the creation time is kept");
t3.ok(state.broadcasts.some(event => event.updates?.[0]?.isRead), "subscribers are told");
t3.strictSame(state.posts, [], "reading a report is not posted");
await setRead(report.id, false);
t3.strictSame((await get(report.id)).isRead, false);
await t3.rejects(setRead(123, true), { message: "Report with ID 123 does not exist" });
});
t2.test("reports saved before the read state load with updated_at_ms as the creation time", async t3 => {
const { get } = await startPlugin(t3, { files: { exp_reports: { "reports.json": JSON.stringify([{
id: 5, player_name: "bob", by_player_name: "alice", reason: "griefing", instance_name: "EXP", updated_at_ms: 1000,
}]) } } });
const report = await get(5);
t3.strictSame([report.createdAtMs, report.isRead], [1000, false]);
});
t2.test("WatchListAddRequest and WatchListRemoveRequest edit the watch list", async t3 => {
const { watch, unwatch, watchSubscription, state } = await startPlugin(t3);
const entry = await watch(" bob ", " griefing ");
t3.strictSame(
[entry.playerName, entry.reason, entry.byPlayerName], ["bob", "griefing", "admin"],
"the entry is trimmed and added by the connected user",
);
t3.strictSame((await watchSubscription(0)).updates, [entry], "subscribers get the entry");
await t3.rejects(watch("bob", "again"), { message: "bob is already on the watch list" });
await t3.rejects(
watch("BOB", "again"), { message: "bob is already on the watch list" }, "names are case insensitive",
);
await t3.rejects(watch(" ", "spam"), { message: "A player name is required" });
await t3.rejects(watch("carol", " "), { message: "A reason is required" });
await unwatch("Bob");
t3.ok(
state.broadcasts.some(event => event instanceof messages.WatchListUpdatedEvent && event.updates[0].isDeleted),
"subscribers are told it was removed",
);
t3.strictSame(await watchSubscription(0), null, "nothing is left to replay");
await t3.rejects(unwatch("bob"), { message: "bob is not on the watch list" });
});
t2.test("a watched player joining is sent to every instance and posted", async t3 => {
const { watch, join, state } = await startPlugin(t3, { config: {
"exp_reports.discord_webhook_url": "https://discord.example/hook",
"exp_reports.json_webhook_url": "https://json.example/hook",
} });
const entry = await watch("bob", "griefing");
await join("carol");
t3.strictSame(state.sent, [], "players not on the list are ignored");
t3.strictSame(state.posts, []);
await join("bob");
t3.strictSame(state.sent.length, 1);
const { dst, message } = state.sent[0];
t3.strictSame(dst, "allInstances");
t3.strictSame(message.toJSON(), new messages.WatchedPlayerJoinedEvent("bob", "EXP", entry, []).toJSON());
const embed = state.posts[0].body.embeds[0];
t3.strictSame(
embed.fields.map(field => [field.name, field.value]),
[["Player", "bob"], ["Instance", "EXP"], ["Watch list, added by admin", "griefing"]],
);
t3.strictSame(state.posts[1].body, { type: "watched_player_joined", ...message.toJSON() });
});
t2.test("watch list names match joins case insensitively", async t3 => {
const { controller, watch, create, join, state } = await startPlugin(t3);
const entry = await watch("Bob", "griefing");
await join("bOB");
t3.strictSame(state.sent[0].message.watchListEntry, entry, "the entry is found");
t3.strictSame(state.sent[0].message.playerName, "bOB", "the event names the player as they joined");
state.sent.length = 0;
controller.config.set("exp_reports.watch_unread_reports", true);
await create("carol", "spam", "alice");
await join("CAROL");
t3.strictSame(state.sent[0].message.unreadReports.length, 1, "unread reports are found");
});
t2.test("the join goes only to the joined instance when announce_to_all_instances is off", async t3 => {
const { controller, watch, join, state } = await startPlugin(t3);
await watch("bob", "griefing");
controller.config.set("exp_reports.announce_to_all_instances", false);
await join("bob");
t3.strictSame(state.sent[0].dst, { instanceId: 1 });
});
t2.test("the discord embed lists the three newest unread reports and counts the rest", async t3 => {
const { create, join, state } = await startPlugin(t3, { config: {
"exp_reports.discord_webhook_url": "https://discord.example/hook",
"exp_reports.watch_unread_reports": true,
} });
for (const [index, reporter] of ["a", "b", "c", "d", "e"].entries()) {
const report = await create("bob", `reason ${index}`, reporter);
report.createdAtMs = 1000 + index;
}
state.posts.length = 0;
await join("bob");
const embed = state.posts[0].body.embeds[0];
t3.strictSame(
embed.fields.slice(2).map(field => field.value), ["reason 4", "reason 3", "reason 2"],
"the newest three are listed",
);
t3.strictSame(embed.footer, { text: "+2 more unread reports" });
t3.strictSame(state.sent[0].message.unreadReports.length, 5, "the instances still get every report");
});
t2.test("unread reports count as the watch list only when configured", async t3 => {
const { controller, create, setRead, join, state } = await startPlugin(t3);
const read = await create("bob", "spam", "alice");
const unread = await create("bob", "griefing", "carol");
await setRead(read.id, true);
await join("bob");
t3.strictSame(state.sent, [], "reports are ignored by default");
controller.config.set("exp_reports.watch_unread_reports", true);
await join("bob");
const { message } = state.sent[0];
t3.strictSame(message.watchListEntry, undefined, "bob is not on the watch list");
t3.strictSame(message.unreadReports.map(report => report.id), [unread.id], "only the unread report is sent");
});
t2.test("subscribing replays only newer records", async t3 => {
const { create, subscription } = await startPlugin(t3);
const report = await create("bob", "griefing", "alice", fromInstance);
+12
View File
@@ -71,6 +71,7 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2,
createIPC: event => ipc.get("exp_reports:create")(event),
listIPC: event => ipc.get("exp_reports:list")(event),
deleteIPC: event => ipc.get("exp_reports:delete")(event),
watchedJoined: event => instance._eventHandlers.get(messages.WatchedPlayerJoinedEvent)(event),
};
}
@@ -151,6 +152,17 @@ t.test("instance plugin", t2 => {
);
});
t2.test("WatchedPlayerJoinedEvent is handed to lua", async t3 => {
const { state, watchedJoined } = await startPlugin(t3);
const entry = new messages.WatchListRecord("bob", "griefing", "admin", 1000);
const event = new messages.WatchedPlayerJoinedEvent("bob", "EXP", entry, [report(1, "bob", "alice")]);
await watchedJoined(event);
const { receiver, payload } = decodeRcon(state.rcons[0]);
t3.strictSame(receiver, "receive_watched_joined");
t3.strictSame(payload, event.toJSON());
});
t2.test("answers are dropped once the instance has stopped", async t3 => {
const { instance, state, listIPC } = await startPlugin(t3);
instance.notifyStatus("stopped");
+62
View File
@@ -3,6 +3,7 @@ import * as messages from "../dist/node/messages.js";
import { testMatrix, testRoundTripJsonSerialisable } from "../../test/common.js";
const sampleReport = new messages.ReportRecord(7, "bob", "alice", "griefing", "EXP", 12345);
const sampleEntry = new messages.WatchListRecord("bob", "griefing", "alice", 12345);
t.test("class ReportRecord", t2 => {
testRoundTripJsonSerialisable(t2, messages.ReportRecord, testMatrix(
@@ -11,6 +12,8 @@ t.test("class ReportRecord", t2 => {
["alice"], // byPlayerName
["griefing"], // reason
["", "EXP"], // instanceName
[12345], // createdAtMs
[false, true], // isRead
[0, 12345], // updatedAtMs
[false, true], // isDeleted
));
@@ -18,6 +21,20 @@ t.test("class ReportRecord", t2 => {
t2.end();
});
t.test("class WatchListRecord", t2 => {
testRoundTripJsonSerialisable(t2, messages.WatchListRecord, testMatrix(
["bob"], // playerName
["griefing"], // reason
["alice"], // byPlayerName
[12345], // createdAtMs
[0, 12345], // updatedAtMs
[false, true], // isDeleted
));
t2.equal(new messages.WatchListRecord("Bob", "griefing", "alice").id, "bob", "the lower case player name is the id");
t2.end();
});
t.test("class ReportUpdatedEvent", t2 => {
testRoundTripJsonSerialisable(t2, messages.ReportUpdatedEvent, testMatrix(
[[], [sampleReport]], // updates
@@ -26,6 +43,25 @@ t.test("class ReportUpdatedEvent", t2 => {
t2.end();
});
t.test("class WatchListUpdatedEvent", t2 => {
testRoundTripJsonSerialisable(t2, messages.WatchListUpdatedEvent, testMatrix(
[[], [sampleEntry]], // updates
));
t2.end();
});
t.test("class WatchedPlayerJoinedEvent", t2 => {
testRoundTripJsonSerialisable(t2, messages.WatchedPlayerJoinedEvent, testMatrix(
["bob"], // playerName
["EXP"], // instanceName
[undefined, sampleEntry], // watchListEntry
[[], [sampleReport]], // unreadReports
));
t2.end();
});
t.test("class ReportListRequest", t2 => {
testRoundTripJsonSerialisable(t2, messages.ReportListRequest, testMatrix(
[undefined, "bob"], // playerName
@@ -59,3 +95,29 @@ t.test("class ReportDeleteRequest", t2 => {
t2.end();
});
t.test("class ReportSetReadRequest", t2 => {
testRoundTripJsonSerialisable(t2, messages.ReportSetReadRequest, testMatrix(
[7], // id
[false, true], // isRead
));
t2.end();
});
t.test("class WatchListAddRequest", t2 => {
testRoundTripJsonSerialisable(t2, messages.WatchListAddRequest, testMatrix(
["bob"], // playerName
["griefing"], // reason
));
t2.end();
});
t.test("class WatchListRemoveRequest", t2 => {
testRoundTripJsonSerialisable(t2, messages.WatchListRemoveRequest, testMatrix(
["bob"], // playerName
));
t2.end();
});
+1
View File
@@ -42,6 +42,7 @@ return Framework.suite(function(env)
by_player_name = by_player_name,
reason = reason or "griefing",
instance_name = "test",
created_at_ms = 1000,
updated_at_ms = 1000,
}
end
+30
View File
@@ -99,4 +99,34 @@ Suite.test("receive_error() prints the refusal to the caller while they are onli
Suite.eq(env.printed, { { to = "alice", "alice has already reported bob" } }, "the offline player is skipped")
end)
Suite.test("receive_watched_joined() tells only admins why the player is watched", function(env)
env.add_player{ name = "alice", admin = true }
env.add_player{ name = "dave", admin = true, connected = false }
env.add_player{ name = "carol" }
env.Reports.receive_watched_joined{
player_name = "bob",
instance_name = "EXP 2",
watch_list_entry = { player_name = "bob", reason = "griefing", by_player_name = "admin", created_at_ms = 1000 },
unread_reports = { env.report("bob", "carol", "spam") },
}
Suite.eq(env.printed, {
{ to = "alice", { "exp-reports.watched-joined", "bob", "EXP 2" } },
{ to = "alice", { "exp-reports.watched-entry", "admin", "griefing" } },
{ to = "alice", { "exp-reports.watched-report", "carol", "spam" } },
}, "the online admin sees the entry and each unread report")
end)
Suite.test("receive_watched_joined() works with only unread reports", function(env)
env.add_player{ name = "alice", admin = true }
env.Reports.receive_watched_joined{
player_name = "bob",
instance_name = "EXP",
unread_reports = { env.report("bob", "carol", "spam") },
}
Suite.eq(env.printed, {
{ to = "alice", { "exp-reports.watched-joined", "bob", "EXP" } },
{ to = "alice", { "exp-reports.watched-report", "carol", "spam" } },
}, "there is no watch list line")
end)
return Suite.run()
+171 -18
View File
@@ -1,13 +1,16 @@
import React, { useContext, useState } from "react";
import { Button, Form, Input, Modal, Popconfirm, Table } from "antd";
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag } from "antd";
import {
ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount,
useColumnSearch, useTableQueryState,
} from "@clusterio/web_ui";
import { ReportCreateRequest, ReportDeleteRequest, ReportRecord } from "../../messages.js";
import { useReports } from "..";
import {
ReportCreateRequest, ReportDeleteRequest, ReportRecord, ReportSetReadRequest,
WatchListAddRequest, WatchListRecord, WatchListRemoveRequest,
} from "../../messages.js";
import { useReports, useWatchList } from "..";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -46,15 +49,54 @@ function CreateReportButton() {
</>;
}
/** Table of every report with search on the player, reporter and reason, and a filter on the instance. */
function ReportsTable() {
type WatchListValues = { playerName: string, reason: string };
/**
* Modal which adds a player to the watch list, filled in from a report or left empty.
* Mounted only while open, the form keeps its initial values from the first mount.
*/
function WatchListAddModal(props: { values: WatchListValues, onClose: () => void }) {
const control = useContext(ControlContext);
const [form] = Form.useForm<WatchListValues>();
async function addPlayer() {
const values = await form.validateFields();
await control.send(new WatchListAddRequest(values.playerName, values.reason));
props.onClose();
}
return <Modal
title="Add to Watch List"
okText="Add"
open
onOk={() => { addPlayer().catch(notifyErrorHandler("Error adding to watch list")); }}
onCancel={props.onClose}
>
<Form form={form} layout="vertical" initialValues={props.values}>
<Form.Item name="playerName" label="Player" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="reason" label="Reason" rules={[{ required: true }]}>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>;
}
/**
* Table of every report with search on the player, reporter and reason, and
* filters on the instance and read state.
*/
function ReportsTable(props: { onWatch: (values: WatchListValues) => void }) {
const control = useContext(ControlContext);
const account = useAccount();
const [reports, synced] = useReports();
const [watchList] = useWatchList();
const tableState = useTableQueryState<ReportRecord>({
namespace: "report",
defaultSortKey: "time",
defaultSortOrder: "descend",
pagination: { defaultPageSize: 50 },
});
const playerSearch = useColumnSearch<ReportRecord>(tableState, "player", report => report.playerName, "Search players");
@@ -64,8 +106,14 @@ function ReportsTable() {
const data = [...reports.values()];
const instanceNames = [...new Set(data.map(report => report.instanceName))].sort(strcmp);
const instanceFilters = instanceNames.map(name => ({ text: name || "Web UI", value: name }));
const canMarkRead = account.hasPermission("exp_reports.report.mark_read");
const canWatch = account.hasPermission("exp_reports.watch_list.add");
const canDelete = account.hasPermission("exp_reports.report.delete");
function setRead(report: ReportRecord, isRead: boolean) {
control.send(new ReportSetReadRequest(report.id, isRead)).catch(notifyErrorHandler("Error updating report"));
}
return <Table
size="small"
columns={[
@@ -107,21 +155,41 @@ function ReportsTable() {
{
title: "Time",
key: "time",
render: (_, report) => new Date(report.updatedAtMs).toLocaleString(),
sorter: (a, b) => a.updatedAtMs - b.updatedAtMs,
render: (_, report) => new Date(report.createdAtMs).toLocaleString(),
sorter: (a, b) => a.createdAtMs - b.createdAtMs,
sortOrder: tableState.sortOrder("time"),
defaultSortOrder: "descend",
},
...canDelete ? [{
{
title: "Status",
key: "status",
render: (_, report) => (report.isRead ? <Tag>Read</Tag> : <Tag color="gold">Unread</Tag>),
filters: [{ text: "Read", value: "read" }, { text: "Unread", value: "unread" }],
filteredValue: tableState.filteredValue("status"),
onFilter: (value, report) => report.isRead === (value === "read"),
},
...canMarkRead || canWatch || canDelete ? [{
key: "actions",
render: (_: unknown, report: ReportRecord) => <Popconfirm
title="Delete this report?"
onConfirm={() => {
control.send(new ReportDeleteRequest(report.id)).catch(notifyErrorHandler("Error deleting report"));
}}
>
<Button size="small" danger onClick={event => event.stopPropagation()}>Delete</Button>
</Popconfirm>,
render: (_: unknown, report: ReportRecord) => <Space size="small">
{canMarkRead && <Button size="small" onClick={() => setRead(report, !report.isRead)}>
{report.isRead ? "Mark unread" : "Mark read"}
</Button>}
{canWatch && <Button
size="small"
disabled={watchList.has(report.playerName.toLowerCase())}
onClick={() => props.onWatch({ playerName: report.playerName, reason: report.reason })}
>
Add to watch list
</Button>}
{canDelete && <Popconfirm
title="Delete this report?"
onConfirm={() => {
control.send(new ReportDeleteRequest(report.id))
.catch(notifyErrorHandler("Error deleting report"));
}}
>
<Button size="small" danger onClick={event => event.stopPropagation()}>Delete</Button>
</Popconfirm>}
</Space>,
}] : [],
]}
dataSource={data}
@@ -132,13 +200,98 @@ function ReportsTable() {
/>;
}
/** Table of the players whose joins are announced to admins. */
function WatchListTable() {
const control = useContext(ControlContext);
const account = useAccount();
const [watchList, synced] = useWatchList();
const tableState = useTableQueryState<WatchListRecord>({
namespace: "watch",
defaultSortKey: "time",
defaultSortOrder: "descend",
pagination: { defaultPageSize: 50 },
});
const playerSearch = useColumnSearch<WatchListRecord>(
tableState, "player", entry => entry.playerName, "Search players",
);
const reasonSearch = useColumnSearch<WatchListRecord>(tableState, "reason", entry => entry.reason, "Search reasons");
const canRemove = account.hasPermission("exp_reports.watch_list.remove");
return <Table
size="small"
columns={[
{
title: "Player",
key: "player",
render: (_, entry) => entry.playerName,
sorter: (a, b) => strcmp(a.playerName, b.playerName),
sortOrder: tableState.sortOrder("player"),
filteredValue: tableState.filteredValue("player"),
...playerSearch,
},
{
title: "Reason",
key: "reason",
render: (_, entry) => entry.reason,
filteredValue: tableState.filteredValue("reason"),
...reasonSearch,
},
{
title: "Added by",
key: "by",
render: (_, entry) => entry.byPlayerName,
sorter: (a, b) => strcmp(a.byPlayerName, b.byPlayerName),
sortOrder: tableState.sortOrder("by"),
},
{
title: "Time",
key: "time",
render: (_, entry) => new Date(entry.createdAtMs).toLocaleString(),
sorter: (a, b) => a.createdAtMs - b.createdAtMs,
sortOrder: tableState.sortOrder("time"),
},
...canRemove ? [{
key: "actions",
render: (_: unknown, entry: WatchListRecord) => <Popconfirm
title={`Remove ${entry.playerName} from the watch list?`}
onConfirm={() => {
control.send(new WatchListRemoveRequest(entry.playerName))
.catch(notifyErrorHandler("Error removing from watch list"));
}}
>
<Button size="small" danger onClick={event => event.stopPropagation()}>Delete</Button>
</Popconfirm>,
}] : [],
]}
dataSource={[...watchList.values()]}
loading={!synced}
rowKey={entry => entry.playerName}
pagination={tableState.pagination}
onChange={tableState.onChange}
/>;
}
export default function ReportsPage() {
const account = useAccount();
const [watchValues, setWatchValues] = useState<WatchListValues>();
const canWatch = account.hasPermission("exp_reports.watch_list.add");
return <PageLayout nav={[{ name: "Reports" }]}>
<PageHeader
title="Reports"
extra={account.hasPermission("exp_reports.report.create") ? <CreateReportButton /> : undefined}
/>
<ReportsTable />
<ReportsTable onWatch={setWatchValues} />
{account.hasPermission("exp_reports.watch_list.subscribe") && <>
<PageHeader
title="Watch List"
extra={canWatch
? <Button type="primary" onClick={() => setWatchValues({ playerName: "", reason: "" })}>Add</Button>
: undefined}
/>
<WatchListTable />
</>}
{watchValues && <WatchListAddModal values={watchValues} onClose={() => setWatchValues(undefined)} />}
</PageLayout>;
}
+7
View File
@@ -7,12 +7,18 @@ import * as messages from "../messages.js";
import ReportsPage from "./components/ReportsPage";
let reports: lib.MapSubscriber<messages.ReportUpdatedEvent> | undefined;
let watchList: lib.MapSubscriber<messages.WatchListUpdatedEvent> | undefined;
export function useReports() {
const subscribe = useCallback((cb: () => void) => reports!.subscribe(cb), []);
return useSyncExternalStore(subscribe, () => reports!.getSnapshot());
}
export function useWatchList() {
const subscribe = useCallback((cb: () => void) => watchList!.subscribe(cb), []);
return useSyncExternalStore(subscribe, () => watchList!.getSnapshot());
}
export default function registerPlugin(context: WebPluginContext) {
context.control.hooks.pages.attach("exp_reports", () => [
{
@@ -24,4 +30,5 @@ export default function registerPlugin(context: WebPluginContext) {
]);
reports = new lib.MapSubscriber(messages.ReportUpdatedEvent, context.control);
watchList = new lib.MapSubscriber(messages.WatchListUpdatedEvent, context.control);
}