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
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:
@@ -50,9 +50,18 @@ export async function GET(request: NextRequest) {
|
||||
break
|
||||
}
|
||||
|
||||
case 'controls-meta':
|
||||
backendPath = '/api/compliance/v1/canonical/controls-meta'
|
||||
case '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
|
||||
}
|
||||
|
||||
case 'control': {
|
||||
const controlId = searchParams.get('id')
|
||||
|
||||
@@ -32,6 +32,11 @@ interface ControlsMeta {
|
||||
atomic: 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()
|
||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
||||
|
||||
// Load metadata (domains, sources — once + on refresh)
|
||||
const loadMeta = useCallback(async () => {
|
||||
// Load frameworks (once)
|
||||
const loadFrameworks = useCallback(async () => {
|
||||
try {
|
||||
const [fwRes, metaRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=frameworks`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-meta`),
|
||||
])
|
||||
if (fwRes.ok) setFrameworks(await fwRes.json())
|
||||
if (metaRes.ok) setMeta(await metaRes.json())
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
||||
if (res.ok) setFrameworks(await res.json())
|
||||
} 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
|
||||
const loadControls = useCallback(async () => {
|
||||
try {
|
||||
@@ -181,8 +191,11 @@ export default function ControlLibraryPage() {
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { loadMeta(); loadReviewCount() }, [loadMeta, loadReviewCount])
|
||||
// Initial load (frameworks only once)
|
||||
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
||||
|
||||
// Load faceted meta when filters change
|
||||
useEffect(() => { loadMeta() }, [loadMeta])
|
||||
|
||||
// Load controls when filters/page/sort change
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
@@ -195,8 +208,8 @@ export default function ControlLibraryPage() {
|
||||
|
||||
// Full reload (after CRUD)
|
||||
const fullReload = useCallback(async () => {
|
||||
await Promise.all([loadControls(), loadMeta(), loadReviewCount()])
|
||||
}, [loadControls, loadMeta, loadReviewCount])
|
||||
await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
|
||||
}, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
|
||||
|
||||
// CRUD handlers
|
||||
const handleCreate = async (data: typeof EMPTY_CONTROL) => {
|
||||
@@ -627,7 +640,7 @@ export default function ControlLibraryPage() {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { loadControls(); loadMeta(); loadReviewCount() }}
|
||||
onClick={() => { loadControls(); loadMeta(); loadFrameworks(); loadReviewCount() }}
|
||||
className="p-2 text-gray-400 hover:text-purple-600"
|
||||
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"
|
||||
>
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="critical">Kritisch{meta?.severity_counts?.critical ? ` (${meta.severity_counts.critical})` : ''}</option>
|
||||
<option value="high">Hoch{meta?.severity_counts?.high ? ` (${meta.severity_counts.high})` : ''}</option>
|
||||
<option value="medium">Mittel{meta?.severity_counts?.medium ? ` (${meta.severity_counts.medium})` : ''}</option>
|
||||
<option value="low">Niedrig{meta?.severity_counts?.low ? ` (${meta.severity_counts.low})` : ''}</option>
|
||||
</select>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="needs_review">Review noetig</option>
|
||||
<option value="too_close">Zu aehnlich</option>
|
||||
<option value="duplicate">Duplikat</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
<option value="draft">Draft{meta?.release_state_counts?.draft ? ` (${meta.release_state_counts.draft})` : ''}</option>
|
||||
<option value="approved">Approved{meta?.release_state_counts?.approved ? ` (${meta.release_state_counts.approved})` : ''}</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{meta?.release_state_counts?.too_close ? ` (${meta.release_state_counts.too_close})` : ''}</option>
|
||||
<option value="duplicate">Duplikat{meta?.release_state_counts?.duplicate ? ` (${meta.release_state_counts.duplicate})` : ''}</option>
|
||||
<option value="deprecated">Deprecated{meta?.release_state_counts?.deprecated ? ` (${meta.release_state_counts.deprecated})` : ''}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
@@ -686,7 +699,7 @@ export default function ControlLibraryPage() {
|
||||
>
|
||||
<option value="">Nachweis</option>
|
||||
{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
|
||||
@@ -696,7 +709,7 @@ export default function ControlLibraryPage() {
|
||||
>
|
||||
<option value="">Kategorie</option>
|
||||
{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
|
||||
@@ -706,7 +719,7 @@ export default function ControlLibraryPage() {
|
||||
>
|
||||
<option value="">Nachweisart</option>
|
||||
{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
|
||||
|
||||
@@ -471,46 +471,164 @@ async def count_controls(
|
||||
|
||||
|
||||
@router.get("/controls-meta")
|
||||
async def controls_meta():
|
||||
"""Return aggregated metadata for filter dropdowns (domains, sources, counts)."""
|
||||
async def controls_meta(
|
||||
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:
|
||||
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
|
||||
FROM canonical_controls
|
||||
FROM canonical_controls WHERE {w_dom}
|
||||
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
|
||||
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
|
||||
""")).fetchall()
|
||||
"""), p_src).fetchall()
|
||||
|
||||
no_source = db.execute(text("""
|
||||
no_source = db.execute(text(f"""
|
||||
SELECT count(*) FROM canonical_controls
|
||||
WHERE source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = ''
|
||||
""")).scalar()
|
||||
WHERE {w_src}
|
||||
AND (source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')
|
||||
"""), p_src).scalar()
|
||||
|
||||
# Type counts for filter dropdown
|
||||
atomic_count = db.execute(text("""
|
||||
SELECT count(*) FROM canonical_controls WHERE decomposition_method = 'pass0b'
|
||||
""")).scalar() or 0
|
||||
|
||||
eigenentwicklung_count = db.execute(text("""
|
||||
# Type facet (skip control_type filter)
|
||||
w_typ, p_typ = _build_where(skip="control_type")
|
||||
atomic_count = db.execute(text(f"""
|
||||
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 source_citation 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
|
||||
WHERE (decomposition_method IS NULL OR decomposition_method != 'pass0b')
|
||||
""")).scalar() or 0
|
||||
WHERE {w_typ}
|
||||
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 {
|
||||
"total": total,
|
||||
@@ -522,6 +640,11 @@ async def controls_meta():
|
||||
"atomic": atomic_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},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -443,18 +443,22 @@ class TestControlsMeta:
|
||||
db.__enter__ = MagicMock(return_value=db)
|
||||
db.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# 4 sequential execute() calls
|
||||
total_r = MagicMock(); total_r.scalar.return_value = 100
|
||||
domain_r = MagicMock(); domain_r.fetchall.return_value = []
|
||||
source_r = MagicMock(); source_r.fetchall.return_value = []
|
||||
nosrc_r = MagicMock(); nosrc_r.scalar.return_value = 20
|
||||
db.execute.side_effect = [total_r, domain_r, source_r, nosrc_r]
|
||||
# Faceted meta does many execute() calls — use a default mock
|
||||
scalar_r = MagicMock()
|
||||
scalar_r.scalar.return_value = 100
|
||||
scalar_r.fetchall.return_value = []
|
||||
db.execute.return_value = scalar_r
|
||||
mock_cls.return_value = db
|
||||
|
||||
resp = _client.get("/api/compliance/v1/canonical/controls-meta")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 100
|
||||
assert data["no_source_count"] == 20
|
||||
assert isinstance(data["domains"], 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
|
||||
|
||||
Reference in New Issue
Block a user