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>
143 lines
5.3 KiB
JavaScript
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>
|
|
)
|
|
}
|