Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
536 lines
20 KiB
TypeScript
536 lines
20 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* University Crawler Control Panel
|
|
*
|
|
* Admin interface for managing the multi-phase university crawling system.
|
|
* Allows starting/stopping the orchestrator, adding universities to the queue,
|
|
* and monitoring crawl progress.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import AdminLayout from '@/components/admin/AdminLayout'
|
|
|
|
// Types matching the Go backend
|
|
interface CrawlQueueItem {
|
|
id: string
|
|
university_id: string
|
|
university_name: string
|
|
university_short: string
|
|
queue_position: number | null
|
|
priority: number
|
|
current_phase: CrawlPhase
|
|
discovery_completed: boolean
|
|
discovery_completed_at?: string
|
|
professors_completed: boolean
|
|
professors_completed_at?: string
|
|
all_staff_completed: boolean
|
|
all_staff_completed_at?: string
|
|
publications_completed: boolean
|
|
publications_completed_at?: string
|
|
discovery_count: number
|
|
professors_count: number
|
|
staff_count: number
|
|
publications_count: number
|
|
retry_count: number
|
|
max_retries: number
|
|
last_error?: string
|
|
started_at?: string
|
|
completed_at?: string
|
|
progress_percent: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type CrawlPhase = 'pending' | 'discovery' | 'professors' | 'all_staff' | 'publications' | 'completed' | 'failed' | 'paused'
|
|
|
|
interface OrchestratorStatus {
|
|
is_running: boolean
|
|
current_university?: CrawlQueueItem
|
|
current_phase: CrawlPhase
|
|
queue_length: number
|
|
completed_today: number
|
|
total_processed: number
|
|
last_activity?: string
|
|
}
|
|
|
|
interface University {
|
|
id: string
|
|
name: string
|
|
short_name?: string
|
|
url: string
|
|
state?: string
|
|
uni_type?: string
|
|
}
|
|
|
|
// Use local API proxy to avoid CORS issues and keep API keys server-side
|
|
const API_BASE = '/api/admin/uni-crawler'
|
|
|
|
// Phase display configuration
|
|
const phaseConfig: Record<CrawlPhase, { label: string; color: string; icon: string }> = {
|
|
pending: { label: 'Wartend', color: 'bg-gray-100 text-gray-700', icon: 'clock' },
|
|
discovery: { label: 'Discovery', color: 'bg-blue-100 text-blue-700', icon: 'search' },
|
|
professors: { label: 'Professoren', color: 'bg-indigo-100 text-indigo-700', icon: 'user' },
|
|
all_staff: { label: 'Alle Mitarbeiter', color: 'bg-purple-100 text-purple-700', icon: 'users' },
|
|
publications: { label: 'Publikationen', color: 'bg-orange-100 text-orange-700', icon: 'book' },
|
|
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700', icon: 'check' },
|
|
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700', icon: 'x' },
|
|
paused: { label: 'Pausiert', color: 'bg-yellow-100 text-yellow-700', icon: 'pause' }
|
|
}
|
|
|
|
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)
|
|
|
|
// Add to queue form
|
|
const [selectedUniversity, setSelectedUniversity] = useState('')
|
|
const [priority, setPriority] = useState(5)
|
|
|
|
// Fetch status
|
|
const fetchStatus = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}?action=status`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setStatus(data)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch status:', err)
|
|
}
|
|
}, [])
|
|
|
|
// Fetch queue
|
|
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)
|
|
}
|
|
}, [])
|
|
|
|
// Fetch universities (for dropdown)
|
|
const fetchUniversities = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}?action=universities`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
// Handle null/undefined universities array from API
|
|
const unis = data.universities ?? data ?? []
|
|
setUniversities(Array.isArray(unis) ? unis : [])
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch universities:', err)
|
|
}
|
|
}, [])
|
|
|
|
// Initial load and polling
|
|
useEffect(() => {
|
|
fetchStatus()
|
|
fetchQueue()
|
|
fetchUniversities()
|
|
|
|
// Poll every 5 seconds
|
|
const interval = setInterval(() => {
|
|
fetchStatus()
|
|
fetchQueue()
|
|
}, 5000)
|
|
|
|
return () => clearInterval(interval)
|
|
}, [fetchStatus, fetchQueue, fetchUniversities])
|
|
|
|
// Start orchestrator
|
|
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 (err) {
|
|
setError('Verbindungsfehler')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Stop orchestrator
|
|
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 (err) {
|
|
setError('Verbindungsfehler')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Add university to queue
|
|
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: 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 (err) {
|
|
setError('Verbindungsfehler')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Remove from queue
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Pause/Resume
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Clear messages after 5 seconds
|
|
useEffect(() => {
|
|
if (success) {
|
|
const timer = setTimeout(() => setSuccess(null), 5000)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [success])
|
|
|
|
useEffect(() => {
|
|
if (error) {
|
|
const timer = setTimeout(() => setError(null), 5000)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [error])
|
|
|
|
return (
|
|
<AdminLayout
|
|
title="University Crawler"
|
|
description="Steuerung des mehrstufigen Crawling-Systems fuer Universitaeten"
|
|
>
|
|
{/* Messages */}
|
|
{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>
|
|
|
|
{/* Status Indicator */}
|
|
<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>
|
|
|
|
{/* Control Buttons */}
|
|
<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>
|
|
|
|
{/* Stats */}
|
|
<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>
|
|
|
|
{/* Current University */}
|
|
{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>
|
|
|
|
{/* Queue List */}
|
|
<div className="lg:col-span-2">
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
|
|
<div className="px-6 py-4 border-b border-gray-100">
|
|
<h2 className="text-lg font-semibold text-gray-900">Crawl Queue ({queue.length})</h2>
|
|
</div>
|
|
|
|
{queue.length === 0 ? (
|
|
<div className="px-6 py-12 text-center text-gray-500">
|
|
Queue ist leer. Fuege Universitaeten hinzu, um das Crawling zu starten.
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{queue.map((item) => (
|
|
<div key={item.id} className="px-6 py-4 hover:bg-gray-50 transition-colors">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-medium text-gray-400">#{item.queue_position || '-'}</span>
|
|
<h3 className="font-medium text-gray-900">{item.university_name}</h3>
|
|
<span className="text-sm text-gray-500">({item.university_short})</span>
|
|
</div>
|
|
|
|
{/* Phase Badge */}
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className={`px-2 py-1 rounded text-xs font-medium ${phaseConfig[item.current_phase].color}`}>
|
|
{phaseConfig[item.current_phase].label}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
Prioritaet: {item.priority}
|
|
</span>
|
|
{item.retry_count > 0 && (
|
|
<span className="text-xs text-orange-600">
|
|
Versuch {item.retry_count}/{item.max_retries}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="mt-3">
|
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 transition-all duration-500"
|
|
style={{ width: `${item.progress_percent}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
|
<span>{item.progress_percent}%</span>
|
|
<span>
|
|
{item.discovery_count} Disc / {item.professors_count} Prof / {item.staff_count} Staff / {item.publications_count} Pub
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Phase Checkmarks */}
|
|
<div className="flex gap-4 mt-3 text-xs">
|
|
<span className={item.discovery_completed ? 'text-green-600' : 'text-gray-400'}>
|
|
{item.discovery_completed ? 'Discovery' : 'Discovery'}
|
|
</span>
|
|
<span className={item.professors_completed ? 'text-green-600' : 'text-gray-400'}>
|
|
{item.professors_completed ? 'Professoren' : 'Professoren'}
|
|
</span>
|
|
<span className={item.all_staff_completed ? 'text-green-600' : 'text-gray-400'}>
|
|
{item.all_staff_completed ? 'Mitarbeiter' : 'Mitarbeiter'}
|
|
</span>
|
|
<span className={item.publications_completed ? 'text-green-600' : 'text-gray-400'}>
|
|
{item.publications_completed ? 'Publikationen' : 'Publikationen'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{item.last_error && (
|
|
<p className="mt-2 text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
|
|
{item.last_error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col gap-2">
|
|
{item.current_phase === 'paused' ? (
|
|
<button
|
|
onClick={() => handleResume(item.university_id)}
|
|
className="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
|
|
>
|
|
Fortsetzen
|
|
</button>
|
|
) : item.current_phase !== 'completed' && item.current_phase !== 'failed' ? (
|
|
<button
|
|
onClick={() => handlePause(item.university_id)}
|
|
className="px-3 py-1 text-xs bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 transition-colors"
|
|
>
|
|
Pausieren
|
|
</button>
|
|
) : null}
|
|
<button
|
|
onClick={() => handleRemove(item.university_id)}
|
|
className="px-3 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
|
|
>
|
|
Entfernen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|