feat(admin): Tab "Browser-Verhalten" — Per-Browser-Matrix + Screenshots (Phase 3)
- BrowserBehaviorView: laedt gespeicherte Matrix (GET), sonst "Browser-Test starten" (POST run, Live-Lauf). Per-Browser-Tabelle (Cookies vor Consent / nach Ablehnen / Ablehnen respektiert / Oberflaeche / Score), Engine-Detail mit Banner-Screenshot + Oberflaechen-Befunden, Mobil-Badge, "nicht verfuegbar"-Zeilen fuer fehlende Browser (arm64-Dev). - Proxys browser-behavior (GET) + browser-behavior/run (POST, langer Timeout). - page.tsx: Tab "Browser-Verhalten" (sichtbar sobald scanbare URL im Snapshot). - consent-tester scan_matrix_summary: banner_findings je Engine im summary (Text/Severity/Norm) → Oberflaechen-Befunde im Tab. - tsc strict clean; Vitest BrowserBehaviorView (2). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Browser-Verhaltens-Matrix — gespeichertes Ergebnis (kein Re-Crawl)
|
||||
* GET /api/sdk/v1/agent/snapshots/{snapshotId}/browser-behavior
|
||||
* → backend /api/compliance/agent/snapshots/{snapshotId}/browser-behavior
|
||||
*
|
||||
* `browser_matrix` ist null, solange der On-demand-Lauf nie ausgelöst wurde.
|
||||
*/
|
||||
|
||||
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}/browser-behavior`,
|
||||
{ signal: AbortSignal.timeout(30_000) },
|
||||
)
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ browser_matrix: null, error: 'Browser-Matrix laden fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Browser-Verhaltens-Matrix — On-demand LIVE-Lauf (Re-Crawl je Engine)
|
||||
* POST /api/sdk/v1/agent/snapshots/{snapshotId}/browser-behavior/run
|
||||
* → backend /api/compliance/agent/snapshots/{snapshotId}/browser-behavior/run
|
||||
*
|
||||
* Teuer (mehrere Browser × 3 Phasen) → langer Timeout. Persistenz passiert
|
||||
* im Backend; die Antwort ist die frische Matrix.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL =
|
||||
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
|
||||
'http://backend-compliance:8002'
|
||||
|
||||
// Vercel-only Hinweis; self-hosted ignoriert es — schadet nicht.
|
||||
export const maxDuration = 400
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ snapshotId: string }> },
|
||||
) {
|
||||
const { snapshotId } = await params
|
||||
let body: unknown = {}
|
||||
try { body = await request.json() } catch { body = {} }
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/browser-behavior/run`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
signal: AbortSignal.timeout(380_000),
|
||||
},
|
||||
)
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Browser-Test fehlgeschlagen: ${String(e)}` },
|
||||
{ status: 504 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* BrowserBehaviorView — On-demand-Browser-Verhaltens-Matrix für einen Snapshot.
|
||||
* Lädt das gespeicherte Ergebnis (GET, kein Re-Crawl); ohne Ergebnis ein
|
||||
* „Browser-Test starten"-Button (POST run → Live-Lauf je Engine). Zeigt je
|
||||
* Browser: Cookies vor Consent / nach Ablehnen / Ablehnen respektiert + Score,
|
||||
* darunter Engine-Detail mit Banner-Screenshot + Oberflächen-Befunden.
|
||||
* Aggregierte Maßnahmen + Cross-Finding folgen separat (Phase 4).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
type Finding = { text: string; severity: string; legal_ref?: string; service?: string }
|
||||
type Surface = { has_impressum_link?: boolean; has_dse_link?: boolean; banner_text_issues?: number }
|
||||
type Summary = {
|
||||
cookies_before_consent?: number; cookies_after_reject?: number
|
||||
reject_respected?: boolean; banner_detected?: boolean; banner_provider?: string
|
||||
banner_screenshot_b64?: string; surface?: Surface; banner_findings?: Finding[]
|
||||
}
|
||||
type Row = {
|
||||
profile_id: string; label: string; engine?: string; is_mobile?: boolean
|
||||
score?: number; verbal?: string; summary?: Summary | null; error?: string
|
||||
}
|
||||
type Matrix = { browser_matrix?: Row[]; aggregate?: Record<string, unknown>; url?: string; scanned_at?: string }
|
||||
|
||||
const sevCls = (s: string) => {
|
||||
const u = (s || '').toUpperCase()
|
||||
if (u === 'CRITICAL' || u === 'HIGH') return 'bg-red-100 text-red-700'
|
||||
if (u === 'MEDIUM') return 'bg-amber-100 text-amber-700'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
const scoreCls = (n?: number) =>
|
||||
n == null ? 'text-gray-400' : n >= 80 ? 'text-green-700' : n >= 60 ? 'text-amber-700' : 'text-red-700'
|
||||
|
||||
export function BrowserBehaviorView({ snapshotId }: { snapshotId: string }) {
|
||||
const [matrix, setMatrix] = useState<Matrix | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sel, setSel] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/browser-behavior`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!cancelled) setMatrix(d?.browser_matrix || null) })
|
||||
.catch(() => { if (!cancelled) setMatrix(null) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [snapshotId])
|
||||
|
||||
const rows = matrix?.browser_matrix || []
|
||||
|
||||
useEffect(() => {
|
||||
if (!sel && rows.length) {
|
||||
const withData = rows.filter(r => r.summary)
|
||||
const worst = [...(withData.length ? withData : rows)]
|
||||
.sort((a, b) => (a.score ?? 999) - (b.score ?? 999))[0]
|
||||
if (worst) setSel(worst.profile_id)
|
||||
}
|
||||
}, [rows, sel])
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true); setError(null)
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/sdk/v1/agent/snapshots/${snapshotId}/browser-behavior/run`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||
const d = await r.json()
|
||||
if (!r.ok || d?.error) setError(d?.error || `Fehler ${r.status}`)
|
||||
else { setMatrix(d); setSel('') }
|
||||
} catch (e) { setError(String(e)) } finally { setRunning(false) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-500">Lade Browser-Verhalten…</div>
|
||||
|
||||
if (!matrix || !rows.length) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-xl p-5 space-y-3 bg-gray-50">
|
||||
<h3 className="font-semibold text-gray-900">Browser-Verhalten testen</h3>
|
||||
<p className="text-sm text-gray-600 max-w-2xl">
|
||||
Prüft das Cookie-Banner live in mehreren Browser-Engines (Chromium,
|
||||
Firefox/Gecko, Safari/WebKit) sowie – sofern verfügbar – in echtem
|
||||
Chrome, Edge, Brave und mobil. Gemessen wird je Browser: werden
|
||||
Cookies <strong>vor</strong> der Einwilligung gesetzt, und werden sie
|
||||
nach <strong>„Ablehnen"</strong> wirklich entfernt? Dazu eine
|
||||
Oberflächenanalyse (Impressum-/DSE-Links, Banner-Auffälligkeiten) mit
|
||||
Screenshot je Engine.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Der Test crawlt die Seite live und dauert je nach Browser-Anzahl
|
||||
einige Minuten.
|
||||
</p>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
<button onClick={run} disabled={running}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{running ? 'Test läuft… (bitte warten)' : 'Browser-Test starten'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selRow = rows.find(r => r.profile_id === sel) || rows[0]
|
||||
const agg: Record<string, unknown> = matrix.aggregate || {}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="text-xs text-gray-500">
|
||||
{matrix.scanned_at ? `Test vom ${String(matrix.scanned_at).slice(0, 16).replace('T', ' ')}` : ''}
|
||||
{agg.profiles_run ? ` · ${String(agg.profiles_run)} Browser` : ''}
|
||||
{' · '}<span className="text-gray-400">Live-Messung, kann von der Snapshot-Zeit abweichen</span>
|
||||
</div>
|
||||
<button onClick={run} disabled={running}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-blue-200 text-blue-700 hover:bg-blue-50 disabled:opacity-50">
|
||||
{running ? 'läuft…' : 'Erneut testen'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-xs">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Browser</th>
|
||||
<th className="px-3 py-2">Cookies vor Consent</th>
|
||||
<th className="px-3 py-2">Cookies nach Ablehnen</th>
|
||||
<th className="px-3 py-2">Ablehnen respektiert</th>
|
||||
<th className="px-3 py-2">Oberfläche</th>
|
||||
<th className="px-3 py-2">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(r => {
|
||||
const s = r.summary
|
||||
const before = s?.cookies_before_consent ?? null
|
||||
const after = s?.cookies_after_reject ?? null
|
||||
const sld = r.profile_id === sel
|
||||
return (
|
||||
<tr key={r.profile_id} onClick={() => setSel(r.profile_id)}
|
||||
className={`border-t border-gray-100 cursor-pointer ${sld ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
|
||||
<td className="px-3 py-2 text-left">
|
||||
{r.label}
|
||||
{r.is_mobile && <span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700">Mobil</span>}
|
||||
</td>
|
||||
{r.error || !s ? (
|
||||
<td colSpan={4} className="px-3 py-2 text-center text-gray-400 text-xs">
|
||||
nicht verfügbar{r.error ? ` (${r.error.slice(0, 40)})` : ''}
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td className={`px-3 py-2 text-center ${(before ?? 0) > 0 ? 'text-red-700 font-semibold' : 'text-green-700'}`}>{before}</td>
|
||||
<td className={`px-3 py-2 text-center ${(after ?? 0) > 0 ? 'text-amber-700' : 'text-green-700'}`}>{after}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{s.reject_respected ? <span className="text-green-700">✓</span> : <span className="text-red-700 font-semibold">✗</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-xs">
|
||||
{!s.surface?.has_impressum_link && <span className="text-amber-700">Impressum fehlt </span>}
|
||||
{!s.surface?.has_dse_link && <span className="text-amber-700">DSE fehlt </span>}
|
||||
{(s.surface?.banner_text_issues ?? 0) > 0
|
||||
? <span className="text-gray-600">{s.surface?.banner_text_issues} Hinweis(e)</span>
|
||||
: (s.surface?.has_impressum_link && s.surface?.has_dse_link ? <span className="text-green-700">ok</span> : null)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className={`px-3 py-2 text-center font-semibold ${scoreCls(r.score)}`}>{r.score ?? '–'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selRow && (
|
||||
<div className="border border-gray-200 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900">{selRow.label}</h3>
|
||||
{selRow.verbal && <span className="text-xs text-gray-500">· {selRow.verbal}</span>}
|
||||
</div>
|
||||
{selRow.summary?.banner_screenshot_b64 ? (
|
||||
<img alt={`Banner ${selRow.label}`}
|
||||
src={`data:image/png;base64,${selRow.summary.banner_screenshot_b64}`}
|
||||
className="max-h-80 rounded-lg border border-gray-200" />
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">Kein Banner-Screenshot erfasst.</div>
|
||||
)}
|
||||
{(selRow.summary?.banner_findings?.length ?? 0) > 0 ? (
|
||||
<ul className="space-y-1.5">
|
||||
{selRow.summary!.banner_findings!.map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded uppercase ${sevCls(f.severity)}`}>{f.severity || 'INFO'}</span>
|
||||
<span className="text-gray-700">
|
||||
{f.text}{f.legal_ref && <span className="text-gray-400"> · {f.legal_ref}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : selRow.summary ? (
|
||||
<div className="text-sm text-green-700">Keine Oberflächen-Auffälligkeiten in dieser Engine.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import { BrowserBehaviorView } from '../BrowserBehaviorView'
|
||||
|
||||
function mockFetch(getBody: unknown) {
|
||||
return vi.fn(async () => ({
|
||||
ok: true, status: 200, json: async () => getBody,
|
||||
})) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('BrowserBehaviorView', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('zeigt den Start-Button, wenn noch keine Matrix existiert', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch({ browser_matrix: null }))
|
||||
render(<BrowserBehaviorView snapshotId="abc" />)
|
||||
expect(await screen.findByText('Browser-Test starten')).toBeInTheDocument()
|
||||
expect(screen.getByText('Browser-Verhalten testen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rendert die Per-Browser-Tabelle + Befund der schlechtesten Engine', async () => {
|
||||
const matrix = {
|
||||
browser_matrix: {
|
||||
browser_matrix: [
|
||||
{
|
||||
profile_id: 'chromium-headed-de', label: 'Chromium', engine: 'blink', score: 92,
|
||||
summary: {
|
||||
cookies_before_consent: 0, cookies_after_reject: 0, reject_respected: true,
|
||||
surface: { has_impressum_link: true, has_dse_link: true, banner_text_issues: 0 },
|
||||
banner_findings: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
profile_id: 'firefox-headed-de', label: 'Firefox', engine: 'gecko', score: 40,
|
||||
summary: {
|
||||
cookies_before_consent: 3, cookies_after_reject: 2, reject_respected: false,
|
||||
surface: { has_impressum_link: false, has_dse_link: true, banner_text_issues: 2 },
|
||||
banner_findings: [{ text: 'Ablehnen weniger prominent', severity: 'HIGH', legal_ref: '§ 25 TDDDG' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
aggregate: { profiles_run: 2, worst_score: 40, best_score: 92 },
|
||||
scanned_at: '2026-06-12T01:00:00Z',
|
||||
},
|
||||
}
|
||||
vi.stubGlobal('fetch', mockFetch(matrix))
|
||||
render(<BrowserBehaviorView snapshotId="abc" />)
|
||||
expect(await screen.findByText('Chromium')).toBeInTheDocument()
|
||||
// Firefox steht in der Tabellenzeile UND als Kopf des Engine-Details
|
||||
// (schlechteste Engine ist vorausgewählt) → mehrfach erwartet.
|
||||
expect(screen.getAllByText('Firefox').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Erneut testen')).toBeInTheDocument()
|
||||
// Schlechteste Engine (Firefox, Score 40) ist vorausgewählt → Befund sichtbar.
|
||||
expect(await screen.findByText(/Ablehnen weniger prominent/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel'
|
||||
import { CookieDeclarationDiff } from '../../_components/CookieDeclarationDiff'
|
||||
import { CookieResultView } from '../../_components/CookieResultView'
|
||||
import { AgentModuleTab } from '../../_components/AgentModuleTab'
|
||||
import { BrowserBehaviorView } from '../../_components/BrowserBehaviorView'
|
||||
|
||||
export default function SnapshotDetail(
|
||||
{ params }: { params: Promise<{ snapshotId: string }> },
|
||||
@@ -53,12 +54,16 @@ export default function SnapshotDetail(
|
||||
const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0
|
||||
const hasDoc = (dt: string) => docs.some(
|
||||
(e: any) => e.doc_type === dt && (e.text || e.content || '').length > 100)
|
||||
// Browser-Verhalten braucht nur eine scanbare URL (on-demand-Live-Lauf).
|
||||
const hasSite = docs.some((e: any) => (e.url || '').trim())
|
||||
|| (!!snap?.site_domain && snap.site_domain !== 'unknown')
|
||||
|
||||
const modules = useMemo(() => [
|
||||
...(hasCookies ? [{ key: 'cookie', label: 'Cookies & Tracking' }] : []),
|
||||
...(hasDoc('impressum') ? [{ key: 'impressum', label: 'Impressum' }] : []),
|
||||
...(hasDoc('dse') ? [{ key: 'dse', label: 'Datenschutzerklärung' }] : []),
|
||||
...(hasDoc('agb') ? [{ key: 'agb', label: 'AGB' }] : []),
|
||||
...(hasSite ? [{ key: 'browser', label: 'Browser-Verhalten' }] : []),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
], [snap])
|
||||
|
||||
@@ -133,6 +138,10 @@ export default function SnapshotDetail(
|
||||
{tab === 'agb' && (
|
||||
<AgentModuleTab snapshotId={snapshotId} docType="agb" label="AGB" />
|
||||
)}
|
||||
|
||||
{tab === 'browser' && (
|
||||
<BrowserBehaviorView snapshotId={snapshotId} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,16 @@ def matrix_scan_dict(result: Any) -> dict:
|
||||
getattr(result, "banner_has_dse_link", False)),
|
||||
"banner_text_issues": len(banner_text_violations),
|
||||
},
|
||||
# Oberflächen-Befunde je Engine (die 20 Banner-Checks: Button-Prominenz,
|
||||
# Toggle-Vorauswahl, Einleitungstext/Links …) — Text + Severity +
|
||||
# Norm-Bezug. Aggregierte Maßnahmen folgen im Cross-Finding.
|
||||
"banner_findings": [
|
||||
{"text": d.get("text", ""),
|
||||
"severity": d.get("severity", "MEDIUM"),
|
||||
"legal_ref": d.get("legal_ref", ""),
|
||||
"service": d.get("service", "")}
|
||||
for d in (_vdict(v) for v in banner_text_violations)
|
||||
][:20],
|
||||
"violations": {
|
||||
"before_consent": len(before_violations),
|
||||
"after_reject": len(reject_violations),
|
||||
|
||||
Reference in New Issue
Block a user