feat: Faceted Search — Dropdown-Counts passen sich aktiven Filtern an
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 42s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s

Backend: controls-meta akzeptiert alle Filter-Parameter und berechnet
Faceted Counts (jede Dimension zaehlt mit allen ANDEREN Filtern).
Neue Facets: severity, verification_method, category, evidence_type,
release_state — zusaetzlich zu domains, sources, type_counts.

Frontend: loadMeta laedt bei jeder Filteraenderung neu, alle Dropdowns
zeigen kontextsensitive Zahlen. Proxy leitet Filter an controls-meta weiter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-26 15:00:40 +01:00
parent 2dee62fa6f
commit 52e463a7c8
4 changed files with 207 additions and 58 deletions

View File

@@ -50,9 +50,18 @@ export async function GET(request: NextRequest) {
break break
} }
case 'controls-meta': case 'controls-meta': {
backendPath = '/api/compliance/v1/canonical/controls-meta' const metaParams = new URLSearchParams()
const metaPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
for (const key of metaPassthrough) {
const val = searchParams.get(key)
if (val) metaParams.set(key, val)
}
const metaQs = metaParams.toString()
backendPath = `/api/compliance/v1/canonical/controls-meta${metaQs ? `?${metaQs}` : ''}`
break break
}
case 'control': { case 'control': {
const controlId = searchParams.get('id') const controlId = searchParams.get('id')

View File

@@ -32,6 +32,11 @@ interface ControlsMeta {
atomic: number atomic: number
eigenentwicklung: number eigenentwicklung: number
} }
severity_counts?: Record<string, number>
verification_method_counts?: Record<string, number>
category_counts?: Record<string, number>
evidence_type_counts?: Record<string, number>
release_state_counts?: Record<string, number>
} }
// ============================================================================= // =============================================================================
@@ -122,18 +127,23 @@ export default function ControlLibraryPage() {
return p.toString() return p.toString()
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch]) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
// Load metadata (domains, sources — once + on refresh) // Load frameworks (once)
const loadMeta = useCallback(async () => { const loadFrameworks = useCallback(async () => {
try { try {
const [fwRes, metaRes] = await Promise.all([ const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
fetch(`${BACKEND_URL}?endpoint=frameworks`), if (res.ok) setFrameworks(await res.json())
fetch(`${BACKEND_URL}?endpoint=controls-meta`),
])
if (fwRes.ok) setFrameworks(await fwRes.json())
if (metaRes.ok) setMeta(await metaRes.json())
} catch { /* ignore */ } } catch { /* ignore */ }
}, []) }, [])
// Load faceted metadata (reloads when filters change)
const loadMeta = useCallback(async () => {
try {
const qs = buildParams()
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`)
if (res.ok) setMeta(await res.json())
} catch { /* ignore */ }
}, [buildParams])
// Load controls page // Load controls page
const loadControls = useCallback(async () => { const loadControls = useCallback(async () => {
try { try {
@@ -181,8 +191,11 @@ export default function ControlLibraryPage() {
} catch { /* ignore */ } } catch { /* ignore */ }
}, []) }, [])
// Initial load // Initial load (frameworks only once)
useEffect(() => { loadMeta(); loadReviewCount() }, [loadMeta, loadReviewCount]) useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
// Load faceted meta when filters change
useEffect(() => { loadMeta() }, [loadMeta])
// Load controls when filters/page/sort change // Load controls when filters/page/sort change
useEffect(() => { loadControls() }, [loadControls]) useEffect(() => { loadControls() }, [loadControls])
@@ -195,8 +208,8 @@ export default function ControlLibraryPage() {
// Full reload (after CRUD) // Full reload (after CRUD)
const fullReload = useCallback(async () => { const fullReload = useCallback(async () => {
await Promise.all([loadControls(), loadMeta(), loadReviewCount()]) await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
}, [loadControls, loadMeta, loadReviewCount]) }, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
// CRUD handlers // CRUD handlers
const handleCreate = async (data: typeof EMPTY_CONTROL) => { const handleCreate = async (data: typeof EMPTY_CONTROL) => {
@@ -627,7 +640,7 @@ export default function ControlLibraryPage() {
/> />
</div> </div>
<button <button
onClick={() => { loadControls(); loadMeta(); loadReviewCount() }} onClick={() => { loadControls(); loadMeta(); loadFrameworks(); loadReviewCount() }}
className="p-2 text-gray-400 hover:text-purple-600" className="p-2 text-gray-400 hover:text-purple-600"
title="Aktualisieren" title="Aktualisieren"
> >
@@ -642,10 +655,10 @@ export default function ControlLibraryPage() {
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500" className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
> >
<option value="">Schweregrad</option> <option value="">Schweregrad</option>
<option value="critical">Kritisch</option> <option value="critical">Kritisch{meta?.severity_counts?.critical ? ` (${meta.severity_counts.critical})` : ''}</option>
<option value="high">Hoch</option> <option value="high">Hoch{meta?.severity_counts?.high ? ` (${meta.severity_counts.high})` : ''}</option>
<option value="medium">Mittel</option> <option value="medium">Mittel{meta?.severity_counts?.medium ? ` (${meta.severity_counts.medium})` : ''}</option>
<option value="low">Niedrig</option> <option value="low">Niedrig{meta?.severity_counts?.low ? ` (${meta.severity_counts.low})` : ''}</option>
</select> </select>
<select <select
value={domainFilter} value={domainFilter}
@@ -663,12 +676,12 @@ export default function ControlLibraryPage() {
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500" className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
> >
<option value="">Status</option> <option value="">Status</option>
<option value="draft">Draft</option> <option value="draft">Draft{meta?.release_state_counts?.draft ? ` (${meta.release_state_counts.draft})` : ''}</option>
<option value="approved">Approved</option> <option value="approved">Approved{meta?.release_state_counts?.approved ? ` (${meta.release_state_counts.approved})` : ''}</option>
<option value="needs_review">Review noetig</option> <option value="needs_review">Review noetig{meta?.release_state_counts?.needs_review ? ` (${meta.release_state_counts.needs_review})` : ''}</option>
<option value="too_close">Zu aehnlich</option> <option value="too_close">Zu aehnlich{meta?.release_state_counts?.too_close ? ` (${meta.release_state_counts.too_close})` : ''}</option>
<option value="duplicate">Duplikat</option> <option value="duplicate">Duplikat{meta?.release_state_counts?.duplicate ? ` (${meta.release_state_counts.duplicate})` : ''}</option>
<option value="deprecated">Deprecated</option> <option value="deprecated">Deprecated{meta?.release_state_counts?.deprecated ? ` (${meta.release_state_counts.deprecated})` : ''}</option>
</select> </select>
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap"> <label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap">
<input <input
@@ -686,7 +699,7 @@ export default function ControlLibraryPage() {
> >
<option value="">Nachweis</option> <option value="">Nachweis</option>
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => ( {Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option> <option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
))} ))}
</select> </select>
<select <select
@@ -696,7 +709,7 @@ export default function ControlLibraryPage() {
> >
<option value="">Kategorie</option> <option value="">Kategorie</option>
{CATEGORY_OPTIONS.map(c => ( {CATEGORY_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}</option> <option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>
))} ))}
</select> </select>
<select <select
@@ -706,7 +719,7 @@ export default function ControlLibraryPage() {
> >
<option value="">Nachweisart</option> <option value="">Nachweisart</option>
{EVIDENCE_TYPE_OPTIONS.map(c => ( {EVIDENCE_TYPE_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}</option> <option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option>
))} ))}
</select> </select>
<select <select

View File

@@ -471,46 +471,164 @@ async def count_controls(
@router.get("/controls-meta") @router.get("/controls-meta")
async def controls_meta(): async def controls_meta(
"""Return aggregated metadata for filter dropdowns (domains, sources, counts).""" severity: Optional[str] = Query(None),
domain: Optional[str] = Query(None),
release_state: Optional[str] = Query(None),
verification_method: Optional[str] = Query(None),
category: Optional[str] = Query(None),
evidence_type: Optional[str] = Query(None),
target_audience: Optional[str] = Query(None),
source: Optional[str] = Query(None),
search: Optional[str] = Query(None),
control_type: Optional[str] = Query(None),
exclude_duplicates: bool = Query(False),
):
"""Return faceted metadata for filter dropdowns.
Each facet's counts respect ALL active filters EXCEPT the facet's own,
so dropdowns always show how many items each option would yield.
"""
def _build_where(skip: Optional[str] = None) -> tuple[str, dict[str, Any]]:
clauses = ["1=1"]
p: dict[str, Any] = {}
if exclude_duplicates:
clauses.append("release_state != 'duplicate'")
if severity and skip != "severity":
clauses.append("severity = :sev")
p["sev"] = severity
if domain and skip != "domain":
clauses.append("LEFT(control_id, LENGTH(:dom)) = :dom")
p["dom"] = domain.upper()
if release_state and skip != "release_state":
clauses.append("release_state = :rs")
p["rs"] = release_state
if verification_method and skip != "verification_method":
clauses.append("verification_method = :vm")
p["vm"] = verification_method
if category and skip != "category":
clauses.append("category = :cat")
p["cat"] = category
if evidence_type and skip != "evidence_type":
clauses.append("evidence_type = :et")
p["et"] = evidence_type
if target_audience and skip != "target_audience":
clauses.append("target_audience LIKE :ta_pattern")
p["ta_pattern"] = f'%"{target_audience}"%'
if source and skip != "source":
if source == "__none__":
clauses.append("(source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')")
else:
clauses.append("source_citation->>'source' = :src")
p["src"] = source
if control_type and skip != "control_type":
if control_type == "atomic":
clauses.append("decomposition_method = 'pass0b'")
elif control_type == "rich":
clauses.append("(decomposition_method IS NULL OR decomposition_method != 'pass0b')")
elif control_type == "eigenentwicklung":
clauses.append("""generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL
AND parent_control_uuid IS NULL""")
if search and skip != "search":
clauses.append("(control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)")
p["q"] = f"%{search}%"
return " AND ".join(clauses), p
with SessionLocal() as db: with SessionLocal() as db:
total = db.execute(text("SELECT count(*) FROM canonical_controls")).scalar() # Total with ALL filters
w_all, p_all = _build_where()
total = db.execute(text(f"SELECT count(*) FROM canonical_controls WHERE {w_all}"), p_all).scalar()
domains = db.execute(text(""" # Domain facet (skip domain filter so user sees all domains)
w_dom, p_dom = _build_where(skip="domain")
domains = db.execute(text(f"""
SELECT UPPER(SPLIT_PART(control_id, '-', 1)) as domain, count(*) as cnt SELECT UPPER(SPLIT_PART(control_id, '-', 1)) as domain, count(*) as cnt
FROM canonical_controls FROM canonical_controls WHERE {w_dom}
GROUP BY domain ORDER BY domain GROUP BY domain ORDER BY domain
""")).fetchall() """), p_dom).fetchall()
sources = db.execute(text(""" # Source facet (skip source filter)
w_src, p_src = _build_where(skip="source")
sources = db.execute(text(f"""
SELECT source_citation->>'source' as src, count(*) as cnt SELECT source_citation->>'source' as src, count(*) as cnt
FROM canonical_controls FROM canonical_controls
WHERE source_citation->>'source' IS NOT NULL AND source_citation->>'source' != '' WHERE {w_src}
AND source_citation->>'source' IS NOT NULL AND source_citation->>'source' != ''
GROUP BY src ORDER BY cnt DESC GROUP BY src ORDER BY cnt DESC
""")).fetchall() """), p_src).fetchall()
no_source = db.execute(text(""" no_source = db.execute(text(f"""
SELECT count(*) FROM canonical_controls SELECT count(*) FROM canonical_controls
WHERE source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '' WHERE {w_src}
""")).scalar() AND (source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')
"""), p_src).scalar()
# Type counts for filter dropdown # Type facet (skip control_type filter)
atomic_count = db.execute(text(""" w_typ, p_typ = _build_where(skip="control_type")
SELECT count(*) FROM canonical_controls WHERE decomposition_method = 'pass0b' atomic_count = db.execute(text(f"""
""")).scalar() or 0
eigenentwicklung_count = db.execute(text("""
SELECT count(*) FROM canonical_controls SELECT count(*) FROM canonical_controls
WHERE generation_strategy = 'ungrouped' WHERE {w_typ} AND decomposition_method = 'pass0b'
"""), p_typ).scalar() or 0
eigenentwicklung_count = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE {w_typ}
AND generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL) AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL AND source_citation IS NULL
AND parent_control_uuid IS NULL AND parent_control_uuid IS NULL
""")).scalar() or 0 """), p_typ).scalar() or 0
rich_count = db.execute(text(""" rich_count = db.execute(text(f"""
SELECT count(*) FROM canonical_controls SELECT count(*) FROM canonical_controls
WHERE (decomposition_method IS NULL OR decomposition_method != 'pass0b') WHERE {w_typ}
""")).scalar() or 0 AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')
"""), p_typ).scalar() or 0
# Severity facet (skip severity filter)
w_sev, p_sev = _build_where(skip="severity")
severity_counts = db.execute(text(f"""
SELECT severity, count(*) as cnt
FROM canonical_controls WHERE {w_sev}
GROUP BY severity ORDER BY severity
"""), p_sev).fetchall()
# Verification method facet
w_vm, p_vm = _build_where(skip="verification_method")
vm_counts = db.execute(text(f"""
SELECT verification_method, count(*) as cnt
FROM canonical_controls WHERE {w_vm} AND verification_method IS NOT NULL
GROUP BY verification_method ORDER BY verification_method
"""), p_vm).fetchall()
# Category facet
w_cat, p_cat = _build_where(skip="category")
cat_counts = db.execute(text(f"""
SELECT category, count(*) as cnt
FROM canonical_controls WHERE {w_cat} AND category IS NOT NULL
GROUP BY category ORDER BY cnt DESC
"""), p_cat).fetchall()
# Evidence type facet
w_et, p_et = _build_where(skip="evidence_type")
et_counts = db.execute(text(f"""
SELECT evidence_type, count(*) as cnt
FROM canonical_controls WHERE {w_et} AND evidence_type IS NOT NULL
GROUP BY evidence_type ORDER BY evidence_type
"""), p_et).fetchall()
# Release state facet
w_rs, p_rs = _build_where(skip="release_state")
rs_counts = db.execute(text(f"""
SELECT release_state, count(*) as cnt
FROM canonical_controls WHERE {w_rs}
GROUP BY release_state ORDER BY release_state
"""), p_rs).fetchall()
return { return {
"total": total, "total": total,
@@ -522,6 +640,11 @@ async def controls_meta():
"atomic": atomic_count, "atomic": atomic_count,
"eigenentwicklung": eigenentwicklung_count, "eigenentwicklung": eigenentwicklung_count,
}, },
"severity_counts": {r[0]: r[1] for r in severity_counts},
"verification_method_counts": {r[0]: r[1] for r in vm_counts},
"category_counts": {r[0]: r[1] for r in cat_counts},
"evidence_type_counts": {r[0]: r[1] for r in et_counts},
"release_state_counts": {r[0]: r[1] for r in rs_counts},
} }

View File

@@ -443,18 +443,22 @@ class TestControlsMeta:
db.__enter__ = MagicMock(return_value=db) db.__enter__ = MagicMock(return_value=db)
db.__exit__ = MagicMock(return_value=False) db.__exit__ = MagicMock(return_value=False)
# 4 sequential execute() calls # Faceted meta does many execute() calls — use a default mock
total_r = MagicMock(); total_r.scalar.return_value = 100 scalar_r = MagicMock()
domain_r = MagicMock(); domain_r.fetchall.return_value = [] scalar_r.scalar.return_value = 100
source_r = MagicMock(); source_r.fetchall.return_value = [] scalar_r.fetchall.return_value = []
nosrc_r = MagicMock(); nosrc_r.scalar.return_value = 20 db.execute.return_value = scalar_r
db.execute.side_effect = [total_r, domain_r, source_r, nosrc_r]
mock_cls.return_value = db mock_cls.return_value = db
resp = _client.get("/api/compliance/v1/canonical/controls-meta") resp = _client.get("/api/compliance/v1/canonical/controls-meta")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["total"] == 100 assert data["total"] == 100
assert data["no_source_count"] == 20
assert isinstance(data["domains"], list) assert isinstance(data["domains"], list)
assert isinstance(data["sources"], list) assert isinstance(data["sources"], list)
assert "type_counts" in data
assert "severity_counts" in data
assert "verification_method_counts" in data
assert "category_counts" in data
assert "evidence_type_counts" in data
assert "release_state_counts" in data