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
|
||||
|
||||
Reference in New Issue
Block a user