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>
312 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|