Fix: Remove broken getKlausurApiUrl and clean up empty lines
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 42s
CI / test-go-edu-search (push) Successful in 34s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 29s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 42s
CI / test-go-edu-search (push) Successful in 34s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 29s
sed replacement left orphaned hostname references in story page and empty lines in getApiBase functions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
export function ArchitectureTab() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* What is this module */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Was macht dieses Modul?
|
||||
</h2>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Das <strong>RAG-Indexierungs-Modul</strong> verarbeitet Dokumente und macht sie fuer die KI-gestuetzte Suche verfuegbar.
|
||||
Es handelt sich <strong>nicht</strong> um klassisches Machine-Learning-Training, sondern um:
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2 text-gray-600 dark:text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">1.</span>
|
||||
<span><strong>Dokumentenextraktion:</strong> PDFs und Bilder werden per OCR in Text umgewandelt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">2.</span>
|
||||
<span><strong>Chunking:</strong> Lange Texte werden in suchbare Abschnitte (1000 Zeichen) aufgeteilt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">3.</span>
|
||||
<span><strong>Embedding:</strong> Jeder Chunk wird in einen Vektor (1536 Dimensionen) umgewandelt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">4.</span>
|
||||
<span><strong>Indexierung:</strong> Vektoren werden in Qdrant gespeichert fuer semantische Suche</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Technische Architektur
|
||||
</h2>
|
||||
|
||||
{/* Visual Pipeline */}
|
||||
<div className="relative">
|
||||
{/* Data Sources Row */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<SourceCard icon="📄" title="NiBiS PDFs" subtitle="Erwartungshorizonte" color="blue" />
|
||||
<SourceCard icon="📤" title="Uploads" subtitle="Eigene EH" color="green" />
|
||||
<SourceCard icon="⚖️" title="Rechtskorpus" subtitle="DSGVO, AI Act" color="purple" />
|
||||
<SourceCard icon="📚" title="Schulordnungen" subtitle="Bundeslaender" color="orange" />
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Processing Layer */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-6 mb-8">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-4">
|
||||
Verarbeitungs-Pipeline
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<PipelineStep icon="🔍" title="OCR" subtitle="Text-Extraktion" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="✂️" title="Chunking" subtitle="1000 Zeichen" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="🧮" title="Embedding" subtitle="1536-dim Vektor" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="💾" title="Speichern" subtitle="Qdrant" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Storage Layer */}
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 rounded-xl p-6 mb-8 border-2 border-indigo-200 dark:border-indigo-800">
|
||||
<h3 className="text-sm font-semibold text-indigo-600 dark:text-indigo-400 uppercase tracking-wide mb-4">
|
||||
Vektor-Datenbank (Qdrant)
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<CollectionCard collection="bp_nibis_eh" label="Offizielle EH" />
|
||||
<CollectionCard collection="bp_eh" label="Benutzer EH" />
|
||||
<CollectionCard collection="bp_legal_corpus" label="Rechtskorpus" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Usage Layer */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border-2 border-emerald-200 dark:border-emerald-800">
|
||||
<h4 className="font-medium text-emerald-700 dark:text-emerald-400 mb-2">Semantische Suche</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Fragen werden in Vektoren umgewandelt und aehnliche Dokumente gefunden
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border-2 border-amber-200 dark:border-amber-800">
|
||||
<h4 className="font-medium text-amber-700 dark:text-amber-400 mb-2">RAG-Antworten</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
LLM generiert Antworten basierend auf gefundenen Dokumenten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Technische Details
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Embedding-Service</h3>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Modell</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">text-embedding-3-small</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Dimensionen</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">1536</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Port</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">8087</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Chunk-Konfiguration</h3>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Chunk-Groesse</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">1000 Zeichen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Ueberlappung</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">200 Zeichen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Distanzmetrik</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">COSINE</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal helper components ---
|
||||
|
||||
function SourceCard({ icon, title, subtitle, color }: {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: string
|
||||
color: string
|
||||
}) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
|
||||
green: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
|
||||
orange: 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-xl border-2 text-center ${colorClasses[color]}`}>
|
||||
<div className="text-3xl mb-2">{icon}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{title}</div>
|
||||
<div className="text-xs text-gray-500">{subtitle}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineStep({ icon, title, subtitle }: {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="text-2xl mb-1">{icon}</div>
|
||||
<div className="font-medium text-sm">{title}</div>
|
||||
<div className="text-xs text-gray-500">{subtitle}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionCard({ collection, label }: { collection: string; label: string }) {
|
||||
return (
|
||||
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg text-center">
|
||||
<div className="font-mono text-xs text-gray-500">{collection}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowDown() {
|
||||
return (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="text-4xl text-gray-400">↓</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowRight() {
|
||||
return <div className="text-2xl text-gray-400">→</div>
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import type { DataSource } from '../types'
|
||||
|
||||
export function DataSourcesTab({ sources }: { sources: DataSource[] }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Introduction */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
|
||||
<h2 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Wie werden Daten hinzugefuegt?
|
||||
</h2>
|
||||
<p className="text-blue-800 dark:text-blue-200 mb-4">
|
||||
Das RAG-System nutzt verschiedene Datenquellen. Jede Quelle hat einen eigenen Ingestion-Prozess:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900 dark:text-white mb-1">Automatisch</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
NiBiS-PDFs werden automatisch aus dem za-download Verzeichnis eingelesen
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900 dark:text-white mb-1">Manuell</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Eigene EH koennen ueber die Klausur-Korrektur hochgeladen werden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Sources List */}
|
||||
<div className="grid gap-4">
|
||||
{sources.map((source) => (
|
||||
<DataSourceCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* How to add data */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Daten hinzufuegen
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<AddDataCard
|
||||
icon="📤"
|
||||
title="Erwartungshorizont hochladen"
|
||||
description="Laden Sie eigene EH-Dokumente in der Klausur-Korrektur hoch"
|
||||
linkHref="/admin/klausur-korrektur"
|
||||
linkText="Zur Klausur-Korrektur →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="🔄"
|
||||
title="NiBiS neu einlesen"
|
||||
description="Starten Sie die automatische Ingestion der NiBiS-PDFs"
|
||||
linkText="Ingestion starten →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="⚖️"
|
||||
title="Rechtskorpus erweitern"
|
||||
description="Neue Regelwerke (DSGVO, BSI, etc.) zum Korpus hinzufuegen"
|
||||
linkText="Regelwerk hinzufuegen →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="📋"
|
||||
title="DSFA-Quellen verwalten"
|
||||
description="WP248, DSK, Muss-Listen mit Lizenzattribution"
|
||||
linkHref="/ai/rag-pipeline/dsfa"
|
||||
linkText="DSFA-Manager oeffnen →"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal helper components ---
|
||||
|
||||
function DataSourceCard({ source }: { source: DataSource }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{source.name}
|
||||
</h3>
|
||||
<DataSourceStatusBadge status={source.status} />
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{source.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Collection: </span>
|
||||
<span className="font-mono text-gray-900 dark:text-white">{source.collection}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Dokumente: </span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{source.document_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Chunks: </span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{source.chunk_count}</span>
|
||||
</div>
|
||||
{source.last_updated && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Aktualisiert: </span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{new Date(source.last_updated).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg">
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataSourceStatusBadge({ status }: { status: DataSource['status'] }) {
|
||||
const className = status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
|
||||
const label = status === 'active' ? 'Aktiv' : status === 'pending' ? 'Ausstehend' : 'Fehler'
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDataCard({ icon, title, description, linkHref, linkText }: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
linkHref?: string
|
||||
linkText: string
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||||
<div className="text-2xl mb-2">{icon}</div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{description}</p>
|
||||
{linkHref ? (
|
||||
<a
|
||||
href={linkHref}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
) : (
|
||||
<button className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
|
||||
{linkText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import type { DatasetStats } from '../types'
|
||||
|
||||
export function DatasetOverview({ stats }: { stats: DatasetStats }) {
|
||||
const maxBundesland = Math.max(...Object.values(stats.by_bundesland))
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Datensatz-Uebersicht
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{stats.total_documents.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{stats.total_chunks.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{stats.training_allowed.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Indexiert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Verteilung nach Bundesland
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.by_bundesland)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([code, count]) => (
|
||||
<div key={code} className="flex items-center gap-3">
|
||||
<span className="w-8 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">
|
||||
{code}
|
||||
</span>
|
||||
<div className="flex-1 h-4 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"
|
||||
style={{ width: `${(count / maxBundesland) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 text-sm text-right text-gray-600 dark:text-gray-400">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { TrainingConfig } from '../types'
|
||||
|
||||
const BUNDESLAENDER = [
|
||||
{ code: 'ni', name: 'Niedersachsen', allowed: true },
|
||||
{ code: 'by', name: 'Bayern', allowed: true },
|
||||
{ code: 'nw', name: 'NRW', allowed: true },
|
||||
{ code: 'he', name: 'Hessen', allowed: true },
|
||||
{ code: 'bw', name: 'Baden-Wuerttemberg', allowed: true },
|
||||
{ code: 'rp', name: 'Rheinland-Pfalz', allowed: true },
|
||||
{ code: 'sn', name: 'Sachsen', allowed: true },
|
||||
{ code: 'sh', name: 'Schleswig-Holstein', allowed: true },
|
||||
{ code: 'th', name: 'Thueringen', allowed: true },
|
||||
{ code: 'be', name: 'Berlin', allowed: false },
|
||||
{ code: 'bb', name: 'Brandenburg', allowed: false },
|
||||
{ code: 'hb', name: 'Bremen', allowed: false },
|
||||
{ code: 'hh', name: 'Hamburg', allowed: false },
|
||||
{ code: 'mv', name: 'Mecklenburg-Vorpommern', allowed: false },
|
||||
{ code: 'sl', name: 'Saarland', allowed: false },
|
||||
{ code: 'st', name: 'Sachsen-Anhalt', allowed: false },
|
||||
]
|
||||
|
||||
export function NewTrainingModal({ isOpen, onClose, onSubmit }: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [config, setConfig] = useState<Partial<TrainingConfig>>({
|
||||
batch_size: 16,
|
||||
learning_rate: 0.00005,
|
||||
epochs: 10,
|
||||
warmup_steps: 500,
|
||||
weight_decay: 0.01,
|
||||
gradient_accumulation: 4,
|
||||
mixed_precision: true,
|
||||
bundeslaender: [],
|
||||
})
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Neue Indexierung starten
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">Schritt {step} von 3</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<StepIndicator currentStep={step} />
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[50vh]">
|
||||
{step === 1 && (
|
||||
<BundeslaenderStep config={config} setConfig={setConfig} />
|
||||
)}
|
||||
{step === 2 && (
|
||||
<ParameterStep config={config} setConfig={setConfig} />
|
||||
)}
|
||||
{step === 3 && (
|
||||
<ConfirmStep config={config} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<button
|
||||
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
{step > 1 ? 'Zurueck' : 'Abbrechen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 3 ? setStep(step + 1) : onSubmit(config)}
|
||||
disabled={step === 1 && (!config.bundeslaender || config.bundeslaender.length === 0)}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{step < 3 ? 'Weiter' : 'Indexierung starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal step components ---
|
||||
|
||||
function StepIndicator({ currentStep }: { currentStep: number }) {
|
||||
return (
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s <= currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}>
|
||||
{s < currentStep ? '\u2713' : s}
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div className={`w-16 h-1 mx-2 rounded ${
|
||||
s < currentStep ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-20 mt-2 text-xs text-gray-500">
|
||||
<span>Daten</span>
|
||||
<span>Parameter</span>
|
||||
<span>Bestaetigen</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BundeslaenderStep({ config, setConfig }: {
|
||||
config: Partial<TrainingConfig>
|
||||
setConfig: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Waehlen Sie die Bundeslaender fuer die Indexierung
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Nur Bundeslaender mit verfuegbaren Dokumenten koennen ausgewaehlt werden.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{BUNDESLAENDER.map((bl) => (
|
||||
<label
|
||||
key={bl.code}
|
||||
className={`flex items-center p-3 rounded-lg border-2 transition cursor-pointer ${
|
||||
config.bundeslaender?.includes(bl.code)
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: bl.allowed
|
||||
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
|
||||
: 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!bl.allowed}
|
||||
checked={config.bundeslaender?.includes(bl.code)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setConfig({ ...config, bundeslaender: [...(config.bundeslaender || []), bl.code] })
|
||||
} else {
|
||||
setConfig({ ...config, bundeslaender: config.bundeslaender?.filter(c => c !== bl.code) })
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 ${
|
||||
config.bundeslaender?.includes(bl.code)
|
||||
? 'bg-blue-500 border-blue-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{config.bundeslaender?.includes(bl.code) && '\u2713'}
|
||||
</span>
|
||||
<span className="flex-1 text-gray-900 dark:text-white">{bl.name}</span>
|
||||
{!bl.allowed && (
|
||||
<span className="text-xs text-red-500">Keine Daten</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ParameterStep({ config, setConfig }: {
|
||||
config: Partial<TrainingConfig>
|
||||
setConfig: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Indexierungs-Parameter
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Diese Parameter steuern die Batch-Verarbeitung der Dokumente.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.batch_size}
|
||||
onChange={(e) => setConfig({ ...config, batch_size: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Dokumente pro Batch</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Durchlaeufe
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.epochs}
|
||||
onChange={(e) => setConfig({ ...config, epochs: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Fuer Validierung</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mixedPrecision"
|
||||
checked={config.mixed_precision}
|
||||
onChange={(e) => setConfig({ ...config, mixed_precision: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 rounded"
|
||||
/>
|
||||
<label htmlFor="mixedPrecision" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Parallele Verarbeitung - schneller bei grossem Datensatz
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmStep({ config }: { config: Partial<TrainingConfig> }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Konfiguration bestaetigen
|
||||
</h3>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Bundeslaender</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{config.bundeslaender?.length || 0} ausgewaehlt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Batch Size</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{config.batch_size}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Parallele Verarbeitung</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{config.mixed_precision ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Was passiert:</strong> Die ausgewaehlten Dokumente werden extrahiert,
|
||||
in Chunks aufgeteilt, und als Vektoren in Qdrant indexiert.
|
||||
Dieser Prozess kann je nach Datenmenge einige Minuten dauern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingJob } from '../types'
|
||||
|
||||
// Tab Button
|
||||
export function TabButton({ active, onClick, children }: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
active
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress Ring Component
|
||||
export function ProgressRing({ progress, size = 120, strokeWidth = 8, color = '#10B981' }: {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
color?: string
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
className="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mini Line Chart Component
|
||||
export function MiniChart({ data, color = '#10B981', height = 60 }: {
|
||||
data: number[]
|
||||
color?: string
|
||||
height?: number
|
||||
}) {
|
||||
if (!data.length) return null
|
||||
|
||||
const max = Math.max(...data)
|
||||
const min = Math.min(...data)
|
||||
const range = max - min || 1
|
||||
const width = 200
|
||||
const padding = 4
|
||||
|
||||
const points = data.map((value, i) => {
|
||||
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
|
||||
const y = padding + (1 - (value - min) / range) * (height - 2 * padding)
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{data.length > 0 && (
|
||||
<circle
|
||||
cx={padding + ((data.length - 1) / (data.length - 1)) * (width - 2 * padding)}
|
||||
cy={padding + (1 - (data[data.length - 1] - min) / range) * (height - 2 * padding)}
|
||||
r={4}
|
||||
fill={color}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
export function StatusBadge({ status }: { status: TrainingJob['status'] }) {
|
||||
const styles = {
|
||||
queued: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||
preparing: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
training: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
validating: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
paused: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
queued: 'In Warteschlange',
|
||||
preparing: 'Vorbereitung',
|
||||
training: 'Indexierung laeuft',
|
||||
validating: 'Validierung',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
paused: 'Pausiert',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
|
||||
{status === 'training' && (
|
||||
<span className="w-2 h-2 mr-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Metric Card
|
||||
export function MetricCard({ label, value, trend, color }: {
|
||||
label: string
|
||||
value: number | string
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{label}</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold" style={{ color: color || 'inherit' }}>
|
||||
{typeof value === 'number' ? value.toFixed(3) : value}
|
||||
</span>
|
||||
{trend && (
|
||||
<span className={`ml-2 text-sm ${
|
||||
trend === 'up' ? 'text-green-500' : trend === 'down' ? 'text-red-500' : 'text-gray-400'
|
||||
}`}>
|
||||
{trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingJob } from '../types'
|
||||
import { ProgressRing, MiniChart, StatusBadge, MetricCard } from './SharedWidgets'
|
||||
|
||||
export function TrainingJobCard({ job, onPause, onResume, onStop, onViewDetails }: {
|
||||
job: TrainingJob
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onViewDetails: () => void
|
||||
}) {
|
||||
const isActive = ['training', 'preparing', 'validating'].includes(job.status)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{job.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Typ: {job.model_type.charAt(0).toUpperCase() + job.model_type.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<ProgressRing
|
||||
progress={job.progress}
|
||||
color={job.status === 'failed' ? '#EF4444' : '#10B981'}
|
||||
/>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">Durchlauf</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{job.current_epoch} / {job.total_epochs}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-500"
|
||||
style={{ width: `${(job.current_epoch / job.total_epochs) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">Dokumente</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{job.documents_processed.toLocaleString()} / {job.total_documents.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-full transition-all duration-500"
|
||||
style={{ width: `${(job.documents_processed / job.total_documents) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 mt-6">
|
||||
<MetricCard label="Loss" value={job.loss} trend="down" color="#3B82F6" />
|
||||
<MetricCard label="Val Loss" value={job.val_loss} trend="down" color="#8B5CF6" />
|
||||
<MetricCard label="Precision" value={job.metrics.precision} color="#10B981" />
|
||||
<MetricCard label="F1 Score" value={job.metrics.f1_score} color="#F59E0B" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Fortschritt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<MiniChart data={job.metrics.loss_history} color="#3B82F6" />
|
||||
<MiniChart data={job.metrics.val_loss_history} color="#8B5CF6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
Gestartet: {job.started_at ? new Date(job.started_at).toLocaleTimeString('de-DE') : '-'}
|
||||
</span>
|
||||
<span>
|
||||
Geschaetzt: {job.estimated_completion
|
||||
? new Date(job.estimated_completion).toLocaleTimeString('de-DE')
|
||||
: '-'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
onClick={job.status === 'paused' ? onResume : onPause}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
{job.status === 'paused' ? 'Fortsetzen' : 'Pausieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/40"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user