Add Bot Activity admin panel: banned-IP view + recent events + emergency unban
Expose the botScore middleware's in-memory scoring/ban state to admins. Previously state lived only in the store Map with no persistence or API — the only visibility was tailing container logs. - botScore: bounded ring buffer (300) recording scan/login-fail/honeypot and ban events (most-recent-first); listState() snapshot of all scored IPs; unban() to clear a single IP. - New admin-only endpoints GET /admin/bot-activity and POST /admin/bot-activity/unban (RBAC admin gate, IP validated). Unban is activity-logged with the admin username. - Bot Activity tab: currently-banned table with Unban, plus a recent-events feed, following the existing admin table patterns. - Tests for the buffer, listState, and unban (guard lets an unbanned IP back through). README updated. Read + emergency-unban only — no ban-add or weight-editing surface. Buffer is in-memory, matching the store; not persisted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
|
||||
@@ -71,6 +72,7 @@ export default function App() {
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
|
||||
@@ -108,6 +108,8 @@ export const api = {
|
||||
getSettings: () => req('/admin/settings'),
|
||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||
botActivity: () => req('/admin/bot-activity'),
|
||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||
listUsers: () => req('/admin/users'),
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
|
||||
@@ -11,6 +11,7 @@ const NAV = [
|
||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||
{ to: '/admin/settings', label: 'Settings' },
|
||||
{ to: '/admin/activity', label: 'Activity' },
|
||||
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||
{ to: '/admin/users', label: 'Users' },
|
||||
{ to: '/admin/account', label: 'Account' },
|
||||
]
|
||||
@@ -22,6 +23,7 @@ const TITLES = {
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Bot Activity',
|
||||
'/admin/users': 'Users',
|
||||
'/admin/account': 'Account Security',
|
||||
}
|
||||
|
||||
142
client/src/routes/admin/views/BotActivityAdmin.jsx
Normal file
142
client/src/routes/admin/views/BotActivityAdmin.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
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, i) => (
|
||||
<tr key={`${ev.ts}-${ev.ip}-${i}`}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user