fix: Faceted Counts — NULL-Werte einbeziehen + AbortController fuer Race Conditions
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 34s
CI/CD / test-python-backend-compliance (push) Successful in 32s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 17s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 2s

Backend: Facets zaehlen jetzt Controls OHNE Wert (z.B. "Ohne Nachweis")
als __none__. Filter unterstuetzen __none__ fuer verification_method,
category, evidence_type. Counts addieren sich immer zum Total.

Frontend: "Ohne X" Optionen in Dropdowns. AbortController verhindert
dass aeltere API-Antworten neuere ueberschreiben.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-26 17:35:52 +01:00
parent 52e463a7c8
commit ac42a0aaa0
2 changed files with 87 additions and 42 deletions

View File

@@ -101,6 +101,10 @@ export default function ControlLibraryPage() {
similarity_score: number; match_rank: number; match_method: string similarity_score: number; match_rank: number; match_method: string
}>>([]) }>>([])
// Abort controllers for cancelling stale requests
const metaAbortRef = useRef<AbortController | null>(null)
const controlsAbortRef = useRef<AbortController | null>(null)
// Debounce search // Debounce search
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
@@ -135,17 +139,25 @@ export default function ControlLibraryPage() {
} catch { /* ignore */ } } catch { /* ignore */ }
}, []) }, [])
// Load faceted metadata (reloads when filters change) // Load faceted metadata (reloads when filters change, cancels stale requests)
const loadMeta = useCallback(async () => { const loadMeta = useCallback(async () => {
if (metaAbortRef.current) metaAbortRef.current.abort()
const controller = new AbortController()
metaAbortRef.current = controller
try { try {
const qs = buildParams() const qs = buildParams()
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`) const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
if (res.ok) setMeta(await res.json()) if (res.ok && !controller.signal.aborted) setMeta(await res.json())
} catch { /* ignore */ } } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
}
}, [buildParams]) }, [buildParams])
// Load controls page // Load controls page (cancels stale requests)
const loadControls = useCallback(async () => { const loadControls = useCallback(async () => {
if (controlsAbortRef.current) controlsAbortRef.current.abort()
const controller = new AbortController()
controlsAbortRef.current = controller
try { try {
setLoading(true) setLoading(true)
@@ -164,19 +176,22 @@ export default function ControlLibraryPage() {
const countQs = buildParams() const countQs = buildParams()
const [ctrlRes, countRes] = await Promise.all([ const [ctrlRes, countRes] = await Promise.all([
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`), fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`), fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
]) ])
if (ctrlRes.ok) setControls(await ctrlRes.json()) if (!controller.signal.aborted) {
if (countRes.ok) { if (ctrlRes.ok) setControls(await ctrlRes.json())
const data = await countRes.json() if (countRes.ok) {
setTotalCount(data.total || 0) const data = await countRes.json()
setTotalCount(data.total || 0)
}
} }
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Fehler beim Laden') setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally { } finally {
setLoading(false) if (!controller.signal.aborted) setLoading(false)
} }
}, [buildParams, sortBy, currentPage]) }, [buildParams, sortBy, currentPage])
@@ -701,6 +716,7 @@ export default function ControlLibraryPage() {
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => ( {Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option> <option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
))} ))}
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
</select> </select>
<select <select
value={categoryFilter} value={categoryFilter}
@@ -711,6 +727,7 @@ export default function ControlLibraryPage() {
{CATEGORY_OPTIONS.map(c => ( {CATEGORY_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option> <option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>
))} ))}
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
</select> </select>
<select <select
value={evidenceTypeFilter} value={evidenceTypeFilter}
@@ -721,6 +738,7 @@ export default function ControlLibraryPage() {
{EVIDENCE_TYPE_OPTIONS.map(c => ( {EVIDENCE_TYPE_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option> <option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option>
))} ))}
{meta?.evidence_type_counts?.['__none__'] ? <option value="__none__">Ohne Nachweisart ({meta.evidence_type_counts['__none__']})</option> : null}
</select> </select>
<select <select
value={audienceFilter} value={audienceFilter}

View File

@@ -347,14 +347,23 @@ async def list_controls(
query += " AND release_state = :rs" query += " AND release_state = :rs"
params["rs"] = release_state params["rs"] = release_state
if verification_method: if verification_method:
query += " AND verification_method = :vm" if verification_method == "__none__":
params["vm"] = verification_method query += " AND verification_method IS NULL"
else:
query += " AND verification_method = :vm"
params["vm"] = verification_method
if category: if category:
query += " AND category = :cat" if category == "__none__":
params["cat"] = category query += " AND category IS NULL"
else:
query += " AND category = :cat"
params["cat"] = category
if evidence_type: if evidence_type:
query += " AND evidence_type = :et" if evidence_type == "__none__":
params["et"] = evidence_type query += " AND evidence_type IS NULL"
else:
query += " AND evidence_type = :et"
params["et"] = evidence_type
if target_audience: if target_audience:
query += " AND target_audience LIKE :ta_pattern" query += " AND target_audience LIKE :ta_pattern"
params["ta_pattern"] = f'%"{target_audience}"%' params["ta_pattern"] = f'%"{target_audience}"%'
@@ -434,14 +443,23 @@ async def count_controls(
query += " AND release_state = :rs" query += " AND release_state = :rs"
params["rs"] = release_state params["rs"] = release_state
if verification_method: if verification_method:
query += " AND verification_method = :vm" if verification_method == "__none__":
params["vm"] = verification_method query += " AND verification_method IS NULL"
else:
query += " AND verification_method = :vm"
params["vm"] = verification_method
if category: if category:
query += " AND category = :cat" if category == "__none__":
params["cat"] = category query += " AND category IS NULL"
else:
query += " AND category = :cat"
params["cat"] = category
if evidence_type: if evidence_type:
query += " AND evidence_type = :et" if evidence_type == "__none__":
params["et"] = evidence_type query += " AND evidence_type IS NULL"
else:
query += " AND evidence_type = :et"
params["et"] = evidence_type
if target_audience: if target_audience:
query += " AND target_audience LIKE :ta_pattern" query += " AND target_audience LIKE :ta_pattern"
params["ta_pattern"] = f'%"{target_audience}"%' params["ta_pattern"] = f'%"{target_audience}"%'
@@ -506,14 +524,23 @@ async def controls_meta(
clauses.append("release_state = :rs") clauses.append("release_state = :rs")
p["rs"] = release_state p["rs"] = release_state
if verification_method and skip != "verification_method": if verification_method and skip != "verification_method":
clauses.append("verification_method = :vm") if verification_method == "__none__":
p["vm"] = verification_method clauses.append("verification_method IS NULL")
else:
clauses.append("verification_method = :vm")
p["vm"] = verification_method
if category and skip != "category": if category and skip != "category":
clauses.append("category = :cat") if category == "__none__":
p["cat"] = category clauses.append("category IS NULL")
else:
clauses.append("category = :cat")
p["cat"] = category
if evidence_type and skip != "evidence_type": if evidence_type and skip != "evidence_type":
clauses.append("evidence_type = :et") if evidence_type == "__none__":
p["et"] = evidence_type clauses.append("evidence_type IS NULL")
else:
clauses.append("evidence_type = :et")
p["et"] = evidence_type
if target_audience and skip != "target_audience": if target_audience and skip != "target_audience":
clauses.append("target_audience LIKE :ta_pattern") clauses.append("target_audience LIKE :ta_pattern")
p["ta_pattern"] = f'%"{target_audience}"%' p["ta_pattern"] = f'%"{target_audience}"%'
@@ -598,28 +625,28 @@ async def controls_meta(
GROUP BY severity ORDER BY severity GROUP BY severity ORDER BY severity
"""), p_sev).fetchall() """), p_sev).fetchall()
# Verification method facet # Verification method facet (include NULLs as __none__)
w_vm, p_vm = _build_where(skip="verification_method") w_vm, p_vm = _build_where(skip="verification_method")
vm_counts = db.execute(text(f""" vm_counts = db.execute(text(f"""
SELECT verification_method, count(*) as cnt SELECT COALESCE(verification_method, '__none__') as vm, count(*) as cnt
FROM canonical_controls WHERE {w_vm} AND verification_method IS NOT NULL FROM canonical_controls WHERE {w_vm}
GROUP BY verification_method ORDER BY verification_method GROUP BY vm ORDER BY vm
"""), p_vm).fetchall() """), p_vm).fetchall()
# Category facet # Category facet (include NULLs as __none__)
w_cat, p_cat = _build_where(skip="category") w_cat, p_cat = _build_where(skip="category")
cat_counts = db.execute(text(f""" cat_counts = db.execute(text(f"""
SELECT category, count(*) as cnt SELECT COALESCE(category, '__none__') as cat, count(*) as cnt
FROM canonical_controls WHERE {w_cat} AND category IS NOT NULL FROM canonical_controls WHERE {w_cat}
GROUP BY category ORDER BY cnt DESC GROUP BY cat ORDER BY cnt DESC
"""), p_cat).fetchall() """), p_cat).fetchall()
# Evidence type facet # Evidence type facet (include NULLs as __none__)
w_et, p_et = _build_where(skip="evidence_type") w_et, p_et = _build_where(skip="evidence_type")
et_counts = db.execute(text(f""" et_counts = db.execute(text(f"""
SELECT evidence_type, count(*) as cnt SELECT COALESCE(evidence_type, '__none__') as et, count(*) as cnt
FROM canonical_controls WHERE {w_et} AND evidence_type IS NOT NULL FROM canonical_controls WHERE {w_et}
GROUP BY evidence_type ORDER BY evidence_type GROUP BY et ORDER BY et
"""), p_et).fetchall() """), p_et).fetchall()
# Release state facet # Release state facet