'use client' /** * CI/CD Dashboard * * Zentrale Uebersicht fuer: * - Gitea Actions Pipelines * - Runner Status * - Container Deployments * - Pipeline Konfiguration */ import { useState, useEffect, useCallback } from 'react' import { PagePurpose } from '@/components/common/PagePurpose' import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar' // ============================================================================ // Types // ============================================================================ interface PipelineStatus { gitea_connected: boolean gitea_url: string last_sbom_update: string | null total_runs: number successful_runs: number failed_runs: number } 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 } 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 } 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 } } interface DockerStats { containers: ContainerInfo[] total_containers: number running_containers: number stopped_containers: number } type TabType = 'overview' | 'woodpecker' | 'pipelines' | 'deployments' | 'setup' | 'scheduler' // Woodpecker Types interface WoodpeckerStep { name: string state: 'pending' | 'running' | 'success' | 'failure' | 'skipped' exit_code: number error?: string } 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[] } interface WoodpeckerStatus { status: 'online' | 'offline' pipelines: WoodpeckerPipeline[] lastUpdate: string error?: string } // ============================================================================ // Helper Components // ============================================================================ 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 (
) } 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` } // ============================================================================ // Main Component // ============================================================================ export default function CICDPage() { const [activeTab, setActiveTab] = useState('overview') // Pipeline State const [pipelineStatus, setPipelineStatus] = useState(null) const [pipelineHistory, setPipelineHistory] = useState([]) const [triggeringPipeline, setTriggeringPipeline] = useState(false) // Container State const [systemStats, setSystemStats] = useState(null) const [dockerStats, setDockerStats] = useState(null) const [containerFilter, setContainerFilter] = useState<'all' | 'running' | 'stopped'>('all') const [actionLoading, setActionLoading] = useState(null) // Woodpecker State const [woodpeckerStatus, setWoodpeckerStatus] = useState(null) const [triggeringWoodpecker, setTriggeringWoodpecker] = useState(false) // General State const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [message, setMessage] = useState(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 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 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 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) } } // ============================================================================ // Helpers // ============================================================================ const getStateColor = (state: 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' } } 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 }) || [] // ============================================================================ // Render // ============================================================================ return (
{/* DevOps Pipeline Sidebar */} {/* Messages */} {error && (
{error}
)} {message && (
{message}
)} {/* Main Content */}
{/* Tabs */}
{/* Tab Content */}
{loading ? (
) : ( <> {/* ================================================================ */} {/* Overview Tab */} {/* ================================================================ */} {activeTab === 'overview' && (
{/* Woodpecker CI Status - Prominent */}

Woodpecker CI

{woodpeckerStatus?.status === 'online' ? 'Online' : 'Offline'}
{woodpeckerStatus?.pipelines?.[0] && (

Pipeline #{woodpeckerStatus.pipelines[0].number}: {' '} {woodpeckerStatus.pipelines[0].status} {' '}auf {woodpeckerStatus.pipelines[0].branch}

)}
{/* Failed steps preview */} {woodpeckerStatus?.pipelines?.[0]?.steps?.some(s => s.state === 'failure') && (

Fehlgeschlagene Steps:

{woodpeckerStatus.pipelines[0].steps.filter(s => s.state === 'failure').map((step, i) => ( {step.name} ))}
)}
{/* Status Cards */}
Gitea Status

{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Nicht verbunden'}

http://macmini:3003

Pipeline Runs

{pipelineStatus?.total_runs || 0}

{pipelineStatus?.successful_runs || 0} erfolgreich

Container

{dockerStats?.running_containers || 0}

von {dockerStats?.total_containers || 0} laufend

Letztes Update

{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleDateString('de-DE') : 'Nie'}

{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleTimeString('de-DE') : '-'}

{/* System Resources */} {systemStats && (

Server Ressourcen ({systemStats.hostname})

CPU 80 ? 'text-red-600' : 'text-slate-900'}`}> {systemStats.cpu.usage_percent.toFixed(1)}%
RAM 80 ? 'text-red-600' : 'text-slate-900'}`}> {systemStats.memory.usage_percent.toFixed(1)}%
Disk 80 ? 'text-red-600' : 'text-slate-900'}`}> {systemStats.disk.usage_percent.toFixed(1)}%
)} {/* Recent Pipeline Runs */} {pipelineHistory.length > 0 && (

Letzte Pipeline Runs

{pipelineHistory.slice(0, 5).map((run) => (

{run.workflow || 'SBOM Pipeline'}

{run.branch} - {run.commit_sha.substring(0, 8)}

{run.status === 'success' ? 'Erfolgreich' : run.status === 'failed' ? 'Fehlgeschlagen' : run.status === 'running' ? 'Laeuft...' : run.status}

{new Date(run.started_at).toLocaleString('de-DE')}

))}
)}
)} {/* ================================================================ */} {/* Woodpecker Tab */} {/* ================================================================ */} {activeTab === 'woodpecker' && (
{/* Woodpecker Status Header */}

Woodpecker CI Pipeline

{woodpeckerStatus?.status === 'online' ? 'Online' : 'Offline'}
Woodpecker UI
{/* Pipeline Stats */}
Gesamt

{woodpeckerStatus?.pipelines?.length || 0}

Erfolgreich

{woodpeckerStatus?.pipelines?.filter(p => p.status === 'success').length || 0}

Fehlgeschlagen

{woodpeckerStatus?.pipelines?.filter(p => p.status === 'failure' || p.status === 'error').length || 0}

Laufend

{woodpeckerStatus?.pipelines?.filter(p => p.status === 'running' || p.status === 'pending').length || 0}

{/* Pipeline List */} {woodpeckerStatus?.pipelines && woodpeckerStatus.pipelines.length > 0 ? (

Pipeline Historie

{woodpeckerStatus.pipelines.map((pipeline) => (
Pipeline #{pipeline.number} {pipeline.status}
{pipeline.branch} {pipeline.commit} {pipeline.event}
{pipeline.message && (

{pipeline.message}

)} {/* Steps Progress */} {pipeline.steps && pipeline.steps.length > 0 && (
{pipeline.steps.map((step, i) => (
))}
{pipeline.steps.map((step, i) => ( {step.name} ))}
)} {/* Errors */} {pipeline.errors && pipeline.errors.length > 0 && (
Fehler:
    {pipeline.errors.map((err, i) => (
  • {err}
  • ))}
)}

{new Date(pipeline.created * 1000).toLocaleDateString('de-DE')}

{new Date(pipeline.created * 1000).toLocaleTimeString('de-DE')}

{pipeline.started && pipeline.finished && (

Dauer: {Math.round((pipeline.finished - pipeline.started) / 60)}m

)}
))}
) : (

Keine Pipelines gefunden

Starte eine neue Pipeline oder pruefe die Woodpecker-Konfiguration

)} {/* Pipeline Configuration Info */}

Pipeline Konfiguration

{`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`}
                    
{/* Workflow Anleitung */}

Workflow-Anleitung

🤖 Automatisch (bei jedem Push/PR):
  • Linting - Code-Qualitaet pruefen (nur PRs)
  • Unit Tests - Go & Python Tests
  • Test-Dashboard - Ergebnisse werden gesendet
  • Backlog - Fehlgeschlagene Tests werden erfasst
👆 Manuell (Button oder Tag):
  • Docker Builds - Container erstellen
  • SBOM/Scans - Sicherheitsanalyse
  • Deployment - In Produktion deployen
  • Pipeline starten - Diesen Button verwenden
⚙️ Setup: API Token konfigurieren

Um Pipelines ueber das Dashboard zu starten, muss ein WOODPECKER_TOKEN konfiguriert werden:

  1. Woodpecker UI oeffnen: http://macmini:8090
  2. Mit Gitea-Account einloggen
  3. Klick auf Profil → User SettingsPersonal Access Tokens
  4. Neues Token erstellen und in .env eintragen: WOODPECKER_TOKEN=...
  5. Container neu starten: docker compose up -d admin-v2
)} {/* ================================================================ */} {/* Pipelines Tab */} {/* ================================================================ */} {activeTab === 'pipelines' && (
{/* Pipeline Controls */}

Gitea Actions Pipelines

Workflows werden bei Push auf main/develop automatisch ausgefuehrt

{/* Available Pipelines */}
SBOM Pipeline

Generiert Software Bill of Materials

5 Jobs: generate, scan, license, upload, summary

Test Pipeline

Unit & Integration Tests

Geplant

Security Pipeline

SAST, SCA, Secrets Scan

Geplant

{/* Pipeline History */}

Pipeline Historie

{pipelineHistory.length === 0 ? (
Keine Pipeline-Runs vorhanden. Starten Sie die erste Pipeline!
) : (
{pipelineHistory.map((run) => ( ))}
Status Workflow Branch Commit Gestartet Dauer
{run.status} {run.workflow || 'SBOM Pipeline'} {run.branch} {run.commit_sha.substring(0, 8)} {new Date(run.started_at).toLocaleString('de-DE')} {run.duration_seconds ? `${run.duration_seconds}s` : '-'}
)}
{/* Pipeline Architecture */}

SBOM Pipeline Architektur

{`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`}
                    
)} {/* ================================================================ */} {/* Deployments Tab */} {/* ================================================================ */} {activeTab === 'deployments' && (
{/* Header */}

Docker Container

{dockerStats && (

{dockerStats.running_containers} laufend, {dockerStats.stopped_containers} gestoppt, {dockerStats.total_containers} gesamt

)}
{/* Container List */} {filteredContainers.length === 0 ? (
Keine Container gefunden
) : (
{filteredContainers.map((container) => (
{container.name} {container.state}
{container.image} {container.ports.length > 0 && ( | {container.ports.slice(0, 2).join(', ')} {container.ports.length > 2 && ` +${container.ports.length - 2}`} )}
{container.state === 'running' && (
CPU: 80 ? 'text-red-600' : 'text-slate-700'}`}> {container.cpu_percent.toFixed(1)}%
RAM: 80 ? 'text-red-600' : 'text-slate-700'}`}> {container.memory_usage} ({container.memory_percent.toFixed(1)}%)
Net: {container.network_rx} / {container.network_tx}
)}
{container.state === 'running' ? ( <> ) : ( )}
))}
)}
)} {/* ================================================================ */} {/* Setup Tab */} {/* ================================================================ */} {activeTab === 'setup' && (

Erstkonfiguration - Gitea CI/CD

Anleitung zur Einrichtung der CI/CD Pipeline mit Gitea Actions auf dem Mac Mini Server.

{/* Gitea Server Info */}

Gitea Server

Web-URL

http://macmini:3003

SSH

macmini:2222

Status

{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Konfiguration erforderlich'}

{/* Implementierte Komponenten */}

Implementierte Komponenten

Komponente Pfad Beschreibung
Gitea Service docker-compose.yml Gitea 1.22 mit Actions enabled
Gitea Runner docker-compose.yml act_runner fuer Job-Ausfuehrung
SBOM Workflow .gitea/workflows/sbom.yaml 5 Jobs: generate, scan, license, upload, summary
Backend API backend/security_api.py SBOM Upload, Pipeline Status, History
Runner Config gitea/runner-config.yaml Labels: ubuntu-latest, self-hosted
{/* Setup Steps */}

Setup-Schritte

1. Gitea oeffnen
http://macmini:3003
2. Admin-Account erstellen

Username: admin, Email: admin@breakpilot.de

3. Repository erstellen

Name: breakpilot-pwa, Visibility: Private

4. Actions aktivieren

Repository Settings → Actions → Enable Repository Actions

5. Runner Token erstellen & starten
{`export GITEA_RUNNER_TOKEN=
docker compose up -d gitea-runner`}
                        
6. Repository pushen
{`git remote add gitea http://macmini:3003/admin/breakpilot-pwa.git
git push gitea main`}
                        
{/* Quick Links */}
)} {/* ================================================================ */} {/* Scheduler Tab (BQAS) */} {/* ================================================================ */} {activeTab === 'scheduler' && (
{/* Status Overview */}

launchd Job

Taeglich um 07:00 Uhr automatisch

Git Hook

Quick Tests bei voice-service Aenderungen

Benachrichtigungen

Desktop-Alerts bei Fehlern aktiviert

{/* Quick Actions */}

Quick Actions (BQAS)

Test Dashboard oeffnen Starte Tests direkt im BQAS Dashboard
{/* GitHub Actions vs Local - Comparison */}

GitHub Actions Alternative

Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.

Feature GitHub Actions Lokaler Scheduler
Taegliche Tests (07:00) schedule: cron macOS launchd
Push-basierte Tests on: push Git post-commit Hook
PR-basierte Tests on: pull_request Nicht moeglich
DSGVO-Konformitaet Daten bei GitHub (US) 100% lokal
Offline-Faehig Nein Ja
{/* Configuration Details */}

Konfiguration

{/* launchd Configuration */}

launchd Job

{`# ~/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/`}
{/* Environment Variables */}

Umgebungsvariablen

BQAS_SERVICE_URL http://localhost:8091
BQAS_REGRESSION_THRESHOLD 0.1
BQAS_NOTIFY_DESKTOP true
BQAS_NOTIFY_SLACK false
{/* Detailed Explanation */}

Detaillierte Erklaerung

Warum ein lokaler Scheduler?

Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten, aber mit dem entscheidenden Vorteil, dass alle Daten zu 100% auf dem lokalen Mac Mini verbleiben. Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.

Komponenten

  • run_bqas.sh - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet
  • launchd Job - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet
  • Git Hook - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet
  • Notifier - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet

Installation

./voice-service/scripts/install_bqas_scheduler.sh install

Vorteile gegenueber GitHub Actions

  • 100% DSGVO-konform - alle Daten bleiben lokal
  • Keine Internet-Abhaengigkeit - funktioniert auch offline
  • Keine GitHub-Kosten fuer private Repositories
  • Schnellere Ausfuehrung ohne Cloud-Overhead
  • Volle Kontrolle ueber Scheduling und Benachrichtigungen
)} )}
) }