This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/website/components/admin/CICDStatusWidget.tsx
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

312 lines
10 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import {
CheckCircle2,
XCircle,
Clock,
Loader2,
GitBranch,
RefreshCw,
ExternalLink,
Play
} from 'lucide-react'
interface PipelineStep {
name: string
state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'
exit_code: number
}
interface Pipeline {
id: number
number: number
status: 'pending' | 'running' | 'success' | 'failure'
event: string
branch: string
commit: string
message: string
author: string
created: number
started: number
finished: number
steps: PipelineStep[]
}
interface CICDStatus {
status: 'online' | 'offline'
pipelines: Pipeline[]
lastUpdate: string
error?: string
}
const StatusIcon = ({ status }: { status: string }) => {
switch (status) {
case 'success':
return <CheckCircle2 className="w-4 h-4 text-green-500" />
case 'failure':
return <XCircle className="w-4 h-4 text-red-500" />
case 'running':
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
case 'pending':
return <Clock className="w-4 h-4 text-yellow-500" />
default:
return <Clock className="w-4 h-4 text-slate-400" />
}
}
const StatusBadge = ({ status }: { status: string }) => {
const colors: Record<string, string> = {
success: 'bg-green-100 text-green-800 border-green-200',
failure: 'bg-red-100 text-red-800 border-red-200',
running: 'bg-blue-100 text-blue-800 border-blue-200',
pending: 'bg-yellow-100 text-yellow-800 border-yellow-200',
}
const labels: Record<string, string> = {
success: 'Erfolgreich',
failure: 'Fehlgeschlagen',
running: 'Läuft',
pending: 'Wartend',
}
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${colors[status] || 'bg-slate-100 text-slate-800'}`}>
{labels[status] || status}
</span>
)
}
function formatDuration(started: number, finished: number): string {
if (!started) return '-'
const end = finished || Date.now() / 1000
const duration = Math.floor(end - started)
if (duration < 60) return `${duration}s`
if (duration < 3600) return `${Math.floor(duration / 60)}m ${duration % 60}s`
return `${Math.floor(duration / 3600)}h ${Math.floor((duration % 3600) / 60)}m`
}
function formatTimeAgo(timestamp: number): string {
if (!timestamp) return '-'
const seconds = Math.floor(Date.now() / 1000 - timestamp)
if (seconds < 60) return 'Gerade eben'
if (seconds < 3600) return `vor ${Math.floor(seconds / 60)} Min`
if (seconds < 86400) return `vor ${Math.floor(seconds / 3600)} Std`
return `vor ${Math.floor(seconds / 86400)} Tagen`
}
export default function CICDStatusWidget() {
const [data, setData] = useState<CICDStatus | null>(null)
const [loading, setLoading] = useState(true)
const [triggering, setTriggering] = useState(false)
const [expanded, setExpanded] = useState(false)
const fetchStatus = async () => {
try {
const response = await fetch('/api/admin/cicd?limit=5')
const result = await response.json()
setData(result)
} catch (error) {
console.error('Failed to fetch CI/CD status:', error)
setData({
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString()
})
} finally {
setLoading(false)
}
}
const triggerPipeline = async () => {
setTriggering(true)
try {
const response = await fetch('/api/admin/cicd', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch: 'main' })
})
if (response.ok) {
// Refresh status after triggering
setTimeout(fetchStatus, 2000)
}
} catch (error) {
console.error('Failed to trigger pipeline:', error)
} finally {
setTriggering(false)
}
}
useEffect(() => {
fetchStatus()
// Auto-refresh every 30 seconds if a pipeline is running
const interval = setInterval(() => {
if (data?.pipelines.some(p => p.status === 'running')) {
fetchStatus()
}
}, 30000)
return () => clearInterval(interval)
}, [])
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-slate-400" />
<span className="text-sm text-slate-500">Lade CI/CD Status...</span>
</div>
</div>
)
}
const latestPipeline = data?.pipelines[0]
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-100">
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-slate-500" />
<h3 className="font-medium text-slate-900">CI/CD Pipeline</h3>
{data?.status === 'online' ? (
<span className="w-2 h-2 rounded-full bg-green-500"></span>
) : (
<span className="w-2 h-2 rounded-full bg-red-500"></span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchStatus}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
title="Aktualisieren"
>
<RefreshCw className="w-4 h-4" />
</button>
<a
href="http://macmini:8090"
target="_blank"
rel="noopener noreferrer"
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
title="Woodpecker öffnen"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
{/* Content */}
<div className="p-4">
{data?.status === 'offline' ? (
<div className="text-sm text-slate-500">
Woodpecker CI nicht erreichbar
</div>
) : latestPipeline ? (
<div className="space-y-3">
{/* Latest Pipeline */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<StatusIcon status={latestPipeline.status} />
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">
#{latestPipeline.number}
</span>
<StatusBadge status={latestPipeline.status} />
</div>
<div className="text-xs text-slate-500 mt-0.5">
{latestPipeline.branch} {latestPipeline.commit}
</div>
</div>
</div>
<div className="text-right text-xs text-slate-500">
<div>{formatTimeAgo(latestPipeline.created)}</div>
<div>{formatDuration(latestPipeline.started, latestPipeline.finished)}</div>
</div>
</div>
{/* Steps Progress */}
{latestPipeline.steps.length > 0 && (
<div className="flex gap-1">
{latestPipeline.steps.map((step, i) => (
<div
key={i}
className={`h-1.5 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>
)}
{/* Expandable Steps */}
{expanded && latestPipeline.steps.length > 0 && (
<div className="mt-2 space-y-1 text-xs">
{latestPipeline.steps.map((step, i) => (
<div key={i} className="flex items-center gap-2 text-slate-600">
<StatusIcon status={step.state} />
<span>{step.name}</span>
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-slate-500 hover:text-slate-700"
>
{expanded ? 'Weniger anzeigen' : 'Details anzeigen'}
</button>
<button
onClick={triggerPipeline}
disabled={triggering}
className="ml-auto flex items-center gap-1 px-2 py-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{triggering ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Play className="w-3 h-3" />
)}
Pipeline starten
</button>
</div>
{/* Recent Pipelines */}
{data.pipelines.length > 1 && (
<div className="pt-3 border-t border-slate-100">
<div className="text-xs font-medium text-slate-500 mb-2">
Letzte Pipelines
</div>
<div className="space-y-1">
{data.pipelines.slice(1, 4).map((p) => (
<div key={p.id} className="flex items-center gap-2 text-xs text-slate-600">
<StatusIcon status={p.status} />
<span>#{p.number}</span>
<span className="text-slate-400">{p.branch}</span>
<span className="ml-auto text-slate-400">
{formatTimeAgo(p.created)}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-sm text-slate-500">
Keine Pipelines gefunden
</div>
)}
</div>
</div>
)
}