feat: Add Document Crawler frontend page and API proxy (Phase 1.4)
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Some checks failed
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
4-tab page (Quellen, Crawl-Jobs, Dokumente, Onboarding-Report) and catch-all API proxy route to document-crawler service on port 8098. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
839
admin-v2/app/(sdk)/sdk/document-crawler/page.tsx
Normal file
839
admin-v2/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-v2/app/api/sdk/v1/crawler/[[...path]]/route.ts
Normal file
114
admin-v2/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