feat: implement Regulatory Signals page with two-pane split and SSE streaming
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,265 @@
|
||||
export function PerceptionPage() {
|
||||
return <div className="page-content"><p>Signals</p></div>;
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface Signal {
|
||||
id: string;
|
||||
source: string;
|
||||
standard: string;
|
||||
status: 'ok' | 'warn' | 'risk' | 'info';
|
||||
title: string;
|
||||
summary: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
impact: 'High' | 'Medium' | 'Low';
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total: number;
|
||||
high_impact: number;
|
||||
medium_impact: number;
|
||||
last_90_days: number;
|
||||
}
|
||||
|
||||
interface DocResult {
|
||||
score: number;
|
||||
name: string;
|
||||
clause: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
const SOURCES = ['All', 'MIIT', 'UN-ECE', 'ISO', 'GB Comm.', 'EUR-Lex', 'IATF'];
|
||||
const IMPACTS = ['All', 'High', 'Medium', 'Low'];
|
||||
|
||||
const MOCK_SIGNALS: Signal[] = [
|
||||
{
|
||||
id: '1', source: 'EUR-Lex', standard: 'EU/2024/1689', status: 'risk',
|
||||
title: 'EU AI Act — High-risk AI in vehicles',
|
||||
summary: 'Article 9 mandates risk management systems for automotive AI classifying as high-risk under Annex III point 3.',
|
||||
date: '2025-11-18', tags: ['automotive', 'GDPR', 'certification'], impact: 'High'
|
||||
},
|
||||
{
|
||||
id: '2', source: 'MIIT', standard: 'Draft-2025-08', status: 'warn',
|
||||
title: 'MIIT Draft — in-vehicle AI training data',
|
||||
summary: 'Draft regulation requires OEM data provenance documentation and OTA audit trails for AI systems.',
|
||||
date: '2025-10-30', tags: ['OTA', 'data-governance', 'China'], impact: 'High'
|
||||
},
|
||||
{
|
||||
id: '3', source: 'ISO', standard: 'ISO/SAE 21434:2021/Amd1', status: 'info',
|
||||
title: 'ISO/SAE 21434 Amendment 1',
|
||||
summary: 'Amendment clarifies CSMS scope for software-only updates and vulnerability disclosure timelines.',
|
||||
date: '2025-10-05', tags: ['cybersecurity', 'CSMS', 'ISO'], impact: 'Medium'
|
||||
},
|
||||
{
|
||||
id: '4', source: 'UN-ECE', standard: 'UNECE WP.29 R155', status: 'ok',
|
||||
title: 'UNECE R155 Corrigendum',
|
||||
summary: 'Editorial corrections to cybersecurity management system requirements. No substantive changes.',
|
||||
date: '2025-09-12', tags: ['type-approval', 'UNECE'], impact: 'Low'
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_DOCS: DocResult[] = [
|
||||
{ score: 94, name: 'Vehicle AI Safety Manual v3.2', clause: '§4.2.1', snippet: 'The risk management process shall identify and evaluate risks arising from AI system decisions in safety-critical scenarios...' },
|
||||
{ score: 87, name: 'ADAS System Requirements', clause: '§7.1', snippet: 'Automated driving functions must document training data lineage and model performance envelopes prior to deployment.' },
|
||||
{ score: 71, name: 'Type Approval Documentation', clause: 'Annex B', snippet: 'Cybersecurity management system certification requires third-party audit of AI decision audit logs retention policy.' },
|
||||
];
|
||||
|
||||
export function PerceptionPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [sourceFilter, setSourceFilter] = useState('All');
|
||||
const [impactFilter, setImpactFilter] = useState('All');
|
||||
const [selected, setSelected] = useState<Signal | null>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [aiOutput, setAiOutput] = useState('');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/perception/stats')
|
||||
.then(r => r.json())
|
||||
.then(setStats)
|
||||
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, last_90_days: 14 }));
|
||||
}, []);
|
||||
|
||||
const filtered = MOCK_SIGNALS.filter(s =>
|
||||
(sourceFilter === 'All' || s.source === sourceFilter) &&
|
||||
(impactFilter === 'All' || s.impact === impactFilter)
|
||||
);
|
||||
|
||||
function runAnalysis() {
|
||||
if (!selected) return;
|
||||
setStreaming(true);
|
||||
setAiOutput('');
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
fetch(`/api/v1/perception/analyze?signal_id=${selected.id}`, { signal: ctrl.signal })
|
||||
.then(async res => {
|
||||
if (!res.body) { setAiOutput('No stream available.'); setStreaming(false); return; }
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = dec.decode(value);
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
try { const j = JSON.parse(data); setAiOutput(p => p + (j.text || '')); }
|
||||
catch { setAiOutput(p => p + data); }
|
||||
}
|
||||
}
|
||||
}
|
||||
setStreaming(false);
|
||||
})
|
||||
.catch(e => {
|
||||
if (e.name !== 'AbortError') setAiOutput('Analysis failed. Check API connection.');
|
||||
setStreaming(false);
|
||||
});
|
||||
}
|
||||
|
||||
function stopAnalysis() {
|
||||
abortRef.current?.abort();
|
||||
setStreaming(false);
|
||||
}
|
||||
|
||||
function selectSignal(sig: Signal) {
|
||||
setSelected(sig);
|
||||
setAiOutput('');
|
||||
setStreaming(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="perception-page">
|
||||
<Topbar
|
||||
title="Regulatory Signals"
|
||||
subtitle="ai-powered · live feed"
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<input placeholder="Search signals..." />
|
||||
</div>
|
||||
<button className="btn sm"><RefreshCw size={13} />Refresh</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="stats-bar">
|
||||
<div className="sbar-cell">
|
||||
<span className="sbar-val">{stats?.total ?? '—'}</span>
|
||||
<span className="sbar-lbl">Total signals</span>
|
||||
</div>
|
||||
<div className="sbar-cell danger">
|
||||
<span className="sbar-val">{stats?.high_impact ?? '—'}</span>
|
||||
<span className="sbar-lbl">High impact</span>
|
||||
</div>
|
||||
<div className="sbar-cell warn">
|
||||
<span className="sbar-val">{stats?.medium_impact ?? '—'}</span>
|
||||
<span className="sbar-lbl">Medium impact</span>
|
||||
</div>
|
||||
<div className="sbar-cell accent">
|
||||
<span className="sbar-val">{stats?.last_90_days ?? '—'}</span>
|
||||
<span className="sbar-lbl">Last 90 days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<div className="chip-group">
|
||||
{SOURCES.map(s => (
|
||||
<button key={s} className={`chip${sourceFilter === s ? ' active' : ''}`} onClick={() => setSourceFilter(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="filter-sep" />
|
||||
<div className="chip-group">
|
||||
{IMPACTS.map(i => (
|
||||
<button key={i} className={`chip${impactFilter === i ? ' active' : ''}`} onClick={() => setImpactFilter(i)}>{i}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="perception-split">
|
||||
<div className="feed-pane">
|
||||
{filtered.map(sig => (
|
||||
<div
|
||||
key={sig.id}
|
||||
className={`ev-card${selected?.id === sig.id ? ' selected' : ''}`}
|
||||
onClick={() => selectSignal(sig)}
|
||||
>
|
||||
<div className="ev-top">
|
||||
<span className="source-tag">{sig.source}</span>
|
||||
<span className="ev-std">{sig.standard}</span>
|
||||
<span className={`status ${sig.status}`}>
|
||||
{sig.status === 'ok' ? 'Final' : sig.status === 'warn' ? 'Draft' : sig.status === 'risk' ? 'Urgent' : 'Published'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ev-title">{sig.title}</div>
|
||||
<div className="ev-summary">{sig.summary}</div>
|
||||
<div className="ev-bottom">
|
||||
<span className="ev-date">{sig.date}</span>
|
||||
<div className="ev-tags">{sig.tags.map(t => <span key={t} className="ev-tag">{t}</span>)}</div>
|
||||
<span className={`impact-dot impact-${sig.impact.toLowerCase()}`}>{sig.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="analysis-pane">
|
||||
{!selected ? (
|
||||
<div className="analysis-empty">
|
||||
<div className="empty-ring" />
|
||||
<p>Select a signal to run impact analysis</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card detail-card">
|
||||
<div className="detail-header">
|
||||
<span className="source-tag">{selected.source}</span>
|
||||
<span className="ev-std">{selected.standard}</span>
|
||||
<span className={`status ${selected.status}`}>
|
||||
{selected.status === 'risk' ? 'Urgent' : 'Published'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-title">{selected.title}</div>
|
||||
<p className="detail-summary">{selected.summary}</p>
|
||||
<div className="detail-actions">
|
||||
{!streaming
|
||||
? <button className="btn sm primary" onClick={runAnalysis}><Play size={12} />Run impact analysis</button>
|
||||
: <button className="btn sm" onClick={stopAnalysis}><Square size={12} />Stop</button>
|
||||
}
|
||||
<button className="btn sm"><ExternalLink size={12} />Source</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card docs-card">
|
||||
<div className="card-header">Affected documents</div>
|
||||
{MOCK_DOCS.map(d => (
|
||||
<div key={d.name} className="doc-row">
|
||||
<span className="doc-score">{d.score}%</span>
|
||||
<div>
|
||||
<div className="doc-name">{d.name} <span className="doc-clause">{d.clause}</span></div>
|
||||
<div className="doc-snippet">{d.snippet}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(aiOutput || streaming) && (
|
||||
<div className="card ai-card">
|
||||
<div className="card-header">AI Impact Analysis</div>
|
||||
<div className="ai-output">
|
||||
{aiOutput}
|
||||
{streaming && <span className="blink-cursor">▋</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="page-footer">
|
||||
<div className="live-dot" />
|
||||
<span>Live feed · Regulation Hub</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user