feat(cra): live-wire CRA tab to POST /api/v1/cra/assess (proxy + useCRA)
CRA tab now computes the assessment live: useCRA POSTs the scenario findings through a new /api/v1/cra/* proxy to the backend mapper and merges the live mapping (CRA requirement, risk, measures, NIST/OWASP crosswalk) with the frontend scenario constants (full measure texts + cyber->safety cross-links, until those move server-side in step 2). Falls back to the static scenario if the backend is unreachable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* CRA API proxy — catch-all. Proxies /api/v1/cra/* to the Python backend
|
||||
* (e.g. POST /api/v1/cra/assess, the standalone CRA risk-assessment endpoint).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
async function forward(request: NextRequest, path: string[], method: 'GET' | 'POST') {
|
||||
const pathStr = path.join('/')
|
||||
const search = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/v1/cra/${pathStr}${search ? `?${search}` : ''}`
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
if (method === 'POST') {
|
||||
try {
|
||||
init.body = JSON.stringify(await request.json())
|
||||
} catch {
|
||||
init.body = '{}'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, init)
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Backend Error: ${response.status}`, details: text },
|
||||
{ status: response.status },
|
||||
)
|
||||
}
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('CRA API proxy error:', error)
|
||||
return NextResponse.json({ error: 'Verbindung zum Backend fehlgeschlagen' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
return forward(request, path, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
return forward(request, path, 'POST')
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CRADemo, CRAFinding, Measure, DEMO_SCENARIO } from './useCRADemo'
|
||||
|
||||
// Live CRA assessment: POST the (demo) findings to the standalone backend
|
||||
// endpoint POST /api/v1/cra/assess and merge the live mapping (CRA requirement,
|
||||
// risk, measures, NIST/OWASP crosswalk) with the frontend scenario constants
|
||||
// (full measure texts + cyber->safety cross-links — until those move server-side
|
||||
// in step 2). Falls back to the static scenario if the backend is unreachable.
|
||||
|
||||
function reqTitle(rationale: string): string {
|
||||
const i = rationale.indexOf(': ')
|
||||
return i >= 0 ? rationale.slice(i + 2) : rationale
|
||||
}
|
||||
|
||||
function merge(live: any): CRADemo {
|
||||
const mapped: Record<string, any> = {}
|
||||
for (const m of live.mapped || []) mapped[m.finding_id] = m
|
||||
|
||||
const findings: CRAFinding[] = DEMO_SCENARIO.findings.map((df) => {
|
||||
const m = mapped[df.id]
|
||||
if (!m) return df
|
||||
return {
|
||||
...df,
|
||||
primary_requirement: m.primary_requirement,
|
||||
requirement_title: reqTitle(m.rationale || ''),
|
||||
requirement_ids: m.requirement_ids || [],
|
||||
annex_anchor: m.annex_anchor || '',
|
||||
iso27001_ref: m.iso27001_ref || [],
|
||||
nist_refs: m.nist_refs || [],
|
||||
owasp_refs: m.owasp_refs || [],
|
||||
risk_level: m.risk_level || df.risk_level,
|
||||
measures: m.measures || [],
|
||||
}
|
||||
})
|
||||
|
||||
const open_measures: Measure[] = (live.open_measures || []).map((om: any) => {
|
||||
const detail = DEMO_SCENARIO.open_measures.find((d) => d.id === om.id)
|
||||
return detail || { id: om.id, name: om.id, description: om.description || '', norm_refs: [] }
|
||||
})
|
||||
|
||||
return {
|
||||
scenario: DEMO_SCENARIO.scenario,
|
||||
findings,
|
||||
by_risk: live.by_risk || DEMO_SCENARIO.by_risk,
|
||||
coverage_pct: live.coverage_pct ?? DEMO_SCENARIO.coverage_pct,
|
||||
requirements_touched: live.requirements_touched || DEMO_SCENARIO.requirements_touched,
|
||||
open_measures,
|
||||
cross_links: DEMO_SCENARIO.cross_links,
|
||||
deadlines: live.deadlines || DEMO_SCENARIO.deadlines,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCRA() {
|
||||
const [data, setData] = useState<CRADemo | null>(null)
|
||||
const [live, setLive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const payload = {
|
||||
findings: DEMO_SCENARIO.findings.map((f) => ({
|
||||
id: f.id, title: f.title, cwe: f.cwe, severity: f.scanner_severity, location: f.location,
|
||||
})),
|
||||
}
|
||||
fetch('/api/v1/cra/assess', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((j) => {
|
||||
if (cancelled) return
|
||||
setData(merge(j))
|
||||
setLive(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('CRA assess fetch failed, using static scenario:', err)
|
||||
if (!cancelled) {
|
||||
setData(DEMO_SCENARIO)
|
||||
setLive(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { data, live }
|
||||
}
|
||||
@@ -73,7 +73,11 @@ const MEASURES: Measure[] = [
|
||||
norm_refs: ['Verordnung (EU) 2024/2847 (CRA), Anhang II', 'DIN EN 40000-1-2 (Entwurf)', 'IEC 62443-3-3'] },
|
||||
]
|
||||
|
||||
const DEMO: CRADemo = {
|
||||
// Scenario constants: the invented Kistenhub IoT findings (input) + the full
|
||||
// curated measure texts + the cyber->safety cross-links. The live useCRA hook
|
||||
// POSTs these findings to the backend and merges the real assessment; this also
|
||||
// serves as the offline fallback.
|
||||
export const DEMO_SCENARIO: CRADemo = {
|
||||
scenario:
|
||||
'Kistenhubgeraet mit (angenommenem) IoT-Modul / Internetanschluss — Fernsteuerung, Telemetrie und Remote-Updates.',
|
||||
findings: [
|
||||
@@ -139,6 +143,3 @@ const DEMO: CRADemo = {
|
||||
],
|
||||
}
|
||||
|
||||
export function useCRADemo() {
|
||||
return { data: DEMO }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useCRADemo } from './_hooks/useCRADemo'
|
||||
import { useCRA } from './_hooks/useCRA'
|
||||
import { CRACyberView } from './_components/CRACyberView'
|
||||
|
||||
export default function CRAPage() {
|
||||
const { data } = useCRADemo()
|
||||
return <CRACyberView data={data} />
|
||||
const { data, live } = useCRA()
|
||||
if (!data) {
|
||||
return <p className="text-sm text-gray-500">CRA-Risikobeurteilung wird geladen …</p>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{!live && (
|
||||
<p className="mb-3 text-[11px] text-amber-600 dark:text-amber-400">
|
||||
Backend nicht erreichbar — statisches Szenario angezeigt.
|
||||
</p>
|
||||
)}
|
||||
<CRACyberView data={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user