Fix: Remove broken getKlausurApiUrl and clean up empty lines
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
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,168 @@
|
||||
'use client'
|
||||
|
||||
import type { DockerStats, ContainerInfo, ContainerFilter } from '../types'
|
||||
import { getStateColor } from './helpers'
|
||||
|
||||
interface DeploymentsTabProps {
|
||||
dockerStats: DockerStats | null
|
||||
filteredContainers: ContainerInfo[]
|
||||
containerFilter: ContainerFilter
|
||||
setContainerFilter: (f: ContainerFilter) => void
|
||||
actionLoading: string | null
|
||||
containerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => Promise<void>
|
||||
loadContainerData: () => Promise<void>
|
||||
}
|
||||
|
||||
export function DeploymentsTab({
|
||||
dockerStats,
|
||||
filteredContainers,
|
||||
containerFilter,
|
||||
setContainerFilter,
|
||||
actionLoading,
|
||||
containerAction,
|
||||
loadContainerData,
|
||||
}: DeploymentsTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">Docker Container</h3>
|
||||
{dockerStats && (
|
||||
<p className="text-sm text-slate-600">
|
||||
{dockerStats.running_containers} laufend, {dockerStats.stopped_containers} gestoppt, {dockerStats.total_containers} gesamt
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={containerFilter}
|
||||
onChange={(e) => setContainerFilter(e.target.value as ContainerFilter)}
|
||||
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="running">Laufend</option>
|
||||
<option value="stopped">Gestoppt</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={loadContainerData}
|
||||
className="px-3 py-1.5 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container List */}
|
||||
{filteredContainers.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">Keine Container gefunden</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredContainers.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
actionLoading={actionLoading}
|
||||
containerAction={containerAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Container Card Sub-component
|
||||
// ============================================================================
|
||||
|
||||
function ContainerCard({
|
||||
container,
|
||||
actionLoading,
|
||||
containerAction,
|
||||
}: {
|
||||
container: ContainerInfo
|
||||
actionLoading: string | null
|
||||
containerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-xl p-4 transition-colors ${
|
||||
container.state === 'running'
|
||||
? 'border-green-200 bg-green-50/30'
|
||||
: 'border-slate-200 bg-slate-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900 truncate">{container.name}</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getStateColor(container.state)}`}>
|
||||
{container.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mb-2">
|
||||
<span className="font-mono">{container.image}</span>
|
||||
{container.ports.length > 0 && (
|
||||
<span className="ml-2 text-slate-400">
|
||||
| {container.ports.slice(0, 2).join(', ')}
|
||||
{container.ports.length > 2 && ` +${container.ports.length - 2}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{container.state === 'running' && (
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-slate-500">CPU:</span>
|
||||
<span className={`font-medium ${container.cpu_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
|
||||
{container.cpu_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-slate-500">RAM:</span>
|
||||
<span className={`font-medium ${container.memory_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
|
||||
{container.memory_usage}
|
||||
</span>
|
||||
<span className="text-slate-400">({container.memory_percent.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-slate-500">Net:</span>
|
||||
<span className="text-slate-700">{container.network_rx} / {container.network_tx}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{container.state === 'running' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => containerAction(container.id, 'restart')}
|
||||
disabled={actionLoading !== null}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === `${container.id}-restart` ? '...' : 'Restart'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => containerAction(container.id, 'stop')}
|
||||
disabled={actionLoading !== null}
|
||||
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === `${container.id}-stop` ? '...' : 'Stop'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => containerAction(container.id, 'start')}
|
||||
disabled={actionLoading !== null}
|
||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === `${container.id}-start` ? '...' : 'Start'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
'use client'
|
||||
|
||||
import type { PipelineStatus, PipelineRun, SystemStats, DockerStats, WoodpeckerStatus, TabType } from '../types'
|
||||
import { ProgressBar } from './helpers'
|
||||
|
||||
interface OverviewTabProps {
|
||||
pipelineStatus: PipelineStatus | null
|
||||
pipelineHistory: PipelineRun[]
|
||||
systemStats: SystemStats | null
|
||||
dockerStats: DockerStats | null
|
||||
woodpeckerStatus: WoodpeckerStatus | null
|
||||
triggeringWoodpecker: boolean
|
||||
triggerWoodpeckerPipeline: () => Promise<void>
|
||||
setActiveTab: (tab: TabType) => void
|
||||
}
|
||||
|
||||
export function OverviewTab({
|
||||
pipelineStatus,
|
||||
pipelineHistory,
|
||||
systemStats,
|
||||
dockerStats,
|
||||
woodpeckerStatus,
|
||||
triggeringWoodpecker,
|
||||
triggerWoodpeckerPipeline,
|
||||
setActiveTab,
|
||||
}: OverviewTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Woodpecker CI Status - Prominent */}
|
||||
<WoodpeckerOverviewCard
|
||||
woodpeckerStatus={woodpeckerStatus}
|
||||
triggeringWoodpecker={triggeringWoodpecker}
|
||||
triggerWoodpeckerPipeline={triggerWoodpeckerPipeline}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className={`p-4 rounded-lg ${pipelineStatus?.gitea_connected ? 'bg-green-50' : 'bg-yellow-50'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`w-3 h-3 rounded-full ${pipelineStatus?.gitea_connected ? 'bg-green-500' : 'bg-yellow-500'}`}></span>
|
||||
<span className="text-sm font-medium">Gitea Status</span>
|
||||
</div>
|
||||
<p className={`text-lg font-bold ${pipelineStatus?.gitea_connected ? 'text-green-700' : 'text-yellow-700'}`}>
|
||||
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Nicht verbunden'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">http://macmini:3003</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Pipeline Runs</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-blue-700">{pipelineStatus?.total_runs || 0}</p>
|
||||
<p className="text-xs text-slate-500">{pipelineStatus?.successful_runs || 0} erfolgreich</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Container</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-purple-700">{dockerStats?.running_containers || 0}</p>
|
||||
<p className="text-xs text-slate-500">von {dockerStats?.total_containers || 0} laufend</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Letztes Update</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-slate-700">
|
||||
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleDateString('de-DE') : 'Nie'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleTimeString('de-DE') : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Resources */}
|
||||
{systemStats && (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-slate-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
Server Ressourcen ({systemStats.hostname})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-slate-600">CPU</span>
|
||||
<span className={`font-bold ${systemStats.cpu.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{systemStats.cpu.usage_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={systemStats.cpu.usage_percent} />
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-slate-600">RAM</span>
|
||||
<span className={`font-bold ${systemStats.memory.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{systemStats.memory.usage_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={systemStats.memory.usage_percent} color="purple" />
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-slate-600">Disk</span>
|
||||
<span className={`font-bold ${systemStats.disk.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{systemStats.disk.usage_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={systemStats.disk.usage_percent} color="green" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Pipeline Runs */}
|
||||
{pipelineHistory.length > 0 && (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-slate-800 mb-3">Letzte Pipeline Runs</h3>
|
||||
<div className="space-y-2">
|
||||
{pipelineHistory.slice(0, 5).map((run) => (
|
||||
<div key={run.id} className="flex items-center justify-between bg-white p-3 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
run.status === 'success' ? 'bg-green-500' :
|
||||
run.status === 'failed' ? 'bg-red-500' :
|
||||
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
|
||||
}`}></span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">{run.workflow || 'SBOM Pipeline'}</p>
|
||||
<p className="text-xs text-slate-500">{run.branch} - {run.commit_sha.substring(0, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${
|
||||
run.status === 'success' ? 'text-green-600' :
|
||||
run.status === 'failed' ? 'text-red-600' :
|
||||
run.status === 'running' ? 'text-yellow-600' : 'text-slate-600'
|
||||
}`}>
|
||||
{run.status === 'success' ? 'Erfolgreich' :
|
||||
run.status === 'failed' ? 'Fehlgeschlagen' :
|
||||
run.status === 'running' ? 'Laeuft...' : run.status}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{new Date(run.started_at).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Woodpecker Overview Card (sub-component)
|
||||
// ============================================================================
|
||||
|
||||
function WoodpeckerOverviewCard({
|
||||
woodpeckerStatus,
|
||||
triggeringWoodpecker,
|
||||
triggerWoodpeckerPipeline,
|
||||
setActiveTab,
|
||||
}: {
|
||||
woodpeckerStatus: WoodpeckerStatus | null
|
||||
triggeringWoodpecker: boolean
|
||||
triggerWoodpeckerPipeline: () => Promise<void>
|
||||
setActiveTab: (tab: TabType) => void
|
||||
}) {
|
||||
const latestPipeline = woodpeckerStatus?.pipelines?.[0]
|
||||
const isOnline = woodpeckerStatus?.status === 'online'
|
||||
const latestStatus = latestPipeline?.status
|
||||
|
||||
const borderClass = isOnline
|
||||
? latestStatus === 'success'
|
||||
? 'border-green-300 bg-green-50'
|
||||
: latestStatus === 'failure' || latestStatus === 'error'
|
||||
? 'border-red-300 bg-red-50'
|
||||
: latestStatus === 'running'
|
||||
? 'border-blue-300 bg-blue-50'
|
||||
: 'border-slate-300 bg-slate-50'
|
||||
: 'border-red-300 bg-red-50'
|
||||
|
||||
const iconBgClass = isOnline
|
||||
? latestStatus === 'success'
|
||||
? 'bg-green-100'
|
||||
: latestStatus === 'failure' || latestStatus === 'error'
|
||||
? 'bg-red-100'
|
||||
: 'bg-blue-100'
|
||||
: 'bg-red-100'
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-xl border-2 ${borderClass}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-lg ${iconBgClass}`}>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-slate-900">Woodpecker CI</h3>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
isOnline ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{latestPipeline && (
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Pipeline #{latestPipeline.number}: {' '}
|
||||
<span className={`font-medium ${
|
||||
latestStatus === 'success' ? 'text-green-600' :
|
||||
latestStatus === 'failure' || latestStatus === 'error' ? 'text-red-600' :
|
||||
latestStatus === 'running' ? 'text-blue-600' : 'text-slate-600'
|
||||
}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
{' '}auf {latestPipeline.branch}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('woodpecker')}
|
||||
className="px-3 py-1.5 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-white"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerWoodpeckerPipeline}
|
||||
disabled={triggeringWoodpecker}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{triggeringWoodpecker ? (
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
)}
|
||||
Starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Failed steps preview */}
|
||||
{latestPipeline?.steps?.some(s => s.state === 'failure') && (
|
||||
<div className="mt-3 pt-3 border-t border-red-200">
|
||||
<p className="text-xs font-medium text-red-700 mb-2">Fehlgeschlagene Steps:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{latestPipeline.steps.filter(s => s.state === 'failure').map((step, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">
|
||||
{step.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import type { PipelineRun } from '../types'
|
||||
|
||||
interface PipelinesTabProps {
|
||||
pipelineHistory: PipelineRun[]
|
||||
triggeringPipeline: boolean
|
||||
triggerPipeline: () => Promise<void>
|
||||
}
|
||||
|
||||
export function PipelinesTab({
|
||||
pipelineHistory,
|
||||
triggeringPipeline,
|
||||
triggerPipeline,
|
||||
}: PipelinesTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pipeline Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">Gitea Actions Pipelines</h3>
|
||||
<p className="text-sm text-slate-600">Workflows werden bei Push auf main/develop automatisch ausgefuehrt</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={triggerPipeline}
|
||||
disabled={triggeringPipeline}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{triggeringPipeline ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Laeuft...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Pipeline starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Available Pipelines */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="font-medium text-green-800">SBOM Pipeline</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700 mb-2">Generiert Software Bill of Materials</p>
|
||||
<p className="text-xs text-green-600">5 Jobs: generate, scan, license, upload, summary</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
|
||||
<span className="font-medium text-slate-600">Test Pipeline</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-2">Unit & Integration Tests</p>
|
||||
<p className="text-xs text-slate-400">Geplant</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
|
||||
<span className="font-medium text-slate-600">Security Pipeline</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-2">SAST, SCA, Secrets Scan</p>
|
||||
<p className="text-xs text-slate-400">Geplant</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline History */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-slate-800 mb-4">Pipeline Historie</h4>
|
||||
{pipelineHistory.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
Keine Pipeline-Runs vorhanden. Starten Sie die erste Pipeline!
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Workflow</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Branch</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Commit</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Gestartet</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{pipelineHistory.map((run) => (
|
||||
<tr key={run.id} className="hover:bg-white">
|
||||
<td className="py-2 px-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
run.status === 'success' ? 'bg-green-100 text-green-800' :
|
||||
run.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
run.status === 'running' ? 'bg-yellow-100 text-yellow-800' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
run.status === 'success' ? 'bg-green-500' :
|
||||
run.status === 'failed' ? 'bg-red-500' :
|
||||
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
|
||||
}`}></span>
|
||||
{run.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-sm text-slate-900">{run.workflow || 'SBOM Pipeline'}</td>
|
||||
<td className="py-2 px-3 text-sm text-slate-600">{run.branch}</td>
|
||||
<td className="py-2 px-3 text-sm font-mono text-slate-500">{run.commit_sha.substring(0, 8)}</td>
|
||||
<td className="py-2 px-3 text-sm text-slate-500">{new Date(run.started_at).toLocaleString('de-DE')}</td>
|
||||
<td className="py-2 px-3 text-sm text-slate-500">
|
||||
{run.duration_seconds ? `${run.duration_seconds}s` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pipeline Architecture */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-slate-800 mb-3">SBOM Pipeline Architektur</h4>
|
||||
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{`Gitea Actions Pipeline (.gitea/workflows/sbom.yaml)
|
||||
|
|
||||
+-- 1. generate-sbom -> Syft generiert CycloneDX SBOM
|
||||
|
|
||||
+-- 2. vulnerability-scan -> Grype scannt auf CVEs
|
||||
|
|
||||
+-- 3. license-check -> Prueft GPL/AGPL Lizenzen
|
||||
|
|
||||
+-- 4. upload-dashboard -> POST /api/v1/security/sbom/upload
|
||||
|
|
||||
+-- 5. summary -> Job Summary generieren`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
'use client'
|
||||
|
||||
export function SchedulerTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatusCard
|
||||
icon={<ClockIcon />}
|
||||
title="launchd Job"
|
||||
description="Taeglich um 07:00 Uhr automatisch"
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<TerminalIcon />}
|
||||
title="Git Hook"
|
||||
description="Quick Tests bei voice-service Aenderungen"
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<BellIcon />}
|
||||
title="Benachrichtigungen"
|
||||
description="Desktop-Alerts bei Fehlern aktiviert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-slate-800 mb-4">Quick Actions (BQAS)</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href="/ai/test-quality"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Test Dashboard oeffnen
|
||||
</a>
|
||||
<span className="text-sm text-slate-500 self-center">
|
||||
Starte Tests direkt im BQAS Dashboard
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Actions vs Local - Comparison */}
|
||||
<ComparisonTable />
|
||||
|
||||
{/* Configuration Details */}
|
||||
<ConfigurationDetails />
|
||||
|
||||
{/* Detailed Explanation */}
|
||||
<DetailedExplanation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sub-components
|
||||
// ============================================================================
|
||||
|
||||
function StatusCard({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold">{title}</h4>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm mt-1 opacity-80">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonTable() {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-slate-800 mb-4">GitHub Actions Alternative</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-white">
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Feature</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-slate-700">GitHub Actions</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-slate-700">Lokaler Scheduler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ComparisonRow
|
||||
feature="Taegliche Tests (07:00)"
|
||||
github={<span className="text-slate-600">schedule: cron</span>}
|
||||
local={<Badge color="emerald">macOS launchd</Badge>}
|
||||
/>
|
||||
<ComparisonRow
|
||||
feature="Push-basierte Tests"
|
||||
github={<span className="text-slate-600">on: push</span>}
|
||||
local={<Badge color="emerald">Git post-commit Hook</Badge>}
|
||||
/>
|
||||
<ComparisonRow
|
||||
feature="PR-basierte Tests"
|
||||
github={<Badge color="emerald">on: pull_request</Badge>}
|
||||
local={<Badge color="amber">Nicht moeglich</Badge>}
|
||||
/>
|
||||
<ComparisonRow
|
||||
feature="DSGVO-Konformitaet"
|
||||
github={<Badge color="amber">Daten bei GitHub (US)</Badge>}
|
||||
local={<Badge color="emerald">100% lokal</Badge>}
|
||||
/>
|
||||
<ComparisonRow
|
||||
feature="Offline-Faehig"
|
||||
github={<Badge color="red">Nein</Badge>}
|
||||
local={<Badge color="emerald">Ja</Badge>}
|
||||
isLast
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonRow({
|
||||
feature,
|
||||
github,
|
||||
local,
|
||||
isLast = false,
|
||||
}: {
|
||||
feature: string
|
||||
github: React.ReactNode
|
||||
local: React.ReactNode
|
||||
isLast?: boolean
|
||||
}) {
|
||||
return (
|
||||
<tr className={isLast ? '' : 'border-b border-slate-100'}>
|
||||
<td className="py-3 px-4 text-slate-600">{feature}</td>
|
||||
<td className="py-3 px-4 text-center">{github}</td>
|
||||
<td className="py-3 px-4 text-center">{local}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ color, children }: { color: 'emerald' | 'amber' | 'red'; children: React.ReactNode }) {
|
||||
const colorClasses = {
|
||||
emerald: 'bg-emerald-100 text-emerald-700',
|
||||
amber: 'bg-amber-100 text-amber-700',
|
||||
red: 'bg-red-100 text-red-700',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${colorClasses[color]}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigurationDetails() {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-slate-800 mb-4">Konfiguration</h3>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* launchd Configuration */}
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-700 mb-3">launchd Job</h4>
|
||||
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-slate-100 overflow-x-auto">
|
||||
<pre>{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
|
||||
Label: com.breakpilot.bqas
|
||||
Schedule: 07:00 taeglich
|
||||
Script: /voice-service/scripts/run_bqas.sh
|
||||
Logs: /var/log/bqas/`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-700 mb-3">Umgebungsvariablen</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<EnvVar name="BQAS_SERVICE_URL" value="http://localhost:8091" />
|
||||
<EnvVar name="BQAS_REGRESSION_THRESHOLD" value="0.1" />
|
||||
<EnvVar name="BQAS_NOTIFY_DESKTOP" value="true" isActive />
|
||||
<EnvVar name="BQAS_NOTIFY_SLACK" value="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvVar({ name, value, isActive }: { name: string; value: string; isActive?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between p-2 bg-white rounded">
|
||||
<span className="font-mono text-slate-600">{name}</span>
|
||||
<span className={isActive ? 'text-emerald-600 font-medium' : value === 'false' ? 'text-slate-400' : 'text-slate-900'}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailedExplanation() {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Detaillierte Erklaerung
|
||||
</h3>
|
||||
|
||||
<div className="prose prose-sm max-w-none text-slate-700">
|
||||
<h4 className="text-base font-semibold mt-4 mb-2">Warum ein lokaler Scheduler?</h4>
|
||||
<p className="mb-4">
|
||||
Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten,
|
||||
aber mit dem entscheidenden Vorteil, dass <strong>alle Daten zu 100% auf dem lokalen Mac Mini verbleiben</strong>.
|
||||
Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-4 mb-2">Komponenten</h4>
|
||||
<ul className="list-disc list-inside space-y-2 mb-4">
|
||||
<li>
|
||||
<strong>run_bqas.sh</strong> - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet
|
||||
</li>
|
||||
<li>
|
||||
<strong>launchd Job</strong> - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet
|
||||
</li>
|
||||
<li>
|
||||
<strong>Git Hook</strong> - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet
|
||||
</li>
|
||||
<li>
|
||||
<strong>Notifier</strong> - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-4 mb-2">Installation</h4>
|
||||
<div className="bg-slate-900 rounded-lg p-3 font-mono text-sm text-slate-100 mb-4">
|
||||
<code>./voice-service/scripts/install_bqas_scheduler.sh install</code>
|
||||
</div>
|
||||
|
||||
<h4 className="text-base font-semibold mt-4 mb-2">Vorteile gegenueber GitHub Actions</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>100% DSGVO-konform - alle Daten bleiben lokal</li>
|
||||
<li>Keine Internet-Abhaengigkeit - funktioniert auch offline</li>
|
||||
<li>Keine GitHub-Kosten fuer private Repositories</li>
|
||||
<li>Schnellere Ausfuehrung ohne Cloud-Overhead</li>
|
||||
<li>Volle Kontrolle ueber Scheduling und Benachrichtigungen</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SVG Icons
|
||||
// ============================================================================
|
||||
|
||||
function ClockIcon() {
|
||||
return (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TerminalIcon() {
|
||||
return (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import type { PipelineStatus } from '../types'
|
||||
|
||||
interface SetupTabProps {
|
||||
pipelineStatus: PipelineStatus | null
|
||||
}
|
||||
|
||||
export function SetupTab({ pipelineStatus }: SetupTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-2">Erstkonfiguration - Gitea CI/CD</h3>
|
||||
<p className="text-slate-600">
|
||||
Anleitung zur Einrichtung der CI/CD Pipeline mit Gitea Actions auf dem Mac Mini Server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gitea Server Info */}
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-blue-800 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
Gitea Server
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<p className="text-sm text-slate-500">Web-URL</p>
|
||||
<p className="font-mono text-blue-700">http://macmini:3003</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<p className="text-sm text-slate-500">SSH</p>
|
||||
<p className="font-mono text-blue-700">macmini:2222</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<p className="text-sm text-slate-500">Status</p>
|
||||
<p className={`font-medium ${pipelineStatus?.gitea_connected ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Konfiguration erforderlich'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Implementierte Komponenten */}
|
||||
<div className="bg-slate-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-slate-800 mb-3">Implementierte Komponenten</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Komponente</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Pfad</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-medium">Gitea Service</td>
|
||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
|
||||
<td className="py-2 px-3 text-slate-600">Gitea 1.22 mit Actions enabled</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-medium">Gitea Runner</td>
|
||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
|
||||
<td className="py-2 px-3 text-slate-600">act_runner fuer Job-Ausfuehrung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-medium">SBOM Workflow</td>
|
||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">.gitea/workflows/sbom.yaml</code></td>
|
||||
<td className="py-2 px-3 text-slate-600">5 Jobs: generate, scan, license, upload, summary</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-medium">Backend API</td>
|
||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">backend/security_api.py</code></td>
|
||||
<td className="py-2 px-3 text-slate-600">SBOM Upload, Pipeline Status, History</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-medium">Runner Config</td>
|
||||
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">gitea/runner-config.yaml</code></td>
|
||||
<td className="py-2 px-3 text-slate-600">Labels: ubuntu-latest, self-hosted</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup Steps */}
|
||||
<div className="bg-orange-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-orange-800 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
Setup-Schritte
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<h5 className="font-medium text-slate-800 mb-1">1. Gitea oeffnen</h5>
|
||||
<code className="text-sm bg-slate-100 px-2 py-1 rounded">http://macmini:3003</code>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<h5 className="font-medium text-slate-800 mb-1">2. Admin-Account erstellen</h5>
|
||||
<p className="text-sm text-slate-600">Username: admin, Email: admin@breakpilot.de</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<h5 className="font-medium text-slate-800 mb-1">3. Repository erstellen</h5>
|
||||
<p className="text-sm text-slate-600">Name: breakpilot-pwa, Visibility: Private</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<h5 className="font-medium text-slate-800 mb-1">4. Actions aktivieren</h5>
|
||||
<p className="text-sm text-slate-600">Repository Settings → Actions → Enable Repository Actions</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<h5 className="font-medium text-slate-800 mb-1">5. Runner Token erstellen & starten</h5>
|
||||
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
|
||||
{`export GITEA_RUNNER_TOKEN=<token>
|
||||
docker compose up -d gitea-runner`}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<h5 className="font-medium text-slate-800 mb-1">6. Repository pushen</h5>
|
||||
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
|
||||
{`git remote add gitea http://macmini:3003/admin/breakpilot-pwa.git
|
||||
git push gitea main`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-purple-800 mb-3">Quick Links</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<a
|
||||
href="http://macmini:3003"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-purple-800">Gitea</p>
|
||||
<p className="text-xs text-slate-500">Git Server & CI/CD</p>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="http://macmini:3003/admin/breakpilot-pwa/actions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-purple-800">Pipeline Actions</p>
|
||||
<p className="text-xs text-slate-500">Workflow Runs</p>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client'
|
||||
|
||||
import type { WoodpeckerStatus, WoodpeckerPipeline } from '../types'
|
||||
|
||||
interface WoodpeckerTabProps {
|
||||
woodpeckerStatus: WoodpeckerStatus | null
|
||||
triggeringWoodpecker: boolean
|
||||
triggerWoodpeckerPipeline: () => Promise<void>
|
||||
}
|
||||
|
||||
export function WoodpeckerTab({
|
||||
woodpeckerStatus,
|
||||
triggeringWoodpecker,
|
||||
triggerWoodpeckerPipeline,
|
||||
}: WoodpeckerTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Woodpecker Status Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-800">Woodpecker CI Pipeline</h3>
|
||||
<span className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
woodpeckerStatus?.status === 'online'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
woodpeckerStatus?.status === 'online' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`} />
|
||||
{woodpeckerStatus?.status === 'online' ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="http://macmini:8090"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-2 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Woodpecker UI
|
||||
</a>
|
||||
<button
|
||||
onClick={triggerWoodpeckerPipeline}
|
||||
disabled={triggeringWoodpecker}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{triggeringWoodpecker ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
Startet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Pipeline starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Stats */}
|
||||
<WoodpeckerStats pipelines={woodpeckerStatus?.pipelines || []} />
|
||||
|
||||
{/* Pipeline List */}
|
||||
{woodpeckerStatus?.pipelines && woodpeckerStatus.pipelines.length > 0 ? (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-slate-800 mb-4">Pipeline Historie</h4>
|
||||
<div className="space-y-3">
|
||||
{woodpeckerStatus.pipelines.map((pipeline) => (
|
||||
<PipelineCard key={pipeline.id} pipeline={pipeline} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center">
|
||||
<svg className="w-12 h-12 text-slate-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p className="text-slate-500">Keine Pipelines gefunden</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Starte eine neue Pipeline oder pruefe die Woodpecker-Konfiguration</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline Configuration Info */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-slate-800 mb-3">Pipeline Konfiguration</h4>
|
||||
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{`Woodpecker CI Pipeline (.woodpecker/main.yml)
|
||||
|
|
||||
+-- 1. go-lint -> Go Linting (PR only)
|
||||
+-- 2. python-lint -> Python Linting (PR only)
|
||||
+-- 3. secrets-scan -> GitLeaks Secrets Scan
|
||||
|
|
||||
+-- 4. test-go-consent -> Go Unit Tests
|
||||
+-- 5. test-go-billing -> Billing Service Tests
|
||||
+-- 6. test-go-school -> School Service Tests
|
||||
+-- 7. test-python -> Python Backend Tests
|
||||
|
|
||||
+-- 8. build-images -> Docker Image Build
|
||||
+-- 9. generate-sbom -> SBOM Generation (Syft)
|
||||
+-- 10. vuln-scan -> Vulnerability Scan (Grype)
|
||||
+-- 11. container-scan -> Container Scan (Trivy)
|
||||
|
|
||||
+-- 12. sign-images -> Cosign Image Signing
|
||||
+-- 13. attest-sbom -> SBOM Attestation
|
||||
+-- 14. provenance -> SLSA Provenance
|
||||
|
|
||||
+-- 15. deploy-prod -> Production Deployment`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Workflow Anleitung */}
|
||||
<WorkflowGuide />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sub-components
|
||||
// ============================================================================
|
||||
|
||||
function WoodpeckerStats({ pipelines }: { pipelines: WoodpeckerPipeline[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Gesamt</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-700">{pipelines.length}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Erfolgreich</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-700">
|
||||
{pipelines.filter(p => p.status === 'success').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Fehlgeschlagen</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-red-700">
|
||||
{pipelines.filter(p => p.status === 'failure' || p.status === 'error').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Laufend</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-700">
|
||||
{pipelines.filter(p => p.status === 'running' || p.status === 'pending').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineCard({ pipeline }: { pipeline: WoodpeckerPipeline }) {
|
||||
const borderClass =
|
||||
pipeline.status === 'success'
|
||||
? 'border-green-200 bg-green-50/30'
|
||||
: pipeline.status === 'failure' || pipeline.status === 'error'
|
||||
? 'border-red-200 bg-red-50/30'
|
||||
: pipeline.status === 'running'
|
||||
? 'border-blue-200 bg-blue-50/30'
|
||||
: 'border-slate-200 bg-white'
|
||||
|
||||
return (
|
||||
<div className={`border rounded-xl p-4 transition-colors ${borderClass}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`w-3 h-3 rounded-full ${
|
||||
pipeline.status === 'success' ? 'bg-green-500' :
|
||||
pipeline.status === 'failure' || pipeline.status === 'error' ? 'bg-red-500' :
|
||||
pipeline.status === 'running' ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'
|
||||
}`} />
|
||||
<span className="font-semibold text-slate-900">Pipeline #{pipeline.number}</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
pipeline.status === 'success' ? 'bg-green-100 text-green-800' :
|
||||
pipeline.status === 'failure' || pipeline.status === 'error' ? 'bg-red-100 text-red-800' :
|
||||
pipeline.status === 'running' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{pipeline.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 mb-2">
|
||||
<span className="font-mono">{pipeline.branch}</span>
|
||||
<span className="mx-2 text-slate-400">•</span>
|
||||
<span className="font-mono text-slate-500">{pipeline.commit}</span>
|
||||
<span className="mx-2 text-slate-400">•</span>
|
||||
<span>{pipeline.event}</span>
|
||||
</div>
|
||||
{pipeline.message && (
|
||||
<p className="text-sm text-slate-500 mb-2 truncate max-w-xl">{pipeline.message}</p>
|
||||
)}
|
||||
|
||||
{/* Steps Progress */}
|
||||
{pipeline.steps && pipeline.steps.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-1 mb-2">
|
||||
{pipeline.steps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
step.state === 'success' ? 'bg-green-500' :
|
||||
step.state === 'failure' ? 'bg-red-500' :
|
||||
step.state === 'running' ? 'bg-blue-500 animate-pulse' :
|
||||
step.state === 'skipped' ? 'bg-slate-200' : 'bg-slate-300'
|
||||
}`}
|
||||
title={`${step.name}: ${step.state}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{pipeline.steps.map((step, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`px-2 py-1 rounded ${
|
||||
step.state === 'success' ? 'bg-green-100 text-green-700' :
|
||||
step.state === 'failure' ? 'bg-red-100 text-red-700' :
|
||||
step.state === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{pipeline.errors && pipeline.errors.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h5 className="text-sm font-medium text-red-800 mb-1">Fehler:</h5>
|
||||
<ul className="text-xs text-red-700 space-y-1">
|
||||
{pipeline.errors.map((err, i) => (
|
||||
<li key={i} className="font-mono">{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm text-slate-500">
|
||||
<p>{new Date(pipeline.created * 1000).toLocaleDateString('de-DE')}</p>
|
||||
<p className="text-xs">{new Date(pipeline.created * 1000).toLocaleTimeString('de-DE')}</p>
|
||||
{pipeline.started && pipeline.finished && (
|
||||
<p className="text-xs mt-1">
|
||||
Dauer: {Math.round((pipeline.finished - pipeline.started) / 60)}m
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowGuide() {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-800 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Workflow-Anleitung
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<h5 className="font-medium text-blue-700 mb-2">Automatisch (bei jedem Push/PR):</h5>
|
||||
<ul className="space-y-1 text-blue-600">
|
||||
<li>- <strong>Linting</strong> - Code-Qualitaet pruefen (nur PRs)</li>
|
||||
<li>- <strong>Unit Tests</strong> - Go & Python Tests</li>
|
||||
<li>- <strong>Test-Dashboard</strong> - Ergebnisse werden gesendet</li>
|
||||
<li>- <strong>Backlog</strong> - Fehlgeschlagene Tests werden erfasst</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-medium text-blue-700 mb-2">Manuell (Button oder Tag):</h5>
|
||||
<ul className="space-y-1 text-blue-600">
|
||||
<li>- <strong>Docker Builds</strong> - Container erstellen</li>
|
||||
<li>- <strong>SBOM/Scans</strong> - Sicherheitsanalyse</li>
|
||||
<li>- <strong>Deployment</strong> - In Produktion deployen</li>
|
||||
<li>- <strong>Pipeline starten</strong> - Diesen Button verwenden</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-blue-200">
|
||||
<h5 className="font-medium text-blue-700 mb-2">Setup: API Token konfigurieren</h5>
|
||||
<p className="text-blue-600 text-sm">
|
||||
Um Pipelines ueber das Dashboard zu starten, muss ein <strong>WOODPECKER_TOKEN</strong> konfiguriert werden:
|
||||
</p>
|
||||
<ol className="mt-2 space-y-1 text-blue-600 text-sm list-decimal list-inside">
|
||||
<li>Woodpecker UI oeffnen: <a href="http://macmini:8090" target="_blank" rel="noopener noreferrer" className="underline hover:text-blue-800">http://macmini:8090</a></li>
|
||||
<li>Mit Gitea-Account einloggen</li>
|
||||
<li>Klick auf Profil → <strong>User Settings</strong> → <strong>Personal Access Tokens</strong></li>
|
||||
<li>Neues Token erstellen und in <code className="bg-blue-100 px-1 rounded">.env</code> eintragen: <code className="bg-blue-100 px-1 rounded">WOODPECKER_TOKEN=...</code></li>
|
||||
<li>Container neu starten: <code className="bg-blue-100 px-1 rounded">docker compose up -d admin-v2</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// ============================================================================
|
||||
// CI/CD Dashboard - Shared Helper Components & Utilities
|
||||
// ============================================================================
|
||||
|
||||
export function ProgressBar({ percent, color = 'blue' }: { percent: number; color?: string }) {
|
||||
const getColor = () => {
|
||||
if (percent > 90) return 'bg-red-500'
|
||||
if (percent > 70) return 'bg-yellow-500'
|
||||
if (color === 'green') return 'bg-green-500'
|
||||
if (color === 'purple') return 'bg-purple-500'
|
||||
return 'bg-blue-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getColor()}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
export function getStateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'running': return 'bg-green-100 text-green-800'
|
||||
case 'exited':
|
||||
case 'dead': return 'bg-red-100 text-red-800'
|
||||
case 'paused': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'restarting': return 'bg-blue-100 text-blue-800'
|
||||
default: return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
// ============================================================================
|
||||
// CI/CD Dashboard Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PipelineStatus {
|
||||
gitea_connected: boolean
|
||||
gitea_url: string
|
||||
last_sbom_update: string | null
|
||||
total_runs: number
|
||||
successful_runs: number
|
||||
failed_runs: number
|
||||
}
|
||||
|
||||
export interface PipelineRun {
|
||||
id: string
|
||||
workflow: string
|
||||
branch: string
|
||||
commit_sha: string
|
||||
status: 'success' | 'failed' | 'running' | 'pending'
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
duration_seconds: number | null
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
id: string
|
||||
name: string
|
||||
image: string
|
||||
status: string
|
||||
state: string
|
||||
created: string
|
||||
ports: string[]
|
||||
cpu_percent: number
|
||||
memory_usage: string
|
||||
memory_limit: string
|
||||
memory_percent: number
|
||||
network_rx: string
|
||||
network_tx: string
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
hostname: string
|
||||
platform: string
|
||||
arch: string
|
||||
uptime: number
|
||||
cpu: {
|
||||
model: string
|
||||
cores: number
|
||||
usage_percent: number
|
||||
}
|
||||
memory: {
|
||||
total: string
|
||||
used: string
|
||||
free: string
|
||||
usage_percent: number
|
||||
}
|
||||
disk: {
|
||||
total: string
|
||||
used: string
|
||||
free: string
|
||||
usage_percent: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
containers: ContainerInfo[]
|
||||
total_containers: number
|
||||
running_containers: number
|
||||
stopped_containers: number
|
||||
}
|
||||
|
||||
export type TabType = 'overview' | 'woodpecker' | 'pipelines' | 'deployments' | 'setup' | 'scheduler'
|
||||
|
||||
// Woodpecker Types
|
||||
export interface WoodpeckerStep {
|
||||
name: string
|
||||
state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'
|
||||
exit_code: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WoodpeckerPipeline {
|
||||
id: number
|
||||
number: number
|
||||
status: 'pending' | 'running' | 'success' | 'failure' | 'error'
|
||||
event: string
|
||||
branch: string
|
||||
commit: string
|
||||
message: string
|
||||
author: string
|
||||
created: number
|
||||
started: number
|
||||
finished: number
|
||||
steps: WoodpeckerStep[]
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface WoodpeckerStatus {
|
||||
status: 'online' | 'offline'
|
||||
pipelines: WoodpeckerPipeline[]
|
||||
lastUpdate: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ContainerFilter = 'all' | 'running' | 'stopped'
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type {
|
||||
PipelineStatus,
|
||||
PipelineRun,
|
||||
SystemStats,
|
||||
DockerStats,
|
||||
WoodpeckerStatus,
|
||||
TabType,
|
||||
ContainerFilter,
|
||||
ContainerInfo,
|
||||
} from './types'
|
||||
|
||||
export interface CiCdData {
|
||||
// Tab
|
||||
activeTab: TabType
|
||||
setActiveTab: (tab: TabType) => void
|
||||
|
||||
// Pipeline
|
||||
pipelineStatus: PipelineStatus | null
|
||||
pipelineHistory: PipelineRun[]
|
||||
triggeringPipeline: boolean
|
||||
triggerPipeline: () => Promise<void>
|
||||
|
||||
// Container
|
||||
systemStats: SystemStats | null
|
||||
dockerStats: DockerStats | null
|
||||
containerFilter: ContainerFilter
|
||||
setContainerFilter: (f: ContainerFilter) => void
|
||||
filteredContainers: ContainerInfo[]
|
||||
actionLoading: string | null
|
||||
containerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => Promise<void>
|
||||
loadContainerData: () => Promise<void>
|
||||
|
||||
// Woodpecker
|
||||
woodpeckerStatus: WoodpeckerStatus | null
|
||||
triggeringWoodpecker: boolean
|
||||
triggerWoodpeckerPipeline: () => Promise<void>
|
||||
|
||||
// General
|
||||
loading: boolean
|
||||
error: string | null
|
||||
message: string | null
|
||||
}
|
||||
|
||||
export function useCiCdData(): CiCdData {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||
|
||||
// Pipeline State
|
||||
const [pipelineStatus, setPipelineStatus] = useState<PipelineStatus | null>(null)
|
||||
const [pipelineHistory, setPipelineHistory] = useState<PipelineRun[]>([])
|
||||
const [triggeringPipeline, setTriggeringPipeline] = useState(false)
|
||||
|
||||
// Container State
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null)
|
||||
const [dockerStats, setDockerStats] = useState<DockerStats | null>(null)
|
||||
const [containerFilter, setContainerFilter] = useState<ContainerFilter>('all')
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
|
||||
// Woodpecker State
|
||||
const [woodpeckerStatus, setWoodpeckerStatus] = useState<WoodpeckerStatus | null>(null)
|
||||
const [triggeringWoodpecker, setTriggeringWoodpecker] = useState(false)
|
||||
|
||||
// General State
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''
|
||||
|
||||
// ============================================================================
|
||||
// Data Loading
|
||||
// ============================================================================
|
||||
|
||||
const loadPipelineData = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, historyRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/status`),
|
||||
fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/history`),
|
||||
])
|
||||
|
||||
if (statusRes.ok) {
|
||||
setPipelineStatus(await statusRes.json())
|
||||
}
|
||||
if (historyRes.ok) {
|
||||
setPipelineHistory(await historyRes.json())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load pipeline data:', err)
|
||||
}
|
||||
}, [BACKEND_URL])
|
||||
|
||||
const loadContainerData = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/infrastructure/mac-mini')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSystemStats(data.system)
|
||||
setDockerStats(data.docker)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load container data:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadWoodpeckerData = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/infrastructure/woodpecker?limit=10')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setWoodpeckerStatus(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load Woodpecker data:', err)
|
||||
setWoodpeckerStatus({
|
||||
status: 'offline',
|
||||
pipelines: [],
|
||||
lastUpdate: new Date().toISOString(),
|
||||
error: 'Verbindung fehlgeschlagen'
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadAllData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
await Promise.all([loadPipelineData(), loadContainerData(), loadWoodpeckerData()])
|
||||
setLoading(false)
|
||||
}, [loadPipelineData, loadContainerData, loadWoodpeckerData])
|
||||
|
||||
useEffect(() => {
|
||||
loadAllData()
|
||||
}, [loadAllData])
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(loadAllData, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadAllData])
|
||||
|
||||
// ============================================================================
|
||||
// Actions
|
||||
// ============================================================================
|
||||
|
||||
const triggerPipeline = async () => {
|
||||
setTriggeringPipeline(true)
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/trigger`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (response.ok) {
|
||||
setMessage('Pipeline gestartet!')
|
||||
setTimeout(loadPipelineData, 2000)
|
||||
setTimeout(loadPipelineData, 5000)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Pipeline-Trigger fehlgeschlagen')
|
||||
} finally {
|
||||
setTriggeringPipeline(false)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerWoodpeckerPipeline = async () => {
|
||||
setTriggeringWoodpecker(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
const response = await fetch('/api/admin/infrastructure/woodpecker', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ branch: 'main' })
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
setMessage(`Woodpecker Pipeline #${result.pipeline?.number || '?'} gestartet!`)
|
||||
setTimeout(loadWoodpeckerData, 2000)
|
||||
setTimeout(loadWoodpeckerData, 5000)
|
||||
} else {
|
||||
setError('Pipeline-Start fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Pipeline konnte nicht gestartet werden')
|
||||
} finally {
|
||||
setTriggeringWoodpecker(false)
|
||||
}
|
||||
}
|
||||
|
||||
const containerAction = async (containerId: string, action: 'start' | 'stop' | 'restart') => {
|
||||
setActionLoading(`${containerId}-${action}`)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/infrastructure/mac-mini', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ container_id: containerId, action }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage(`Container ${action} erfolgreich`)
|
||||
setTimeout(loadContainerData, 1000)
|
||||
setTimeout(loadContainerData, 3000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Derived
|
||||
// ============================================================================
|
||||
|
||||
const filteredContainers = dockerStats?.containers.filter(c => {
|
||||
if (containerFilter === 'all') return true
|
||||
if (containerFilter === 'running') return c.state === 'running'
|
||||
if (containerFilter === 'stopped') return c.state !== 'running'
|
||||
return true
|
||||
}) || []
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
pipelineStatus,
|
||||
pipelineHistory,
|
||||
triggeringPipeline,
|
||||
triggerPipeline,
|
||||
systemStats,
|
||||
dockerStats,
|
||||
containerFilter,
|
||||
setContainerFilter,
|
||||
filteredContainers,
|
||||
actionLoading,
|
||||
containerAction,
|
||||
loadContainerData,
|
||||
woodpeckerStatus,
|
||||
triggeringWoodpecker,
|
||||
triggerWoodpeckerPipeline,
|
||||
loading,
|
||||
error,
|
||||
message,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { LLMRoutingOption } from '@/types/infrastructure-modules'
|
||||
import type { FailedTest, BacklogItem, BacklogPriority } from '../types'
|
||||
|
||||
// ==============================================================================
|
||||
// FailedTestCard
|
||||
// ==============================================================================
|
||||
|
||||
function FailedTestCard({
|
||||
test,
|
||||
onStatusChange,
|
||||
onPriorityChange,
|
||||
priority = 'medium',
|
||||
failureCount = 1,
|
||||
}: {
|
||||
test: FailedTest
|
||||
onStatusChange: (testId: string, status: string) => void
|
||||
onPriorityChange?: (testId: string, priority: string) => void
|
||||
priority?: BacklogPriority
|
||||
failureCount?: number
|
||||
}) {
|
||||
const errorTypeColors: Record<string, string> = {
|
||||
assertion: 'bg-amber-100 text-amber-700',
|
||||
nil_pointer: 'bg-red-100 text-red-700',
|
||||
type_error: 'bg-purple-100 text-purple-700',
|
||||
network: 'bg-blue-100 text-blue-700',
|
||||
timeout: 'bg-orange-100 text-orange-700',
|
||||
logic_error: 'bg-slate-100 text-slate-700',
|
||||
unknown: 'bg-slate-100 text-slate-700',
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-red-100 text-red-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
fixed: 'bg-emerald-100 text-emerald-700',
|
||||
wont_fix: 'bg-slate-100 text-slate-700',
|
||||
flaky: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: 'bg-red-500 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
low: 'bg-slate-400 text-white',
|
||||
}
|
||||
|
||||
const priorityLabels: Record<string, string> = {
|
||||
critical: '!!! Kritisch',
|
||||
high: '!! Hoch',
|
||||
medium: '! Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 hover:border-red-300 transition-colors">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityColors[priority]}`}>
|
||||
{priorityLabels[priority]}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${errorTypeColors[test.error_type] || errorTypeColors.unknown}`}>
|
||||
{test.error_type.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{test.service}</span>
|
||||
{failureCount > 1 && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-red-100 text-red-600 text-xs font-medium">
|
||||
{failureCount}x fehlgeschlagen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-mono text-sm font-medium text-slate-900 truncate" title={test.name}>
|
||||
{test.name}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 truncate" title={test.file_path}>
|
||||
{test.file_path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 ml-2">
|
||||
<select
|
||||
value={test.status}
|
||||
onChange={(e) => onStatusChange(test.id, e.target.value)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium cursor-pointer border-0 ${statusColors[test.status]}`}
|
||||
>
|
||||
<option value="open">Offen</option>
|
||||
<option value="in_progress">In Arbeit</option>
|
||||
<option value="fixed">Behoben</option>
|
||||
<option value="wont_fix">Ignoriert</option>
|
||||
<option value="flaky">Flaky</option>
|
||||
</select>
|
||||
{onPriorityChange && (
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => onPriorityChange(test.id, e.target.value)}
|
||||
className="px-2 py-1 rounded text-xs font-medium cursor-pointer border border-slate-200"
|
||||
>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-red-800 font-medium mb-1">Fehlermeldung:</p>
|
||||
<p className="text-xs text-red-700 font-mono break-words">
|
||||
{test.error_message || 'Keine Details verfuegbar'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{test.suggestion && (
|
||||
<div className="bg-emerald-50 rounded-lg p-3">
|
||||
<p className="text-sm text-emerald-800 font-medium mb-1">Loesungsvorschlag:</p>
|
||||
<p className="text-xs text-emerald-700">
|
||||
{test.suggestion}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-100 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Zuletzt fehlgeschlagen: {test.last_failed ? new Date(test.last_failed).toLocaleString('de-DE') : 'Unbekannt'}</span>
|
||||
<button
|
||||
className="text-orange-600 hover:text-orange-700 font-medium"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(test.id)
|
||||
}}
|
||||
>
|
||||
ID kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// BacklogTab
|
||||
// ==============================================================================
|
||||
|
||||
export function BacklogTab({
|
||||
failedTests,
|
||||
onStatusChange,
|
||||
onPriorityChange,
|
||||
isLoading,
|
||||
backlogItems,
|
||||
usePostgres = false,
|
||||
}: {
|
||||
failedTests: FailedTest[]
|
||||
onStatusChange: (testId: string, status: string) => void
|
||||
onPriorityChange?: (testId: string, priority: string) => void
|
||||
isLoading: boolean
|
||||
backlogItems?: BacklogItem[]
|
||||
usePostgres?: boolean
|
||||
}) {
|
||||
const [filterStatus, setFilterStatus] = useState<string>('open')
|
||||
const [filterService, setFilterService] = useState<string>('all')
|
||||
const [filterPriority, setFilterPriority] = useState<string>('all')
|
||||
const [llmAutoAnalysis, setLlmAutoAnalysis] = useState<boolean>(true)
|
||||
const [llmRouting, setLlmRouting] = useState<LLMRoutingOption>('smart_routing')
|
||||
|
||||
// Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy
|
||||
const items = usePostgres && backlogItems ? backlogItems : failedTests
|
||||
|
||||
// Gruppiere nach Service
|
||||
const services = [...new Set(items.map(t => 'service' in t ? t.service : (t as BacklogItem).service))]
|
||||
|
||||
// Filtere Items
|
||||
const filteredItems = items.filter(item => {
|
||||
const status = 'status' in item ? item.status : 'open'
|
||||
const service = 'service' in item ? item.service : ''
|
||||
const priority = 'priority' in item ? (item as BacklogItem).priority : 'medium'
|
||||
|
||||
if (filterStatus !== 'all' && status !== filterStatus) return false
|
||||
if (filterService !== 'all' && service !== filterService) return false
|
||||
if (filterPriority !== 'all' && priority !== filterPriority) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Zaehle nach Status
|
||||
const openCount = items.filter(t => t.status === 'open').length
|
||||
const inProgressCount = items.filter(t => t.status === 'in_progress').length
|
||||
const fixedCount = items.filter(t => t.status === 'fixed').length
|
||||
const flakyCount = items.filter(t => t.status === 'flaky').length
|
||||
|
||||
// Zaehle nach Prioritaet (nur bei PostgreSQL)
|
||||
const criticalCount = backlogItems?.filter(t => t.priority === 'critical').length || 0
|
||||
const highCount = backlogItems?.filter(t => t.priority === 'high').length || 0
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Konvertiere BacklogItem zu FailedTest fuer die Anzeige
|
||||
const convertToFailedTest = (item: BacklogItem): FailedTest => ({
|
||||
id: String(item.id),
|
||||
name: item.test_name,
|
||||
service: item.service,
|
||||
file_path: item.test_file || '',
|
||||
error_message: item.error_message || '',
|
||||
error_type: item.error_type || 'unknown',
|
||||
suggestion: item.fix_suggestion || '',
|
||||
run_id: '',
|
||||
last_failed: item.last_failed_at,
|
||||
status: item.status,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<p className="text-2xl font-bold text-red-600">{openCount}</p>
|
||||
<p className="text-sm text-red-700">Offene Fehler</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<p className="text-2xl font-bold text-blue-600">{inProgressCount}</p>
|
||||
<p className="text-sm text-blue-700">In Arbeit</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
|
||||
<p className="text-2xl font-bold text-emerald-600">{fixedCount}</p>
|
||||
<p className="text-sm text-emerald-700">Behoben</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<p className="text-2xl font-bold text-purple-600">{flakyCount}</p>
|
||||
<p className="text-sm text-purple-700">Flaky</p>
|
||||
</div>
|
||||
{usePostgres && criticalCount + highCount > 0 && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
||||
<p className="text-2xl font-bold text-orange-600">{criticalCount + highCount}</p>
|
||||
<p className="text-sm text-orange-700">Kritisch/Hoch</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PostgreSQL Badge */}
|
||||
{usePostgres && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-50 border border-emerald-200 rounded-lg w-fit">
|
||||
<svg className="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-xs text-emerald-700 font-medium">Persistente Speicherung aktiv (PostgreSQL)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM Analysis Toggle */}
|
||||
<LLMAnalysisPanel
|
||||
llmAutoAnalysis={llmAutoAnalysis}
|
||||
setLlmAutoAnalysis={setLlmAutoAnalysis}
|
||||
llmRouting={llmRouting}
|
||||
setLlmRouting={setLlmRouting}
|
||||
/>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div>
|
||||
<label className="text-sm text-slate-600 mr-2">Status:</label>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="open">Offen ({openCount})</option>
|
||||
<option value="in_progress">In Arbeit ({inProgressCount})</option>
|
||||
<option value="fixed">Behoben ({fixedCount})</option>
|
||||
<option value="flaky">Flaky ({flakyCount})</option>
|
||||
<option value="wont_fix">Ignoriert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-600 mr-2">Service:</label>
|
||||
<select
|
||||
value={filterService}
|
||||
onChange={(e) => setFilterService(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||
>
|
||||
<option value="all">Alle Services</option>
|
||||
{services.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{usePostgres && (
|
||||
<div>
|
||||
<label className="text-sm text-slate-600 mr-2">Prioritaet:</label>
|
||||
<select
|
||||
value={filterPriority}
|
||||
onChange={(e) => setFilterPriority(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto text-sm text-slate-500">
|
||||
{filteredItems.length} von {items.length} Tests angezeigt
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test-Liste */}
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-12 bg-emerald-50 rounded-xl border border-emerald-200">
|
||||
<svg className="w-12 h-12 mx-auto text-emerald-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-emerald-700 font-medium">
|
||||
{filterStatus === 'open' ? 'Keine offenen Fehler!' : 'Keine Tests mit diesem Filter gefunden.'}
|
||||
</p>
|
||||
{filterStatus === 'open' && (
|
||||
<p className="text-sm text-emerald-600 mt-2">
|
||||
Alle Tests bestanden. Bereit fuer Go-Live!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredItems.map((item) => {
|
||||
const test = usePostgres && 'test_name' in item
|
||||
? convertToFailedTest(item as BacklogItem)
|
||||
: item as FailedTest
|
||||
const priority = usePostgres && 'priority' in item
|
||||
? (item as BacklogItem).priority
|
||||
: 'medium'
|
||||
const failureCount = usePostgres && 'failure_count' in item
|
||||
? (item as BacklogItem).failure_count
|
||||
: 1
|
||||
|
||||
return (
|
||||
<FailedTestCard
|
||||
key={test.id}
|
||||
test={test}
|
||||
onStatusChange={onStatusChange}
|
||||
onPriorityChange={onPriorityChange}
|
||||
priority={priority}
|
||||
failureCount={failureCount}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm text-blue-800 font-medium">Workflow fuer fehlgeschlagene Tests:</p>
|
||||
<ol className="text-xs text-blue-700 mt-2 space-y-1 list-decimal list-inside">
|
||||
<li>Markiere den Test als "In Arbeit" wenn du daran arbeitest</li>
|
||||
<li>Analysiere die Fehlermeldung und den Loesungsvorschlag</li>
|
||||
<li>Behebe den Fehler im Code</li>
|
||||
<li>Fuehre den Test erneut aus (Button im Service-Tab)</li>
|
||||
<li>Markiere als "Behoben" wenn der Test besteht</li>
|
||||
{usePostgres && <li>Setze "Flaky" fuer sporadisch fehlschlagende Tests</li>}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// LLM Analysis Panel (internal)
|
||||
// ==============================================================================
|
||||
|
||||
function LLMAnalysisPanel({
|
||||
llmAutoAnalysis,
|
||||
setLlmAutoAnalysis,
|
||||
llmRouting,
|
||||
setLlmRouting,
|
||||
}: {
|
||||
llmAutoAnalysis: boolean
|
||||
setLlmAutoAnalysis: (v: boolean) => void
|
||||
llmRouting: LLMRoutingOption
|
||||
setLlmRouting: (v: LLMRoutingOption) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-800">Automatische LLM-Analyse</h4>
|
||||
<p className="text-xs text-slate-500">KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={llmAutoAnalysis}
|
||||
onChange={(e) => setLlmAutoAnalysis(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{llmAutoAnalysis && (
|
||||
<div className="mt-4 pt-4 border-t border-violet-200">
|
||||
<p className="text-xs text-slate-600 mb-3">LLM-Routing Strategie:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<RoutingOption
|
||||
value="local_only"
|
||||
current={llmRouting}
|
||||
onChange={setLlmRouting}
|
||||
label="Nur lokales 32B LLM"
|
||||
badge="DSGVO"
|
||||
badgeColor="bg-emerald-100 text-emerald-700"
|
||||
/>
|
||||
<RoutingOption
|
||||
value="claude_preferred"
|
||||
current={llmRouting}
|
||||
onChange={setLlmRouting}
|
||||
label="Claude bevorzugt"
|
||||
badge="Qualitaet"
|
||||
badgeColor="bg-blue-100 text-blue-700"
|
||||
/>
|
||||
<RoutingOption
|
||||
value="smart_routing"
|
||||
current={llmRouting}
|
||||
onChange={setLlmRouting}
|
||||
label="Smart Routing"
|
||||
badge="Empfohlen"
|
||||
badgeColor="bg-amber-100 text-amber-700"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
{llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'}
|
||||
{llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'}
|
||||
{llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoutingOption({
|
||||
value,
|
||||
current,
|
||||
onChange,
|
||||
label,
|
||||
badge,
|
||||
badgeColor,
|
||||
}: {
|
||||
value: LLMRoutingOption
|
||||
current: LLMRoutingOption
|
||||
onChange: (v: LLMRoutingOption) => void
|
||||
label: string
|
||||
badge: string
|
||||
badgeColor: string
|
||||
}) {
|
||||
const isActive = current === value
|
||||
return (
|
||||
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'bg-violet-100 border-violet-300 text-violet-800'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="llm-routing"
|
||||
value={value}
|
||||
checked={isActive}
|
||||
onChange={() => onChange(value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${badgeColor}`}>{badge}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { CoverageData } from '../types'
|
||||
|
||||
export function CoverageChart({ data }: { data: CoverageData[] }) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
Keine Coverage-Daten verfuegbar
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sortedData = [...data].sort((a, b) => b.coverage_percent - a.coverage_percent)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sortedData.map((item) => (
|
||||
<div key={item.service}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-slate-600 truncate max-w-[200px]">{item.display_name}</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
item.coverage_percent >= 80 ? 'text-emerald-600' : item.coverage_percent >= 60 ? 'text-amber-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{item.coverage_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
item.coverage_percent >= 80 ? 'bg-emerald-500' : item.coverage_percent >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${item.coverage_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameworkDistribution({ data }: { data: Record<string, number> }) {
|
||||
const total = Object.values(data).reduce((a, b) => a + b, 0)
|
||||
if (total === 0) return null
|
||||
|
||||
const frameworkLabels: Record<string, string> = {
|
||||
go_test: 'Go Tests',
|
||||
pytest: 'Python (pytest)',
|
||||
jest: 'Jest (TS)',
|
||||
vitest: 'Vitest (SDK)',
|
||||
playwright: 'Playwright (E2E)',
|
||||
bqas_golden: 'BQAS Golden',
|
||||
bqas_rag: 'BQAS RAG',
|
||||
bqas_synthetic: 'BQAS Synthetic',
|
||||
}
|
||||
|
||||
const frameworkColors: Record<string, string> = {
|
||||
go_test: 'bg-cyan-500',
|
||||
pytest: 'bg-yellow-500',
|
||||
jest: 'bg-blue-500',
|
||||
vitest: 'bg-orange-500',
|
||||
playwright: 'bg-purple-500',
|
||||
bqas_golden: 'bg-emerald-500',
|
||||
bqas_rag: 'bg-teal-500',
|
||||
bqas_synthetic: 'bg-amber-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(data)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([framework, count]) => (
|
||||
<div key={framework} className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${frameworkColors[framework] || 'bg-slate-400'}`} />
|
||||
<span className="text-sm text-slate-600 flex-1">{frameworkLabels[framework] || framework}</span>
|
||||
<span className="text-sm font-medium text-slate-900">{count}</span>
|
||||
<span className="text-xs text-slate-400">({((count / total) * 100).toFixed(0)}%)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export function GuideTab() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl border border-orange-200 p-6">
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Was ist das Test Dashboard?
|
||||
</h2>
|
||||
<p className="text-slate-700 leading-relaxed">
|
||||
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System.
|
||||
Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren.
|
||||
Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt.
|
||||
Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<TestCategoryCard
|
||||
icon="🐹" title="Go Unit Tests (~57)" color="cyan"
|
||||
description="consent-service, billing-service, school-service, edu-search-service, ai-compliance-sdk"
|
||||
/>
|
||||
<TestCategoryCard
|
||||
icon="🐍" title="Python Tests (~50)" color="yellow"
|
||||
description="backend, voice-service, klausur-service, geo-service"
|
||||
/>
|
||||
<TestCategoryCard
|
||||
icon="🎯" title="BQAS Golden (97)" color="emerald"
|
||||
description="Validierte Referenz-Tests mit LLM-Judge fuer Intent-Erkennung"
|
||||
/>
|
||||
<TestCategoryCard
|
||||
icon="📚" title="BQAS RAG (~20)" color="teal"
|
||||
description="RAG-Judge Tests fuer Retrieval, Citations, Hallucination-Control"
|
||||
/>
|
||||
<TestCategoryCard
|
||||
icon="📘" title="TypeScript Jest (~8)" color="blue"
|
||||
description="Website Unit Tests fuer React-Komponenten"
|
||||
/>
|
||||
<TestCategoryCard
|
||||
icon="⚡" title="SDK Vitest (~43)" color="orange"
|
||||
description="AI Compliance SDK Unit Tests: Types, Export, Components, Reducer"
|
||||
/>
|
||||
<TestCategoryCard
|
||||
icon="🎭" title="SDK Playwright (~25)" color="purple"
|
||||
description="SDK E2E Tests: Navigation, Workflow, Command Bar, Export"
|
||||
/>
|
||||
<TestCategoryCard
|
||||
icon="🌐" title="Website E2E (~5)" color="slate"
|
||||
description="End-to-End Tests fuer kritische User Flows"
|
||||
/>
|
||||
<TestCategoryCard
|
||||
icon="🔗" title="Integration Tests (~15)" color="indigo"
|
||||
description="Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Architektur</h3>
|
||||
<pre className="bg-slate-50 p-4 rounded-lg text-xs overflow-x-auto">
|
||||
{`┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Admin-v2 Test Dashboard │
|
||||
│ /infrastructure/tests │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
|
||||
│ │ Unit Tests │ │ SDK Tests │ │ BQAS │ │ E2E Tests │ │
|
||||
│ │ (Go, Py) │ │ (Vitest) │ │ (LLM/RAG) │ │ (Playwright)│ │
|
||||
│ └────────────┘ └────────────┘ └────────────┘ └─────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Test Registry API │ │
|
||||
│ │ /backend/api/tests/registry.py │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Tests bleiben wo sie sind:
|
||||
- /consent-service/internal/**/*_test.go
|
||||
- /backend/tests/test_*.py
|
||||
- /voice-service/tests/bqas/
|
||||
- /admin-v2/components/sdk/__tests__/*.test.ts (Vitest)
|
||||
- /admin-v2/e2e/specs/*.spec.ts (Playwright)`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* CI/CD Workflow Anleitung */}
|
||||
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
CI/CD Integration
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800 mb-2">Automatisch (bei jedem Push/PR)</h4>
|
||||
<ul className="space-y-2 text-sm text-blue-700">
|
||||
<CIItem icon="✓" color="green" label="Unit Tests" detail="Go & Python Tests laufen automatisch" />
|
||||
<CIItem icon="✓" color="green" label="Test-Ergebnisse" detail="Werden ans Dashboard gesendet" />
|
||||
<CIItem icon="✓" color="green" label="Backlog" detail="Fehlgeschlagene Tests erscheinen hier" />
|
||||
<CIItem icon="✓" color="green" label="Linting" detail="Code-Qualitaet bei PRs pruefen" />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800 mb-2">Manuell (Button oder Tag)</h4>
|
||||
<ul className="space-y-2 text-sm text-blue-700">
|
||||
<CIItem icon="▶" color="orange" label="Docker Builds" detail="Container erstellen" />
|
||||
<CIItem icon="▶" color="orange" label="SBOM/Scans" detail="Sicherheitsanalyse ausfuehren" />
|
||||
<CIItem icon="▶" color="orange" label="Deployment" detail="In Produktion deployen" />
|
||||
<CIItem icon="▶" color="orange" label="Pipeline starten" detail="Im CI/CD Dashboard" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-blue-200">
|
||||
<p className="text-sm text-blue-600">
|
||||
<strong>Daten-Fluss:</strong> Woodpecker CI → POST /api/tests/ci-result → PostgreSQL → Test Dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/ai/test-quality"
|
||||
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">BQAS Dashboard</p>
|
||||
<p className="text-xs text-slate-500">Detaillierte BQAS-Metriken und Trend-Analyse</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/infrastructure/ci-cd"
|
||||
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">CI/CD Pipelines</p>
|
||||
<p className="text-xs text-slate-500">Gitea Actions und automatische Test-Planung</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TestCategoryCard({
|
||||
icon,
|
||||
title,
|
||||
color,
|
||||
description,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
color: string
|
||||
description: string
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
cyan: 'bg-cyan-50 border-cyan-200 text-cyan-800 text-cyan-700',
|
||||
yellow: 'bg-yellow-50 border-yellow-200 text-yellow-800 text-yellow-700',
|
||||
emerald: 'bg-emerald-50 border-emerald-200 text-emerald-800 text-emerald-700',
|
||||
teal: 'bg-teal-50 border-teal-200 text-teal-800 text-teal-700',
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-800 text-blue-700',
|
||||
orange: 'bg-orange-50 border-orange-200 text-orange-800 text-orange-700',
|
||||
purple: 'bg-purple-50 border-purple-200 text-purple-800 text-purple-700',
|
||||
slate: 'bg-slate-50 border-slate-200 text-slate-800 text-slate-700',
|
||||
indigo: 'bg-indigo-50 border-indigo-200 text-indigo-800 text-indigo-700',
|
||||
}
|
||||
|
||||
// Build explicit class strings for Tailwind to detect
|
||||
const bgBorder = `bg-${color}-50 border-${color}-200`
|
||||
const titleColor = `text-${color}-800`
|
||||
const descColor = `text-${color}-700`
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-lg border ${bgBorder}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<h4 className={`font-medium ${titleColor}`}>{title}</h4>
|
||||
</div>
|
||||
<p className={`text-sm ${descColor}`}>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CIItem({
|
||||
icon,
|
||||
color,
|
||||
label,
|
||||
detail,
|
||||
}: {
|
||||
icon: string
|
||||
color: 'green' | 'orange'
|
||||
label: string
|
||||
detail: string
|
||||
}) {
|
||||
const iconColor = color === 'green' ? 'text-green-500' : 'text-orange-500'
|
||||
return (
|
||||
<li className="flex items-start gap-2">
|
||||
<span className={`${iconColor} mt-1`}>{icon}</span>
|
||||
<span><strong>{label}</strong> - {detail}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
trend,
|
||||
color = 'blue',
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
trend?: 'up' | 'down' | 'stable'
|
||||
color?: 'blue' | 'green' | 'red' | 'yellow' | 'orange' | 'purple'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-50 border-blue-200',
|
||||
green: 'bg-emerald-50 border-emerald-200',
|
||||
red: 'bg-red-50 border-red-200',
|
||||
yellow: 'bg-amber-50 border-amber-200',
|
||||
orange: 'bg-orange-50 border-orange-200',
|
||||
purple: 'bg-purple-50 border-purple-200',
|
||||
}
|
||||
|
||||
const trendIcons = {
|
||||
up: (
|
||||
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
),
|
||||
down: (
|
||||
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
),
|
||||
stable: (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-5 ${colorClasses[color]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600">{title}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
||||
{subtitle && <p className="mt-1 text-xs text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
{trend && <div className="mt-1">{trendIcons[trend]}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import type { ServiceTestInfo } from '../types'
|
||||
|
||||
export interface ServiceProgress {
|
||||
current_file: string
|
||||
files_done: number
|
||||
files_total: number
|
||||
passed: number
|
||||
failed: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export function ServiceTestCard({
|
||||
service,
|
||||
onRun,
|
||||
isRunning,
|
||||
progress,
|
||||
}: {
|
||||
service: ServiceTestInfo
|
||||
onRun: (service: string) => void
|
||||
isRunning: boolean
|
||||
progress?: ServiceProgress
|
||||
}) {
|
||||
const passRate = service.total_tests > 0 ? (service.passed_tests / service.total_tests) * 100 : 0
|
||||
|
||||
const getLanguageIcon = (lang: string) => {
|
||||
switch (lang) {
|
||||
case 'go':
|
||||
return '🐹'
|
||||
case 'python':
|
||||
return '🐍'
|
||||
case 'typescript':
|
||||
return '📘'
|
||||
case 'mixed':
|
||||
return '🔀'
|
||||
default:
|
||||
return '📦'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'bg-emerald-100 text-emerald-700'
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'running':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-700'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:border-orange-300 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getLanguageIcon(service.language)}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">{service.display_name}</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
{service.port ? `Port ${service.port}` : 'Library'} • {service.language}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(service.status)}`}>
|
||||
{service.status === 'passed' ? 'Bestanden' : service.status === 'failed' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Pass Rate</span>
|
||||
<span className="font-medium text-slate-900">{passRate.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
passRate >= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${passRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-2 bg-slate-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-slate-900">{service.total_tests}</p>
|
||||
<p className="text-xs text-slate-500">Tests</p>
|
||||
</div>
|
||||
<div className="p-2 bg-emerald-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-emerald-600">{service.passed_tests}</p>
|
||||
<p className="text-xs text-slate-500">Bestanden</p>
|
||||
</div>
|
||||
<div className="p-2 bg-red-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-red-600">{service.failed_tests}</p>
|
||||
<p className="text-xs text-slate-500">Fehler</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{service.coverage_percent && (
|
||||
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-100">
|
||||
<span className="text-slate-600">Coverage</span>
|
||||
<span className={`font-medium ${service.coverage_percent >= 70 ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||
{service.coverage_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress-Anzeige wenn Tests laufen */}
|
||||
{isRunning && progress && progress.status === 'running' && (
|
||||
<div className="mb-3 p-3 bg-orange-50 rounded-lg border border-orange-200">
|
||||
<div className="flex items-center justify-between text-xs text-orange-700 mb-2">
|
||||
<span className="font-mono truncate max-w-[180px]">{progress.current_file || 'Starte...'}</span>
|
||||
<span>{progress.files_done}/{progress.files_total} Dateien</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-orange-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-orange-500 rounded-full transition-all"
|
||||
style={{ width: `${progress.files_total > 0 ? (progress.files_done / progress.files_total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 text-xs">
|
||||
<span className="text-emerald-600 font-medium">{progress.passed} bestanden</span>
|
||||
<span className="text-red-600 font-medium">{progress.failed} fehler</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onRun(service.service)}
|
||||
disabled={isRunning}
|
||||
className={`w-full py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isRunning
|
||||
? 'bg-orange-100 text-orange-600 cursor-wait'
|
||||
: 'bg-orange-600 text-white hover:bg-orange-700 active:scale-98'
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{progress && progress.status === 'running' ? `${progress.passed + progress.failed} Tests...` : 'Laeuft...'}
|
||||
</span>
|
||||
) : (
|
||||
'Tests starten'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TestRun } from '../types'
|
||||
|
||||
export function TestRunsTable({ runs }: { runs: TestRun[] }) {
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
Keine Test-Laeufe vorhanden
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-600">ID</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-600">Service</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-600">Zeitpunkt</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Tests</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Bestanden</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Dauer</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-slate-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4 font-mono text-xs text-slate-500">{run.id.slice(-8)}</td>
|
||||
<td className="py-3 px-4 text-slate-900">{run.service}</td>
|
||||
<td className="py-3 px-4 text-slate-600">
|
||||
{new Date(run.started_at).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-600">{run.total_tests}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className="text-emerald-600">{run.passed_tests}</span>
|
||||
<span className="text-slate-400"> / </span>
|
||||
<span className="text-red-600">{run.failed_tests}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-500">
|
||||
{run.duration_seconds.toFixed(1)}s
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
run.status === 'completed'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: run.status === 'failed'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: run.status === 'running'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { Toast } from '../types'
|
||||
|
||||
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border animate-slide-in ${
|
||||
toast.type === 'success'
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
|
||||
: toast.type === 'error'
|
||||
? 'bg-red-50 border-red-200 text-red-800'
|
||||
: toast.type === 'loading'
|
||||
? 'bg-blue-50 border-blue-200 text-blue-800'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{toast.type === 'loading' ? (
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : toast.type === 'success' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : toast.type === 'error' ? (
|
||||
<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>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium">{toast.message}</span>
|
||||
{toast.type !== 'loading' && (
|
||||
<button onClick={() => onDismiss(toast.id)} className="ml-2 opacity-60 hover:opacity-100">
|
||||
<svg className="w-4 h-4" 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>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type {
|
||||
ServiceTestInfo,
|
||||
TestRegistryStats,
|
||||
TestRun,
|
||||
CoverageData,
|
||||
TabType,
|
||||
Toast,
|
||||
FailedTest,
|
||||
BacklogItem,
|
||||
} from '../types'
|
||||
import { API_BASE, DEMO_SERVICES, DEMO_STATS } from '../_lib/constants'
|
||||
import type { ServiceProgress } from '../_components/ServiceTestCard'
|
||||
|
||||
export function useTestDashboard() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Toast state
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const toastIdRef = useRef(0)
|
||||
|
||||
const addToast = useCallback((type: Toast['type'], message: string) => {
|
||||
const id = ++toastIdRef.current
|
||||
setToasts((prev) => [...prev, { id, type, message }])
|
||||
if (type !== 'loading') {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 5000)
|
||||
}
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const updateToast = useCallback((id: number, type: Toast['type'], message: string) => {
|
||||
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, type, message } : t)))
|
||||
if (type !== 'loading') {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 5000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Data states
|
||||
const [services, setServices] = useState<ServiceTestInfo[]>([])
|
||||
const [stats, setStats] = useState<TestRegistryStats | null>(null)
|
||||
const [coverage, setCoverage] = useState<CoverageData[]>([])
|
||||
const [testRuns, setTestRuns] = useState<TestRun[]>([])
|
||||
const [failedTests, setFailedTests] = useState<FailedTest[]>([])
|
||||
const [backlogItems, setBacklogItems] = useState<BacklogItem[]>([])
|
||||
const [usePostgres, setUsePostgres] = useState(false)
|
||||
|
||||
// Running states
|
||||
const [runningServices, setRunningServices] = useState<Set<string>>(new Set())
|
||||
|
||||
// Progress states fuer laufende Tests
|
||||
const [serviceProgress, setServiceProgress] = useState<Record<string, ServiceProgress>>({})
|
||||
|
||||
// Fetch data
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const registryResponse = await fetch(`${API_BASE}/registry`)
|
||||
if (registryResponse.ok) {
|
||||
const data = await registryResponse.json()
|
||||
setServices(data.services || DEMO_SERVICES)
|
||||
setStats(data.stats || DEMO_STATS)
|
||||
} else {
|
||||
setServices(DEMO_SERVICES)
|
||||
setStats(DEMO_STATS)
|
||||
}
|
||||
|
||||
const coverageResponse = await fetch(`${API_BASE}/coverage`)
|
||||
if (coverageResponse.ok) {
|
||||
const data = await coverageResponse.json()
|
||||
setCoverage(data.services || [])
|
||||
} else {
|
||||
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
|
||||
service: s.service,
|
||||
display_name: s.display_name,
|
||||
coverage_percent: s.coverage_percent!,
|
||||
language: s.language,
|
||||
})))
|
||||
}
|
||||
|
||||
const runsResponse = await fetch(`${API_BASE}/runs`)
|
||||
if (runsResponse.ok) {
|
||||
const data = await runsResponse.json()
|
||||
setTestRuns(data.runs || [])
|
||||
}
|
||||
|
||||
// Lade fehlgeschlagene Tests fuer Backlog
|
||||
const failedResponse = await fetch(`${API_BASE}/failed`)
|
||||
if (failedResponse.ok) {
|
||||
const data = await failedResponse.json()
|
||||
setFailedTests(data.tests || [])
|
||||
}
|
||||
|
||||
// Versuche PostgreSQL-Backlog zu laden (neue API)
|
||||
try {
|
||||
const backlogResponse = await fetch(`${API_BASE}/backlog`)
|
||||
if (backlogResponse.ok) {
|
||||
const data = await backlogResponse.json()
|
||||
if (data.items && data.items.length > 0) {
|
||||
setBacklogItems(data.items)
|
||||
setUsePostgres(true)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// PostgreSQL nicht verfuegbar, nutze Legacy
|
||||
setUsePostgres(false)
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch test registry data:', err)
|
||||
setServices(DEMO_SERVICES)
|
||||
setStats(DEMO_STATS)
|
||||
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
|
||||
service: s.service,
|
||||
display_name: s.display_name,
|
||||
coverage_percent: s.coverage_percent!,
|
||||
language: s.language,
|
||||
})))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Update failed test status
|
||||
const updateTestStatus = async (testId: string, status: string) => {
|
||||
try {
|
||||
// Nutze PostgreSQL-Endpoint wenn verfuegbar
|
||||
const endpoint = usePostgres
|
||||
? `${API_BASE}/backlog/${testId}/status`
|
||||
: `${API_BASE}/failed/${encodeURIComponent(testId)}/status?status=${status}`
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: usePostgres ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: usePostgres ? JSON.stringify({ status }) : undefined,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Aktualisiere lokalen State
|
||||
if (usePostgres) {
|
||||
setBacklogItems(prev =>
|
||||
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
|
||||
)
|
||||
}
|
||||
setFailedTests(prev =>
|
||||
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
|
||||
)
|
||||
addToast('success', `Test-Status auf "${status}" gesetzt`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update test status:', err)
|
||||
// Trotzdem lokal aktualisieren fuer bessere UX
|
||||
setFailedTests(prev =>
|
||||
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
|
||||
)
|
||||
if (usePostgres) {
|
||||
setBacklogItems(prev =>
|
||||
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update failed test priority (nur PostgreSQL)
|
||||
const updateTestPriority = async (testId: string, priority: string) => {
|
||||
if (!usePostgres) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/backlog/${testId}/priority`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ priority }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setBacklogItems(prev =>
|
||||
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
|
||||
)
|
||||
addToast('success', `Prioritaet auf "${priority}" gesetzt`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update test priority:', err)
|
||||
// Trotzdem lokal aktualisieren
|
||||
setBacklogItems(prev =>
|
||||
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests mit Progress-Polling
|
||||
const runTests = async (service: string) => {
|
||||
setRunningServices((prev) => new Set(prev).add(service))
|
||||
const loadingToast = addToast('loading', `Tests fuer ${service} werden gestartet...`)
|
||||
|
||||
// Progress-Polling starten
|
||||
let pollInterval: NodeJS.Timeout | null = null
|
||||
const pollProgress = async () => {
|
||||
try {
|
||||
const progressResponse = await fetch(`${API_BASE}/progress/${service}`)
|
||||
if (progressResponse.ok) {
|
||||
const progress = await progressResponse.json()
|
||||
setServiceProgress((prev) => ({
|
||||
...prev,
|
||||
[service]: progress,
|
||||
}))
|
||||
|
||||
// Toast-Message mit aktuellem Fortschritt aktualisieren
|
||||
if (progress.status === 'running' && progress.files_total > 0) {
|
||||
const toastMsg = `${service}: ${progress.current_file} (${progress.passed} bestanden, ${progress.failed} fehler)`
|
||||
updateToast(loadingToast, 'loading', toastMsg)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling (alle 1 Sekunde)
|
||||
pollInterval = setInterval(pollProgress, 1000)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/run/${service}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Warte kurz und pruefe finalen Progress
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
await pollProgress()
|
||||
const finalProgress = serviceProgress[service]
|
||||
const passedMsg = finalProgress ? `${finalProgress.passed} bestanden, ${finalProgress.failed} fehler` : 'abgeschlossen'
|
||||
updateToast(loadingToast, 'success', `${service}: Tests ${passedMsg}`)
|
||||
await fetchData()
|
||||
} else {
|
||||
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to run tests:', err)
|
||||
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
|
||||
} finally {
|
||||
// Polling stoppen
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
setRunningServices((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(service)
|
||||
return next
|
||||
})
|
||||
// Progress-Daten entfernen nach Abschluss
|
||||
setServiceProgress((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[service]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Filter services by category
|
||||
const unitServices = services.filter(s => !s.service.startsWith('bqas-'))
|
||||
const bqasServices = services.filter(s => s.service.startsWith('bqas-'))
|
||||
|
||||
return {
|
||||
// Tab
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
// Loading / Error
|
||||
isLoading,
|
||||
error,
|
||||
fetchData,
|
||||
// Toast
|
||||
toasts,
|
||||
removeToast,
|
||||
// Data
|
||||
services,
|
||||
stats,
|
||||
coverage,
|
||||
testRuns,
|
||||
failedTests,
|
||||
backlogItems,
|
||||
usePostgres,
|
||||
// Running
|
||||
runningServices,
|
||||
serviceProgress,
|
||||
// Actions
|
||||
updateTestStatus,
|
||||
updateTestPriority,
|
||||
runTests,
|
||||
// Derived
|
||||
unitServices,
|
||||
bqasServices,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ServiceTestInfo, TestRegistryStats } from '../types'
|
||||
|
||||
// API Configuration
|
||||
export const API_BASE = '/api/tests'
|
||||
|
||||
// Demo data for when API is not available
|
||||
export const DEMO_SERVICES: ServiceTestInfo[] = [
|
||||
{ service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' },
|
||||
{ service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' },
|
||||
{ service: 'voice-service', display_name: 'Voice Service', port: 8091, language: 'python', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 68.9, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'bqas-golden', display_name: 'BQAS Golden Suite', port: 8091, language: 'python', total_tests: 97, passed_tests: 89, failed_tests: 8, skipped_tests: 0, pass_rate: 91.7, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'failed' },
|
||||
{ service: 'bqas-rag', display_name: 'BQAS RAG Tests', port: 8091, language: 'python', total_tests: 20, passed_tests: 18, failed_tests: 2, skipped_tests: 0, pass_rate: 90.0, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'failed' },
|
||||
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
||||
]
|
||||
|
||||
export const DEMO_STATS: TestRegistryStats = {
|
||||
total_tests: 278,
|
||||
total_passed: 263,
|
||||
total_failed: 15,
|
||||
total_skipped: 0,
|
||||
overall_pass_rate: 94.6,
|
||||
average_coverage: 78.5,
|
||||
services_count: 11,
|
||||
by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 },
|
||||
by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 },
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user