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>
This commit is contained in:
311
website/components/admin/CICDStatusWidget.tsx
Normal file
311
website/components/admin/CICDStatusWidget.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user