feat(cra): Management-Fortschritts-Ansicht (Ticket-Status-Readback)

Liest den Lebenszyklus jedes Befunds (status + tracker_issue_url) aus dem
Scanner zurück und rollt ihn zu einem Management-Bild auf: % erledigt,
4-Phasen (offen/in Arbeit/erledigt/ausgeschlossen), offenes Restrisiko nach
Schweregrad, Fortschritt je CRA-Anforderung und eine Aufgaben-/Ticket-Tabelle
mit Jira-Link. Neuer Endpoint GET/POST /api/v1/cra/progress (dünn → Service
cra_progress, rein deterministisch, kein /assess-Schema-Drift). Frontend:
ProgressView in Ebene 1 (CRACyberView), live je Scanner-Repo, sonst Demo-Status.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-16 10:10:45 +02:00
parent 7a4f086151
commit 9e9d780902
8 changed files with 541 additions and 2 deletions
@@ -1,14 +1,17 @@
'use client'
import { CRADemo, Measure } from '../_hooks/useCRADemo'
import { useCRAProgress } from '../_hooks/useCRAProgress'
import { ManagementSummary } from './ManagementSummary'
import { ProgressView } from './ProgressView'
import { CyberSafetyHero } from './CyberSafetyHero'
import { TechFindings } from './TechFindings'
export function CRACyberView({ data }: { data: CRADemo }) {
export function CRACyberView({ data, scannerRepo = '' }: { data: CRADemo; scannerRepo?: string }) {
const measuresById: Record<string, Measure> = Object.fromEntries(
data.open_measures.map((m) => [m.id, m]),
)
const { progress, live: progressLive } = useCRAProgress(scannerRepo, data.findings)
return (
<div className="space-y-8">
@@ -27,6 +30,9 @@ export function CRACyberView({ data }: { data: CRADemo }) {
{/* Ebene 1 — Management: Risiko & Aufwand auf einen Blick */}
<ManagementSummary data={data} measuresById={measuresById} />
{/* Ebene 1 — Projekt-Fortschritt: Ticket-Status zurückgelesen */}
<ProgressView progress={progress} live={progressLive} />
{/* Ebene 2 — Safety × Cyber: das Alleinstellungsmerkmal */}
<CyberSafetyHero data={data} />
@@ -0,0 +1,172 @@
'use client'
import { CRAProgress, ProgressReq, ProgressTheme } from '../_hooks/useCRAProgress'
import { RISK_BADGE, RISK_LABEL } from './cra-badges'
const PHASE_BADGE: Record<string, string> = {
offen: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
in_arbeit: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
erledigt: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
ausgeschlossen: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400',
}
const PHASE_BAR: Record<string, string> = {
offen: 'bg-gray-300 dark:bg-gray-600',
in_arbeit: 'bg-blue-500',
erledigt: 'bg-emerald-500',
}
function PhaseBadge({ phase, label }: { phase: string; label: string }) {
return (
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${PHASE_BADGE[phase] || PHASE_BADGE.offen}`}>
{label}
</span>
)
}
// Segmented completion bar over the actionable findings (excl. ausgeschlossen).
function CompletionBar({ p }: { p: CRAProgress }) {
const base = Math.max(1, p.actionable)
const seg = (n: number) => `${(100 * n) / base}%`
return (
<div className="flex h-3 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className={PHASE_BAR.erledigt} style={{ width: seg(p.by_phase.erledigt || 0) }} title={`Erledigt: ${p.by_phase.erledigt || 0}`} />
<div className={PHASE_BAR.in_arbeit} style={{ width: seg(p.by_phase.in_arbeit || 0) }} title={`In Arbeit: ${p.by_phase.in_arbeit || 0}`} />
<div className={PHASE_BAR.offen} style={{ width: seg(p.by_phase.offen || 0) }} title={`Offen: ${p.by_phase.offen || 0}`} />
</div>
)
}
function ReqRow({ r }: { r: ProgressReq }) {
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1 py-2 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{r.title || r.req_id}</span>
<PhaseBadge phase={r.phase} label={r.phase_label} />
{r.open_risk && (
<span className={`inline-block rounded px-1.5 py-0.5 text-[11px] font-semibold ${RISK_BADGE[r.open_risk]}`}>
{RISK_LABEL[r.open_risk]} offen
</span>
)}
</div>
<div className="mt-1 flex h-2 w-full max-w-md overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className="bg-emerald-500" style={{ width: `${r.completion_pct}%` }} />
</div>
</div>
<div className="text-right text-sm text-gray-500 whitespace-nowrap">
{r.erledigt}/{r.offen + r.in_arbeit + r.erledigt} erledigt
</div>
</div>
)
}
function ThemeRow({ t }: { t: ProgressTheme }) {
return (
<tr className="border-b border-gray-100 dark:border-gray-700/50 align-top">
<td className="py-2.5 px-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{t.title}</div>
{t.location && <div className="text-xs text-gray-400">{t.location}</div>}
</td>
<td className="py-2.5 px-3 text-sm text-gray-700 dark:text-gray-200">{t.requirement_title || t.requirement}</td>
<td className="py-2.5 px-3">
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${RISK_BADGE[t.risk_level] || RISK_BADGE.LOW}`}>
{RISK_LABEL[t.risk_level] || t.risk_level}
</span>
</td>
<td className="py-2.5 px-3"><PhaseBadge phase={t.phase} label={t.phase_label} /></td>
<td className="py-2.5 px-3 text-sm">
{t.has_ticket ? (
<a href={t.tracker_url} target="_blank" rel="noopener noreferrer" className="text-purple-600 hover:underline">
Ticket öffnen
</a>
) : (
<span className="text-gray-400">kein Ticket</span>
)}
</td>
</tr>
)
}
export function ProgressView({ progress, live }: { progress: CRAProgress | null; live: boolean }) {
if (!progress || progress.total === 0) return null
const p = progress
const repo = p.source?.repo_id
return (
<section className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">Projekt-Fortschritt</h3>
<p className="text-sm text-gray-500">
Status je Befund zurückgelesen aus den Tickets der Entwicklung.
</p>
</div>
<span className={`text-xs rounded px-2 py-1 ${live ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'}`}>
{live ? `Live · Repo ${repo ? repo.slice(0, 8) : ''}` : 'Demo-Status'}
</span>
</div>
{/* Headline completion */}
<div className="grid gap-4 md:grid-cols-[auto_1fr] items-center">
<div className="text-center md:text-left">
<p className="text-5xl font-bold text-emerald-600 dark:text-emerald-400 leading-none">{p.completion_pct}%</p>
<p className="text-sm text-gray-500 mt-1">erledigt ({p.by_phase.erledigt || 0} von {p.actionable})</p>
</div>
<div className="space-y-2">
<CompletionBar p={p} />
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span className="text-emerald-600 dark:text-emerald-400"> {p.by_phase.erledigt || 0} Erledigt</span>
<span className="text-blue-600 dark:text-blue-400"> {p.by_phase.in_arbeit || 0} In Arbeit</span>
<span className="text-gray-500"> {p.by_phase.offen || 0} Offen</span>
{p.by_phase.ausgeschlossen ? (
<span className="text-slate-400"> {p.by_phase.ausgeschlossen} Ausgeschlossen</span>
) : null}
</div>
</div>
</div>
{/* What's left, by risk */}
{p.open_count > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-300">Noch offen:</span>
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const).map((lvl) =>
p.by_risk_open[lvl] ? (
<span key={lvl} className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-sm font-semibold ${RISK_BADGE[lvl]}`}>
{p.by_risk_open[lvl]} {RISK_LABEL[lvl]}
</span>
) : null,
)}
</div>
)}
{/* Per-requirement progress */}
{p.requirements.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-1">Fortschritt je Anforderung</h4>
<div>
{p.requirements.map((r) => <ReqRow key={r.req_id} r={r} />)}
</div>
</div>
)}
{/* Tasks & tickets */}
<div className="overflow-x-auto">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-1">Aufgaben &amp; Tickets</h4>
<table className="w-full text-sm">
<thead>
<tr className="text-gray-500 border-b border-gray-200 dark:border-gray-700 text-left">
<th className="py-2 px-3">Befund</th>
<th className="py-2 px-3">Anforderung</th>
<th className="py-2 px-3">Risiko</th>
<th className="py-2 px-3">Status</th>
<th className="py-2 px-3">Ticket</th>
</tr>
</thead>
<tbody>
{p.themes.map((t) => <ThemeRow key={t.finding_id} t={t} />)}
</tbody>
</table>
</div>
</section>
)
}
@@ -0,0 +1,97 @@
'use client'
import { useEffect, useState } from 'react'
import { CRAFinding } from './useCRADemo'
// Management progress: reads each finding's lifecycle (status + Jira ticket) back
// from the scanner and rolls it up. Live for a linked scanner repo; for the demo
// (no repo) it POSTs the demo findings with a sample status mix so the layout is
// illustrative. Mirrors the backend compliance/services/cra_progress.py phases.
export interface ProgressTheme {
finding_id: string
title: string
requirement: string
requirement_title: string
risk_level: string
phase: string
phase_label: string
status: string
tracker_url: string
has_ticket: boolean
location: string
updated_at: string
}
export interface ProgressReq {
req_id: string
title: string
total: number
offen: number
in_arbeit: number
erledigt: number
ausgeschlossen: number
phase: string
phase_label: string
completion_pct: number
open_risk: string
}
export interface CRAProgress {
total: number
actionable: number
completion_pct: number
by_phase: Record<string, number>
by_status: Record<string, number>
by_risk_open: Record<string, number>
open_count: number
requirements: ProgressReq[]
themes: ProgressTheme[]
source?: { scanner: boolean; pulled: number; repo_id?: string }
}
// Demo status mix (no real repo): one fixed, two ticketed/in-progress, rest open.
const DEMO_STATUS: Record<string, { status: string; tracker?: string }> = {
'KH-CY-6': { status: 'resolved' },
'KH-CY-4': { status: 'triaged', tracker: 'https://jira.example.com/browse/BP-204' },
'KH-CY-3': { status: 'triaged', tracker: 'https://jira.example.com/browse/BP-203' },
}
export function useCRAProgress(scannerRepo: string, demoFindings: CRAFinding[]) {
const [progress, setProgress] = useState<CRAProgress | null>(null)
const [live, setLive] = useState(false)
useEffect(() => {
let cancelled = false
;(async () => {
if (scannerRepo) {
try {
const r = await fetch(`/api/v1/cra/progress?repo_id=${encodeURIComponent(scannerRepo)}`)
if (r.ok) {
const j = await r.json()
if (!cancelled) { setProgress(j); setLive(true) }
return
}
} catch { /* fall through to demo */ }
}
try {
const findings = demoFindings.map((f) => ({
id: f.id, cwe: f.cwe, severity: f.scanner_severity, title: f.title,
status: DEMO_STATUS[f.id]?.status || 'open',
tracker_issue_url: DEMO_STATUS[f.id]?.tracker || null,
}))
const r = await fetch('/api/v1/cra/progress', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ findings }),
})
if (r.ok) {
const j = await r.json()
if (!cancelled) { setProgress(j); setLive(false) }
}
} catch { /* leave null → view hides */ }
})()
return () => { cancelled = true }
}, [scannerRepo, demoFindings])
return { progress, live }
}
@@ -31,7 +31,7 @@ export default function CRAPage() {
)}
<ScannerRepoPicker value={scannerRepo} onChange={setScannerRepo} />
<WeightsControl weights={weights} onChange={setWeights} />
<CRACyberView data={data} />
<CRACyberView data={data} scannerRepo={scannerRepo} />
<SnapshotPanel snapshots={snapshots} onSave={saveSnapshot} onView={viewSnapshot} />
</div>
)