website (17 pages + 3 components): - multiplayer/wizard, middleware/wizard+test-wizard, communication - builds/wizard, staff-search, voice, sbom/wizard - foerderantrag, mail/tasks, tools/communication, sbom - compliance/evidence, uni-crawler, brandbook (already done) - CollectionsTab, IngestionTab, RiskHeatmap backend-lehrer (5 files): - letters_api (641 → 2), certificates_api (636 → 2) - alerts_agent/db/models (636 → 3) - llm_gateway/communication_service (614 → 2) - game/database already done in prior batch klausur-service (2 files): - hybrid_vocab_extractor (664 → 2) - klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2) voice-service (3 files): - bqas/rag_judge (618 → 3), runner (529 → 2) - enhanced_task_orchestrator (519 → 2) studio-v2 (6 files): - korrektur/[klausurId] (578 → 4), fairness (569 → 2) - AlertsWizard (552 → 2), OnboardingWizard (513 → 2) - korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
183 lines
9.7 KiB
TypeScript
183 lines
9.7 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* University Crawler Control Panel
|
|
*
|
|
* Admin interface for managing the multi-phase university crawling system.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import AdminLayout from '@/components/admin/AdminLayout'
|
|
import {
|
|
CrawlQueueItem,
|
|
OrchestratorStatus,
|
|
University,
|
|
API_BASE,
|
|
phaseConfig,
|
|
} from './_components/types'
|
|
import { QueueList } from './_components/QueueList'
|
|
|
|
export default function UniCrawlerPage() {
|
|
const [status, setStatus] = useState<OrchestratorStatus | null>(null)
|
|
const [queue, setQueue] = useState<CrawlQueueItem[]>([])
|
|
const [universities, setUniversities] = useState<University[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [success, setSuccess] = useState<string | null>(null)
|
|
const [selectedUniversity, setSelectedUniversity] = useState('')
|
|
const [priority, setPriority] = useState(5)
|
|
|
|
const fetchStatus = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}?action=status`)
|
|
if (res.ok) setStatus(await res.json())
|
|
} catch (err) { console.error('Failed to fetch status:', err) }
|
|
}, [])
|
|
|
|
const fetchQueue = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}?action=queue`)
|
|
if (res.ok) { const data = await res.json(); setQueue(data.queue || []) }
|
|
} catch (err) { console.error('Failed to fetch queue:', err) }
|
|
}, [])
|
|
|
|
const fetchUniversities = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}?action=universities`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const unis = data.universities ?? data ?? []
|
|
setUniversities(Array.isArray(unis) ? unis : [])
|
|
}
|
|
} catch (err) { console.error('Failed to fetch universities:', err) }
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchStatus(); fetchQueue(); fetchUniversities()
|
|
const interval = setInterval(() => { fetchStatus(); fetchQueue() }, 5000)
|
|
return () => clearInterval(interval)
|
|
}, [fetchStatus, fetchQueue, fetchUniversities])
|
|
|
|
const handleStart = async () => {
|
|
setLoading(true); setError(null)
|
|
try {
|
|
const res = await fetch(`${API_BASE}?action=start`, { method: 'POST' })
|
|
if (res.ok) { setSuccess('Orchestrator gestartet'); fetchStatus() }
|
|
else { const data = await res.json(); setError(data.error || 'Start fehlgeschlagen') }
|
|
} catch { setError('Verbindungsfehler') }
|
|
finally { setLoading(false) }
|
|
}
|
|
|
|
const handleStop = async () => {
|
|
setLoading(true); setError(null)
|
|
try {
|
|
const res = await fetch(`${API_BASE}?action=stop`, { method: 'POST' })
|
|
if (res.ok) { setSuccess('Orchestrator gestoppt'); fetchStatus() }
|
|
else { const data = await res.json(); setError(data.error || 'Stop fehlgeschlagen') }
|
|
} catch { setError('Verbindungsfehler') }
|
|
finally { setLoading(false) }
|
|
}
|
|
|
|
const handleAddToQueue = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!selectedUniversity) return
|
|
setLoading(true); setError(null)
|
|
try {
|
|
const res = await fetch(`${API_BASE}?action=queue`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ university_id: selectedUniversity, priority, initiated_by: 'admin-ui' })
|
|
})
|
|
if (res.ok) { setSuccess('Universitaet zur Queue hinzugefuegt'); setSelectedUniversity(''); setPriority(5); fetchQueue() }
|
|
else { const data = await res.json(); setError(data.error || 'Hinzufuegen fehlgeschlagen') }
|
|
} catch { setError('Verbindungsfehler') }
|
|
finally { setLoading(false) }
|
|
}
|
|
|
|
const handleRemove = async (universityId: string) => {
|
|
if (!confirm('Wirklich aus der Queue entfernen?')) return
|
|
try { const res = await fetch(`${API_BASE}?university_id=${universityId}`, { method: 'DELETE' }); if (res.ok) fetchQueue() }
|
|
catch (err) { console.error('Remove failed:', err) }
|
|
}
|
|
|
|
const handlePause = async (universityId: string) => {
|
|
try { const res = await fetch(`${API_BASE}?action=pause&university_id=${universityId}`, { method: 'POST' }); if (res.ok) fetchQueue() }
|
|
catch (err) { console.error('Pause failed:', err) }
|
|
}
|
|
|
|
const handleResume = async (universityId: string) => {
|
|
try { const res = await fetch(`${API_BASE}?action=resume&university_id=${universityId}`, { method: 'POST' }); if (res.ok) fetchQueue() }
|
|
catch (err) { console.error('Resume failed:', err) }
|
|
}
|
|
|
|
useEffect(() => { if (success) { const t = setTimeout(() => setSuccess(null), 5000); return () => clearTimeout(t) } }, [success])
|
|
useEffect(() => { if (error) { const t = setTimeout(() => setError(null), 5000); return () => clearTimeout(t) } }, [error])
|
|
|
|
return (
|
|
<AdminLayout title="University Crawler" description="Steuerung des mehrstufigen Crawling-Systems fuer Universitaeten">
|
|
{error && <div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{error}</div>}
|
|
{success && <div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">{success}</div>}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Status Card */}
|
|
<div className="lg:col-span-1">
|
|
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Orchestrator Status</h2>
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className={`w-4 h-4 rounded-full ${status?.is_running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
|
|
<span className={`font-medium ${status?.is_running ? 'text-green-700' : 'text-gray-600'}`}>
|
|
{status?.is_running ? 'Laeuft' : 'Gestoppt'}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-3 mb-6">
|
|
<button onClick={handleStart} disabled={loading || status?.is_running} className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Start</button>
|
|
<button onClick={handleStop} disabled={loading || !status?.is_running} className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Stop</button>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between text-sm"><span className="text-gray-500">In Queue:</span><span className="font-medium">{status?.queue_length || 0}</span></div>
|
|
<div className="flex justify-between text-sm"><span className="text-gray-500">Heute abgeschlossen:</span><span className="font-medium text-green-600">{status?.completed_today || 0}</span></div>
|
|
<div className="flex justify-between text-sm"><span className="text-gray-500">Gesamt verarbeitet:</span><span className="font-medium">{status?.total_processed || 0}</span></div>
|
|
{status?.last_activity && (
|
|
<div className="flex justify-between text-sm"><span className="text-gray-500">Letzte Aktivitaet:</span><span className="font-medium text-xs">{new Date(status.last_activity).toLocaleTimeString('de-DE')}</span></div>
|
|
)}
|
|
</div>
|
|
{status?.current_university && (
|
|
<div className="mt-6 pt-6 border-t border-gray-100">
|
|
<h3 className="text-sm font-medium text-gray-500 mb-2">Aktuelle Verarbeitung</h3>
|
|
<div className="bg-blue-50 rounded-lg p-3">
|
|
<p className="font-medium text-blue-900">{status.current_university.university_name}</p>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[status.current_phase].color}`}>{phaseConfig[status.current_phase].label}</span>
|
|
<span className="text-xs text-blue-600">{status.current_university.progress_percent}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add to Queue Form */}
|
|
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100 mt-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Zur Queue hinzufuegen</h2>
|
|
<form onSubmit={handleAddToQueue} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Universitaet</label>
|
|
<select value={selectedUniversity} onChange={(e) => setSelectedUniversity(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
<option value="">Waehlen...</option>
|
|
{universities.map((uni) => (<option key={uni.id} value={uni.id}>{uni.name} {uni.short_name && `(${uni.short_name})`}</option>))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet (1-10)</label>
|
|
<input type="number" min={1} max={10} value={priority} onChange={(e) => setPriority(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
|
<p className="text-xs text-gray-500 mt-1">Hoeher = dringender</p>
|
|
</div>
|
|
<button type="submit" disabled={loading || !selectedUniversity} className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Hinzufuegen</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<QueueList queue={queue} onPause={handlePause} onResume={handleResume} onRemove={handleRemove} />
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|