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'
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react'
|
||||
import { ComplianceResultTabs } from './ComplianceResultTabs'
|
||||
import { DocumentRow } from './DocumentRow'
|
||||
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
||||
import { DOCUMENT_TYPES, type DocTypeId } from './_document_types'
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
import { useCompanyOrigin } from './_useCompanyOrigin'
|
||||
|
||||
|
||||
export function ComplianceCheckTab() {
|
||||
export function ComplianceCheckTab({ onComplete }: { onComplete?: () => void } = {}) {
|
||||
const [docs, setDocs] = useState<DocsState>(initState)
|
||||
const { companyName, setCompanyName, originDomain, setOriginDomain } = useCompanyOrigin()
|
||||
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)
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-4">
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Results — strukturierte Themen-Tabs (Impressum, …) + Roh-Checkliste */}
|
||||
{results && results.results && (
|
||||
<ComplianceResultTabs results={results} />
|
||||
)}
|
||||
|
||||
{/* 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 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>
|
||||
{/* Nach Abschluss: Hinweis auf die Historie unten. Die eigentlichen
|
||||
Ergebnisse leben in der Snapshot-Detail-Seite (oberster Eintrag). */}
|
||||
{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
|
||||
markierter Eintrag). Klick ihn an, um die Auswertung zu öffnen.
|
||||
</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'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { ScanResult } from './_components/ScanResult'
|
||||
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
|
||||
import { BannerCheckTab } from './_components/BannerCheckTab'
|
||||
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
||||
import { AgentTestTab } from './_components/AgentTestTab'
|
||||
|
||||
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' },
|
||||
]
|
||||
import { SnapshotHistoryList } from './_components/SnapshotHistoryList'
|
||||
|
||||
export default function AgentPage() {
|
||||
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
|
||||
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'compliance-check')
|
||||
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
|
||||
// Nach einem abgeschlossenen Check die Historie unten neu laden.
|
||||
const [historyKey, setHistoryKey] = useState(0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<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 className="flex border-b border-gray-200 overflow-x-auto">
|
||||
{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>
|
||||
<ComplianceCheckTab onComplete={() => setHistoryKey(k => k + 1)} />
|
||||
|
||||
{tab === 'scan' && (
|
||||
<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 />}
|
||||
<SnapshotHistoryList refreshKey={historyKey} />
|
||||
|
||||
<ComplianceFAQ />
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,14 @@
|
||||
'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.
|
||||
* Check-Historie (eigene Route) — listet gespeicherte Snapshots.
|
||||
* Identische Liste wie unter /sdk/agent, nur als Vollseite.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
import React from 'react'
|
||||
import { SnapshotHistoryList } from '../_components/SnapshotHistoryList'
|
||||
|
||||
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>
|
||||
@@ -41,31 +18,7 @@ export default function SnapshotHistory() {
|
||||
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>
|
||||
)}
|
||||
<SnapshotHistoryList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user