feat(ui): Master Controls Browser — 13.5K MCs with member drill-down
- New page /sdk/master-controls with sortable, searchable MC list - Click MC → expandable detail panel with atomic controls - Shows L1 token, L2 subtopic, phase, severity, regulation source - API proxy via pg directly to compliance.master_controls - Sidebar entry added Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Pool } from 'pg'
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://breakpilot:breakpilot123@bp-core-postgres:5432/breakpilot_db',
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/master-controls?action=list|detail|members
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'list'
|
||||
|
||||
if (action === 'list') {
|
||||
return handleList(searchParams)
|
||||
}
|
||||
if (action === 'detail') {
|
||||
return handleDetail(searchParams)
|
||||
}
|
||||
if (action === 'members') {
|
||||
return handleMembers(searchParams)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(params: URLSearchParams) {
|
||||
const search = params.get('search') || ''
|
||||
const minControls = parseInt(params.get('min_controls') || '0')
|
||||
const sortBy = params.get('sort') || 'total_controls'
|
||||
const order = params.get('order') || 'DESC'
|
||||
const limit = Math.min(parseInt(params.get('limit') || '100'), 500)
|
||||
const offset = parseInt(params.get('offset') || '0')
|
||||
|
||||
const validSort = ['total_controls', 'canonical_name', 'created_at'].includes(sortBy) ? sortBy : 'total_controls'
|
||||
const validOrder = order === 'ASC' ? 'ASC' : 'DESC'
|
||||
|
||||
let where = 'WHERE 1=1'
|
||||
const args: unknown[] = []
|
||||
let idx = 1
|
||||
|
||||
if (search) {
|
||||
where += ` AND mc.canonical_name ILIKE $${idx}`
|
||||
args.push(`%${search}%`)
|
||||
idx++
|
||||
}
|
||||
if (minControls > 0) {
|
||||
where += ` AND mc.total_controls >= $${idx}`
|
||||
args.push(minControls)
|
||||
idx++
|
||||
}
|
||||
|
||||
const countRes = await pool.query(
|
||||
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
|
||||
)
|
||||
const total = parseInt(countRes.rows[0].count)
|
||||
|
||||
args.push(limit, offset)
|
||||
const res = await pool.query(
|
||||
`SELECT mc.id, mc.master_control_id, mc.canonical_name,
|
||||
mc.total_controls, mc.phases_covered, mc.phase_control_count
|
||||
FROM compliance.master_controls mc
|
||||
${where}
|
||||
ORDER BY mc.${validSort} ${validOrder}
|
||||
LIMIT $${idx} OFFSET $${idx + 1}`,
|
||||
args
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
master_controls: res.rows,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDetail(params: URLSearchParams) {
|
||||
const id = params.get('id')
|
||||
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
|
||||
|
||||
const res = await pool.query(
|
||||
`SELECT mc.id, mc.master_control_id, mc.canonical_name,
|
||||
mc.total_controls, mc.phases_covered, mc.phase_control_count
|
||||
FROM compliance.master_controls mc
|
||||
WHERE mc.master_control_id = $1 OR mc.id::text = $1`,
|
||||
[id]
|
||||
)
|
||||
|
||||
if (res.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ master_control: res.rows[0] })
|
||||
}
|
||||
|
||||
async function handleMembers(params: URLSearchParams) {
|
||||
const mcId = params.get('mc_id')
|
||||
if (!mcId) return NextResponse.json({ error: 'mc_id required' }, { status: 400 })
|
||||
|
||||
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
|
||||
const offset = parseInt(params.get('offset') || '0')
|
||||
|
||||
const res = await pool.query(
|
||||
`SELECT cc.control_id, cc.title, cc.objective, cc.severity,
|
||||
mcm.phase, mcm.action,
|
||||
COALESCE(pc.source_citation::jsonb->>'source', '') as regulation_source,
|
||||
COALESCE(pc.source_citation::jsonb->>'article', '') as regulation_article
|
||||
FROM compliance.master_control_members mcm
|
||||
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
|
||||
LEFT JOIN compliance.canonical_controls pc ON pc.id = cc.parent_control_uuid
|
||||
WHERE mcm.master_control_uuid = (
|
||||
SELECT id FROM compliance.master_controls WHERE master_control_id = $1 OR id::text = $1 LIMIT 1
|
||||
)
|
||||
ORDER BY mcm.phase, cc.control_id
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[mcId, limit, offset]
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
mc_id: mcId,
|
||||
members: res.rows,
|
||||
count: res.rows.length,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface MC {
|
||||
id: string
|
||||
master_control_id: string
|
||||
canonical_name: string
|
||||
total_controls: number
|
||||
phases_covered: string[]
|
||||
phase_control_count: Record<string, number>
|
||||
}
|
||||
|
||||
interface Member {
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
severity: string
|
||||
phase: string
|
||||
action: string
|
||||
regulation_source: string
|
||||
regulation_article: string
|
||||
}
|
||||
|
||||
const API = '/api/sdk/v1/master-controls'
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
const SEV_COLORS: Record<string, string> = {
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
medium: 'bg-yellow-100 text-yellow-800',
|
||||
low: 'bg-blue-100 text-blue-800',
|
||||
}
|
||||
|
||||
export default function MasterControlsPage() {
|
||||
const [mcs, setMcs] = useState<MC[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortBy, setSortBy] = useState('total_controls')
|
||||
const [sortOrder, setSortOrder] = useState('DESC')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Detail view
|
||||
const [selectedMC, setSelectedMC] = useState<MC | null>(null)
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [membersLoading, setMembersLoading] = useState(false)
|
||||
|
||||
const loadMCs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'list', limit: String(PAGE_SIZE), offset: String(offset),
|
||||
sort: sortBy, order: sortOrder,
|
||||
})
|
||||
if (search) params.set('search', search)
|
||||
const res = await fetch(`${API}?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setMcs(data.master_controls || [])
|
||||
setTotal(data.total || 0)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [offset, search, sortBy, sortOrder])
|
||||
|
||||
useEffect(() => { loadMCs() }, [loadMCs])
|
||||
|
||||
const loadMembers = async (mc: MC) => {
|
||||
setSelectedMC(mc)
|
||||
setMembersLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API}?action=members&mc_id=${mc.master_control_id}&limit=200`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setMembers(data.members || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setMembersLoading(false)
|
||||
}
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortBy === field) {
|
||||
setSortOrder(sortOrder === 'DESC' ? 'ASC' : 'DESC')
|
||||
} else {
|
||||
setSortBy(field)
|
||||
setSortOrder('DESC')
|
||||
}
|
||||
setOffset(0)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
||||
|
||||
// L1 token = part before first underscore that has a sub-part
|
||||
const getL1Token = (name: string) => {
|
||||
const parts = name.split('_')
|
||||
// Find the longest known L1 prefix
|
||||
for (let i = Math.min(parts.length, 3); i >= 1; i--) {
|
||||
const candidate = parts.slice(0, i).join('_')
|
||||
if (['access_control', 'audit_logging', 'key_management', 'risk_management',
|
||||
'network_security', 'network_segmentation', 'multi_factor_auth',
|
||||
'transport_encryption', 'data_subject_rights', 'data_breach_notification',
|
||||
'data_processing_agreement', 'data_processing_register',
|
||||
'third_party_management', 'change_management', 'human_resources_security',
|
||||
'physical_security', 'secure_development', 'api_security',
|
||||
'input_validation', 'container_security', 'logging_configuration',
|
||||
'cookie_consent', 'video_surveillance', 'supply_chain_due_diligence',
|
||||
'critical_infrastructure', 'sustainability_reporting',
|
||||
'financial_reporting', 'consumer_protection', 'compliance_audit',
|
||||
'asset_management', 'disaster_recovery', 'patch_management',
|
||||
'password_policy', 'session_management', 'privileged_access',
|
||||
'certificate_management', 'personal_data', 'sensitive_data',
|
||||
'health_data', 'product_safety', 'medical_device', 'payment_services',
|
||||
'supervisory_authority', 'data_retention', 'data_transfer',
|
||||
'data_classification', 'privacy_by_design',
|
||||
].includes(candidate)) return candidate
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Master Controls</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{total.toLocaleString()} Master Controls mit {mcs.reduce((s, m) => s + m.total_controls, 0).toLocaleString()}+ Atomic Controls
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search + Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach MC-Name (z.B. encryption, incident, access_control)..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setOffset(0) }}
|
||||
className="flex-1 px-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
Seite {currentPage} von {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* MC List */}
|
||||
<div className={`${selectedMC ? 'w-1/2' : 'w-full'} transition-all`}>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-purple-600"
|
||||
onClick={() => handleSort('canonical_name')}>
|
||||
Name {sortBy === 'canonical_name' ? (sortOrder === 'ASC' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-purple-600"
|
||||
onClick={() => handleSort('total_controls')}>
|
||||
Controls {sortBy === 'total_controls' ? (sortOrder === 'ASC' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Phasen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-400">Laden...</td></tr>
|
||||
) : mcs.map(mc => {
|
||||
const l1 = getL1Token(mc.canonical_name)
|
||||
const l2 = mc.canonical_name.slice(l1.length + 1) || ''
|
||||
return (
|
||||
<tr
|
||||
key={mc.id}
|
||||
onClick={() => loadMembers(mc)}
|
||||
className={`cursor-pointer hover:bg-purple-50 transition-colors ${
|
||||
selectedMC?.id === mc.id ? 'bg-purple-100' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
|
||||
{l1}
|
||||
</span>
|
||||
{l2 && (
|
||||
<span className="ml-1.5 text-sm text-gray-700">{l2.replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900">
|
||||
{mc.total_controls}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-500">
|
||||
{(mc.phases_covered || []).length}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1 text-sm border rounded disabled:opacity-50"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{offset + 1}-{Math.min(offset + PAGE_SIZE, total)} von {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
className="px-3 py-1 text-sm border rounded disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member Detail Panel */}
|
||||
{selectedMC && (
|
||||
<div className="w-1/2">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden sticky top-4">
|
||||
<div className="px-4 py-3 bg-purple-50 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-purple-900">{selectedMC.canonical_name}</h2>
|
||||
<p className="text-xs text-purple-600">{selectedMC.total_controls} Controls, {(selectedMC.phases_covered || []).length} Phasen</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedMC(null)} className="text-purple-400 hover:text-purple-600 text-lg">×</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
{membersLoading ? (
|
||||
<div className="p-8 text-center text-gray-400">Laden...</div>
|
||||
) : members.map((m, i) => (
|
||||
<div key={i} className="px-4 py-3 border-b border-gray-50 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.control_id}</span>
|
||||
{m.severity && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV_COLORS[m.severity] || ''}`}>
|
||||
{m.severity}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">{m.phase}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-900">{m.title}</p>
|
||||
{m.regulation_source && (
|
||||
<p className="text-xs text-blue-600 mt-1">{m.regulation_source} {m.regulation_article}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{members.length === 0 && !membersLoading && (
|
||||
<div className="p-8 text-center text-gray-400">Keine Members gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,21 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
<AdditionalModuleItem href="/sdk/email-templates" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="E-Mail-Templates" isActive={pathname === '/sdk/email-templates'} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* Master Controls Browser */}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/master-controls"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
}
|
||||
label="Master Controls"
|
||||
isActive={pathname?.startsWith('/sdk/master-controls') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
{/* Maschinenrecht / CE */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
|
||||
Reference in New Issue
Block a user