mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-15 16:24:52 +09:00
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:
1 parent
82abc2c332
commit
44ae3a5f0e
14 files changed
+826
-43
No files matched your search
@@ -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,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);
|
||||
}
|
||||
Reference in new issue
Block a user