feat(agent): /sdk/agent auf Compliance-Check + Snapshot-Historie reduzieren
- Tabs Website-Scan (nie funktioniert), Banner-Check, Agent-Test entfernt; Tab-Leiste weg, da nur noch Compliance-Check übrig. - Unter dem Compliance-Check jetzt die Snapshot-Historie (neuer SnapshotHistoryList): neuester oben + farblich markiert, Klick → Detail- Seite mit den Ergebnissen. Macht /sdk/agent/snapshots erreichbar. - ComplianceCheckTab zeigt nach dem Lauf keine Inline-Ergebnisse mehr, sondern einen Hinweis auf die Historie (onComplete refresht sie). - Tote Komponenten gelöscht (ScanResult/BannerCheckTab/AgentTestTab). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,337 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AgentTestTab — Top-Level für den 5-URL-Test eines Specialist-Agents.
|
|
||||||
* Sections:
|
|
||||||
* 1. Agent-Wähler + 5 URL-Slots + Start-Button
|
|
||||||
* 2. Methodik-Erklärung (was wir tun, warum)
|
|
||||||
* 3. Live-Event-Log
|
|
||||||
* 4. Pro Slot: SlotCard (siehe AgentSlotCard.tsx)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
import type { AgentInfo, RunResult, SlotOutput, StreamEvent } from './_agentTypes'
|
|
||||||
import { AgentSlotCard } from './AgentSlotCard'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'agent-test-state-v1'
|
|
||||||
const MAX_SLOTS = 5
|
|
||||||
|
|
||||||
export function AgentTestTab() {
|
|
||||||
const [agents, setAgents] = useState<AgentInfo[]>([])
|
|
||||||
const [agentId, setAgentId] = useState<string>('')
|
|
||||||
const [urls, setUrls] = useState<string[]>(['', '', '', '', ''])
|
|
||||||
const [running, setRunning] = useState(false)
|
|
||||||
const [runId, setRunId] = useState<string>('')
|
|
||||||
const [events, setEvents] = useState<StreamEvent[]>([])
|
|
||||||
const [result, setResult] = useState<RunResult | null>(null)
|
|
||||||
const [error, setError] = useState<string>('')
|
|
||||||
const eventSrcRef = useRef<EventSource | null>(null)
|
|
||||||
|
|
||||||
// Restore state from localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const s = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (s) {
|
|
||||||
const parsed = JSON.parse(s)
|
|
||||||
if (parsed.agentId) setAgentId(parsed.agentId)
|
|
||||||
if (Array.isArray(parsed.urls)) {
|
|
||||||
const padded = [...parsed.urls.slice(0, MAX_SLOTS),
|
|
||||||
...new Array(MAX_SLOTS).fill('')].slice(0, MAX_SLOTS)
|
|
||||||
setUrls(padded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* noop */ }
|
|
||||||
}, [])
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY,
|
|
||||||
JSON.stringify({ agentId, urls }))
|
|
||||||
} catch { /* quota */ }
|
|
||||||
}, [agentId, urls])
|
|
||||||
|
|
||||||
// Load agents
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/sdk/v1/specialist-agent/agents')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => {
|
|
||||||
const list: AgentInfo[] = d.agents || []
|
|
||||||
setAgents(list)
|
|
||||||
if (list.length && !agentId) setAgentId(list[0].agent_id)
|
|
||||||
})
|
|
||||||
.catch(e => setError(`Agent-Liste fehlgeschlagen: ${e}`))
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const startTest = async () => {
|
|
||||||
setError('')
|
|
||||||
setResult(null)
|
|
||||||
setEvents([])
|
|
||||||
const cleanUrls = urls.map(u => u.trim()).filter(Boolean)
|
|
||||||
if (!agentId) { setError('Kein Agent ausgewählt.'); return }
|
|
||||||
if (cleanUrls.length === 0) { setError('Mind. eine URL angeben.'); return }
|
|
||||||
setRunning(true)
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/sdk/v1/specialist-agent/test/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ agent_id: agentId, urls: cleanUrls }),
|
|
||||||
})
|
|
||||||
if (!r.ok) {
|
|
||||||
const j = await r.json().catch(() => ({}))
|
|
||||||
throw new Error(j.error || `HTTP ${r.status}`)
|
|
||||||
}
|
|
||||||
const data = await r.json()
|
|
||||||
setRunId(data.run_id)
|
|
||||||
openStream(data.run_id)
|
|
||||||
pollResult(data.run_id)
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message || String(e))
|
|
||||||
setRunning(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openStream = (rid: string) => {
|
|
||||||
try { eventSrcRef.current?.close() } catch { /* noop */ }
|
|
||||||
const es = new EventSource(
|
|
||||||
`/api/sdk/v1/specialist-agent/test/stream/${rid}`,
|
|
||||||
)
|
|
||||||
eventSrcRef.current = es
|
|
||||||
es.onmessage = (ev) => {
|
|
||||||
try {
|
|
||||||
const data: StreamEvent = JSON.parse(ev.data)
|
|
||||||
setEvents(prev => [...prev, data])
|
|
||||||
if (data.type === 'stream_close' || data.type === 'run_complete') {
|
|
||||||
try { es.close() } catch { /* noop */ }
|
|
||||||
}
|
|
||||||
} catch { /* noop */ }
|
|
||||||
}
|
|
||||||
es.onerror = () => { try { es.close() } catch { /* noop */ } }
|
|
||||||
}
|
|
||||||
|
|
||||||
const pollResult = async (rid: string) => {
|
|
||||||
for (let i = 0; i < 360; i++) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(
|
|
||||||
`/api/sdk/v1/specialist-agent/run/${rid}/result`,
|
|
||||||
)
|
|
||||||
if (r.ok) {
|
|
||||||
const d: RunResult = await r.json()
|
|
||||||
if (d.finished) {
|
|
||||||
setResult(d); setRunning(false); return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* noop */ }
|
|
||||||
await new Promise(s => setTimeout(s, 2000))
|
|
||||||
}
|
|
||||||
setRunning(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const slotOutputs = useMemo(() => {
|
|
||||||
if (!result) return []
|
|
||||||
const items: { slot: string; output: SlotOutput }[] = []
|
|
||||||
for (const slot of Object.keys(result.results)) {
|
|
||||||
items.push({ slot, output: result.results[slot] })
|
|
||||||
}
|
|
||||||
return items.sort((a, b) => a.slot.localeCompare(b.slot))
|
|
||||||
}, [result])
|
|
||||||
|
|
||||||
const selectedAgent = agents.find(a => a.agent_id === agentId)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<InputCard
|
|
||||||
agents={agents}
|
|
||||||
agentId={agentId}
|
|
||||||
setAgentId={setAgentId}
|
|
||||||
selectedAgent={selectedAgent}
|
|
||||||
urls={urls}
|
|
||||||
setUrls={setUrls}
|
|
||||||
running={running}
|
|
||||||
runId={runId}
|
|
||||||
startTest={startTest}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MethodikInfo />
|
|
||||||
|
|
||||||
{running && events.length > 0 && <EventLog events={events} />}
|
|
||||||
|
|
||||||
{slotOutputs.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{slotOutputs.map(({ slot, output }) => (
|
|
||||||
<AgentSlotCard
|
|
||||||
key={slot} slot={slot} output={output} runId={runId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputCard({
|
|
||||||
agents, agentId, setAgentId, selectedAgent, urls, setUrls,
|
|
||||||
running, runId, startTest, error,
|
|
||||||
}: {
|
|
||||||
agents: AgentInfo[]
|
|
||||||
agentId: string
|
|
||||||
setAgentId: (s: string) => void
|
|
||||||
selectedAgent?: AgentInfo
|
|
||||||
urls: string[]
|
|
||||||
setUrls: (urls: string[]) => void
|
|
||||||
running: boolean
|
|
||||||
runId: string
|
|
||||||
startTest: () => void
|
|
||||||
error: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
|
|
||||||
<h2 className="text-lg font-semibold">Agent-Test (max. {MAX_SLOTS} URLs)</h2>
|
|
||||||
<div className="flex flex-wrap gap-3 items-end">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-600">Agent</label>
|
|
||||||
<select
|
|
||||||
value={agentId}
|
|
||||||
onChange={e => setAgentId(e.target.value)}
|
|
||||||
className="border rounded px-2 py-1 text-sm"
|
|
||||||
>
|
|
||||||
{agents.map(a => (
|
|
||||||
<option key={a.agent_id} value={a.agent_id}>
|
|
||||||
{a.agent_id} v{a.agent_version} ({a.mc_count} MCs)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{selectedAgent && (
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Doc-Type: <code>{selectedAgent.doc_type}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{urls.map((u, i) => (
|
|
||||||
<div key={i} className="flex gap-2">
|
|
||||||
<span className="text-xs font-mono text-gray-500 w-8 pt-1.5">
|
|
||||||
URL{i + 1}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
value={u}
|
|
||||||
onChange={e => {
|
|
||||||
const next = [...urls]; next[i] = e.target.value
|
|
||||||
setUrls(next)
|
|
||||||
}}
|
|
||||||
placeholder="https://example.com/impressum"
|
|
||||||
className="flex-1 border rounded px-2 py-1 text-sm font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={startTest}
|
|
||||||
disabled={running}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white text-sm px-4 py-2 rounded"
|
|
||||||
>
|
|
||||||
{running ? 'Laufend...' : 'Test starten'}
|
|
||||||
</button>
|
|
||||||
{runId && (
|
|
||||||
<span className="text-xs text-gray-500 self-center">
|
|
||||||
Run-ID: <code>{runId}</code>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border-l-4 border-red-400 p-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MethodikInfo() {
|
|
||||||
return (
|
|
||||||
<details className="rounded border bg-slate-50 px-3 py-2 text-xs text-gray-700">
|
|
||||||
<summary className="cursor-pointer font-semibold">
|
|
||||||
Methodik — wie geprüft wird
|
|
||||||
</summary>
|
|
||||||
<ol className="list-decimal ml-5 mt-2 space-y-1">
|
|
||||||
<li>
|
|
||||||
<strong>Pattern-Checks</strong> — deterministische Regex-Tests
|
|
||||||
gegen Pflichtangaben-Schema (z.B. § 5 TMG/DDG). Schnell,
|
|
||||||
reproduzierbar. <em>Hinweis:</em> diese Pattern-IDs (z.B.
|
|
||||||
<code>IMP-MC-001</code>) sind <strong>interne Test-IDs</strong>,
|
|
||||||
nicht die Master-Control-IDs aus der Datenbank. BreakPilot hat
|
|
||||||
313k Atomic-Controls → 13.588 dedup. Master-Controls; davon
|
|
||||||
~1.778 für dieses Compliance-Agent-Tool ausgewählt. Die formale
|
|
||||||
Verknüpfung Pattern-Check → Master-Control folgt in einem
|
|
||||||
späteren Schritt (Sprint 1.12).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Knowledge-Base</strong> — kuratierte Patterns aus
|
|
||||||
anonymisierten Mandanten-FAQs.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Auto-Learning-Pattern-Library</strong> — Labels die
|
|
||||||
der LLM-Validator gefunden hat (z.B. „Telefonnr." statt
|
|
||||||
„Telefon") werden persistiert. Beim nächsten Run sind sie
|
|
||||||
deterministisch erkennbar — der LLM wird seltener gerufen.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Semantic-Validator (LLM)</strong> — nur bei
|
|
||||||
missing-Pflichtangabe: ein Aufruf des Self-Hosted-LLM
|
|
||||||
(<code>qwen3.5:35b-a3b</code> auf macmini) prüft ob die
|
|
||||||
Angabe doch da ist, nur unter abweichendem Label. Bei
|
|
||||||
Treffer wird HIGH→LOW demoted und „Umbenennen zu Standard"
|
|
||||||
empfohlen.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>LLM-Eskalation (Fallback)</strong> — wenn der
|
|
||||||
Validator unsicher bleibt: OVH 120b, dann anonymisierter
|
|
||||||
Claude-Cloud-Call. Aktuell deaktiviert (OVH-Key leer).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Cross-Placement-Agent</strong> — erkennt deplatzierten
|
|
||||||
Content (Copyright, Disclaimer, WEEE im Impressum) +
|
|
||||||
empfiehlt Footer-Reiter „Legal".
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p className="mt-2 italic text-gray-500">
|
|
||||||
Disclaimer: keine Aussagen wie „rechtssicher" oder „konform" —
|
|
||||||
nur Findings + Empfehlungen + Herleitung. Verbotene Begriffe
|
|
||||||
werden vom Linter aus Agent-Outputs entfernt.
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventLog({ events }: { events: StreamEvent[] }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded border bg-gray-50 p-3 max-h-48 overflow-y-auto">
|
|
||||||
<div className="text-xs font-mono space-y-0.5">
|
|
||||||
{events.slice(-30).map((ev, i) => (
|
|
||||||
<div key={i}>
|
|
||||||
<span className="text-gray-400">[{ev.type}]</span>{' '}
|
|
||||||
{ev.slot && <span className="text-blue-600">{ev.slot}</span>}{' '}
|
|
||||||
{ev.severity && (
|
|
||||||
<span className={severityColor(ev.severity)}>{ev.severity}</span>
|
|
||||||
)}{' '}
|
|
||||||
{ev.title || ev.error || ev.label || ev.model || ev.url || ''}
|
|
||||||
{ev.word_count !== undefined && (
|
|
||||||
<span className="text-gray-500">
|
|
||||||
{' '}({ev.word_count} Wörter)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function severityColor(sev: string) {
|
|
||||||
return sev === 'HIGH' ? 'text-red-600 font-semibold' :
|
|
||||||
sev === 'MEDIUM' ? 'text-amber-600 font-semibold' :
|
|
||||||
sev === 'LOW' ? 'text-blue-600' : 'text-gray-600'
|
|
||||||
}
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { ChecklistView } from './ChecklistView'
|
|
||||||
|
|
||||||
interface CheckItem {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
passed: boolean
|
|
||||||
severity: string
|
|
||||||
matched_text: string
|
|
||||||
level?: number
|
|
||||||
parent?: string | null
|
|
||||||
skipped?: boolean
|
|
||||||
hint?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BannerResult {
|
|
||||||
banner_detected: boolean
|
|
||||||
banner_provider: string
|
|
||||||
banner_checks?: {
|
|
||||||
violations: { code: string; text: string; severity: string }[]
|
|
||||||
has_impressum_link?: boolean
|
|
||||||
has_dse_link?: boolean
|
|
||||||
}
|
|
||||||
structured_checks?: CheckItem[]
|
|
||||||
completeness_pct?: number
|
|
||||||
correctness_pct?: number
|
|
||||||
phases?: {
|
|
||||||
before_consent: { cookies: string[]; scripts: string[]; tracking_services: string[]; violations: any[] }
|
|
||||||
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
|
|
||||||
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
|
|
||||||
}
|
|
||||||
email_status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORIES = [
|
|
||||||
{ id: 'all', label: 'Alle Kategorien' },
|
|
||||||
{ id: 'necessary', label: 'Notwendig' },
|
|
||||||
{ id: 'statistics', label: 'Statistik' },
|
|
||||||
{ id: 'marketing', label: 'Marketing' },
|
|
||||||
{ id: 'functional', label: 'Funktional' },
|
|
||||||
{ id: 'preferences', label: 'Praeferenzen' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function BannerCheckTab() {
|
|
||||||
const [url, setUrl] = useState(() =>
|
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('banner-check-url') || '' : ''
|
|
||||||
)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [progress, setProgress] = useState('')
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [result, setResult] = useState<BannerResult | null>(() => {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
try { const s = localStorage.getItem('banner-check-result'); return s ? JSON.parse(s) : null } catch { return null }
|
|
||||||
})
|
|
||||||
const [categories, setCategories] = useState<string[]>(['all'])
|
|
||||||
const [useAgent, setUseAgent] = useState(false)
|
|
||||||
const [mcResults, setMcResults] = useState<any>(null)
|
|
||||||
const [history, setHistory] = useState<{ url: string; date: string; provider: string; violations: number; pct: number; resultKey: string }[]>(() => {
|
|
||||||
if (typeof window === 'undefined') return []
|
|
||||||
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Persist URL
|
|
||||||
React.useEffect(() => { localStorage.setItem('banner-check-url', url) }, [url])
|
|
||||||
|
|
||||||
const toggleCategory = (id: string) => {
|
|
||||||
if (id === 'all') {
|
|
||||||
setCategories(['all'])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCategories(prev => {
|
|
||||||
const without = prev.filter(c => c !== 'all' && c !== id)
|
|
||||||
const next = prev.includes(id) ? without : [...without, id]
|
|
||||||
return next.length === 0 ? ['all'] : next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScan = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!url.trim()) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setResult(null)
|
|
||||||
setProgress('Cookie-Banner wird analysiert...')
|
|
||||||
|
|
||||||
const selectedCategories = categories.includes('all') ? [] : categories
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/sdk/v1/agent/banner-check', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url: url.trim(), categories: selectedCategories }),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
|
||||||
const data = await res.json()
|
|
||||||
setResult(data)
|
|
||||||
localStorage.setItem('banner-check-result', JSON.stringify(data))
|
|
||||||
|
|
||||||
// If agent mode: also run cookie doc-check with 381 MCs
|
|
||||||
if (useAgent) {
|
|
||||||
setProgress('KI-Agent prueft Cookie-Richtlinie (381 MCs)...')
|
|
||||||
try {
|
|
||||||
const mcRes = await fetch('/api/sdk/v1/agent/doc-check', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
entries: [{ doc_type: 'cookie', label: 'Cookie-Richtlinie', url: url.trim() }],
|
|
||||||
recipient: 'dsb@breakpilot.local',
|
|
||||||
use_agent: true,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (mcRes.ok) {
|
|
||||||
const { check_id } = await mcRes.json()
|
|
||||||
if (check_id) {
|
|
||||||
for (let i = 0; i < 60; i++) {
|
|
||||||
await new Promise(r => setTimeout(r, 3000))
|
|
||||||
const poll = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
|
|
||||||
if (!poll.ok) continue
|
|
||||||
const pd = await poll.json()
|
|
||||||
if (pd.progress) setProgress(`KI-Agent: ${pd.progress}`)
|
|
||||||
if (pd.status === 'completed' && pd.result) { setMcResults(pd.result); break }
|
|
||||||
if (pd.status === 'failed') break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* agent check is optional */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to history with persistent result
|
|
||||||
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
|
|
||||||
const resultKey = `banner-check-result-${Date.now()}`
|
|
||||||
try { localStorage.setItem(resultKey, JSON.stringify(data)) } catch { /* quota */ }
|
|
||||||
const entry = {
|
|
||||||
url: url.trim(),
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
provider: data.banner_provider || 'Unbekannt',
|
|
||||||
violations,
|
|
||||||
pct: data.completeness_pct ?? 0,
|
|
||||||
resultKey,
|
|
||||||
}
|
|
||||||
const updated = [entry, ...history].slice(0, 30)
|
|
||||||
setHistory(updated)
|
|
||||||
localStorage.setItem('banner-check-history', JSON.stringify(updated))
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setProgress('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadFromHistory = (entry: { url: string; resultKey?: string }) => {
|
|
||||||
setUrl(entry.url)
|
|
||||||
if (entry.resultKey) {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(entry.resultKey)
|
|
||||||
if (saved) { setResult(JSON.parse(saved)); return }
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
// Fallback: load last result
|
|
||||||
try {
|
|
||||||
const last = localStorage.getItem('banner-check-result')
|
|
||||||
if (last) setResult(JSON.parse(last))
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const structuredChecks = result?.structured_checks || []
|
|
||||||
const hasStructured = structuredChecks.length > 0
|
|
||||||
const compPct = result?.completeness_pct ?? 0
|
|
||||||
const corrPct = result?.correctness_pct ?? 0
|
|
||||||
|
|
||||||
const checklistResults = hasStructured ? [{
|
|
||||||
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
|
||||||
url: url,
|
|
||||||
doc_type: 'banner',
|
|
||||||
word_count: 0,
|
|
||||||
completeness_pct: compPct,
|
|
||||||
correctness_pct: corrPct,
|
|
||||||
checks: structuredChecks,
|
|
||||||
findings_count: structuredChecks.filter(c => !c.passed && !c.skipped).length,
|
|
||||||
error: '',
|
|
||||||
}] : []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<h3 className="text-sm font-semibold text-blue-900">Cookie-Banner Compliance Check</h3>
|
|
||||||
<p className="text-xs text-blue-700 mt-1">
|
|
||||||
Playwright-basierter 3-Phasen-Test: Vor Interaktion, nach Ablehnen, nach Akzeptieren.
|
|
||||||
Prueft Dark Patterns, Pre-Consent-Cookies, Farbkontrast, Klick-Paritaet und 36 weitere Kriterien.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button type="button" onClick={() => setUseAgent(!useAgent)}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
|
||||||
useAgent ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
|
||||||
}`}>
|
|
||||||
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
|
||||||
{useAgent ? 'KI-Agent aktiv (381 Cookie-MCs)' : 'KI-Agent aus'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleScan} className="space-y-3">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<input
|
|
||||||
type="url" value={url} onChange={e => setUrl(e.target.value)}
|
|
||||||
placeholder="https://www.example.com/"
|
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
|
||||||
disabled={loading} required
|
|
||||||
/>
|
|
||||||
<button type="submit" disabled={loading || !url.trim()}
|
|
||||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
|
|
||||||
{loading ? (
|
|
||||||
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>Pruefe...</>
|
|
||||||
) : 'Banner pruefen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CATEGORIES.map(cat => (
|
|
||||||
<label key={cat.id}
|
|
||||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium cursor-pointer border transition-colors ${
|
|
||||||
categories.includes(cat.id)
|
|
||||||
? 'bg-purple-100 border-purple-300 text-purple-800'
|
|
||||||
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input type="checkbox" checked={categories.includes(cat.id)}
|
|
||||||
onChange={() => toggleCategory(cat.id)} className="sr-only" />
|
|
||||||
<span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${
|
|
||||||
categories.includes(cat.id) ? 'bg-purple-600 border-purple-600' : 'border-gray-400'
|
|
||||||
}`}>
|
|
||||||
{categories.includes(cat.id) && (
|
|
||||||
<svg className="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 12 12">
|
|
||||||
<path d="M10 3L4.5 8.5 2 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{cat.label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{progress && (
|
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
|
|
||||||
<svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
{progress}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{result.phases && (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
|
||||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">{result.banner_detected ? '🛡️' : '⚠️'}</span>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
|
||||||
{result.banner_detected
|
|
||||||
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
|
||||||
: 'Kein Cookie-Banner erkannt'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 mt-0.5">3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
|
||||||
<PhaseBox label="Vor Consent" icon="🔒"
|
|
||||||
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
|
||||||
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
|
||||||
violations={result.phases.before_consent.violations?.length ?? 0} />
|
|
||||||
<PhaseBox label="Nach Ablehnen" icon="🚫"
|
|
||||||
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
|
||||||
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
|
||||||
violations={result.phases.after_reject.violations?.length ?? 0} />
|
|
||||||
<PhaseBox label="Nach Akzeptieren" icon="✅"
|
|
||||||
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
|
||||||
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
|
||||||
violations={0} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasStructured && (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
|
||||||
<ChecklistView results={checklistResults} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result.email_status && (
|
|
||||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${result.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
|
||||||
E-Mail: {result.email_status === 'sent' ? 'Gesendet' : result.email_status}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* MC Agent Results (Cookie-Richtlinie) */}
|
|
||||||
{mcResults?.results && (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">KI-Agent: Cookie-Richtlinie (381 MCs)</h4>
|
|
||||||
<ChecklistView results={mcResults.results} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!result.banner_detected && !hasStructured && (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach §25 TDDDG Pflicht.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* History */}
|
|
||||||
{history.length > 0 && (
|
|
||||||
<div className="border border-gray-200 rounded-xl p-4">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Banner-Checks</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{history.map((h, i) => (
|
|
||||||
<button key={i} onClick={() => loadFromHistory(h)}
|
|
||||||
className="w-full flex items-center justify-between p-2.5 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
||||||
{' · '}{h.provider}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
|
||||||
<span className={`text-xs font-medium ${h.violations > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
|
||||||
{h.violations} Findings
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs font-medium ${h.pct === 100 ? 'text-green-700' : h.pct >= 50 ? 'text-yellow-700' : 'text-red-700'}`}>
|
|
||||||
{h.pct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PhaseBox({ label, icon, cookies, scripts, violations }: {
|
|
||||||
label: string; icon: string; cookies: number; scripts: number; violations: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg">{icon}</div>
|
|
||||||
<div className="text-xs font-medium text-gray-700">{label}</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-1">{cookies} Cookies, {scripts} Scripts</div>
|
|
||||||
{violations > 0 && <div className="text-xs text-red-600 font-medium">{violations} Verstoesse</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef } from 'react'
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
import { ComplianceResultTabs } from './ComplianceResultTabs'
|
|
||||||
import { DocumentRow } from './DocumentRow'
|
import { DocumentRow } from './DocumentRow'
|
||||||
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
||||||
import { DOCUMENT_TYPES, type DocTypeId } from './_document_types'
|
import { DOCUMENT_TYPES, type DocTypeId } from './_document_types'
|
||||||
@@ -13,7 +12,7 @@ import {
|
|||||||
import { useCompanyOrigin } from './_useCompanyOrigin'
|
import { useCompanyOrigin } from './_useCompanyOrigin'
|
||||||
|
|
||||||
|
|
||||||
export function ComplianceCheckTab() {
|
export function ComplianceCheckTab({ onComplete }: { onComplete?: () => void } = {}) {
|
||||||
const [docs, setDocs] = useState<DocsState>(initState)
|
const [docs, setDocs] = useState<DocsState>(initState)
|
||||||
const { companyName, setCompanyName, originDomain, setOriginDomain } = useCompanyOrigin()
|
const { companyName, setCompanyName, originDomain, setOriginDomain } = useCompanyOrigin()
|
||||||
const [scanContext, setScanContext] = useScanContext()
|
const [scanContext, setScanContext] = useScanContext()
|
||||||
@@ -243,21 +242,16 @@ export function ComplianceCheckTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadFromHistory = (entry: HistoryEntry) => {
|
|
||||||
if (entry.resultKey) {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(entry.resultKey)
|
|
||||||
if (saved) { setResults(JSON.parse(saved)); return }
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const last = localStorage.getItem(STORAGE_KEY_RESULTS)
|
|
||||||
if (last) setResults(JSON.parse(last))
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextReady = isContextComplete(scanContext)
|
const contextReady = isContextComplete(scanContext)
|
||||||
|
|
||||||
|
// Nach Abschluss eines Checks (loading true→false mit Ergebnis) die
|
||||||
|
// Snapshot-Historie unten neu laden — der frische Snapshot erscheint oben.
|
||||||
|
const prevLoading = useRef(false)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (prevLoading.current && !loading && results) onComplete?.()
|
||||||
|
prevLoading.current = loading
|
||||||
|
}, [loading, results, onComplete])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Info box */}
|
{/* Info box */}
|
||||||
@@ -390,37 +384,12 @@ export function ComplianceCheckTab() {
|
|||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results — strukturierte Themen-Tabs (Impressum, …) + Roh-Checkliste */}
|
{/* Nach Abschluss: Hinweis auf die Historie unten. Die eigentlichen
|
||||||
{results && results.results && (
|
Ergebnisse leben in der Snapshot-Detail-Seite (oberster Eintrag). */}
|
||||||
<ComplianceResultTabs results={results} />
|
{results && results.results && !loading && (
|
||||||
)}
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-800">
|
||||||
|
Check abgeschlossen — das Ergebnis steht unten in der Historie (oberster, farblich
|
||||||
{/* History */}
|
markierter Eintrag). Klick ihn an, um die Auswertung zu öffnen.
|
||||||
{history.length > 0 && (
|
|
||||||
<div className="border border-gray-200 rounded-xl p-4">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Compliance-Checks</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{history.map((h, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => loadFromHistory(h)}
|
|
||||||
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left"
|
|
||||||
>
|
|
||||||
<span className="text-gray-600">
|
|
||||||
{new Date(h.date).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xs text-gray-500">{h.docCount} Dok.</span>
|
|
||||||
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
|
||||||
{h.findings} Findings
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,325 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { TextReference } from './TextReference'
|
|
||||||
|
|
||||||
interface ServiceInfo {
|
|
||||||
name: string
|
|
||||||
category: string
|
|
||||||
provider: string
|
|
||||||
country: string
|
|
||||||
eu_adequate: boolean
|
|
||||||
requires_consent: boolean
|
|
||||||
legal_ref: string
|
|
||||||
in_dse: boolean
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextRef {
|
|
||||||
found: boolean
|
|
||||||
source_url: string
|
|
||||||
document_type: string
|
|
||||||
section_heading: string
|
|
||||||
section_number: string
|
|
||||||
parent_section: string
|
|
||||||
paragraph_index: number
|
|
||||||
original_text: string
|
|
||||||
issue: string
|
|
||||||
correction_type: string
|
|
||||||
correction_text: string
|
|
||||||
insert_after: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScanFinding {
|
|
||||||
code: string
|
|
||||||
doc_title?: string
|
|
||||||
severity: string
|
|
||||||
text: string
|
|
||||||
correction: string
|
|
||||||
text_reference: TextRef | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DiscoveredDocument {
|
|
||||||
title: string
|
|
||||||
completeness_pct: number
|
|
||||||
word_count?: number
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScanData {
|
|
||||||
pages_scanned: number
|
|
||||||
pages_list: string[]
|
|
||||||
services: ServiceInfo[]
|
|
||||||
findings: ScanFinding[]
|
|
||||||
discovered_documents?: DiscoveredDocument[]
|
|
||||||
ai_detected: boolean
|
|
||||||
chatbot_detected: boolean
|
|
||||||
chatbot_provider: string
|
|
||||||
missing_pages: Record<string, number>
|
|
||||||
email_status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_ICON: Record<string, { icon: string; color: string }> = {
|
|
||||||
ok: { icon: '\u2713', color: 'text-green-600' },
|
|
||||||
undocumented: { icon: '\u2717', color: 'text-red-600' },
|
|
||||||
outdated: { icon: '~', color: 'text-yellow-600' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEV_STYLE: Record<string, { bg: string; text: string; dot: string }> = {
|
|
||||||
HIGH: { bg: 'bg-red-50 border-red-200', text: 'text-red-800', dot: 'bg-red-500' },
|
|
||||||
MEDIUM: { bg: 'bg-yellow-50 border-yellow-200', text: 'text-yellow-800', dot: 'bg-yellow-500' },
|
|
||||||
LOW: { bg: 'bg-blue-50 border-blue-200', text: 'text-blue-800', dot: 'bg-blue-500' },
|
|
||||||
CRITICAL: { bg: 'bg-red-100 border-red-300', text: 'text-red-900', dot: 'bg-red-700' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScanResult({ data }: { data: ScanData }) {
|
|
||||||
const [expandedCorrection, setExpandedCorrection] = useState<string | null>(null)
|
|
||||||
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const undocCount = data.services.filter(s => s.status === 'undocumented').length
|
|
||||||
const okCount = data.services.filter(s => s.status === 'ok').length
|
|
||||||
const highCount = data.findings.filter(f => f.severity === 'HIGH' || f.severity === 'CRITICAL').length
|
|
||||||
const docs = data.discovered_documents || []
|
|
||||||
|
|
||||||
// Group findings by doc_title
|
|
||||||
const docFindings: Record<string, ScanFinding[]> = {}
|
|
||||||
const generalFindings: ScanFinding[] = []
|
|
||||||
for (const f of data.findings) {
|
|
||||||
if (f.doc_title) {
|
|
||||||
if (!docFindings[f.doc_title]) docFindings[f.doc_title] = []
|
|
||||||
docFindings[f.doc_title].push(f)
|
|
||||||
} else {
|
|
||||||
generalFindings.push(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Summary Bar */}
|
|
||||||
<div className="grid grid-cols-4 gap-3">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
||||||
<p className="text-2xl font-bold text-gray-900">{data.pages_scanned}</p>
|
|
||||||
<p className="text-xs text-gray-500">Seiten</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
|
||||||
<p className="text-2xl font-bold text-green-700">{okCount}</p>
|
|
||||||
<p className="text-xs text-gray-500">Dokumentiert</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-red-50 rounded-lg p-3 text-center">
|
|
||||||
<p className="text-2xl font-bold text-red-700">{undocCount}</p>
|
|
||||||
<p className="text-xs text-gray-500">Nicht in DSE</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
|
||||||
<p className="text-2xl font-bold text-purple-700">{docs.length}</p>
|
|
||||||
<p className="text-xs text-gray-500">Dokumente</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scanned Pages */}
|
|
||||||
{data.pages_list?.length > 0 && (
|
|
||||||
<details className="text-sm">
|
|
||||||
<summary className="text-gray-600 cursor-pointer hover:text-gray-800">
|
|
||||||
{data.pages_scanned} Seiten gescannt
|
|
||||||
</summary>
|
|
||||||
<ul className="mt-2 space-y-1 ml-4">
|
|
||||||
{data.pages_list.map((p, i) => {
|
|
||||||
const isMissing = data.missing_pages[p]
|
|
||||||
return (
|
|
||||||
<li key={i} className={`text-xs ${isMissing ? 'text-red-600' : 'text-gray-500'}`}>
|
|
||||||
{isMissing ? '\u2717' : '\u2713'} {p}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Services Table */}
|
|
||||||
{data.services.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Dienstleister (SOLL/IST)</h4>
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Status</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Dienst</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Land</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">In DSE</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{data.services.map((s, i) => {
|
|
||||||
const st = STATUS_ICON[s.status] || STATUS_ICON.ok
|
|
||||||
return (
|
|
||||||
<tr key={i} className={s.status === 'undocumented' ? 'bg-red-50' : ''}>
|
|
||||||
<td className={`px-3 py-2 font-bold ${st.color}`}>{st.icon}</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<span className="font-medium text-gray-900">{s.name}</span>
|
|
||||||
<span className="text-gray-400 text-xs ml-2">{s.provider}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-gray-600">{s.country}</td>
|
|
||||||
<td className="px-3 py-2">{s.in_dse ? '\u2713' : <span className="text-red-600 font-medium">Nein</span>}</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* === Document-Centric View === */}
|
|
||||||
{docs.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Rechtliche Dokumente ({docs.length})
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{docs.map((doc, i) => {
|
|
||||||
const isExpanded = expandedDoc === doc.title
|
|
||||||
const findings = docFindings[doc.title] || []
|
|
||||||
const pct = doc.completeness_pct
|
|
||||||
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
|
||||||
const statusLabel = pct >= 80 ? 'OK' : pct >= 50 ? 'Lueckenhaft' : 'Mangelhaft'
|
|
||||||
const statusColor = pct >= 80 ? 'text-green-700 bg-green-50' : pct >= 50 ? 'text-yellow-700 bg-yellow-50' : 'text-red-700 bg-red-50'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpandedDoc(isExpanded ? null : doc.title)}
|
|
||||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50/50 hover:bg-gray-50 text-left"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<svg className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isExpanded ? 'rotate-90' : ''}`}
|
|
||||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">{doc.title}</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{doc.word_count} Woerter
|
|
||||||
{findings.length > 0 && <span className="text-red-600 ml-2">{findings.length} Maengel</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
|
||||||
{/* Completeness bar */}
|
|
||||||
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
||||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${statusColor}`}>
|
|
||||||
{pct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-gray-100 space-y-2">
|
|
||||||
{findings.length > 0 ? (
|
|
||||||
findings.map((f, fi) => {
|
|
||||||
const sev = SEV_STYLE[f.severity] || SEV_STYLE.MEDIUM
|
|
||||||
return (
|
|
||||||
<div key={fi} className="flex items-start gap-2 text-sm">
|
|
||||||
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${sev.dot}`} />
|
|
||||||
<span className="text-gray-700">{f.text}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-green-600">Alle Pflichtangaben vorhanden.</p>
|
|
||||||
)}
|
|
||||||
{doc.url && (
|
|
||||||
<a href={doc.url} target="_blank" rel="noopener noreferrer"
|
|
||||||
className="text-xs text-purple-600 hover:underline mt-2 inline-block">
|
|
||||||
Dokument oeffnen
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* General Findings (not associated with a specific document) */}
|
|
||||||
{generalFindings.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Allgemeine Findings ({generalFindings.length})
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{generalFindings.map((f, i) => {
|
|
||||||
const sev = SEV_STYLE[f.severity] || SEV_STYLE.MEDIUM
|
|
||||||
const corrKey = `gen-${i}`
|
|
||||||
const isExp = expandedCorrection === corrKey
|
|
||||||
return (
|
|
||||||
<div key={i} className={`border rounded-lg p-3 ${sev.bg}`}>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className={`text-xs font-bold px-2 py-0.5 rounded ${sev.text} bg-white`}>
|
|
||||||
{f.severity}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
|
||||||
</div>
|
|
||||||
{/* Text Reference (original text + position + correction) */}
|
|
||||||
{f.text_reference && (
|
|
||||||
<TextReference ref={f.text_reference} correction={f.correction} />
|
|
||||||
)}
|
|
||||||
{/* Fallback: correction without text reference */}
|
|
||||||
{!f.text_reference && f.correction && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
|
||||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
|
||||||
{isExp ? 'Korrektur ausblenden' : 'Korrekturvorschlag'}
|
|
||||||
</button>
|
|
||||||
{isExp && (
|
|
||||||
<div className="mt-2 bg-white border border-gray-200 rounded-lg p-3 relative">
|
|
||||||
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-sans">{f.correction}</pre>
|
|
||||||
<button onClick={() => navigator.clipboard.writeText(f.correction)}
|
|
||||||
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">
|
|
||||||
Kopieren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* PDF Export Button */}
|
|
||||||
<div className="pt-4 border-t flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/sdk/v1/agent/scans/pdf', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url: '', scan_type: 'scan', analysis_mode: 'post_launch', result: data }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const blob = await res.blob()
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = 'compliance-report.pdf'
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
} catch (e) { console.error('PDF export failed:', e) }
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
PDF herunterladen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SnapshotHistoryList — Check-Historie aus gespeicherten Snapshots.
|
||||||
|
*
|
||||||
|
* Neuester Snapshot oben + farblich abgesetzt. Klick → Detail-Seite mit den
|
||||||
|
* Ergebnissen (/sdk/agent/snapshots/{id}). `refreshKey` neu setzen, um nach
|
||||||
|
* einem frisch gelaufenen Compliance-Check neu zu laden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SnapshotHistoryList(
|
||||||
|
{ refreshKey = 0, limit = 50 }: { refreshKey?: number; limit?: number },
|
||||||
|
) {
|
||||||
|
const [snaps, setSnaps] = useState<SnapMeta[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
fetch(`/api/sdk/v1/agent/snapshots?limit=${limit}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (!cancelled) setSnaps(d.snapshots || []) })
|
||||||
|
.catch(() => { if (!cancelled) setSnaps([]) })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [refreshKey, limit])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Historie</h2>
|
||||||
|
{!loading && snaps.length > 0 && (
|
||||||
|
<span className="text-xs text-gray-400">{snaps.length} Checks</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-gray-500">Lade Historie…</div>
|
||||||
|
) : snaps.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-400 border border-dashed border-gray-200 rounded-lg px-4 py-6 text-center">
|
||||||
|
Noch keine Checks — starte oben einen Compliance-Check.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg divide-y divide-gray-100 overflow-hidden">
|
||||||
|
{snaps.map((s, i) => (
|
||||||
|
<Link
|
||||||
|
key={s.id}
|
||||||
|
href={`/sdk/agent/snapshots/${s.id}`}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 text-sm transition-colors ${
|
||||||
|
i === 0 ? 'bg-purple-50 hover:bg-purple-100' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-600 text-white shrink-0">
|
||||||
|
aktuellster
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`font-medium w-44 truncate ${i === 0 ? 'text-purple-900' : 'text-gray-800'}`}>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,194 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ScanResult } from './_components/ScanResult'
|
|
||||||
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
|
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
|
||||||
import { BannerCheckTab } from './_components/BannerCheckTab'
|
|
||||||
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
||||||
import { AgentTestTab } from './_components/AgentTestTab'
|
import { SnapshotHistoryList } from './_components/SnapshotHistoryList'
|
||||||
|
|
||||||
type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check' | 'agent-test' | 'impressum-check' | 'doc-check'
|
|
||||||
|
|
||||||
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
|
|
||||||
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
|
|
||||||
{ id: 'compliance-check', label: 'Compliance-Check', desc: 'Alle rechtlichen Dokumente zusammen pruefen' },
|
|
||||||
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
|
|
||||||
{ id: 'agent-test', label: 'Agent-Test', desc: 'Specialist-Agent gegen 5 URLs isoliert testen' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function AgentPage() {
|
export default function AgentPage() {
|
||||||
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
|
// Nach einem abgeschlossenen Check die Historie unten neu laden.
|
||||||
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'compliance-check')
|
const [historyKey, setHistoryKey] = useState(0)
|
||||||
const [scanLoading, setScanLoading] = useState(false)
|
|
||||||
const [scanError, setScanError] = useState<string | null>(null)
|
|
||||||
const [scanData, setScanData] = useState<any>(() => {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
try { const s = localStorage.getItem('agent-scan-result'); return s ? JSON.parse(s) : null } catch { return null }
|
|
||||||
})
|
|
||||||
const [scanProgress, setScanProgress] = useState<string>('')
|
|
||||||
const [activeScanId, setActiveScanId] = useState<string>(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-id') || '' : '')
|
|
||||||
const [scanHistory, setScanHistory] = useState<{ url: string; date: string; findings: number; docs: number; resultKey: string }[]>(() => {
|
|
||||||
if (typeof window === 'undefined') return []
|
|
||||||
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
|
|
||||||
})
|
|
||||||
|
|
||||||
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
|
|
||||||
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
|
|
||||||
|
|
||||||
// Resume polling if scan was in progress
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!activeScanId || scanData?.services) return
|
|
||||||
let cancelled = false
|
|
||||||
setScanLoading(true)
|
|
||||||
setScanProgress('Scan laeuft noch...')
|
|
||||||
const poll = async () => {
|
|
||||||
while (!cancelled) {
|
|
||||||
await new Promise(r => setTimeout(r, 5000))
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sdk/v1/agent/scan?scan_id=${activeScanId}`)
|
|
||||||
if (!res.ok) continue
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.progress) setScanProgress(data.progress)
|
|
||||||
if (data.status === 'completed' && data.result) {
|
|
||||||
setScanData(data.result); setScanProgress(''); setScanLoading(false)
|
|
||||||
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
|
|
||||||
localStorage.removeItem('agent-scan-id'); setActiveScanId('')
|
|
||||||
_addToHistory(data.result); return
|
|
||||||
}
|
|
||||||
if (data.status === 'failed' || data.status === 'not_found') {
|
|
||||||
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
|
|
||||||
setScanProgress(''); setScanLoading(false)
|
|
||||||
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); return
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
poll()
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const _addToHistory = (result: any) => {
|
|
||||||
const resultKey = `scan-result-${Date.now()}`
|
|
||||||
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
|
|
||||||
const entry = { url: url || result.url || '', date: new Date().toISOString(), findings: result.findings?.length || 0, docs: result.discovered_documents?.length || 0, resultKey }
|
|
||||||
const updated = [entry, ...scanHistory].slice(0, 30)
|
|
||||||
setScanHistory(updated); localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScan = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!url.trim()) return
|
|
||||||
setScanLoading(true); setScanError(null); setScanData(null); setScanProgress('Scan wird gestartet...')
|
|
||||||
try {
|
|
||||||
const startRes = await fetch('/api/sdk/v1/agent/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url.trim(), mode: 'post_launch' }) })
|
|
||||||
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
|
|
||||||
const { scan_id } = await startRes.json()
|
|
||||||
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
|
||||||
setActiveScanId(scan_id); localStorage.setItem('agent-scan-id', scan_id)
|
|
||||||
let attempts = 0
|
|
||||||
while (attempts < 120) {
|
|
||||||
await new Promise(r => setTimeout(r, 5000))
|
|
||||||
const pollRes = await fetch(`/api/sdk/v1/agent/scan?scan_id=${scan_id}`)
|
|
||||||
if (!pollRes.ok) { attempts++; continue }
|
|
||||||
const pollData = await pollRes.json()
|
|
||||||
if (pollData.progress) setScanProgress(pollData.progress)
|
|
||||||
if (pollData.status === 'completed' && pollData.result) {
|
|
||||||
setScanData(pollData.result); setScanProgress('')
|
|
||||||
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
|
|
||||||
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); _addToHistory(pollData.result); break
|
|
||||||
}
|
|
||||||
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
|
|
||||||
attempts++
|
|
||||||
}
|
|
||||||
if (attempts >= 120) throw new Error('Scan-Timeout (10 Minuten)')
|
|
||||||
} catch (e) { setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler'); setScanProgress('') }
|
|
||||||
finally { setScanLoading(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToCheck = (targetTab: AnalysisTab, checkUrl: string) => {
|
|
||||||
const keyMap: Record<string, string> = { 'doc-check': 'doc-check-prefill-url', 'banner-check': 'banner-check-url', 'impressum-check': 'impressum-check-url' }
|
|
||||||
if (keyMap[targetTab]) localStorage.setItem(keyMap[targetTab], checkUrl)
|
|
||||||
setTab(targetTab)
|
|
||||||
}
|
|
||||||
|
|
||||||
const discoveredDocs = scanData?.discovered_documents || []
|
|
||||||
const scannedUrl = scanData?.url || url
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
|
||||||
<p className="text-gray-500 mt-1">Analysiere Webseiten und Dokumente auf DSGVO-Konformitaet.</p>
|
<p className="text-gray-500 mt-1">Webseiten + Dokumente auf DSGVO-Konformität prüfen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex border-b border-gray-200 overflow-x-auto">
|
<ComplianceCheckTab onComplete={() => setHistoryKey(k => k + 1)} />
|
||||||
{TABS.map(t => (
|
|
||||||
<button key={t.id} onClick={() => setTab(t.id)}
|
|
||||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
|
||||||
tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === 'scan' && (
|
<SnapshotHistoryList refreshKey={historyKey} />
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
|
||||||
<h3 className="text-sm font-semibold text-indigo-900">Website-Scan (Discovery)</h3>
|
|
||||||
<p className="text-xs text-indigo-700 mt-1">Findet alle rechtlichen Dokumente (DSI, AGB, Impressum, Cookie, Widerruf), erkennt eingesetzte Drittdienste und prueft ob sie in der DSE dokumentiert sind.</p>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleScan} className="flex gap-3">
|
|
||||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)} placeholder="https://www.example.com/"
|
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm" disabled={scanLoading} required />
|
|
||||||
<button type="submit" disabled={scanLoading || !url.trim()}
|
|
||||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
|
|
||||||
{scanLoading ? (<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>Scanne...</>) : 'Website scannen'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{scanProgress && <div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3"><svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>{scanProgress}</div>}
|
|
||||||
{scanError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>}
|
|
||||||
{scanData && (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">Jetzt pruefen</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button onClick={() => navigateToCheck('banner-check', scannedUrl)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
|
||||||
<div className="text-sm font-medium text-gray-900">Cookie-Banner pruefen</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">3-Phasen Dark-Pattern-Analyse</div>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => navigateToCheck('impressum-check', scannedUrl + '/impressum')} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
|
||||||
<div className="text-sm font-medium text-gray-900">Impressum pruefen</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
|
|
||||||
</button>
|
|
||||||
{discoveredDocs.map((doc: any, i: number) => (
|
|
||||||
<button key={i} onClick={() => navigateToCheck('doc-check', doc.url)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">{doc.title || doc.url}</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{doc.doc_type?.toUpperCase()} · {doc.word_count || '?'} Woerter{doc.completeness_pct != null && ` · ${doc.completeness_pct}%`}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{scanData?.services && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
|
|
||||||
{scanHistory.length > 0 && (
|
|
||||||
<div className="border border-gray-200 rounded-xl p-4">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{scanHistory.map((h, i) => (
|
|
||||||
<button key={i} onClick={() => { setUrl(h.url); if (h.resultKey) { try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {} } }}
|
|
||||||
className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
|
||||||
<div className="min-w-0 flex-1"><div className="text-sm font-medium text-gray-900 truncate">{h.url}</div><div className="text-xs text-gray-500">{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></div>
|
|
||||||
<div className="flex items-center gap-3 shrink-0 ml-3">{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>{h.findings} Findings</span></div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'compliance-check' && <ComplianceCheckTab />}
|
|
||||||
{tab === 'banner-check' && <BannerCheckTab />}
|
|
||||||
{tab === 'agent-test' && <AgentTestTab />}
|
|
||||||
|
|
||||||
<ComplianceFAQ />
|
<ComplianceFAQ />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check-Historie — listet gespeicherte Snapshots (alle Sites/Module).
|
* Check-Historie (eigene Route) — listet gespeicherte Snapshots.
|
||||||
* Ein DSB/Mitarbeiter kann jeden früheren Check öffnen, ohne neuen Check
|
* Identische Liste wie unter /sdk/agent, nur als Vollseite.
|
||||||
* zu starten. Daten kommen aus den Snapshot-Rohdaten.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import { SnapshotHistoryList } from '../_components/SnapshotHistoryList'
|
||||||
|
|
||||||
interface SnapMeta {
|
|
||||||
id: string
|
|
||||||
check_id?: string
|
|
||||||
site_domain?: string
|
|
||||||
site_label?: string
|
|
||||||
created_at?: string
|
|
||||||
replay_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SnapshotHistory() {
|
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 (
|
return (
|
||||||
<div className="p-6 max-w-4xl space-y-4">
|
<div className="p-6 max-w-4xl space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -41,31 +18,7 @@ export default function SnapshotHistory() {
|
|||||||
ansehbar, ohne neuen Check zu starten.
|
ansehbar, ohne neuen Check zu starten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<SnapshotHistoryList />
|
||||||
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user