Add a watch list and read state to exp_reports

Players on the watch list are announced to every online admin on every
instance when they join, and the join is posted to the Discord and JSON
webhooks. The Reports page has a second table for the watch list with an
Add button and a Delete button per row.

Reports can be marked read or unread from the web interface. Each report
row also has an Add to watch list button which opens the add form filled
in with the player and reason. Setting exp_reports.watch_unread_reports
treats players with unread reports as watched.

Reports now store created_at_ms because marking them read changes
updated_at_ms. Old reports.json files migrate on load with updated_at_ms
as the creation time. The report webhook moved from the datastore update
listener to the create handler so reading a report is not posted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-20 18:36:22 +00:00
1 parent 82abc2c332
commit 44ae3a5f0e
14 files changed
+826 -43

No files matched your search

+168 -17
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,11 +49,49 @@ 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",
@@ -64,8 +105,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 +154,42 @@ 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)}
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,96 @@ 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",
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"),
defaultSortOrder: "descend",
},
...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);
}