feat: add "Reset for Smoke Test" button in admin settings

Truncates sources, curriculum, content, quiz banks/results/cache, topics
and relations in dependency order so AI-generated state can be wiped
between smoke runs without leaving dangling references. Handbook sync
state is cleared by default (otherwise re-sync is a no-op); user
progress and leaderboard are opt-in. Team members, settings, and LLM
telemetry are preserved.

UI lives in Admin → Settings → Danger Zone and requires typing RESET
before the button enables. Per-collection deletion counts are reported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-05-22 16:42:54 +02:00
parent 25cae2fea9
commit 3626cc0525
2 changed files with 159 additions and 1 deletions

View File

@@ -338,6 +338,56 @@ export async function getRecentLlmCalls(limit = 100) {
}
}
// ── Reset for Smoke Testing ──────────────────────────────────────────────────
/**
* Truncate the collections that hold AI-generated and progress data.
*
* Order matters: rows that reference `topics.id` (relations, content,
* quiz_banks) are deleted before topics themselves so we never leave
* dangling references mid-wipe.
*
* `team_members`, `settings`, and `llm_calls` are intentionally preserved.
*
* @param {{ includeHandbookState?: boolean, includeProgress?: boolean }} [opts]
* @returns {Promise<Array<{collection: string, deleted: number, error?: string}>>}
*/
export async function resetForSmokeTest({
includeHandbookState = true,
includeProgress = false,
} = {}) {
const collections = [
'relations',
'content',
'quiz_banks',
'quiz_results',
'quiz_cache',
'curriculum',
'sources',
'topics',
];
if (includeHandbookState) collections.push('handbook_sync_state');
if (includeProgress) collections.push('learn_progress', 'leaderboard');
const results = [];
for (const name of collections) {
try {
const rows = await pb.collection(name).getFullList({ fields: 'id', requestKey: null });
// Sequential deletes — bulk delete-many isn't in the JS SDK and parallel
// deletes on the same collection sometimes 409 in PocketBase.
let deleted = 0;
for (const r of rows) {
await pb.collection(name).delete(r.id, { requestKey: null });
deleted++;
}
results.push({ collection: name, deleted });
} catch (err) {
results.push({ collection: name, deleted: 0, error: err?.message || String(err) });
}
}
return results;
}
// ── Handbook Sync State ───────────────────────────────────────────────────────
export async function getHandbookSyncStates() {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays, Activity } from 'lucide-react';
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays, Activity, RefreshCw, AlertTriangle } from 'lucide-react';
import Card from '../../components/ui/Card';
import Tag from '../../components/ui/Tag';
import Button from '../../components/ui/Button';
@@ -30,6 +30,12 @@ const Admin = () => {
const [useSimulation, setUseSimulation] = useState(false);
const [saveStatus, setSaveStatus] = useState(null);
const [resetConfirmText, setResetConfirmText] = useState('');
const [resetIncludeHandbook, setResetIncludeHandbook] = useState(true);
const [resetIncludeProgress, setResetIncludeProgress] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [resetReport, setResetReport] = useState(null);
const loadSources = async () => {
const data = await db.getSources();
setSources(data);
@@ -58,6 +64,23 @@ const Admin = () => {
setTimeout(() => setSaveStatus(null), 3000);
};
const runSmokeReset = async () => {
setIsResetting(true);
setResetReport(null);
try {
const report = await db.resetForSmokeTest({
includeHandbookState: resetIncludeHandbook,
includeProgress: resetIncludeProgress,
});
setResetReport(report);
setResetConfirmText('');
} catch (err) {
setResetReport([{ collection: '(unexpected)', deleted: 0, error: err?.message || String(err) }]);
} finally {
setIsResetting(false);
}
};
const handleDeleteSource = async (id) => {
if (confirm('Are you sure you want to delete this source? This will not delete topics already extracted.')) {
await db.deleteSource(id);
@@ -269,6 +292,91 @@ const Admin = () => {
</div>
</form>
</Card>
<Card className="border border-red-300 bg-red-50/40 mt-8">
<div className="space-y-4">
<div className="flex items-start gap-3">
<AlertTriangle size={22} className="text-red-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-lg font-medium text-red-700">Danger Zone Reset for Smoke Test</h3>
<p className="text-sm text-fg-muted mt-1">
Deletes all AI-generated content so the platform can be exercised from a clean slate. Team members, settings, and LLM telemetry are preserved.
</p>
</div>
</div>
<div className="text-sm text-fg-muted bg-paper border border-bg-warm rounded-[var(--r-sm)] p-3">
<p className="font-medium text-fg mb-2">Will be cleared:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Sources</li>
<li>Curriculum</li>
<li>Content (per-topic learning modules)</li>
<li>Quiz banks, quiz results, quiz cache</li>
<li>Topics &amp; relations</li>
</ul>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={resetIncludeHandbook}
onChange={(e) => setResetIncludeHandbook(e.target.checked)}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
Also clear handbook sync state (so &quot;Sync Employee Handbook&quot; re-processes every file)
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={resetIncludeProgress}
onChange={(e) => setResetIncludeProgress(e.target.checked)}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
Also clear user progress &amp; leaderboard
</label>
</div>
<div>
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">
Type <span className="font-mono text-red-700">RESET</span> to confirm
</label>
<input
type="text"
value={resetConfirmText}
onChange={(e) => setResetConfirmText(e.target.value)}
placeholder="RESET"
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-2 text-sm bg-paper font-mono"
disabled={isResetting}
/>
</div>
<Button
onClick={runSmokeReset}
disabled={resetConfirmText !== 'RESET' || isResetting}
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700 flex items-center gap-2"
>
<RefreshCw size={16} className={isResetting ? 'animate-spin' : ''} />
{isResetting ? 'Resetting…' : 'Reset for Smoke Test'}
</Button>
{resetReport && (
<div className="mt-2 text-sm bg-paper border border-bg-warm rounded-[var(--r-sm)] p-3 space-y-1">
<p className="font-medium text-fg mb-1">Reset complete</p>
{resetReport.map((row) => (
<div key={row.collection} className="flex items-center justify-between font-mono text-xs">
<span className="text-fg-muted">{row.collection}</span>
{row.error ? (
<span className="text-red-600">error: {row.error}</span>
) : (
<span className="text-teal">{row.deleted} deleted</span>
)}
</div>
))}
</div>
)}
</div>
</Card>
</div>
)}
</div>