feat(iace): hazard block view — parent/child grouping
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-admin-compliance (push) Successful in 2m9s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-tts (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
Build + Deploy / build-document-crawler (push) Successful in 13s
Build + Deploy / build-dsms-gateway (push) Successful in 15s
Build + Deploy / build-dsms-node (push) Successful in 13s
CI / nodejs-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m14s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 59s
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 28s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 2m54s

Backend:
- hazard_blocks.go: ComputeHazardBlocks() groups hazards by category +
  component + zone. Parent = highest risk in group. Children covered by
  parent's measures are flagged (no separate assessment needed).
- iace_handler_blocks.go: GET /projects/:id/hazard-blocks endpoint
  with summary stats (blocks, covered children, assessments saved)

Frontend:
- HazardBlockView.tsx: Expandable block view with summary cards,
  parent-child hierarchy, coverage badges, and "abgedeckt" indicators
- hazards/page.tsx: New "Bloecke" tab alongside "Hazard-Liste" and
  "Risikobewertung"

No database schema changes — grouping is computed at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-13 11:36:04 +02:00
parent 8ad0519367
commit d31c2fe018
5 changed files with 441 additions and 1 deletions
@@ -0,0 +1,182 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { CATEGORY_LABELS } from './types'
import { RiskBadge } from './RiskBadge'
interface BlockHazard {
hazard: {
id: string; name: string; description: string; category: string
hazardous_zone: string; scenario?: string; possible_harm?: string
}
assessment?: { severity: number; exposure: number; probability: number; inherent_risk: number; risk_level: string } | null
mitigation_ids: string[]
}
interface HazardBlock {
parent_hazard: BlockHazard
children: BlockHazard[]
block_key: string
shared_measure_count: number
children_covered_by_parent: boolean
}
interface BlockSummary {
total_blocks: number
parent_only_blocks: number
blocks_with_children: number
total_hazards: number
covered_children: number
uncovered_children: number
assessments_needed: number
assessments_saved: number
}
export function HazardBlockView() {
const { projectId } = useParams<{ projectId: string }>()
const [blocks, setBlocks] = useState<HazardBlock[]>([])
const [summary, setSummary] = useState<BlockSummary | null>(null)
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
useEffect(() => {
if (!projectId) return
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) {
setBlocks(data.blocks || [])
setSummary(data.summary || null)
}
})
.finally(() => setLoading(false))
}, [projectId])
const toggle = (key: string) => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))
if (loading) return <div className="text-sm text-gray-400 py-8 text-center">Lade Bloecke...</div>
return (
<div className="space-y-4">
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard label="Bloecke" value={summary.total_blocks} sub={`${summary.total_hazards} Gefaehrdungen`} />
<SummaryCard label="Mit Kindern" value={summary.blocks_with_children} sub={`${summary.covered_children} abgedeckt`} color="green" />
<SummaryCard label="Bewertungen noetig" value={summary.assessments_needed} sub={`von ${summary.total_hazards}`} color="purple" />
<SummaryCard label="Eingespart" value={summary.assessments_saved} sub="durch Gruppierung" color="green" />
</div>
)}
{/* Block List */}
<div className="space-y-2">
{blocks.map((block) => {
const isOpen = expanded[block.block_key]
const parent = block.parent_hazard
const childCount = block.children.length
const covered = block.children_covered_by_parent
return (
<div key={block.block_key} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Parent Row */}
<div
className={`flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${childCount > 0 ? '' : 'opacity-90'}`}
onClick={() => childCount > 0 && toggle(block.block_key)}
>
{/* Expand Arrow */}
{childCount > 0 ? (
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? '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>
) : (
<div className="w-4 h-4" />
)}
{/* Name + Category */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{parent.hazard.name}</span>
<span className="text-xs text-gray-400">{CATEGORY_LABELS[parent.hazard.category] || parent.hazard.category}</span>
</div>
{parent.hazard.hazardous_zone && (
<div className="text-xs text-gray-500 truncate">{parent.hazard.hazardous_zone}</div>
)}
</div>
{/* Risk */}
{parent.assessment ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-500">R={parent.assessment.inherent_risk}</span>
<RiskBadge level={parent.assessment.risk_level} />
</div>
) : (
<span className="text-xs text-gray-400">Nicht bewertet</span>
)}
{/* Child count badge */}
{childCount > 0 && (
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
covered
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}>
+{childCount}
{covered && (
<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>
)}
</div>
)}
{/* Measures count */}
<span className="text-xs text-gray-400">{block.shared_measure_count} M.</span>
</div>
{/* Children (expanded) */}
{isOpen && childCount > 0 && (
<div className="border-t border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-850">
{covered && (
<div className="px-4 py-2 text-xs text-green-600 dark:text-green-400 bg-green-50/50 dark:bg-green-900/10 border-b border-green-100 dark:border-green-900/30">
Alle Untergefaehrdungen durch Massnahmen der Muttergefaehrdung abgedeckt keine separate Bewertung noetig.
</div>
)}
{block.children.map((child) => (
<div key={child.hazard.id} className="flex items-center gap-3 px-4 py-2 pl-12 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-xs text-gray-700 dark:text-gray-300">{child.hazard.name}</span>
{child.hazard.hazardous_zone && (
<span className="text-xs text-gray-400 ml-2">[{child.hazard.hazardous_zone}]</span>
)}
</div>
{child.assessment ? (
<span className="text-xs text-gray-500">R={child.assessment.inherent_risk}</span>
) : covered ? (
<span className="text-xs text-green-500">Abgedeckt</span>
) : (
<span className="text-xs text-yellow-500">Offen</span>
)}
</div>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
function SummaryCard({ label, value, sub, color }: { label: string; value: number; sub: string; color?: string }) {
const textColor = color === 'green' ? 'text-green-600' : color === 'purple' ? 'text-purple-600' : 'text-gray-900 dark:text-white'
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
<div className={`text-xl font-bold ${textColor}`}>{value}</div>
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{label}</div>
<div className="text-[10px] text-gray-400">{sub}</div>
</div>
)
}
@@ -4,6 +4,7 @@ import React, { useState, useMemo, useCallback } from 'react'
import { useParams } from 'next/navigation'
import { HazardForm } from './_components/HazardForm'
import { HazardTable } from './_components/HazardTable'
import { HazardBlockView } from './_components/HazardBlockView'
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
import type { ResidualFilter } from './_components/ResidualRiskPanel'
@@ -12,7 +13,7 @@ import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
import { CustomHazardModal } from './_components/CustomHazardModal'
import { useHazards } from './_hooks/useHazards'
type ViewMode = 'list' | 'risk'
type ViewMode = 'list' | 'risk' | 'blocks'
export default function HazardsPage() {
const params = useParams()
@@ -69,6 +70,10 @@ export default function HazardsPage() {
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
Risikobewertung
</button>
<button onClick={() => setView('blocks')}
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'blocks' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
Bloecke
</button>
</div>
</div>
<div className="flex items-center gap-2">
@@ -172,6 +177,8 @@ export default function HazardsPage() {
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
</>
) : view === 'blocks' ? (
<HazardBlockView />
) : (
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
)
@@ -0,0 +1,67 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GetHazardBlocks handles GET /projects/:id/hazard-blocks
// Returns hazards grouped into parent-child blocks based on shared category,
// component, and zone. The parent hazard in each block has the highest risk.
// Children covered by the parent's measures are flagged accordingly.
func (h *IACEHandler) GetHazardBlocks(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
ctx := c.Request.Context()
hazards, err := h.store.ListHazards(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"})
return
}
assessmentMap, _ := h.store.GetLatestAssessmentsByProject(ctx, projectID)
var assessments []iace.RiskAssessment
for _, a := range assessmentMap {
assessments = append(assessments, a)
}
mitigations, _ := h.store.ListMitigationsByProject(ctx, projectID)
blocks := iace.ComputeHazardBlocks(hazards, assessments, mitigations)
// Compute summary stats
totalBlocks := len(blocks)
parentOnly := 0
coveredChildren := 0
uncoveredChildren := 0
for _, b := range blocks {
if len(b.Children) == 0 {
parentOnly++
} else if b.ChildrenCoveredByParent {
coveredChildren += len(b.Children)
} else {
uncoveredChildren += len(b.Children)
}
}
c.JSON(http.StatusOK, gin.H{
"blocks": blocks,
"summary": gin.H{
"total_blocks": totalBlocks,
"parent_only_blocks": parentOnly,
"blocks_with_children": totalBlocks - parentOnly,
"total_hazards": len(hazards),
"covered_children": coveredChildren,
"uncovered_children": uncoveredChildren,
"assessments_needed": totalBlocks - parentOnly + uncoveredChildren + parentOnly,
"assessments_saved": coveredChildren,
},
})
}
+1
View File
@@ -432,6 +432,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
iaceRoutes.POST("/library-search", h.SearchLibrary)
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks)
iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
@@ -0,0 +1,183 @@
package iace
import (
"sort"
"strings"
"github.com/google/uuid"
)
// HazardBlock groups related hazards under a parent hazard.
// The parent is the hazard with the highest inherent risk in the group.
// Child hazards are covered by the same or similar protective measures.
type HazardBlock struct {
ParentHazard HazardBlockEntry `json:"parent_hazard"`
Children []HazardBlockEntry `json:"children"`
BlockKey string `json:"block_key"`
SharedMeasureCount int `json:"shared_measure_count"`
// If true, the parent's measures cover all children → children
// don't need individual risk assessment.
ChildrenCoveredByParent bool `json:"children_covered_by_parent"`
}
// HazardBlockEntry is a hazard with its assessment and linked measures.
type HazardBlockEntry struct {
Hazard Hazard `json:"hazard"`
Assessment *RiskAssessment `json:"assessment,omitempty"`
MitigationIDs []uuid.UUID `json:"mitigation_ids"`
}
// ComputeHazardBlocks groups hazards into blocks based on category + component.
// Within each block, the hazard with the highest risk becomes the parent.
// Children whose measures are a subset of the parent's measures are marked as covered.
func ComputeHazardBlocks(
hazards []Hazard,
assessments []RiskAssessment,
mitigations []Mitigation,
) []HazardBlock {
if len(hazards) == 0 {
return nil
}
// Build assessment lookup: hazard_id → latest assessment
assessMap := make(map[uuid.UUID]*RiskAssessment)
for i := range assessments {
a := &assessments[i]
if existing, ok := assessMap[a.HazardID]; !ok || a.Version > existing.Version {
assessMap[a.HazardID] = a
}
}
// Build mitigation lookup: hazard_id → []mitigation_ids
mitsByHazard := make(map[uuid.UUID][]uuid.UUID)
for _, m := range mitigations {
mitsByHazard[m.HazardID] = append(mitsByHazard[m.HazardID], m.ID)
}
// Group by blockKey = category + ":" + componentID
groups := make(map[string][]HazardBlockEntry)
for _, h := range hazards {
key := buildBlockKey(h)
entry := HazardBlockEntry{
Hazard: h,
Assessment: assessMap[h.ID],
MitigationIDs: mitsByHazard[h.ID],
}
groups[key] = append(groups[key], entry)
}
// Build blocks: sort each group by risk, first is parent
var blocks []HazardBlock
for key, entries := range groups {
sortByRiskDesc(entries, assessMap)
parent := entries[0]
children := entries[1:]
// Check if parent's measures cover children
parentMitSet := toUUIDSet(parent.MitigationIDs)
allCovered := true
for _, child := range children {
if !mitigationsCoveredBy(child, parent, mitigations) {
allCovered = false
break
}
}
block := HazardBlock{
ParentHazard: parent,
Children: children,
BlockKey: key,
SharedMeasureCount: len(parentMitSet),
ChildrenCoveredByParent: allCovered && len(children) > 0,
}
blocks = append(blocks, block)
}
// Sort blocks: largest (most children) first, then by parent risk
sort.Slice(blocks, func(i, j int) bool {
ri := inherentRisk(blocks[i].ParentHazard, assessMap)
rj := inherentRisk(blocks[j].ParentHazard, assessMap)
if len(blocks[i].Children) != len(blocks[j].Children) {
return len(blocks[i].Children) > len(blocks[j].Children)
}
return ri > rj
})
return blocks
}
func buildBlockKey(h Hazard) string {
comp := h.ComponentID.String()
zone := NormalizeDEPublic(h.HazardousZone)
// Use first 3 significant words of zone for grouping
words := strings.Fields(zone)
var sig []string
for _, w := range words {
w = strings.Trim(w, ".,;:!?()/-")
if len(w) >= 4 {
sig = append(sig, w)
}
if len(sig) >= 2 {
break
}
}
zoneKey := strings.Join(sig, "_")
return h.Category + ":" + comp + ":" + zoneKey
}
func sortByRiskDesc(entries []HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) {
sort.Slice(entries, func(i, j int) bool {
ri := inherentRisk(entries[i], assessMap)
rj := inherentRisk(entries[j], assessMap)
return ri > rj
})
}
func inherentRisk(entry HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) float64 {
if entry.Assessment != nil {
return entry.Assessment.InherentRisk
}
if a, ok := assessMap[entry.Hazard.ID]; ok {
return a.InherentRisk
}
return 0
}
// mitigationsCoveredBy checks if child's measures are functionally covered
// by parent's measures (same reduction type and hazard category).
func mitigationsCoveredBy(child, parent HazardBlockEntry, allMits []Mitigation) bool {
if len(child.MitigationIDs) == 0 {
return true // No measures needed → covered by default
}
mitMap := make(map[uuid.UUID]Mitigation)
for _, m := range allMits {
mitMap[m.ID] = m
}
// Check: for each child mitigation type, parent has same type
parentTypes := make(map[ReductionType]bool)
for _, mid := range parent.MitigationIDs {
if m, ok := mitMap[mid]; ok {
parentTypes[m.ReductionType] = true
}
}
for _, mid := range child.MitigationIDs {
if m, ok := mitMap[mid]; ok {
if !parentTypes[m.ReductionType] {
return false
}
}
}
return true
}
func toUUIDSet(ids []uuid.UUID) map[uuid.UUID]bool {
s := make(map[uuid.UUID]bool, len(ids))
for _, id := range ids {
s[id] = true
}
return s
}