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,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>
)
}