Files
website/client/src/routes/admin/views/BotActivityAdmin.jsx
wtclaude 12d50fd615
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
chore(quality): resolve SonarQube code smells across website
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 04:35:39 -05:00

143 lines
5.3 KiB
JavaScript

import { useCallback, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Read-only visibility into the botScore middleware: who is currently banned and
// a feed of recent scoring events. The only action is an emergency unban for
// false positives — there is no ban/adjust-weights surface here by design.
const mono = { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.82rem' }
export default function BotActivityAdmin() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(() => api.admin.botActivity(), [tick])
const [busyIp, setBusyIp] = useState('')
const ips = data?.ips || []
const events = data?.events || []
const banned = ips.filter((e) => e.banned)
async function unban(ip) {
if (!window.confirm(`Unban ${ip}? This clears its score and ban immediately.`)) return
setBusyIp(ip)
try {
await api.admin.unbanIp(ip)
reload()
} catch {
// Surface nothing intrusive; a reload will re-fetch true state either way.
reload()
} finally {
setBusyIp('')
}
}
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load bot activity." />
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Live, in-memory scoring and ban state from the bot-protection middleware. State resets
when the server restarts.
</p>
{/* Currently banned IPs */}
<div>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.1rem', color: 'var(--head)' }}>
Currently banned{banned.length > 0 ? ` (${banned.length})` : ''}
</h2>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">IP</th>
<th className="adm-th">Score</th>
<th className="adm-th">Banned until</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{banned.length === 0 && (
<tr>
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>
No IPs are currently banned.
</td>
</tr>
)}
{banned.map((e) => (
<tr key={e.ip}>
<td className="adm-td" style={{ ...mono, color: 'var(--head)' }}>
{e.ip}
</td>
<td className="adm-td">{e.score}</td>
<td className="adm-td dim">{dateTime(e.bannedUntil)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button
onClick={() => unban(e.ip)}
disabled={busyIp === e.ip}
className="btn btn-sq"
style={{ borderColor: '#d98b84', color: '#d98b84', padding: '5px 12px', fontSize: '0.82rem' }}
>
{busyIp === e.ip ? 'Unbanning…' : 'Unban'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Recent scoring events */}
<div>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.1rem', color: 'var(--head)' }}>
Recent events
</h2>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">When</th>
<th className="adm-th">IP</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Path</th>
<th className="adm-th">Points</th>
<th className="adm-th">Score</th>
</tr>
</thead>
<tbody>
{events.length === 0 && (
<tr>
<td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>
No events recorded yet.
</td>
</tr>
)}
{events.map((ev) => (
<tr key={`${ev.ts}-${ev.ip}-${ev.type}`}>
<td className="adm-td dim">{dateTime(ev.ts)}</td>
<td className="adm-td" style={{ ...mono, color: 'var(--text)' }}>
{ev.ip}
</td>
<td className="adm-td">
<span style={{ ...mono, color: ev.type === 'ban' ? '#d98b84' : 'var(--accent)' }}>
{ev.reason}
</span>
</td>
<td className="adm-td dim" style={{ ...mono, wordBreak: 'break-all' }}>
{ev.path || '—'}
</td>
<td className="adm-td dim">{ev.points ? `+${ev.points}` : '—'}</td>
<td className="adm-td">{ev.score}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)
}