feat: Sync Document Crawler frontend page and API proxy (Phase 1.4)
Synced from admin-v2: 4-tab crawler page and catch-all API proxy route. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
839
admin-lehrer/app/(sdk)/sdk/document-crawler/page.tsx
Normal file
839
admin-lehrer/app/(sdk)/sdk/document-crawler/page.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface CrawlSource {
|
||||
id: string
|
||||
name: string
|
||||
source_type: string
|
||||
path: string
|
||||
file_extensions: string[]
|
||||
max_depth: number
|
||||
exclude_patterns: string[]
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface CrawlJob {
|
||||
id: string
|
||||
source_id: string
|
||||
source_name?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
job_type: 'full' | 'delta'
|
||||
files_found: number
|
||||
files_processed: number
|
||||
files_new: number
|
||||
files_changed: number
|
||||
files_skipped: number
|
||||
files_error: number
|
||||
error_message?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface CrawlDocument {
|
||||
id: string
|
||||
file_name: string
|
||||
file_extension: string
|
||||
file_size_bytes: number
|
||||
classification: string | null
|
||||
classification_confidence: number | null
|
||||
classification_corrected: boolean
|
||||
extraction_status: string
|
||||
archived: boolean
|
||||
ipfs_cid: string | null
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
version_count: number
|
||||
source_name?: string
|
||||
}
|
||||
|
||||
interface OnboardingReport {
|
||||
id: string
|
||||
total_documents_found: number
|
||||
classification_breakdown: Record<string, number>
|
||||
gaps: GapItem[]
|
||||
compliance_score: number
|
||||
gap_summary?: { critical: number; high: number; medium: number }
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface GapItem {
|
||||
id: string
|
||||
category: string
|
||||
description: string
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM'
|
||||
regulation: string
|
||||
requiredAction: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const TENANT_ID = '00000000-0000-0000-0000-000000000001' // Default tenant
|
||||
|
||||
async function api(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`/api/sdk/v1/crawler/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CLASSIFICATION LABELS
|
||||
// =============================================================================
|
||||
|
||||
const CLASSIFICATION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
VVT: { label: 'VVT', color: 'bg-blue-100 text-blue-700' },
|
||||
TOM: { label: 'TOM', color: 'bg-green-100 text-green-700' },
|
||||
DSE: { label: 'DSE', color: 'bg-purple-100 text-purple-700' },
|
||||
AVV: { label: 'AVV', color: 'bg-orange-100 text-orange-700' },
|
||||
DSFA: { label: 'DSFA', color: 'bg-red-100 text-red-700' },
|
||||
Loeschkonzept: { label: 'Loeschkonzept', color: 'bg-yellow-100 text-yellow-700' },
|
||||
Einwilligung: { label: 'Einwilligung', color: 'bg-pink-100 text-pink-700' },
|
||||
Vertrag: { label: 'Vertrag', color: 'bg-indigo-100 text-indigo-700' },
|
||||
Richtlinie: { label: 'Richtlinie', color: 'bg-teal-100 text-teal-700' },
|
||||
Schulungsnachweis: { label: 'Schulung', color: 'bg-cyan-100 text-cyan-700' },
|
||||
Sonstiges: { label: 'Sonstiges', color: 'bg-gray-100 text-gray-700' },
|
||||
}
|
||||
|
||||
const ALL_CLASSIFICATIONS = Object.keys(CLASSIFICATION_LABELS)
|
||||
|
||||
// =============================================================================
|
||||
// TAB: QUELLEN (Sources)
|
||||
// =============================================================================
|
||||
|
||||
function SourcesTab() {
|
||||
const [sources, setSources] = useState<CrawlSource[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formPath, setFormPath] = useState('')
|
||||
const [testResult, setTestResult] = useState<Record<string, string>>({})
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('sources')
|
||||
setSources(data || [])
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSources() }, [loadSources])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formName || !formPath) return
|
||||
await api('sources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: formName, path: formPath }),
|
||||
})
|
||||
setFormName('')
|
||||
setFormPath('')
|
||||
setShowForm(false)
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api(`sources/${id}`, { method: 'DELETE' })
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleToggle = async (source: CrawlSource) => {
|
||||
await api(`sources/${source.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: !source.enabled }),
|
||||
})
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTestResult(prev => ({ ...prev, [id]: 'testing...' }))
|
||||
const result = await api(`sources/${id}/test`, { method: 'POST' })
|
||||
setTestResult(prev => ({ ...prev, [id]: result?.message || 'Fehler' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Crawl-Quellen</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
+ Neue Quelle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
placeholder="z.B. Compliance-Ordner"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pfad (relativ zu /data/crawl)</label>
|
||||
<input
|
||||
value={formPath}
|
||||
onChange={e => setFormPath(e.target.value)}
|
||||
placeholder="z.B. compliance-docs"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCreate} className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700">
|
||||
Erstellen
|
||||
</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
<p className="text-lg font-medium">Keine Quellen konfiguriert</p>
|
||||
<p className="text-sm mt-1">Erstellen Sie eine Crawl-Quelle um Dokumente zu scannen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map(s => (
|
||||
<div key={s.id} className="bg-white rounded-xl border border-gray-200 p-5 flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${s.enabled ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{s.name}</div>
|
||||
<div className="text-sm text-gray-500 truncate">{s.path}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Tiefe: {s.max_depth} | Formate: {(typeof s.file_extensions === 'string' ? JSON.parse(s.file_extensions) : s.file_extensions).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
{testResult[s.id] && (
|
||||
<span className="text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded">{testResult[s.id]}</span>
|
||||
)}
|
||||
<button onClick={() => handleTest(s.id)} className="text-sm text-blue-600 hover:text-blue-800">Testen</button>
|
||||
<button onClick={() => handleToggle(s)} className="text-sm text-gray-600 hover:text-gray-800">
|
||||
{s.enabled ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(s.id)} className="text-sm text-red-600 hover:text-red-800">Loeschen</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: CRAWL-JOBS
|
||||
// =============================================================================
|
||||
|
||||
function JobsTab() {
|
||||
const [jobs, setJobs] = useState<CrawlJob[]>([])
|
||||
const [sources, setSources] = useState<CrawlSource[]>([])
|
||||
const [selectedSource, setSelectedSource] = useState('')
|
||||
const [jobType, setJobType] = useState<'full' | 'delta'>('full')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [j, s] = await Promise.all([api('jobs'), api('sources')])
|
||||
setJobs(j || [])
|
||||
setSources(s || [])
|
||||
if (!selectedSource && s?.length > 0) setSelectedSource(s[0].id)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [selectedSource])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
// Auto-refresh running jobs
|
||||
useEffect(() => {
|
||||
const hasRunning = jobs.some(j => j.status === 'running' || j.status === 'pending')
|
||||
if (!hasRunning) return
|
||||
const interval = setInterval(loadData, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [jobs, loadData])
|
||||
|
||||
const handleTrigger = async () => {
|
||||
if (!selectedSource) return
|
||||
await api('jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source_id: selectedSource, job_type: jobType }),
|
||||
})
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
await api(`jobs/${id}/cancel`, { method: 'POST' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
switch (s) {
|
||||
case 'completed': return 'bg-green-100 text-green-700'
|
||||
case 'running': return 'bg-blue-100 text-blue-700'
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'failed': return 'bg-red-100 text-red-700'
|
||||
case 'cancelled': return 'bg-gray-100 text-gray-600'
|
||||
default: return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Trigger form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Neuen Crawl starten</h3>
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Quelle</label>
|
||||
<select
|
||||
value={selectedSource}
|
||||
onChange={e => setSelectedSource(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={jobType}
|
||||
onChange={e => setJobType(e.target.value as 'full' | 'delta')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="full">Voll-Scan</option>
|
||||
<option value="delta">Delta-Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
disabled={!selectedSource}
|
||||
className="px-6 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job list */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Noch keine Crawl-Jobs ausgefuehrt.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{jobs.map(job => (
|
||||
<div key={job.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{job.source_name || 'Quelle'}</span>
|
||||
<span className="text-xs text-gray-400">{job.job_type === 'delta' ? 'Delta' : 'Voll'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(job.status === 'running' || job.status === 'pending') && (
|
||||
<button onClick={() => handleCancel(job.id)} className="text-xs text-red-600 hover:text-red-800">
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(job.created_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{job.status === 'running' && job.files_found > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${(job.files_processed / job.files_found) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{job.files_processed} / {job.files_found} Dateien verarbeitet
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-6 gap-2 text-center">
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{job.files_found}</div>
|
||||
<div className="text-xs text-gray-500">Gefunden</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{job.files_processed}</div>
|
||||
<div className="text-xs text-gray-500">Verarbeitet</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-green-700">{job.files_new}</div>
|
||||
<div className="text-xs text-green-600">Neu</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-blue-700">{job.files_changed}</div>
|
||||
<div className="text-xs text-blue-600">Geaendert</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-500">{job.files_skipped}</div>
|
||||
<div className="text-xs text-gray-500">Uebersprungen</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-red-700">{job.files_error}</div>
|
||||
<div className="text-xs text-red-600">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: DOKUMENTE
|
||||
// =============================================================================
|
||||
|
||||
function DocumentsTab() {
|
||||
const [docs, setDocs] = useState<CrawlDocument[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterClass, setFilterClass] = useState('')
|
||||
const [archiving, setArchiving] = useState<Record<string, boolean>>({})
|
||||
|
||||
const loadDocs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = filterClass ? `?classification=${filterClass}` : ''
|
||||
const data = await api(`documents${params}`)
|
||||
setDocs(data?.documents || [])
|
||||
setTotal(data?.total || 0)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [filterClass])
|
||||
|
||||
useEffect(() => { loadDocs() }, [loadDocs])
|
||||
|
||||
const handleReclassify = async (docId: string, newClass: string) => {
|
||||
await api(`documents/${docId}/classify`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ classification: newClass }),
|
||||
})
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
const handleArchive = async (docId: string) => {
|
||||
setArchiving(prev => ({ ...prev, [docId]: true }))
|
||||
try {
|
||||
await api(`documents/${docId}/archive`, { method: 'POST' })
|
||||
loadDocs()
|
||||
} catch { /* ignore */ }
|
||||
setArchiving(prev => ({ ...prev, [docId]: false }))
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{total} Dokumente</h2>
|
||||
<select
|
||||
value={filterClass}
|
||||
onChange={e => setFilterClass(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{ALL_CLASSIFICATIONS.map(c => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c]?.label || c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : docs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Keine Dokumente gefunden. Starten Sie zuerst einen Crawl-Job.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Datei</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Kategorie</th>
|
||||
<th className="text-center px-4 py-3 font-medium">Konfidenz</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Groesse</th>
|
||||
<th className="text-center px-4 py-3 font-medium">Archiv</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{docs.map(doc => {
|
||||
const cls = CLASSIFICATION_LABELS[doc.classification || ''] || CLASSIFICATION_LABELS['Sonstiges']
|
||||
return (
|
||||
<tr key={doc.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 truncate max-w-xs">{doc.file_name}</div>
|
||||
<div className="text-xs text-gray-400">{doc.source_name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={doc.classification || 'Sonstiges'}
|
||||
onChange={e => handleReclassify(doc.id, e.target.value)}
|
||||
className={`px-2 py-1 text-xs font-medium rounded border-0 ${cls.color}`}
|
||||
>
|
||||
{ALL_CLASSIFICATIONS.map(c => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c].label}</option>
|
||||
))}
|
||||
</select>
|
||||
{doc.classification_corrected && (
|
||||
<span className="ml-1 text-xs text-orange-500" title="Manuell korrigiert">*</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{doc.classification_confidence != null && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${doc.classification_confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(doc.classification_confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatSize(doc.file_size_bytes)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{doc.archived ? (
|
||||
<span className="text-green-600 text-xs font-medium" title={doc.ipfs_cid || ''}>IPFS</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!doc.archived && (
|
||||
<button
|
||||
onClick={() => handleArchive(doc.id)}
|
||||
disabled={archiving[doc.id]}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 disabled:opacity-50"
|
||||
>
|
||||
{archiving[doc.id] ? 'Archiviert...' : 'Archivieren'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: ONBOARDING-REPORT
|
||||
// =============================================================================
|
||||
|
||||
function ReportTab() {
|
||||
const [reports, setReports] = useState<OnboardingReport[]>([])
|
||||
const [activeReport, setActiveReport] = useState<OnboardingReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const loadReports = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('reports')
|
||||
setReports(data || [])
|
||||
if (data?.length > 0 && !activeReport) {
|
||||
const detail = await api(`reports/${data[0].id}`)
|
||||
setActiveReport(detail)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [activeReport])
|
||||
|
||||
useEffect(() => { loadReports() }, [loadReports])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await api('reports/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
setActiveReport(result)
|
||||
loadReports()
|
||||
} catch { /* ignore */ }
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
const handleSelectReport = async (id: string) => {
|
||||
const detail = await api(`reports/${id}`)
|
||||
setActiveReport(detail)
|
||||
}
|
||||
|
||||
// Compliance score ring
|
||||
const ComplianceRing = ({ score }: { score: number }) => {
|
||||
const radius = 50
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (score / 100) * circumference
|
||||
const color = score >= 75 ? '#16a34a' : score >= 50 ? '#f59e0b' : '#dc2626'
|
||||
|
||||
return (
|
||||
<div className="relative w-36 h-36">
|
||||
<svg className="w-full h-full -rotate-90">
|
||||
<circle cx="68" cy="68" r={radius} fill="none" stroke="#e5e7eb" strokeWidth="8" />
|
||||
<circle
|
||||
cx="68" cy="68" r={radius} fill="none"
|
||||
stroke={color} strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold" style={{ color }}>{score.toFixed(0)}%</span>
|
||||
<span className="text-xs text-gray-500">Compliance</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Onboarding-Report</h2>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{generating ? 'Generiere...' : 'Neuen Report erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Report selector */}
|
||||
{reports.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{reports.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => handleSelectReport(r.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border whitespace-nowrap ${
|
||||
activeReport?.id === r.id
|
||||
? 'bg-purple-50 border-purple-300 text-purple-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{new Date(r.created_at).toLocaleString('de-DE')} — {r.compliance_score.toFixed(0)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : !activeReport ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
<p className="text-lg font-medium">Kein Report vorhanden</p>
|
||||
<p className="text-sm mt-1">Fuehren Sie zuerst einen Crawl durch und generieren Sie dann einen Report.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Score + Stats */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<ComplianceRing score={activeReport.compliance_score} />
|
||||
<div className="flex-1 grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-gray-900">{activeReport.total_documents_found}</div>
|
||||
<div className="text-sm text-gray-500">Dokumente gefunden</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{Object.keys(activeReport.classification_breakdown || {}).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Kategorien abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-red-600">
|
||||
{(activeReport.gaps || []).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Luecken identifiziert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Dokumenten-Verteilung</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(activeReport.classification_breakdown || {}).map(([cat, count]) => {
|
||||
const cls = CLASSIFICATION_LABELS[cat] || CLASSIFICATION_LABELS['Sonstiges']
|
||||
return (
|
||||
<span key={cat} className={`px-3 py-1.5 text-sm font-medium rounded-lg ${cls.color}`}>
|
||||
{cls.label}: {count as number}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{Object.keys(activeReport.classification_breakdown || {}).length === 0 && (
|
||||
<span className="text-gray-400 text-sm">Keine Dokumente klassifiziert</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gap summary */}
|
||||
{activeReport.gap_summary && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-red-50 rounded-xl border border-red-100">
|
||||
<div className="text-3xl font-bold text-red-600">{activeReport.gap_summary.critical}</div>
|
||||
<div className="text-sm text-red-600 font-medium">Kritisch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||
<div className="text-3xl font-bold text-orange-600">{activeReport.gap_summary.high}</div>
|
||||
<div className="text-sm text-orange-600 font-medium">Hoch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-yellow-50 rounded-xl border border-yellow-100">
|
||||
<div className="text-3xl font-bold text-yellow-600">{activeReport.gap_summary.medium}</div>
|
||||
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap details */}
|
||||
{(activeReport.gaps || []).length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Compliance-Luecken</h3>
|
||||
<div className="space-y-3">
|
||||
{activeReport.gaps.map((gap) => (
|
||||
<div
|
||||
key={gap.id}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
gap.severity === 'CRITICAL'
|
||||
? 'bg-red-50 border-red-500'
|
||||
: gap.severity === 'HIGH'
|
||||
? 'bg-orange-50 border-orange-500'
|
||||
: 'bg-yellow-50 border-yellow-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{gap.category}</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{gap.description}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
gap.severity === 'CRITICAL' ? 'bg-red-100 text-red-700'
|
||||
: gap.severity === 'HIGH' ? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{gap.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
type Tab = 'sources' | 'jobs' | 'documents' | 'report'
|
||||
|
||||
export default function DocumentCrawlerPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('sources')
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'sources', label: 'Quellen' },
|
||||
{ id: 'jobs', label: 'Crawl-Jobs' },
|
||||
{ id: 'documents', label: 'Dokumente' },
|
||||
{ id: 'report', label: 'Onboarding-Report' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Document Crawler & Auto-Onboarding</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Automatisches Scannen von Dateisystemen, KI-Klassifizierung, IPFS-Archivierung und Compliance Gap-Analyse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'sources' && <SourcesTab />}
|
||||
{activeTab === 'jobs' && <JobsTab />}
|
||||
{activeTab === 'documents' && <DocumentsTab />}
|
||||
{activeTab === 'report' && <ReportTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
admin-lehrer/app/api/sdk/v1/crawler/[[...path]]/route.ts
Normal file
114
admin-lehrer/app/api/sdk/v1/crawler/[[...path]]/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Document Crawler API Proxy - Catch-all route
|
||||
* Proxies all /api/sdk/v1/crawler/* requests to document-crawler service (port 8098)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const CRAWLER_BACKEND_URL = process.env.CRAWLER_API_URL || 'http://document-crawler:8098'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
pathSegments: string[] | undefined,
|
||||
method: string
|
||||
) {
|
||||
const pathStr = pathSegments?.join('/') || ''
|
||||
const searchParams = request.nextUrl.searchParams.toString()
|
||||
const basePath = `${CRAWLER_BACKEND_URL}/api/v1/crawler`
|
||||
const url = pathStr
|
||||
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
||||
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Forward all relevant headers
|
||||
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
|
||||
for (const name of headerNames) {
|
||||
const value = request.headers.get(name)
|
||||
if (value) {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
|
||||
// Forward body for non-GET requests
|
||||
if (method !== 'GET' && method !== 'DELETE') {
|
||||
try {
|
||||
const body = await request.json()
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
} catch {
|
||||
// No body or non-JSON body
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorJson
|
||||
try {
|
||||
errorJson = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorJson = { error: errorText }
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, ...errorJson },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Document Crawler API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Document Crawler Backend fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'POST')
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'PUT')
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await params
|
||||
return proxyRequest(request, path, 'DELETE')
|
||||
}
|
||||
Reference in New Issue
Block a user