feat(iace): block-aware risk table + benchmark quality badges
Build + Deploy / build-admin-compliance (push) Successful in 2m29s
Build + Deploy / build-backend-compliance (push) Successful in 3m6s
Build + Deploy / build-ai-sdk (push) Successful in 49s
Build + Deploy / build-developer-portal (push) Successful in 1m4s
Build + Deploy / build-tts (push) Successful in 1m34s
Build + Deploy / build-document-crawler (push) Successful in 44s
Build + Deploy / build-dsms-gateway (push) Successful in 27s
Build + Deploy / build-dsms-node (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 18s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m31s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 42s
CI / test-python-backend (push) Successful in 39s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 21s
CI / validate-canonical-controls (push) Successful in 13s
Build + Deploy / trigger-orca (push) Successful in 2m55s

Risk Assessment tab now shows block grouping:
- BlockAwareRiskTable: Parents bold/purple, children indented
- Collapse/expand blocks, "Abgedeckt" badge for covered children
- Ungroup button to remove child from block
- Info bar showing block count and covered children

Benchmark tab improvements:
- Green/Yellow/Red quality badges (Exakt/Aehnlich/Schwach)
- GT risk factor detail (F/W/P/S) shown per entry
- Match counts in tab header (X exakt, Y aehnlich)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-13 15:00:19 +02:00
parent d0d1b38f5c
commit 81a0568537
3 changed files with 290 additions and 22 deletions
@@ -14,8 +14,12 @@ type TabType = 'matched' | 'missing' | 'extra'
export function HazardComparisonTable({ matched, missing, extra }: Props) {
const [tab, setTab] = useState<TabType>('matched')
// Compute quality levels for matched pairs
const greenCount = matched.filter(p => p.match_score >= 0.7).length
const yellowCount = matched.filter(p => p.match_score >= 0.4 && p.match_score < 0.7).length
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
{ id: 'matched', label: 'Zugeordnet', count: matched.length, color: 'text-green-600' },
{ id: 'matched', label: `Zugeordnet (${greenCount} exakt, ${yellowCount} aehnlich)`, count: matched.length, color: 'text-green-600' },
{ id: 'missing', label: 'Fehlend', count: missing.length, color: 'text-red-600' },
{ id: 'extra', label: 'Zusaetzlich', count: extra.length, color: 'text-gray-500' },
]
@@ -63,26 +67,38 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{pairs.map((p, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-3 py-2 text-gray-400">{p.gt_entry.nr}</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
</td>
<td className="px-3 py-2 text-center">
<RiskBadge risk={p.gt_entry.risk_in.r} />
</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
<div className="text-gray-400">{p.engine_hazard.category}</div>
</td>
<td className="px-3 py-2 text-center">
<ScoreBadge score={p.match_score} />
</td>
<td className="px-3 py-2 text-gray-400">{p.match_reason}</td>
</tr>
))}
{pairs.map((p, i) => {
const quality = p.match_score >= 0.7 ? 'green' : p.match_score >= 0.4 ? 'yellow' : 'red'
const rowBg = quality === 'green' ? 'bg-green-50/30 dark:bg-green-900/5'
: quality === 'yellow' ? 'bg-yellow-50/30 dark:bg-yellow-900/5' : ''
const gtRiskLevel = p.gt_entry.risk_in.r >= 30 ? 'hoch' : p.gt_entry.risk_in.r >= 15 ? 'mittel' : 'niedrig'
return (
<tr key={i} className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 ${rowBg}`}>
<td className="px-3 py-2 text-gray-400">{p.gt_entry.nr}</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
</td>
<td className="px-3 py-2 text-center">
<RiskBadge risk={p.gt_entry.risk_in.r} />
<div className="text-[9px] text-gray-400 mt-0.5">
F{p.gt_entry.risk_in.f} W{p.gt_entry.risk_in.w} P{p.gt_entry.risk_in.p} S{p.gt_entry.risk_in.s}
</div>
</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
<div className="text-gray-400">{p.engine_hazard.category}</div>
</td>
<td className="px-3 py-2 text-center">
<ScoreBadge score={p.match_score} />
</td>
<td className="px-3 py-2">
<QualityBadge quality={quality} />
<div className="text-[10px] text-gray-400 mt-0.5">{p.match_reason}</div>
</td>
</tr>
)
})}
</tbody>
</table>
)
@@ -153,6 +169,20 @@ function ScoreBadge({ score }: { score: number }) {
return <span className={`font-bold ${color}`}>{pct}%</span>
}
function QualityBadge({ quality }: { quality: 'green' | 'yellow' | 'red' }) {
const styles = {
green: 'bg-green-100 text-green-700 border-green-200',
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
red: 'bg-red-100 text-red-700 border-red-200',
}
const labels = { green: 'Exakt', yellow: 'Aehnlich', red: 'Schwach' }
return (
<span className={`inline-block px-1.5 py-0.5 rounded border text-[10px] font-medium ${styles[quality]}`}>
{labels[quality]}
</span>
)
}
function EmptyState({ text }: { text: string }) {
return <div className="px-4 py-8 text-center text-sm text-gray-400">{text}</div>
}
@@ -0,0 +1,237 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import { Hazard } from './types'
import { RiskAssessmentTable } from './RiskAssessmentTable'
interface BlockData {
parent_hazard: { hazard: { id: string } }
children: { hazard: { id: string } }[]
children_covered_by_parent: boolean
block_key: string
}
interface BlockInfo {
isParent: boolean
isChild: boolean
isCovered: boolean
blockKey: string
parentId: string
childCount: number
}
interface Props {
projectId: string
hazards: Hazard[]
onReassess?: () => void
decisions?: Record<string, boolean | null>
onDecision?: (hazardId: string, acceptable: boolean | null) => void
}
/**
* Wraps RiskAssessmentTable with block-awareness:
* - Injects block metadata into hazards so the table can show grouping
* - Provides controls to ungroup/promote children
*/
export function BlockAwareRiskTable({ projectId, hazards, onReassess, decisions, onDecision }: Props) {
const [blocks, setBlocks] = useState<BlockData[]>([])
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [ungrouped, setUngrouped] = useState<Record<string, boolean>>({})
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.blocks) setBlocks(d.blocks) })
.catch(() => {})
}, [projectId])
// Build lookup: hazardId → block info
const blockMap = useMemo(() => {
const map: Record<string, BlockInfo> = {}
for (const b of blocks) {
if (b.children.length === 0) continue
const pid = b.parent_hazard.hazard.id
map[pid] = {
isParent: true, isChild: false, isCovered: false,
blockKey: b.block_key, parentId: pid, childCount: b.children.length,
}
for (const c of b.children) {
if (ungrouped[c.hazard.id]) continue
map[c.hazard.id] = {
isParent: false, isChild: true,
isCovered: b.children_covered_by_parent,
blockKey: b.block_key, parentId: pid, childCount: 0,
}
}
}
return map
}, [blocks, ungrouped])
// Sort hazards: parents first, then their children, then standalone
const sortedHazards = useMemo(() => {
const parents: Hazard[] = []
const childrenByParent: Record<string, Hazard[]> = {}
const standalone: Hazard[] = []
for (const h of hazards) {
const info = blockMap[h.id]
if (!info) {
standalone.push(h)
} else if (info.isParent) {
parents.push(h)
childrenByParent[h.id] = []
} else if (info.isChild) {
const arr = childrenByParent[info.parentId]
if (arr) arr.push(h)
else standalone.push(h)
}
}
// Sort parents by risk desc
parents.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
standalone.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
// Interleave: parent → children → parent → children → ... → standalone
const result: Hazard[] = []
for (const p of parents) {
result.push(p)
const isCollapsed = collapsed[p.id]
if (!isCollapsed && childrenByParent[p.id]) {
result.push(...childrenByParent[p.id])
}
}
result.push(...standalone)
return result
}, [hazards, blockMap, collapsed])
const toggleCollapse = (parentId: string) => {
setCollapsed(prev => ({ ...prev, [parentId]: !prev[parentId] }))
}
const handleUngroup = (childId: string) => {
setUngrouped(prev => ({ ...prev, [childId]: true }))
}
// Count blocks with children
const blockCount = blocks.filter(b => b.children.length > 0).length
const coveredCount = Object.values(blockMap).filter(b => b.isChild && b.isCovered).length
return (
<div className="space-y-2">
{/* Block info bar */}
{blockCount > 0 && (
<div className="flex items-center gap-4 px-4 py-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-xs">
<span className="font-medium text-purple-700 dark:text-purple-300">
{blockCount} Bloecke erkannt
</span>
{coveredCount > 0 && (
<span className="text-green-600">
{coveredCount} Kinder durch Mutter abgedeckt
</span>
)}
<span className="text-gray-500">
Klick auf Block-Icon zum Auf-/Zuklappen. Rechtsklick oder Aktion zum Entgruppieren.
</span>
</div>
)}
{/* Enhanced table with block decorations */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs whitespace-nowrap">
<thead>
<tr className="bg-gray-100 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
<th className="w-8 px-1 py-1.5"></th>
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
<th colSpan={4} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Risiko (S x F x P)</th>
<th className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{sortedHazards.map(h => {
const info = blockMap[h.id]
const isParent = info?.isParent
const isChild = info?.isChild
const isCovered = info?.isCovered
const childCount = info?.childCount || 0
const isCollapsedParent = isParent && collapsed[h.id]
return (
<tr key={h.id} className={`transition-colors ${
isChild ? 'bg-gray-50/50 dark:bg-gray-850' :
isParent ? 'bg-white dark:bg-gray-800' : ''
} ${isCovered ? 'opacity-60' : ''} hover:bg-gray-50 dark:hover:bg-gray-750`}>
{/* Block indicator */}
<td className="px-1 py-2 text-center">
{isParent && (
<button onClick={() => toggleCollapse(h.id)}
className="w-5 h-5 flex items-center justify-center rounded hover:bg-purple-100 text-purple-600 transition-colors"
title={`${childCount} Kinder ${isCollapsedParent ? 'anzeigen' : 'verbergen'}`}>
<svg className={`w-3 h-3 transition-transform ${isCollapsedParent ? '' : 'rotate-90'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{isChild && (
<div className="flex items-center justify-center gap-0.5">
<div className="w-1 h-1 rounded-full bg-gray-300" />
<button onClick={() => handleUngroup(h.id)}
className="w-4 h-4 flex items-center justify-center rounded hover:bg-red-100 text-gray-400 hover:text-red-500 transition-colors"
title="Aus Block entfernen">
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
</td>
{/* Name */}
<td className={`px-3 py-2 ${isChild ? 'pl-8' : ''}`}>
<div className={`font-medium ${isParent ? 'text-purple-800 dark:text-purple-300' : 'text-gray-900 dark:text-white'}`}>
{h.name}
{isParent && <span className="ml-1 text-[10px] text-purple-500">({childCount})</span>}
</div>
{h.hazardous_zone && <div className="text-[10px] text-gray-400 truncate max-w-[200px]">{h.hazardous_zone}</div>}
</td>
{/* Category */}
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600 text-gray-500">
{h.category?.replace(/_/g, ' ')}
</td>
{/* Risk */}
<td className="px-2 py-2 text-center">{h.severity || '-'}</td>
<td className="px-2 py-2 text-center">{h.exposure || '-'}</td>
<td className="px-2 py-2 text-center">{h.probability || '-'}</td>
<td className="px-2 py-2 text-center font-bold border-r border-gray-200 dark:border-gray-600">
{h.r_inherent || '-'}
</td>
{/* Status */}
<td className="px-3 py-2 text-center">
{isCovered ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-medium">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Abgedeckt
</span>
) : h.r_inherent ? (
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium ${
(h.r_inherent || 0) <= 20 ? 'bg-green-100 text-green-700' :
(h.r_inherent || 0) <= 60 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{(h.r_inherent || 0) <= 20 ? 'Niedrig' : (h.r_inherent || 0) <= 60 ? 'Mittel' : 'Hoch'}
</span>
) : (
<span className="text-gray-400">Offen</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation'
import { HazardForm } from './_components/HazardForm'
import { HazardTable } from './_components/HazardTable'
import { HazardBlockView } from './_components/HazardBlockView'
import { BlockAwareRiskTable } from './_components/BlockAwareRiskTable'
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
import type { ResidualFilter } from './_components/ResidualRiskPanel'
@@ -174,7 +175,7 @@ export default function HazardsPage() {
<>
<ResidualRiskPanel hazards={h.hazards} decisions={decisions}
activeFilter={residualFilter} onFilterChange={setResidualFilter} />
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
<BlockAwareRiskTable projectId={projectId} hazards={filteredHazards}
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
</>
) : view === 'blocks' ? (