feat(admin): add Guard Events tab to AI Management page
All checks were successful
build-and-release / build (push) Successful in 1m36s
All checks were successful
build-and-release / build (push) Successful in 1m36s
Shows blocked AI chat messages from ai_guard_violations, paginated, filterable by guard_type (keyword / moderation_api / length / flood). Fetches from GET /api/admin/ai/guard-events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
11041ba91c
commit
fcf65664bf
1 changed files with 157 additions and 2 deletions
|
|
@ -28,7 +28,7 @@ async function fetchJson(path: string, opts?: RequestInit) {
|
|||
}
|
||||
|
||||
export default function AiManagementPage() {
|
||||
const [activeTab, setActiveTab] = createSignal<'plans' | 'features' | 'user' | 'usage'>('plans');
|
||||
const [activeTab, setActiveTab] = createSignal<'plans' | 'features' | 'user' | 'usage' | 'guard'>('plans');
|
||||
const [userId, setUserId] = createSignal('');
|
||||
const [planCode, setPlanCode] = createSignal('');
|
||||
const [creditAmount, setCreditAmount] = createSignal(10);
|
||||
|
|
@ -75,11 +75,12 @@ export default function AiManagementPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const tabs: { key: 'plans' | 'features' | 'user' | 'usage'; label: string }[] = [
|
||||
const tabs: { key: 'plans' | 'features' | 'user' | 'usage' | 'guard'; label: string }[] = [
|
||||
{ key: 'plans', label: 'Plans' },
|
||||
{ key: 'features', label: 'Features' },
|
||||
{ key: 'user', label: 'User Management' },
|
||||
{ key: 'usage', label: 'Usage Lookup' },
|
||||
{ key: 'guard', label: '🛡 Guard Events' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -230,6 +231,160 @@ export default function AiManagementPage() {
|
|||
<UsageLookup />
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'guard'}>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">AI Guard Events</h2>
|
||||
<p style="font-size:13px;color:#6b7280;margin-bottom:16px">
|
||||
Messages blocked by the content guard (prompt injection keywords + OpenAI Moderation API).
|
||||
</p>
|
||||
<GuardEvents />
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Guard Events panel ────────────────────────────────────────────────────────
|
||||
|
||||
const GUARD_TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
keyword: { label: 'Keyword', color: '#f59e0b' },
|
||||
moderation_api: { label: 'Moderation API', color: '#ef4444' },
|
||||
length: { label: 'Length', color: '#6b7280' },
|
||||
flood: { label: 'Flood', color: '#8b5cf6' },
|
||||
};
|
||||
|
||||
function GuardEvents() {
|
||||
const [events, setEvents] = createSignal<any[]>([]);
|
||||
const [total, setTotal] = createSignal(0);
|
||||
const [filterType, setFilterType] = createSignal('');
|
||||
const [offset, setOffset] = createSignal(0);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [err, setErr] = createSignal('');
|
||||
const LIMIT = 50;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const qs = new URLSearchParams({ limit: String(LIMIT), offset: String(offset()) });
|
||||
if (filterType()) qs.set('guard_type', filterType());
|
||||
const data = await fetchJson(`/guard-events?${qs.toString()}`);
|
||||
setEvents(data.events || []);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (e: any) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load on mount and whenever filter/offset changes
|
||||
const handleFilter = () => { setOffset(0); load(); };
|
||||
const handlePrev = () => { setOffset(Math.max(0, offset() - LIMIT)); load(); };
|
||||
const handleNext = () => { setOffset(offset() + LIMIT); load(); };
|
||||
|
||||
// Initial load
|
||||
load();
|
||||
|
||||
return (
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
{/* Filter bar */}
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<select
|
||||
value={filterType()}
|
||||
onChange={(e) => setFilterType(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
<option value="keyword">Keyword</option>
|
||||
<option value="moderation_api">Moderation API</option>
|
||||
<option value="length">Length</option>
|
||||
<option value="flood">Flood</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn-primary"
|
||||
onClick={handleFilter}
|
||||
disabled={loading()}
|
||||
style="padding:7px 16px;font-size:13px"
|
||||
>
|
||||
{loading() ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
<span style="font-size:13px;color:#6b7280;margin-left:auto">{total()} total events</span>
|
||||
</div>
|
||||
|
||||
<Show when={err()}><p style="color:#b91c1c;font-size:13px">{err()}</p></Show>
|
||||
|
||||
{/* Table */}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm" style="font-size:13px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:130px">Time</th>
|
||||
<th style="width:110px">Type</th>
|
||||
<th>Reason</th>
|
||||
<th>Message excerpt</th>
|
||||
<th style="width:130px">User ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={events().length === 0 && !loading()}>
|
||||
<tr>
|
||||
<td colspan="5" style="text-align:center;color:#9ca3af;padding:24px">No guard events found.</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<For each={events()}>
|
||||
{(ev: any) => {
|
||||
const typeInfo = GUARD_TYPE_LABELS[ev.guard_type] ?? { label: ev.guard_type, color: '#6b7280' };
|
||||
return (
|
||||
<tr>
|
||||
<td style="white-space:nowrap;color:#6b7280">
|
||||
{new Date(ev.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<span style={`display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${typeInfo.color}22;color:${typeInfo.color}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td style="color:#374151">{ev.reason}</td>
|
||||
<td style="font-family:monospace;font-size:12px;color:#4b5563;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title={ev.message_excerpt}>
|
||||
{ev.message_excerpt}
|
||||
</td>
|
||||
<td style="font-family:monospace;font-size:11px;color:#9ca3af">
|
||||
{ev.user_id ? ev.user_id.slice(0, 8) + '…' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Show when={total() > LIMIT}>
|
||||
<div style="display:flex;gap:10px;align-items:center;justify-content:flex-end;font-size:13px">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onClick={handlePrev}
|
||||
disabled={offset() === 0 || loading()}
|
||||
style="padding:5px 14px"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span style="color:#6b7280">
|
||||
{offset() + 1}–{Math.min(offset() + LIMIT, total())} of {total()}
|
||||
</span>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onClick={handleNext}
|
||||
disabled={offset() + LIMIT >= total() || loading()}
|
||||
style="padding:5px 14px"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue