feat(agent): Cookie-Result-View + Check-Historie aus Snapshots

Snapshot-getriebene Result-Views, entkoppelt vom Live-Check:
- CookieResultView: laedt cmp_vendors aus einem Snapshot (kein Re-Crawl),
  KPIs (Anbieter/Cookies/Marketing/Drittland) + Empfaenger-Gruppen
  (Eigene/AVV/Joint-Controller) + aufklappbare Vendor->Cookie-Tabelle.
- Historie (/sdk/agent/snapshots): alle gespeicherten Checks, jederzeit
  oeffnbar (DSB/Mitarbeiter) + Detail-Seite je Snapshot.
- Next.js-Proxys fuer GET /snapshots (Liste) + /snapshots/{id} (einzeln).

BMW-Snapshot 4603d15b: 83 Vendors / 780 Cookies. Library-Abgleich
(cookie_knowledge_db.lookup_cookie) folgt als Phase B.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-11 00:51:25 +02:00
parent a28db8f8f0
commit 3332eb0bf9
6 changed files with 418 additions and 0 deletions
@@ -0,0 +1,34 @@
/**
* Snapshot-Proxy
* GET /api/sdk/v1/agent/snapshots/{snapshotId}
* → backend /api/compliance/agent/snapshots/{snapshotId}
*
* Liefert die persistierten Roh-Daten eines Checks (cmp_vendors + Cookies +
* banner_result) — Basis für den Cookie-Result-View OHNE Re-Crawl.
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = await params
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}`,
{ signal: AbortSignal.timeout(60_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'Snapshot-Laden zum Backend fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,33 @@
/**
* Snapshot-Liste (Historie)
* GET /api/sdk/v1/agent/snapshots?domain=&limit=
* → backend /api/compliance/agent/snapshots
*
* Ohne domain: alle letzten Snapshots (Historie zum Durchklicken).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain') || ''
const limit = searchParams.get('limit') || '50'
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots`
+ `?domain=${encodeURIComponent(domain)}&limit=${encodeURIComponent(limit)}`,
{ signal: AbortSignal.timeout(30_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'Snapshot-Liste zum Backend fehlgeschlagen', snapshots: [] },
{ status: 503 },
)
}
}
@@ -0,0 +1,177 @@
'use client'
/**
* CookieResultView — strukturierte Cookie-/Vendor-Auswertung aus einem
* gespeicherten Snapshot (cmp_vendors), OHNE Re-Crawl.
*
* KPIs + Empfänger-Gruppen (Eigene / Auftragsverarbeiter / Joint Controller —
* wie im Audit-Mail-VVT) + aufklappbare Vendor→Cookie-Tabelle. Verarbeitet
* Mengen (780 Cookies bei BMW): Vendors gruppiert, Cookies on-demand.
*/
import React, { useMemo, useState } from 'react'
export interface SnapshotCookie {
name: string
expiry?: string
purpose?: string
is_third_party?: boolean
functional_role?: string
}
export interface SnapshotVendor {
name: string
cookies?: SnapshotCookie[]
category?: string
country?: string
recipient_type?: string
compliance_score?: number
compliance_flags?: string[]
opt_out_ok?: boolean
}
interface Snapshot {
id: string
site_domain?: string
created_at?: string
cmp_vendors?: SnapshotVendor[]
}
const ROLE_LABEL: Record<string, string> = {
unknown: 'Unbekannt', ad_pixel: 'Werbe-Pixel', auth_token: 'Auth-Token',
preference: 'Präferenz', visitor_id: 'Besucher-ID', consent_state: 'Consent',
tracking: 'Tracking',
}
const CAT_COLOR: Record<string, string> = {
necessary: 'bg-green-100 text-green-700', functional: 'bg-blue-100 text-blue-700',
statistics: 'bg-amber-100 text-amber-700', marketing: 'bg-red-100 text-red-700',
}
const EEA = new Set([
'DE','FR','IE','NL','AT','BE','BG','HR','CY','CZ','DK','EE','FI','GR','HU',
'IT','LV','LT','LU','MT','PL','PT','RO','SK','SI','ES','SE','IS','LI','NO',
])
const GROUPS = [
{ key: 'own', label: 'Eigene Verarbeitungen (VVT, Art. 30)', test: (r: string) => !r || r === 'INTERNAL' || r === 'GROUP' || r === 'CONTROLLER' },
{ key: 'proc', label: 'Auftragsverarbeiter (AVV, Art. 28)', test: (r: string) => r === 'PROCESSOR' },
{ key: 'joint', label: 'Joint Controller (Art. 26)', test: (r: string) => r === 'JOINT_CONTROLLER' },
{ key: 'other', label: 'Sonstige Empfänger', test: () => true },
]
function scoreColor(s?: number): string {
if (s == null) return 'text-gray-400'
return s >= 80 ? 'text-green-700' : s >= 50 ? 'text-amber-700' : 'text-red-700'
}
function Tile({ label, value, tone }: { label: string; value: React.ReactNode; tone: string }) {
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className={`text-2xl font-semibold leading-none ${tone}`}>{value}</div>
<div className="text-xs text-gray-500 mt-1.5">{label}</div>
</div>
)
}
function VendorRow({ v }: { v: SnapshotVendor }) {
const [open, setOpen] = useState(false)
const cookies = v.cookies || []
const cat = (v.category || '').toLowerCase()
const drittland = !!v.country && !EEA.has((v.country || '').toUpperCase())
return (
<div>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 text-xs"
>
<span className={`text-gray-400 transition-transform ${open ? 'rotate-90' : ''}`}></span>
<span className="font-medium text-gray-800 flex-1 min-w-0 truncate">{v.name}</span>
{cat && (
<span className={`px-1.5 py-0.5 rounded text-[10px] ${CAT_COLOR[cat] || 'bg-gray-100 text-gray-600'}`}>
{v.category}
</span>
)}
{drittland && (
<span className="px-1.5 py-0.5 rounded text-[10px] bg-red-50 text-red-600" title="außerhalb EWR">
{v.country}
</span>
)}
<span className="text-gray-500 w-16 text-right">{cookies.length} Cookies</span>
<span className={`w-10 text-right font-semibold ${scoreColor(v.compliance_score)}`}>
{v.compliance_score != null ? `${v.compliance_score}%` : '—'}
</span>
</button>
{open && cookies.length > 0 && (
<div className="ml-6 mb-1 border-l-2 border-gray-200">
<table className="w-full text-[11px]">
<thead className="text-gray-400">
<tr>
<th className="px-2 py-1 text-left font-normal">Cookie</th>
<th className="px-2 py-1 text-left font-normal">Rolle</th>
<th className="px-2 py-1 text-left font-normal">Zweck</th>
<th className="px-2 py-1 text-left font-normal">Laufzeit</th>
</tr>
</thead>
<tbody>
{cookies.map((c, i) => (
<tr key={i} className="border-t border-gray-100">
<td className="px-2 py-1 font-mono text-gray-700 break-all">{c.name}</td>
<td className="px-2 py-1 text-gray-500">{ROLE_LABEL[c.functional_role || 'unknown'] || c.functional_role}</td>
<td className="px-2 py-1 text-gray-500">{c.purpose ? c.purpose.slice(0, 60) : <span className="text-amber-600 italic">kein Zweck</span>}</td>
<td className="px-2 py-1 text-gray-400">{c.expiry || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
export function CookieResultView({ snapshot }: { snapshot: Snapshot }) {
const vendors = snapshot.cmp_vendors || []
const stats = useMemo(() => {
const cookies = vendors.reduce((n, v) => n + (v.cookies?.length || 0), 0)
const marketing = vendors.filter(v => (v.category || '').toLowerCase() === 'marketing').length
const drittland = vendors.filter(v => v.country && !EEA.has(v.country.toUpperCase())).length
return { cookies, marketing, drittland }
}, [vendors])
const grouped = useMemo(() => GROUPS.map(g => ({
...g,
vendors: vendors
.filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key)
.sort((a, b) => (a.compliance_score ?? 100) - (b.compliance_score ?? 100)),
})).filter(g => g.vendors.length > 0), [vendors])
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Cookie-Auswertung {snapshot.site_domain || 'Snapshot'}
</h2>
<p className="text-xs text-gray-500 mt-0.5">
aus gespeichertem Snapshot (kein Re-Crawl) ·{' '}
{snapshot.created_at ? snapshot.created_at.slice(0, 19).replace('T', ' ') : ''}
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Tile label="Anbieter" value={vendors.length} tone="text-gray-800" />
<Tile label="Cookies gesamt" value={stats.cookies} tone="text-gray-800" />
<Tile label="Marketing-Anbieter" value={stats.marketing} tone={stats.marketing > 0 ? 'text-red-700' : 'text-gray-800'} />
<Tile label="Drittland (außerhalb EWR)" value={stats.drittland} tone={stats.drittland > 0 ? 'text-amber-700' : 'text-gray-800'} />
</div>
{grouped.map(g => (
<div key={g.key} className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b text-xs font-semibold text-gray-700">
{g.label} <span className="text-gray-400 font-normal">({g.vendors.length})</span>
</div>
<div className="divide-y divide-gray-100">
{g.vendors.map((v, i) => <VendorRow key={i} v={v} />)}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CookieResultView } from '../CookieResultView'
const SNAP = {
id: 'abc',
site_domain: 'bmw.de',
created_at: '2026-06-10T22:16:11',
cmp_vendors: [
{
name: 'Salesforce', category: 'necessary', country: 'US',
recipient_type: 'PROCESSOR', compliance_score: 91,
cookies: [
{ name: 'LSKey-c$Policy', functional_role: 'consent_state', purpose: '', expiry: '1 Jahr' },
{ name: 'sid', functional_role: 'auth_token', purpose: 'Login', expiry: 'Session' },
],
},
{
name: 'BMW AG — eShop', category: 'necessary', country: '',
recipient_type: 'INTERNAL', compliance_score: 100,
cookies: [{ name: 'x', functional_role: 'preference', purpose: 'Sprache' }],
},
],
}
describe('CookieResultView', () => {
it('zeigt KPIs + Empfänger-Gruppen aus dem Snapshot', () => {
render(<CookieResultView snapshot={SNAP} />)
expect(screen.getByText(/Cookie-Auswertung/)).toBeInTheDocument()
// 2 Anbieter, 3 Cookies gesamt
expect(screen.getByText('Anbieter')).toBeInTheDocument()
expect(screen.getByText('Cookies gesamt')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
// Gruppen: Eigene + Auftragsverarbeiter
expect(screen.getByText(/Eigene Verarbeitungen/)).toBeInTheDocument()
expect(screen.getByText(/Auftragsverarbeiter/)).toBeInTheDocument()
expect(screen.getByText('Salesforce')).toBeInTheDocument()
})
it('klappt einen Vendor auf und zeigt die Cookies', () => {
render(<CookieResultView snapshot={SNAP} />)
fireEvent.click(screen.getByText('Salesforce'))
expect(screen.getByText('LSKey-c$Policy')).toBeInTheDocument()
expect(screen.getByText(/kein Zweck/)).toBeInTheDocument() // leerer purpose
})
})
@@ -0,0 +1,56 @@
'use client'
/**
* Snapshot-Detail — öffnet einen gespeicherten Check aus der Historie und
* zeigt die Ergebnis-Views aus den Rohdaten (kein Re-Crawl). Aktuell:
* Cookie-Auswertung. Impressum/AGB/… folgen als weitere Module hier.
*/
import React, { use as useUnwrap, useEffect, useState } from 'react'
import Link from 'next/link'
import { CookieResultView } from '../../_components/CookieResultView'
export default function SnapshotDetail(
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = useUnwrap(params)
const [snap, setSnap] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}`)
.then(r => r.json())
.then(d => {
if (cancelled) return
if (d?.error) setError(d.error)
else setSnap(d)
})
.catch(e => { if (!cancelled) setError(String(e)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [snapshotId])
const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0
return (
<div className="p-6 max-w-6xl space-y-4">
<Link href="/sdk/agent/snapshots" className="text-xs text-blue-700 hover:underline">
Zurück zur Historie
</Link>
{loading ? (
<div className="text-sm text-gray-500">Lade Snapshot</div>
) : error || !snap ? (
<div className="text-sm text-red-600">Snapshot nicht gefunden.</div>
) : hasCookies ? (
<CookieResultView snapshot={snap} />
) : (
<div className="text-sm text-gray-500">
Dieser Snapshot enthält keine Cookie-/Vendor-Daten.
</div>
)}
</div>
)
}
@@ -0,0 +1,71 @@
'use client'
/**
* Check-Historie — listet gespeicherte Snapshots (alle Sites/Module).
* Ein DSB/Mitarbeiter kann jeden früheren Check öffnen, ohne neuen Check
* zu starten. Daten kommen aus den Snapshot-Rohdaten.
*/
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
interface SnapMeta {
id: string
check_id?: string
site_domain?: string
site_label?: string
created_at?: string
replay_count?: number
}
export default function SnapshotHistory() {
const [snaps, setSnaps] = useState<SnapMeta[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
fetch('/api/sdk/v1/agent/snapshots?limit=50')
.then(r => r.json())
.then(d => { if (!cancelled) setSnaps(d.snapshots || []) })
.catch(() => { if (!cancelled) setSnaps([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
return (
<div className="p-6 max-w-4xl space-y-4">
<div>
<h1 className="text-xl font-semibold text-gray-900">Check-Historie</h1>
<p className="text-xs text-gray-500 mt-1">
Frühere Compliance-Checks aus gespeicherten Snapshots jederzeit
ansehbar, ohne neuen Check zu starten.
</p>
</div>
{loading ? (
<div className="text-sm text-gray-500">Lade Historie</div>
) : snaps.length === 0 ? (
<div className="text-sm text-gray-400">Keine gespeicherten Checks gefunden.</div>
) : (
<div className="border rounded-lg divide-y divide-gray-100">
{snaps.map(s => (
<Link
key={s.id}
href={`/sdk/agent/snapshots/${s.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 text-sm"
>
<span className="font-medium text-gray-800 w-44 truncate">
{s.site_label || s.site_domain || 'unbekannt'}
</span>
<span className="text-gray-500 flex-1 min-w-0 truncate">{s.site_domain}</span>
<span className="text-xs text-gray-400 whitespace-nowrap">
{(s.created_at || '').slice(0, 16).replace('T', ' ')}
</span>
<span className="text-gray-300"></span>
</Link>
))}
</div>
)}
</div>
)
}