docs: enterprise refactor plan with ralph specs

This commit is contained in:
debian
2026-03-04 16:17:03 -05:00
parent 4c92712d20
commit f8191133c8
204 changed files with 32722 additions and 422 deletions

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

22
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Dashboard } from './pages/Dashboard'
import { SessionDetail } from './pages/SessionDetail'
import { AnomalyDetail } from './pages/AnomalyDetail'
import { Settings } from './pages/Settings'
import { VisualReview } from './pages/VisualReview'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sessions/:sessionId" element={<SessionDetail />} />
<Route path="/anomalies/:anomalyId" element={<AnomalyDetail />} />
<Route path="/settings" element={<Settings />} />
<Route path="/visual-review" element={<VisualReview />} />
</Routes>
</BrowserRouter>
)
}
export default App

View File

@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { AnomalyList } from '../components/AnomalyList';
import type { AnomalySummary } from '../types';
function makeAnomaly(overrides: Partial<AnomalySummary> = {}): AnomalySummary {
return {
id: 'anom_1',
type: 'http_error',
severity: 'high',
description: 'HTTP 500 on form submit',
timestamp: 1000000,
...overrides,
};
}
function renderList(anomalies: AnomalySummary[]) {
return render(
<MemoryRouter>
<AnomalyList anomalies={anomalies} title="Test Anomalies" />
</MemoryRouter>
);
}
describe('AnomalyList', () => {
it('renders title', () => {
renderList([]);
expect(screen.getByText('Test Anomalies')).toBeDefined();
});
it('shows empty state when no anomalies', () => {
renderList([]);
expect(screen.getByText(/no anomalies/i)).toBeDefined();
});
it('renders anomaly cards', () => {
renderList([makeAnomaly(), makeAnomaly({ id: 'anom_2', description: 'Another error' })]);
expect(screen.getByText('HTTP 500 on form submit')).toBeDefined();
expect(screen.getByText('Another error')).toBeDefined();
});
it('filters by severity when severity button is clicked', async () => {
const user = userEvent.setup();
renderList([
makeAnomaly({ id: 'a1', severity: 'high', description: 'High error' }),
makeAnomaly({ id: 'a2', severity: 'low', description: 'Low error' }),
]);
// Both are visible initially (all severities selected)
expect(screen.getByText('High error')).toBeDefined();
expect(screen.getByText('Low error')).toBeDefined();
// Click "high" to deselect it
const highBtn = screen.getAllByRole('button').find((b) => b.textContent === 'HIGH');
if (highBtn) await user.click(highBtn);
// High error should now be hidden
expect(screen.queryByText('High error')).toBeNull();
expect(screen.getByText('Low error')).toBeDefined();
});
it('filters by description search', async () => {
const user = userEvent.setup();
renderList([
makeAnomaly({ id: 'a1', description: 'Server crashed unexpectedly' }),
makeAnomaly({ id: 'a2', description: 'Timeout on login' }),
]);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'crashed');
expect(screen.getByText('Server crashed unexpectedly')).toBeDefined();
expect(screen.queryByText('Timeout on login')).toBeNull();
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { NewSessionForm } from '../components/NewSessionForm';
// Mock the api module
vi.mock('../hooks/useApi', () => ({
api: {
createSession: vi.fn(),
},
apiFetch: vi.fn(),
}));
import { api } from '../hooks/useApi';
function renderForm(onCreated = vi.fn()) {
return render(
<MemoryRouter>
<NewSessionForm onCreated={onCreated} />
</MemoryRouter>
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('NewSessionForm', () => {
it('renders URL field and submit button', () => {
renderForm();
expect(screen.getByLabelText(/target url/i)).toBeDefined();
expect(screen.getByRole('button', { name: /start exploration/i })).toBeDefined();
});
it('renders auth type selector', () => {
renderForm();
expect(screen.getByLabelText(/auth type/i)).toBeDefined();
});
it('shows login flow fields when login_flow is selected', async () => {
const user = userEvent.setup();
renderForm();
const authSelect = screen.getByLabelText(/auth type/i);
await user.selectOptions(authSelect, 'login_flow');
expect(screen.getByLabelText(/login url/i)).toBeDefined();
expect(screen.getByLabelText(/username$/i)).toBeDefined();
expect(screen.getByLabelText(/password$/i)).toBeDefined();
});
it('does NOT show login flow fields when auth is none', () => {
renderForm();
expect(screen.queryByLabelText(/login url/i)).toBeNull();
});
it('calls onCreated with sessionId on success', async () => {
const user = userEvent.setup();
const onCreated = vi.fn();
(api.createSession as ReturnType<typeof vi.fn>).mockResolvedValue({
sessionId: 'sess_test',
status: 'running',
startedAt: new Date().toISOString(),
});
renderForm(onCreated);
const urlInput = screen.getByLabelText(/target url/i);
await user.clear(urlInput);
await user.type(urlInput, 'http://localhost:3000');
await user.click(screen.getByRole('button', { name: /start exploration/i }));
await waitFor(() => {
expect(onCreated).toHaveBeenCalledWith('sess_test');
});
});
it('shows error message on failed session creation', async () => {
const user = userEvent.setup();
(api.createSession as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Max concurrent sessions reached'));
renderForm();
await user.click(screen.getByRole('button', { name: /start exploration/i }));
await waitFor(() => {
expect(screen.getByText(/max concurrent sessions reached/i)).toBeDefined();
});
});
it('renders fuzzing toggle and intensity selector', () => {
renderForm();
expect(screen.getByLabelText(/enable fuzzing/i)).toBeDefined();
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SeverityBadge } from '../components/SeverityBadge';
describe('SeverityBadge', () => {
it('renders severity text in uppercase', () => {
render(<SeverityBadge severity="high" />);
expect(screen.getByText('HIGH')).toBeDefined();
});
it('applies red background for critical', () => {
const { container } = render(<SeverityBadge severity="critical" />);
const badge = container.firstChild as HTMLElement;
expect(badge.className).toContain('bg-red-500');
});
it('applies orange background for high', () => {
const { container } = render(<SeverityBadge severity="high" />);
const badge = container.firstChild as HTMLElement;
expect(badge.className).toContain('bg-orange-500');
});
it('applies yellow background for medium', () => {
const { container } = render(<SeverityBadge severity="medium" />);
const badge = container.firstChild as HTMLElement;
expect(badge.className).toContain('bg-yellow-500');
});
it('applies blue background for low', () => {
const { container } = render(<SeverityBadge severity="low" />);
const badge = container.firstChild as HTMLElement;
expect(badge.className).toContain('bg-blue-500');
});
});

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom';
import type { AnomalySummary } from '../types';
import { SeverityBadge } from './SeverityBadge';
const BROWSER_COLORS: Record<string, string> = {
chromium: 'bg-blue-800 text-blue-200',
firefox: 'bg-orange-800 text-orange-200',
webkit: 'bg-purple-800 text-purple-200',
};
interface Props {
anomaly: AnomalySummary;
}
export function AnomalyCard({ anomaly }: Props) {
return (
<div className="bg-gray-800 rounded-lg p-4 flex items-start gap-3">
<SeverityBadge severity={anomaly.severity} className="shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link to={`/anomalies/${anomaly.id}`} className="text-blue-400 hover:underline text-sm font-medium">
{anomaly.type}
</Link>
{anomaly.browser && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${BROWSER_COLORS[anomaly.browser] ?? 'bg-gray-700 text-gray-300'}`}>
{anomaly.browser}
</span>
)}
</div>
<p className="text-gray-400 text-xs mt-0.5 truncate">{anomaly.description}</p>
<p className="text-gray-600 text-xs mt-1">{new Date(anomaly.timestamp).toLocaleString()}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useState, useMemo } from 'react';
import type { AnomalySummary, Severity, AnomalyType, Session, BrowserType } from '../types';
import { AnomalyCard } from './AnomalyCard';
import { SeverityBadge } from './SeverityBadge';
const ALL_SEVERITIES: Severity[] = ['low', 'medium', 'high', 'critical'];
const ALL_TYPES: AnomalyType[] = [
'http_error',
'js_exception',
'console_error',
'navigation_fail',
'element_missing',
'timeout',
'validation_bypass',
'server_error_on_fuzz',
'xss_reflection',
'visual_regression',
'accessibility_violation',
'mobile_layout_issue',
'performance_degradation',
'offline_handling_missing',
'slow_network_no_feedback',
'external_service_crash',
];
const ALL_BROWSERS: Array<BrowserType | 'all'> = ['all', 'chromium', 'firefox', 'webkit'];
type SortMode = 'newest' | 'severity';
const SEVERITY_ORDER: Record<Severity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
};
interface Props {
anomalies: AnomalySummary[];
title?: string;
sessions?: Session[];
}
export function AnomalyList({ anomalies, title = 'Anomalies', sessions }: Props) {
const [severities, setSeverities] = useState<Set<Severity>>(new Set(ALL_SEVERITIES));
const [typeFilter, setTypeFilter] = useState<AnomalyType | 'all'>('all');
const [sessionFilter, setSessionFilter] = useState<string>('all');
const [browserFilter, setBrowserFilter] = useState<BrowserType | 'all'>('all');
const [search, setSearch] = useState('');
const [sortMode, setSortMode] = useState<SortMode>('newest');
function toggleSeverity(s: Severity) {
setSeverities((prev) => {
const next = new Set(prev);
if (next.has(s)) {
next.delete(s);
} else {
next.add(s);
}
return next;
});
}
const filtered = useMemo(() => {
let result = anomalies.filter((a) => {
if (!severities.has(a.severity)) return false;
if (typeFilter !== 'all' && a.type !== typeFilter) return false;
if (sessionFilter !== 'all' && a.sessionId !== sessionFilter) return false;
if (browserFilter !== 'all' && a.browser !== browserFilter) return false;
if (search.trim() && !a.description.toLowerCase().includes(search.trim().toLowerCase())) return false;
return true;
});
if (sortMode === 'newest') {
result = [...result].sort((a, b) => b.timestamp - a.timestamp);
} else {
result = [...result].sort((a, b) => {
const diff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
if (diff !== 0) return diff;
return b.timestamp - a.timestamp;
});
}
return result;
}, [anomalies, severities, typeFilter, sessionFilter, search, sortMode]);
return (
<section>
<h2 className="text-base font-semibold text-gray-300 mb-3">{title}</h2>
{/* Filter bar */}
<div className="bg-gray-800 rounded-lg p-4 mb-4 space-y-3">
{/* Severity checkboxes */}
<div className="flex flex-wrap gap-2 items-center">
<span className="text-xs text-gray-400 mr-1">Severity:</span>
{ALL_SEVERITIES.map((s) => (
<button
key={s}
type="button"
onClick={() => toggleSeverity(s)}
className={`transition-opacity ${severities.has(s) ? 'opacity-100' : 'opacity-30'}`}
>
<SeverityBadge severity={s} />
</button>
))}
</div>
{/* Row: type, session, search, sort */}
<div className="flex flex-wrap gap-2">
{/* Type dropdown */}
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as AnomalyType | 'all')}
className="bg-gray-700 text-gray-200 text-xs rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="all">All types</option>
{ALL_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
{/* Session dropdown (only if sessions prop provided) */}
{sessions && sessions.length > 0 && (
<select
value={sessionFilter}
onChange={(e) => setSessionFilter(e.target.value)}
className="bg-gray-700 text-gray-200 text-xs rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="all">All sessions</option>
{sessions.map((sess) => (
<option key={sess.sessionId} value={sess.sessionId}>
{sess.url} ({sess.sessionId.slice(0, 8)})
</option>
))}
</select>
)}
{/* Browser filter */}
<select
value={browserFilter}
onChange={(e) => setBrowserFilter(e.target.value as BrowserType | 'all')}
className="bg-gray-700 text-gray-200 text-xs rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{ALL_BROWSERS.map((b) => (
<option key={b} value={b}>{b === 'all' ? 'All browsers' : b}</option>
))}
</select>
{/* Description search */}
<input
type="text"
placeholder="Search description…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-gray-700 text-gray-200 text-xs rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1 min-w-32"
/>
{/* Sort */}
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
className="bg-gray-700 text-gray-200 text-xs rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="newest">Newest first</option>
<option value="severity">Severity desc</option>
</select>
</div>
</div>
{filtered.length === 0 ? (
<p className="text-gray-500 text-sm">No anomalies match the current filters.</p>
) : (
<div className="space-y-2">
{filtered.map((a) => (
<AnomalyCard key={a.id} anomaly={a} />
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react';
export interface FeedEvent {
id: string;
event: string;
text: string;
timestamp: number;
}
const EVENT_COLOR: Record<string, string> = {
'state:discovered': 'text-green-400',
'action:executed': 'text-yellow-400',
'anomaly:detected': 'text-red-400',
'session:started': 'text-blue-400',
'session:completed': 'text-blue-400',
'session:error': 'text-red-500',
};
interface Props {
events: FeedEvent[];
}
export function LiveFeed({ events }: Props) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [events.length]);
return (
<div className="bg-gray-900 rounded-lg p-4 h-72 overflow-y-auto font-mono text-xs space-y-1">
{events.length === 0 && (
<p className="text-gray-600">Waiting for events</p>
)}
{events.map((e) => (
<div key={e.id} className="flex gap-2">
<span className="text-gray-600 shrink-0">{new Date(e.timestamp).toLocaleTimeString()}</span>
<span className={`font-semibold shrink-0 ${EVENT_COLOR[e.event] ?? 'text-gray-400'}`}>{e.event}</span>
<span className="text-gray-300 truncate">{e.text}</span>
</div>
))}
<div ref={bottomRef} />
</div>
);
}

View File

@@ -0,0 +1,434 @@
import { useState } from 'react';
import { api } from '../hooks/useApi';
import type { AuthType, FuzzingIntensity, NetworkProfile } from '../types';
interface Props {
onCreated: (sessionId: string) => void;
}
interface HeaderPair {
key: string;
value: string;
}
export function NewSessionForm({ onCreated }: Props) {
// Basic fields
const [url, setUrl] = useState('http://localhost:3000');
const [seed, setSeed] = useState(42);
const [maxStates, setMaxStates] = useState(50);
const [maxDepth, setMaxDepth] = useState(5);
const [actionDelayMs, setActionDelayMs] = useState(500);
const [allowedDomains, setAllowedDomains] = useState('');
const [excludedPaths, setExcludedPaths] = useState('');
// Auth
const [authType, setAuthType] = useState<AuthType>('none');
// login_flow fields
const [loginUrl, setLoginUrl] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [usernameSelector, setUsernameSelector] = useState('');
const [passwordSelector, setPasswordSelector] = useState('');
const [submitSelector, setSubmitSelector] = useState('');
// cookies field
const [cookiesJson, setCookiesJson] = useState('');
// headers field
const [headerPairs, setHeaderPairs] = useState<HeaderPair[]>([{ key: '', value: '' }]);
// Fuzzing
const [fuzzingEnabled, setFuzzingEnabled] = useState(true);
const [fuzzingIntensity, setFuzzingIntensity] = useState<FuzzingIntensity>('medium');
// Network Chaos
const [chaosEnabled, setChaosEnabled] = useState(false);
const [chaosProfile, setChaosProfile] = useState<NetworkProfile>('fast-3g');
const [chaosExpanded, setChaosExpanded] = useState(false);
const [blockedEndpoints, setBlockedEndpoints] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function addHeaderPair() {
setHeaderPairs((prev) => [...prev, { key: '', value: '' }]);
}
function removeHeaderPair(index: number) {
setHeaderPairs((prev) => prev.filter((_, i) => i !== index));
}
function updateHeaderPair(index: number, field: 'key' | 'value', val: string) {
setHeaderPairs((prev) =>
prev.map((pair, i) => (i === index ? { ...pair, [field]: val } : pair))
);
}
function buildAuth() {
if (authType === 'none') return null;
if (authType === 'login_flow') {
return {
type: 'login_flow',
loginUrl,
username,
password,
usernameSelector,
passwordSelector,
submitSelector,
};
}
if (authType === 'cookies') {
try {
const cookies = JSON.parse(cookiesJson || '[]');
return { type: 'cookies', cookies };
} catch {
throw new Error('Invalid JSON for cookies array');
}
}
if (authType === 'headers') {
const headers: Record<string, string> = {};
for (const { key, value } of headerPairs) {
if (key.trim()) headers[key.trim()] = value;
}
return { type: 'headers', headers };
}
return null;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const auth = buildAuth();
const config = {
maxStates,
maxDepth,
actionDelayMs,
allowedDomains: allowedDomains
? allowedDomains.split(',').map((s) => s.trim()).filter(Boolean)
: [],
excludedPaths: excludedPaths
? excludedPaths.split(',').map((s) => s.trim()).filter(Boolean)
: [],
auth,
fuzzingEnabled,
fuzzingIntensity,
networkChaos: {
enabled: chaosEnabled,
profile: chaosProfile,
blockedEndpoints: blockedEndpoints
? blockedEndpoints.split(',').map((s) => s.trim()).filter(Boolean)
: [],
slowEndpoints: [],
},
};
const res = await api.createSession({ url, seed, config });
onCreated(res.sessionId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start session');
} finally {
setLoading(false);
}
}
const inputClass =
'w-full bg-gray-700 text-white rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500';
const labelClass = 'block text-sm text-gray-400 mb-1';
const sectionClass = 'border-t border-gray-700 pt-4';
return (
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-lg p-6 space-y-4">
<h2 className="text-lg font-semibold text-white">New Exploration</h2>
{/* Target URL */}
<div>
<label htmlFor="url" className={labelClass}>Target URL <span className="text-red-400">*</span></label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
className={inputClass}
/>
</div>
{/* Seed + Max States + Max Depth */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>Seed</label>
<input
type="number"
value={seed}
onChange={(e) => setSeed(Number(e.target.value))}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Max States</label>
<input
type="number"
value={maxStates}
onChange={(e) => setMaxStates(Number(e.target.value))}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Max Depth</label>
<input
type="number"
value={maxDepth}
onChange={(e) => setMaxDepth(Number(e.target.value))}
className={inputClass}
/>
</div>
</div>
{/* Action Delay */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Action Delay (ms)</label>
<input
type="number"
value={actionDelayMs}
onChange={(e) => setActionDelayMs(Number(e.target.value))}
className={inputClass}
/>
</div>
</div>
{/* Allowed Domains + Excluded Paths */}
<div className={sectionClass}>
<div className="mb-3">
<label className={labelClass}>Allowed Domains <span className="text-gray-500">(comma-separated)</span></label>
<input
type="text"
value={allowedDomains}
onChange={(e) => setAllowedDomains(e.target.value)}
placeholder="hostname auto-detected"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Excluded Paths <span className="text-gray-500">(comma-separated)</span></label>
<input
type="text"
value={excludedPaths}
onChange={(e) => setExcludedPaths(e.target.value)}
placeholder="/logout, /admin"
className={inputClass}
/>
</div>
</div>
{/* Auth */}
<div className={sectionClass}>
<div className="mb-3">
<label htmlFor="authType" className={labelClass}>Auth Type</label>
<select
id="authType"
value={authType}
onChange={(e) => setAuthType(e.target.value as AuthType)}
className={inputClass}
>
<option value="none">None</option>
<option value="cookies">Cookies</option>
<option value="headers">Headers</option>
<option value="login_flow">Login Flow</option>
</select>
</div>
{authType === 'login_flow' && (
<div className="space-y-3 bg-gray-900 rounded p-4">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">Login Flow Config</p>
<div>
<label htmlFor="loginUrl" className={labelClass}>Login URL</label>
<input id="loginUrl" type="url" value={loginUrl} onChange={(e) => setLoginUrl(e.target.value)} className={inputClass} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="loginUsername" className={labelClass}>Username</label>
<input id="loginUsername" type="text" value={username} onChange={(e) => setUsername(e.target.value)} className={inputClass} />
</div>
<div>
<label htmlFor="loginPassword" className={labelClass}>Password</label>
<input id="loginPassword" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className={inputClass} />
</div>
</div>
<div>
<label className={labelClass}>Username Selector</label>
<input type="text" value={usernameSelector} onChange={(e) => setUsernameSelector(e.target.value)} placeholder="#username" className={inputClass} />
</div>
<div>
<label className={labelClass}>Password Selector</label>
<input type="text" value={passwordSelector} onChange={(e) => setPasswordSelector(e.target.value)} placeholder="#password" className={inputClass} />
</div>
<div>
<label className={labelClass}>Submit Selector</label>
<input type="text" value={submitSelector} onChange={(e) => setSubmitSelector(e.target.value)} placeholder="button[type=submit]" className={inputClass} />
</div>
</div>
)}
{authType === 'cookies' && (
<div className="bg-gray-900 rounded p-4">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide mb-2">Cookie Array (JSON)</p>
<textarea
value={cookiesJson}
onChange={(e) => setCookiesJson(e.target.value)}
rows={4}
placeholder='[{"name":"token","value":"abc","domain":"example.com"}]'
className="w-full bg-gray-700 text-white rounded px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y"
/>
</div>
)}
{authType === 'headers' && (
<div className="bg-gray-900 rounded p-4 space-y-2">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">Request Headers</p>
{headerPairs.map((pair, i) => (
<div key={i} className="flex gap-2 items-center">
<input
type="text"
value={pair.key}
onChange={(e) => updateHeaderPair(i, 'key', e.target.value)}
placeholder="Header name"
className="flex-1 bg-gray-700 text-white rounded px-2 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
type="text"
value={pair.value}
onChange={(e) => updateHeaderPair(i, 'value', e.target.value)}
placeholder="Value"
className="flex-1 bg-gray-700 text-white rounded px-2 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => removeHeaderPair(i)}
disabled={headerPairs.length === 1}
className="text-red-400 hover:text-red-300 disabled:opacity-30 text-sm px-1"
>
x
</button>
</div>
))}
<button
type="button"
onClick={addHeaderPair}
className="text-blue-400 hover:text-blue-300 text-xs mt-1"
>
+ Add header
</button>
</div>
)}
</div>
{/* Fuzzing */}
<div className={sectionClass}>
<div className="flex items-center gap-3 mb-3">
<input
id="fuzzingEnabled"
type="checkbox"
checked={fuzzingEnabled}
onChange={(e) => setFuzzingEnabled(e.target.checked)}
className="w-4 h-4 accent-blue-500"
/>
<label htmlFor="fuzzingEnabled" className="text-sm text-gray-300 cursor-pointer">
Enable Fuzzing
</label>
</div>
{fuzzingEnabled && (
<div>
<label className={labelClass}>Fuzzing Intensity</label>
<select
value={fuzzingIntensity}
onChange={(e) => setFuzzingIntensity(e.target.value as FuzzingIntensity)}
className={inputClass}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
)}
</div>
{/* Network Chaos */}
<div className={sectionClass}>
<button
type="button"
onClick={() => setChaosExpanded((v) => !v)}
className="flex items-center gap-2 w-full text-left"
>
<span className={`text-xs transition-transform ${chaosExpanded ? 'rotate-90' : ''}`}></span>
<span className="text-sm text-gray-300 font-medium">Network Chaos</span>
{chaosEnabled && (
<span className="ml-2 text-xs bg-orange-900/50 text-orange-300 px-1.5 py-0.5 rounded">enabled</span>
)}
</button>
{chaosExpanded && (
<div className="mt-3 space-y-3">
<div className="flex items-center gap-3">
<input
id="chaosEnabled"
type="checkbox"
checked={chaosEnabled}
onChange={(e) => setChaosEnabled(e.target.checked)}
className="w-4 h-4 accent-orange-500"
/>
<label htmlFor="chaosEnabled" className="text-sm text-gray-300 cursor-pointer">
Enable Network Chaos
</label>
</div>
{chaosEnabled && (
<>
<div>
<label className={labelClass}>Network Profile</label>
<select
value={chaosProfile}
onChange={(e) => setChaosProfile(e.target.value as NetworkProfile)}
className={inputClass}
>
<option value="fast-3g">Fast 3G (~1.5 Mbps)</option>
<option value="slow-3g">Slow 3G (~400 Kbps)</option>
<option value="2g">2G (~200 Kbps)</option>
<option value="offline">Offline</option>
</select>
</div>
<div>
<label className={labelClass}>
Blocked Endpoints <span className="text-gray-500">(comma-separated patterns)</span>
</label>
<input
type="text"
value={blockedEndpoints}
onChange={(e) => setBlockedEndpoints(e.target.value)}
placeholder="*/api/analytics, */ads/*"
className={inputClass}
/>
</div>
<p className="text-xs text-gray-500">
Network throttling uses Chromium CDP. Has no effect on Firefox/WebKit browsers.
</p>
</>
)}
</div>
)}
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-medium py-2 px-4 rounded transition-colors"
>
{loading ? 'Starting…' : 'Start Exploration'}
</button>
</form>
);
}

View File

@@ -0,0 +1,52 @@
import { Link } from 'react-router-dom';
import type { Session } from '../types';
const STATUS_COLOR: Record<string, string> = {
running: 'text-green-400',
completed: 'text-blue-400',
stopped: 'text-yellow-400',
error: 'text-red-400',
};
interface Props {
sessions: Session[];
}
export function SessionList({ sessions }: Props) {
if (sessions.length === 0) {
return <p className="text-gray-500 text-sm">No sessions yet.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead>
<tr className="text-gray-400 border-b border-gray-700">
<th className="pb-2 pr-4">Status</th>
<th className="pb-2 pr-4">URL</th>
<th className="pb-2 pr-4">States</th>
<th className="pb-2 pr-4">Anomalies</th>
<th className="pb-2">Started</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.sessionId} className="border-b border-gray-800 hover:bg-gray-800/40">
<td className={`py-2 pr-4 font-medium ${STATUS_COLOR[s.status] ?? 'text-gray-400'}`}>
{s.status}
</td>
<td className="py-2 pr-4">
<Link to={`/sessions/${s.sessionId}`} className="text-blue-400 hover:underline truncate max-w-xs block">
{s.url}
</Link>
</td>
<td className="py-2 pr-4 text-gray-300">{s.statesVisited}</td>
<td className="py-2 pr-4 text-gray-300">{s.anomaliesFound}</td>
<td className="py-2 text-gray-500">{new Date(s.startedAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { Severity } from '../types';
const STYLES: Record<Severity, string> = {
critical: 'bg-red-500 text-white',
high: 'bg-orange-500 text-white',
medium: 'bg-yellow-500 text-black',
low: 'bg-blue-500 text-white',
};
interface Props {
severity: Severity;
className?: string;
}
export function SeverityBadge({ severity, className = '' }: Props) {
return (
<span
className={`text-xs font-bold px-2 py-1 rounded ${STYLES[severity] ?? 'bg-gray-600 text-white'} ${className}`}
>
{severity.toUpperCase()}
</span>
);
}

View File

@@ -0,0 +1,74 @@
/**
* useApi — fetch helper with error handling.
*/
const BASE = '/api';
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error((body as { error?: string }).error ?? res.statusText);
}
return res.json() as Promise<T>;
}
export const api = {
getSessions: () => apiFetch<import('../types').Session[]>('/sessions'),
getSession: (id: string) => apiFetch<import('../types').Session>(`/sessions/${id}`),
createSession: (body: { url: string; seed: number; config?: Partial<import('../types').ExplorationConfig> }) =>
apiFetch<{ sessionId: string; status: string; startedAt: string }>('/sessions', {
method: 'POST',
body: JSON.stringify(body),
}),
stopSession: (id: string) =>
apiFetch<{ stopped: boolean }>(`/sessions/${id}`, { method: 'DELETE' }),
getSessionPerformance: (id: string) =>
apiFetch<import('../types').PerformanceMetrics[]>(`/sessions/${id}/performance`),
getAnomalies: (params?: { sessionId?: string; severity?: string; type?: string }) => {
const qs = new URLSearchParams();
if (params?.sessionId) qs.set('sessionId', params.sessionId);
if (params?.severity) qs.set('severity', params.severity);
if (params?.type) qs.set('type', params.type);
const q = qs.toString() ? `?${qs.toString()}` : '';
return apiFetch<import('../types').AnomalySummary[]>(`/anomalies${q}`);
},
getAnomaly: (id: string) => apiFetch<import('../types').Anomaly>(`/anomalies/${id}`),
replayAnomaly: (id: string) =>
apiFetch<{ replayId: string; status: string }>(`/anomalies/${id}/replay`, { method: 'POST' }),
enrichAnomaly: (id: string) =>
apiFetch<{ status: string; anomalyId: string }>(`/anomalies/${id}/enrich`, { method: 'POST' }),
getStats: () => apiFetch<import('../types').Stats>('/stats'),
getConfig: () => apiFetch<import('../types').ServerConfig>('/config'),
patchConfig: (body: Partial<import('../types').ServerConfig>) =>
apiFetch<import('../types').ServerConfig>('/config', { method: 'PATCH', body: JSON.stringify(body) }),
// Schedules
getSchedules: () => apiFetch<import('../types').Schedule[]>('/schedules'),
createSchedule: (body: { name: string; url: string; cronExpression: string; config?: object; enabled?: boolean }) =>
apiFetch<import('../types').Schedule>('/schedules', { method: 'POST', body: JSON.stringify(body) }),
patchSchedule: (id: string, body: Partial<{ name: string; url: string; cronExpression: string; enabled: boolean }>) =>
apiFetch<import('../types').Schedule>(`/schedules/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
deleteSchedule: (id: string) =>
apiFetch<void>(`/schedules/${id}`, { method: 'DELETE' }),
// Visual regression
getVisualComparisons: (params?: { sessionId?: string; status?: string }) => {
const qs = new URLSearchParams();
if (params?.sessionId) qs.set('sessionId', params.sessionId);
if (params?.status) qs.set('status', params.status);
const q = qs.toString() ? `?${qs.toString()}` : '';
return apiFetch<import('../types').VisualComparison[]>(`/visual/comparisons${q}`);
},
approveBaseline: (comparisonId: string) =>
apiFetch<{ baselineId: string; status: string }>(`/visual/baselines/${comparisonId}/approve`, { method: 'POST' }),
rejectBaseline: (comparisonId: string) =>
apiFetch<{ status: string }>(`/visual/baselines/${comparisonId}/reject`, { method: 'POST' }),
approveAllBaselines: (sessionId?: string) =>
apiFetch<{ approved: number }>('/visual/baselines/approve-all', { method: 'POST', body: JSON.stringify({ sessionId }) }),
};

View File

@@ -0,0 +1,40 @@
/**
* useSocket — reusable socket.io-client connection.
*/
import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
export type SocketHandler = (event: string, data: unknown) => void;
export function useSocket(onEvent: SocketHandler) {
const socketRef = useRef<Socket | null>(null);
const handlerRef = useRef<SocketHandler>(onEvent);
handlerRef.current = onEvent;
useEffect(() => {
const socket = io({ path: '/socket.io' });
socketRef.current = socket;
const events = [
'session:started',
'state:discovered',
'action:executed',
'anomaly:detected',
'session:completed',
'session:error',
];
events.forEach((evt) => {
socket.on(evt, (data: unknown) => handlerRef.current(evt, data));
});
return () => { socket.disconnect(); };
}, []);
const emit = useCallback((event: string, data: unknown) => {
socketRef.current?.emit(event, data);
}, []);
return { emit };
}

9
frontend/src/index.css Normal file
View File

@@ -0,0 +1,9 @@
@import "tailwindcss";
body {
margin: 0;
min-height: 100vh;
background-color: #111827;
color: #f9fafb;
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,370 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api } from '../hooks/useApi';
import { useSocket } from '../hooks/useSocket';
import { SeverityBadge } from '../components/SeverityBadge';
import type { Anomaly, AIEnrichment, WsAnomalyEnriched } from '../types';
// ─── Provider badge ────────────────────────────────────────────────────────────
const PROVIDER_COLORS: Record<string, string> = {
claude: 'bg-purple-900/50 text-purple-300 border-purple-700',
openai: 'bg-green-900/50 text-green-300 border-green-700',
ollama: 'bg-blue-900/50 text-blue-300 border-blue-700',
};
const PROVIDER_LABELS: Record<string, string> = {
claude: 'Claude',
openai: 'GPT',
ollama: 'Llama',
};
function ProviderBadge({ provider }: { provider: string }) {
const cls = PROVIDER_COLORS[provider] ?? 'bg-gray-700 text-gray-300 border-gray-600';
const label = PROVIDER_LABELS[provider] ?? provider;
return (
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${cls}`}>{label}</span>
);
}
// ─── AI Analysis panel ────────────────────────────────────────────────────────
function AIAnalysisPanel({
anomalyId,
enrichment,
}: {
anomalyId: string;
enrichment?: AIEnrichment;
}) {
const [analyzing, setAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
const [promptCopied, setPromptCopied] = useState(false);
async function handleAnalyze() {
setAnalyzing(true);
setAnalyzeError(null);
try {
await api.enrichAnomaly(anomalyId);
// Result will arrive via WebSocket event anomaly:enriched
} catch (err) {
setAnalyzeError(err instanceof Error ? err.message : 'Enrichment failed');
setAnalyzing(false);
}
}
async function handleCopyPrompt() {
const prompt = enrichment?.debugPrompt ?? '';
if (!prompt) return;
try {
await navigator.clipboard.writeText(prompt);
setPromptCopied(true);
setTimeout(() => setPromptCopied(false), 2000);
} catch {
/* ignore */
}
}
// If enrichment is being waited for (analyzing = true but enrichment arrived via WS)
useEffect(() => {
if (enrichment && analyzing) setAnalyzing(false);
}, [enrichment, analyzing]);
return (
<section className="bg-gray-800 rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-200">AI Analysis</h2>
{enrichment && <ProviderBadge provider={enrichment.provider} />}
</div>
{!enrichment && (
<div className="space-y-3">
<p className="text-sm text-gray-400">
Get AI-powered root cause analysis, user impact assessment, and a suggested fix.
</p>
{analyzeError && <p className="text-red-400 text-sm">{analyzeError}</p>}
<button
onClick={handleAnalyze}
disabled={analyzing}
className="bg-purple-600 hover:bg-purple-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded transition-colors flex items-center gap-2"
>
{analyzing ? (
<>
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
Analyzing
</>
) : (
'✨ Analyze with AI'
)}
</button>
</div>
)}
{enrichment && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-gray-900 rounded p-4">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Root Cause</p>
<p className="text-sm text-gray-200">{enrichment.rootCause}</p>
</div>
<div className="bg-gray-900 rounded p-4">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">User Impact</p>
<p className="text-sm text-gray-200">{enrichment.userImpact}</p>
</div>
<div className="bg-gray-900 rounded p-4">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Suggested Fix</p>
<p className="text-sm text-gray-200">{enrichment.suggestedFix}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 items-center text-xs text-gray-500">
<span>Confidence: <strong className="text-gray-300">{enrichment.confidence}</strong></span>
<span>·</span>
<span>Model: <strong className="text-gray-300">{enrichment.model}</strong></span>
<span>·</span>
<span>{new Date(enrichment.generatedAt).toLocaleString()}</span>
</div>
{enrichment.debugPrompt && (
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">Debug Prompt</p>
<button
onClick={handleCopyPrompt}
className="text-xs bg-gray-700 hover:bg-gray-600 text-gray-200 px-3 py-1 rounded transition-colors"
>
{promptCopied ? 'Copied!' : 'Copy debug prompt'}
</button>
</div>
<pre className="text-xs text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto whitespace-pre-wrap max-h-48 overflow-y-auto">
{enrichment.debugPrompt}
</pre>
</div>
)}
</div>
)}
</section>
);
}
// ─── AnomalyDetail ────────────────────────────────────────────────────────────
export function AnomalyDetail() {
const { anomalyId } = useParams<{ anomalyId: string }>();
const [anomaly, setAnomaly] = useState<Anomaly | null>(null);
const [replayStatus, setReplayStatus] = useState<string | null>(null);
const [replayLoading, setReplayLoading] = useState(false);
const [copyStatus, setCopyStatus] = useState<string | null>(null);
const loadAnomaly = useCallback(() => {
if (!anomalyId) return;
api.getAnomaly(anomalyId).then(setAnomaly).catch(() => null);
}, [anomalyId]);
useEffect(() => { loadAnomaly(); }, [loadAnomaly]);
// Listen for AI enrichment via WebSocket
useSocket(useCallback((event, data) => {
if (event === 'anomaly:enriched') {
const d = data as WsAnomalyEnriched;
if (d.anomalyId === anomalyId) {
setAnomaly((prev) => prev ? { ...prev, aiEnrichment: d.enrichment } : prev);
}
}
}, [anomalyId]));
async function handleReplay() {
if (!anomalyId) return;
setReplayLoading(true);
try {
const res = await api.replayAnomaly(anomalyId);
setReplayStatus(`Replay started (${res.replayId})`);
} catch (err) {
setReplayStatus(`Error: ${err instanceof Error ? err.message : 'unknown'}`);
} finally {
setReplayLoading(false);
}
}
async function handleCopyReplayCommand() {
if (!anomalyId) return;
const cmd = `abe replay --anomaly-id ${anomalyId}`;
try {
await navigator.clipboard.writeText(cmd);
setCopyStatus('Copied!');
setTimeout(() => setCopyStatus(null), 2000);
} catch {
setCopyStatus('Failed to copy');
setTimeout(() => setCopyStatus(null), 2000);
}
}
if (!anomaly) {
return <div className="max-w-4xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
}
return (
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header */}
<header>
<Link to="/" className="text-blue-400 text-sm hover:underline"> Dashboard</Link>
{anomaly.sessionId && (
<Link to={`/sessions/${anomaly.sessionId}`} className="text-blue-400 text-sm hover:underline ml-4">
Session
</Link>
)}
<div className="flex items-center gap-3 mt-2 flex-wrap">
<SeverityBadge severity={anomaly.severity} />
<h1 className="text-xl font-bold text-white">{anomaly.type}</h1>
{anomaly.browser && (
<span className={`text-xs px-2 py-0.5 rounded font-medium border ${
anomaly.browser === 'chromium' ? 'bg-blue-900/50 text-blue-300 border-blue-700' :
anomaly.browser === 'firefox' ? 'bg-orange-900/50 text-orange-300 border-orange-700' :
'bg-purple-900/50 text-purple-300 border-purple-700'
}`}>
{anomaly.browser}
</span>
)}
{anomaly.aiEnrichment && <ProviderBadge provider={anomaly.aiEnrichment.provider} />}
</div>
<p className="text-gray-400 mt-1">{anomaly.description}</p>
<p className="text-gray-600 text-xs mt-1">{new Date(anomaly.timestamp).toLocaleString()}</p>
</header>
{/* AI Analysis panel */}
<AIAnalysisPanel
anomalyId={anomaly.id}
enrichment={anomaly.aiEnrichment}
/>
{/* Reproduction Steps */}
<section className="bg-gray-800 rounded-lg p-5">
<h2 className="text-base font-semibold text-gray-200 mb-3">Reproduction Steps</h2>
{anomaly.actionTrace.length === 0 ? (
<p className="text-gray-500 text-sm">No action trace recorded.</p>
) : (
<ol className="space-y-2 list-decimal list-inside">
{anomaly.actionTrace.map((action) => (
<li key={action.id} className="text-sm text-gray-300">
<span className="text-yellow-400 font-medium">{action.type}</span>
{action.selector && <span className="text-gray-400 ml-2">{action.selector}</span>}
{action.value && <span className="text-gray-500 ml-2"> "{action.value}"</span>}
{action.url && <span className="text-blue-400 ml-2">{action.url}</span>}
</li>
))}
</ol>
)}
</section>
{/* Evidence */}
<section className="bg-gray-800 rounded-lg p-5 space-y-4">
<h2 className="text-base font-semibold text-gray-200">Evidence</h2>
{anomaly.screenshotUrl ? (
<div>
<p className="text-sm text-gray-400 mb-2">Screenshot</p>
<img src={anomaly.screenshotUrl} alt="Bug screenshot" className="max-w-full rounded border border-gray-700" />
</div>
) : (
<p className="text-gray-500 text-sm">No screenshot available.</p>
)}
{anomaly.evidence.domSnapshotPath && (
<div>
<a
href={`/api/anomalies/${anomaly.id}/dom`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 text-sm hover:underline"
>
View DOM Snapshot
</a>
</div>
)}
</section>
{/* HTTP Log */}
{anomaly.evidence.httpLog && anomaly.evidence.httpLog.length > 0 && (
<section className="bg-gray-800 rounded-lg p-5">
<h2 className="text-base font-semibold text-gray-200 mb-3">HTTP Log</h2>
<table className="w-full text-xs text-left">
<thead>
<tr className="text-gray-400 border-b border-gray-700">
<th className="pb-2 pr-3">Method</th>
<th className="pb-2 pr-3">Status</th>
<th className="pb-2 pr-3">Duration</th>
<th className="pb-2">URL</th>
</tr>
</thead>
<tbody>
{anomaly.evidence.httpLog.map((r, i) => (
<tr key={i} className="border-b border-gray-900">
<td className="py-1 pr-3 text-yellow-400">{r.method}</td>
<td className={`py-1 pr-3 font-bold ${r.status >= 400 ? 'text-red-400' : 'text-green-400'}`}>{r.status}</td>
<td className="py-1 pr-3 text-gray-400">{r.durationMs}ms</td>
<td className="py-1 text-gray-300 truncate max-w-xs">{r.url}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{/* Raw Errors */}
{anomaly.evidence.rawErrors && anomaly.evidence.rawErrors.length > 0 && (
<section className="bg-gray-800 rounded-lg p-5">
<h2 className="text-base font-semibold text-gray-200 mb-3">Raw Errors</h2>
<pre className="text-xs text-red-300 bg-gray-900 rounded p-3 overflow-x-auto whitespace-pre-wrap">
{anomaly.evidence.rawErrors.join('\n')}
</pre>
</section>
)}
{/* Replay */}
<section className="bg-gray-800 rounded-lg p-5">
<h2 className="text-base font-semibold text-gray-200 mb-3">Replay</h2>
<div className="flex flex-wrap gap-2 items-center">
<button
onClick={handleReplay}
disabled={replayLoading}
className="bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded transition-colors"
>
{replayLoading ? 'Starting…' : 'Run Replay'}
</button>
<a
href={`/api/anomalies/${anomalyId}/report.json`}
target="_blank"
rel="noopener noreferrer"
className="bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium px-4 py-2 rounded transition-colors"
>
Download JSON
</a>
<a
href={`/api/anomalies/${anomalyId}/report.md`}
target="_blank"
rel="noopener noreferrer"
className="bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium px-4 py-2 rounded transition-colors"
>
Download Markdown
</a>
<button
onClick={handleCopyReplayCommand}
className="bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium px-4 py-2 rounded transition-colors"
>
{copyStatus ?? 'Copy Replay Command'}
</button>
</div>
{replayStatus && <p className="text-sm text-gray-400 mt-2">{replayStatus}</p>}
<div className="mt-3 bg-gray-900 rounded px-3 py-2">
<code className="text-xs text-gray-300 font-mono">
abe replay --anomaly-id {anomalyId}
</code>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { api } from '../hooks/useApi';
import { useSocket } from '../hooks/useSocket';
import { NewSessionForm } from '../components/NewSessionForm';
import { SessionList } from '../components/SessionList';
import { AnomalyList } from '../components/AnomalyList';
import type { Session, AnomalySummary, Stats } from '../types';
export function Dashboard() {
const navigate = useNavigate();
const [sessions, setSessions] = useState<Session[]>([]);
const [anomalies, setAnomalies] = useState<AnomalySummary[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [showForm, setShowForm] = useState(false);
const load = useCallback(async () => {
const [s, a, st] = await Promise.all([api.getSessions(), api.getAnomalies(), api.getStats()]);
setSessions(s);
setAnomalies(a);
setStats(st);
}, []);
useEffect(() => { load(); }, [load]);
useSocket(useCallback((event, _data) => {
if (['session:started', 'session:completed', 'session:error', 'anomaly:detected'].includes(event)) {
load();
}
}, [load]));
function handleCreated(sessionId: string) {
setShowForm(false);
navigate(`/sessions/${sessionId}`);
}
return (
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">ABE Dashboard</h1>
<p className="text-gray-400 text-sm mt-1">Autonomous Bug Explorer</p>
</div>
<div className="flex gap-3 items-center">
<Link to="/settings" className="text-gray-400 hover:text-white text-sm transition-colors">Settings</Link>
<button
onClick={() => setShowForm((v) => !v)}
className="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded transition-colors"
>
{showForm ? 'Cancel' : '+ New Exploration'}
</button>
</div>
</header>
{/* Stats Bar */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-gray-800 rounded-lg p-4 text-center">
<p className="text-2xl font-bold text-white">{stats.totalSessions}</p>
<p className="text-gray-400 text-xs mt-1">Total Sessions</p>
</div>
<div className="bg-gray-800 rounded-lg p-4 text-center">
<p className="text-2xl font-bold text-white">{stats.totalAnomalies}</p>
<p className="text-gray-400 text-xs mt-1">Total Anomalies</p>
</div>
<div className="bg-gray-800 rounded-lg p-4 text-center">
<p className={`text-2xl font-bold ${stats.criticalHighCount > 0 ? 'text-red-400' : 'text-white'}`}>
{stats.criticalHighCount}
</p>
<p className="text-gray-400 text-xs mt-1">Critical / High</p>
</div>
<div className="bg-gray-800 rounded-lg p-4 text-center">
<p className={`text-2xl font-bold ${stats.runningSessions > 0 ? 'text-green-400' : 'text-white'}`}>
{stats.runningSessions}
</p>
<p className="text-gray-400 text-xs mt-1">Running Now</p>
</div>
</div>
)}
{showForm && <NewSessionForm onCreated={handleCreated} />}
<section>
<h2 className="text-base font-semibold text-gray-300 mb-3">Sessions</h2>
<SessionList sessions={sessions} />
</section>
<AnomalyList anomalies={anomalies} title="Recent Anomalies" sessions={sessions} />
</div>
);
}

View File

@@ -0,0 +1,247 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api } from '../hooks/useApi';
import { useSocket } from '../hooks/useSocket';
import { LiveFeed, type FeedEvent } from '../components/LiveFeed';
import { AnomalyList } from '../components/AnomalyList';
import type { Session, AnomalySummary, PerformanceMetrics, WsStateDiscovered, WsActionExecuted, WsAnomalyDetected, WsSessionCompleted, WsSessionError, WsSessionStarted } from '../types';
let eventCounter = 0;
function nextId() { return String(++eventCounter); }
// ─── Performance helpers ───────────────────────────────────────────────────────
function metricColor(value: number, warn: number, bad: number): string {
if (value >= bad) return 'text-red-400';
if (value >= warn) return 'text-yellow-400';
return 'text-green-400';
}
function lcpColor(ms: number) { return metricColor(ms, 2500, 4000); }
function ttfbColor(ms: number) { return metricColor(ms, 800, 1800); }
function clsColor(v: number) { return metricColor(v, 0.1, 0.25); }
function inpColor(ms: number) { return metricColor(ms, 200, 500); }
function PerformanceTab({ sessionId }: { sessionId: string }) {
const [metrics, setMetrics] = useState<PerformanceMetrics[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
api.getSessionPerformance(sessionId)
.then(setMetrics)
.catch(() => setMetrics([]))
.finally(() => setLoading(false));
}, [sessionId]);
if (loading) return <p className="text-gray-400 text-sm">Loading performance data</p>;
if (metrics.length === 0) {
return (
<div className="bg-gray-800 rounded-lg p-8 text-center text-gray-500 text-sm">
No performance metrics recorded. Run a session with performance monitoring enabled.
</div>
);
}
// Bar chart: LCP per state
const maxLcp = Math.max(...metrics.filter((m) => m.lcp !== null).map((m) => m.lcp!), 1);
return (
<div className="space-y-6">
{/* Summary table */}
<div className="bg-gray-800 rounded-lg overflow-hidden">
<table className="w-full text-xs text-left">
<thead>
<tr className="bg-gray-900 text-gray-400">
<th className="px-4 py-3">URL</th>
<th className="px-4 py-3">TTFB</th>
<th className="px-4 py-3">LCP</th>
<th className="px-4 py-3">CLS</th>
<th className="px-4 py-3">INP</th>
<th className="px-4 py-3">Load</th>
</tr>
</thead>
<tbody>
{metrics.map((m) => (
<tr key={m.id} className="border-t border-gray-700">
<td className="px-4 py-2 text-gray-300 truncate max-w-xs">{m.url}</td>
<td className={`px-4 py-2 font-mono ${ttfbColor(m.ttfb)}`}>{m.ttfb}ms</td>
<td className={`px-4 py-2 font-mono ${m.lcp !== null ? lcpColor(m.lcp) : 'text-gray-500'}`}>
{m.lcp !== null ? `${Math.round(m.lcp)}ms` : '—'}
</td>
<td className={`px-4 py-2 font-mono ${m.cls !== null ? clsColor(m.cls) : 'text-gray-500'}`}>
{m.cls !== null ? m.cls.toFixed(3) : '—'}
</td>
<td className={`px-4 py-2 font-mono ${m.inp !== null ? inpColor(m.inp) : 'text-gray-500'}`}>
{m.inp !== null ? `${Math.round(m.inp)}ms` : '—'}
</td>
<td className="px-4 py-2 font-mono text-gray-400">{m.loadComplete}ms</td>
</tr>
))}
</tbody>
</table>
</div>
{/* LCP bar chart */}
{metrics.some((m) => m.lcp !== null) && (
<div className="bg-gray-800 rounded-lg p-5">
<h3 className="text-sm font-semibold text-gray-300 mb-4">LCP by State</h3>
<div className="space-y-2">
{metrics.filter((m) => m.lcp !== null).map((m) => {
const pct = (m.lcp! / maxLcp) * 100;
return (
<div key={m.id} className="flex items-center gap-3">
<span className="text-xs text-gray-400 w-48 truncate shrink-0">{m.url}</span>
<div className="flex-1 bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className={`h-3 rounded-full transition-all ${
m.lcp! >= 4000 ? 'bg-red-500' : m.lcp! >= 2500 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-mono w-16 text-right shrink-0 ${lcpColor(m.lcp!)}`}>
{Math.round(m.lcp!)}ms
</span>
</div>
);
})}
</div>
<div className="flex gap-4 mt-3 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-green-500 rounded-full inline-block" /> &lt; 2500ms Good</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-yellow-500 rounded-full inline-block" /> 25004000ms Needs improvement</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-red-500 rounded-full inline-block" /> &gt; 4000ms Poor</span>
</div>
</div>
)}
</div>
);
}
// ─── SessionDetail ────────────────────────────────────────────────────────────
type Tab = 'feed' | 'anomalies' | 'performance';
export function SessionDetail() {
const { sessionId } = useParams<{ sessionId: string }>();
const [session, setSession] = useState<Session | null>(null);
const [anomalies, setAnomalies] = useState<AnomalySummary[]>([]);
const [feedEvents, setFeedEvents] = useState<FeedEvent[]>([]);
const [stopping, setStopping] = useState(false);
const [tab, setTab] = useState<Tab>('feed');
const loadSession = useCallback(async () => {
if (!sessionId) return;
const s = await api.getSession(sessionId).catch(() => null);
setSession(s);
}, [sessionId]);
const loadAnomalies = useCallback(async () => {
if (!sessionId) return;
const a = await api.getAnomalies({ sessionId }).catch(() => []);
setAnomalies(a);
}, [sessionId]);
useEffect(() => {
loadSession();
loadAnomalies();
}, [loadSession, loadAnomalies]);
function pushEvent(event: string, text: string) {
setFeedEvents((prev) => [...prev, { id: nextId(), event, text, timestamp: Date.now() }]);
}
useSocket(useCallback((event, data) => {
const d = data as Record<string, unknown>;
if (d['sessionId'] !== sessionId) return;
switch (event) {
case 'session:started':
pushEvent(event, `Started: ${(d as unknown as WsSessionStarted).url}`);
loadSession();
break;
case 'state:discovered':
pushEvent(event, `${(d as unknown as WsStateDiscovered).url}${(d as unknown as WsStateDiscovered).title}`);
loadSession();
break;
case 'action:executed':
pushEvent(event, `${(d as unknown as WsActionExecuted).actionType} ${(d as unknown as WsActionExecuted).selector ?? ''}`);
break;
case 'anomaly:detected':
pushEvent(event, `[${(d as unknown as WsAnomalyDetected).severity}] ${(d as unknown as WsAnomalyDetected).description}`);
loadAnomalies();
break;
case 'session:completed':
pushEvent(event, `Done — ${(d as unknown as WsSessionCompleted).statesVisited} states, ${(d as unknown as WsSessionCompleted).anomaliesFound} anomalies`);
loadSession();
break;
case 'session:error':
pushEvent(event, `Error: ${(d as unknown as WsSessionError).error}`);
loadSession();
break;
}
}, [sessionId, loadSession, loadAnomalies]));
async function handleStop() {
if (!sessionId) return;
setStopping(true);
await api.stopSession(sessionId).catch(() => null);
await loadSession();
setStopping(false);
}
if (!session) {
return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
}
const TABS: Array<{ key: Tab; label: string }> = [
{ key: 'feed', label: 'Live Feed' },
{ key: 'anomalies', label: `Anomalies (${anomalies.length})` },
{ key: 'performance', label: 'Performance' },
];
return (
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
<header className="flex items-start justify-between">
<div>
<Link to="/" className="text-blue-400 text-sm hover:underline"> Dashboard</Link>
<h1 className="text-xl font-bold text-white mt-1 truncate">{session.url}</h1>
<div className="flex gap-4 text-sm text-gray-400 mt-1">
<span>Status: <strong className="text-white">{session.status}</strong></span>
<span>Seed: <strong className="text-white">{session.seed ?? '—'}</strong></span>
<span>States: <strong className="text-white">{session.statesVisited}</strong></span>
<span>Anomalies: <strong className="text-white">{session.anomaliesFound}</strong></span>
</div>
</div>
{session.status === 'running' && (
<button
onClick={handleStop}
disabled={stopping}
className="bg-red-600 hover:bg-red-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded transition-colors"
>
{stopping ? 'Stopping…' : 'Stop'}
</button>
)}
</header>
{/* Tab bar */}
<div className="flex gap-1 bg-gray-800 rounded-lg p-1">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 py-2 text-sm rounded font-medium transition-colors ${
tab === t.key ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'feed' && <LiveFeed events={feedEvents} />}
{tab === 'anomalies' && <AnomalyList anomalies={anomalies} title="Anomalies Found" />}
{tab === 'performance' && sessionId && <PerformanceTab sessionId={sessionId} />}
</div>
);
}

View File

@@ -0,0 +1,414 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../hooks/useApi';
import type { ServerConfig, NotifyMinSeverity, Schedule } from '../types';
const MOCK_API_KEY = 'abe_sk_mockkey1234567890';
const CRON_PRESETS = [
{ label: 'Every minute', value: '* * * * *' },
{ label: 'Every 5 minutes', value: '*/5 * * * *' },
{ label: 'Every 15 minutes', value: '*/15 * * * *' },
{ label: 'Every hour', value: '0 * * * *' },
{ label: 'Every 6 hours', value: '0 */6 * * *' },
{ label: 'Daily at 2am', value: '0 2 * * *' },
{ label: 'Weekly (Mon 9am)', value: '0 9 * * 1' },
{ label: 'Custom', value: 'custom' },
];
function NewScheduleModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
const [name, setName] = useState('');
const [url, setUrl] = useState('http://localhost:3000');
const [preset, setPreset] = useState(CRON_PRESETS[3]!.value);
const [customCron, setCustomCron] = useState('');
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const cronExpression = preset === 'custom' ? customCron : preset;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!cronExpression.trim()) {
setError('Cron expression is required');
return;
}
setSaving(true);
setError(null);
try {
await api.createSchedule({ name, url, cronExpression: cronExpression.trim(), enabled });
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create schedule');
} finally {
setSaving(false);
}
}
const inputClass = 'w-full bg-gray-700 text-white rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500';
const labelClass = 'block text-sm text-gray-400 mb-1';
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-md shadow-xl space-y-4" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center">
<h3 className="text-base font-semibold text-white">New Schedule</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white text-lg">&times;</button>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className={labelClass}>Name <span className="text-red-400">*</span></label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} required className={inputClass} placeholder="Daily smoke test" />
</div>
<div>
<label className={labelClass}>Target URL <span className="text-red-400">*</span></label>
<input type="url" value={url} onChange={(e) => setUrl(e.target.value)} required className={inputClass} />
</div>
<div>
<label className={labelClass}>Frequency</label>
<select value={preset} onChange={(e) => setPreset(e.target.value)} className={inputClass}>
{CRON_PRESETS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
{preset === 'custom' && (
<div>
<label className={labelClass}>Custom Cron Expression</label>
<input
type="text"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder="0 */2 * * *"
className={inputClass}
/>
<p className="text-xs text-gray-500 mt-1">Format: minute hour day-of-month month day-of-week</p>
</div>
)}
{preset !== 'custom' && (
<p className="text-xs text-gray-500">
Cron: <code className="bg-gray-700 px-1 rounded">{cronExpression}</code>
</p>
)}
<div className="flex items-center gap-2">
<input
id="schedEnabled"
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="w-4 h-4 accent-blue-500"
/>
<label htmlFor="schedEnabled" className="text-sm text-gray-300 cursor-pointer">Enable immediately</label>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={saving}
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium py-2 rounded transition-colors"
>
{saving ? 'Creating…' : 'Create Schedule'}
</button>
<button type="button" onClick={onClose} className="px-4 bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm rounded">
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export function Settings() {
const [_config, setConfig] = useState<ServerConfig | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [slackUrl, setSlackUrl] = useState('');
const [minSeverity, setMinSeverity] = useState<NotifyMinSeverity>('high');
const [notifySaving, setNotifySaving] = useState(false);
const [notifySaved, setNotifySaved] = useState(false);
const [notifyError, setNotifyError] = useState<string | null>(null);
const [defaultMaxStates, setDefaultMaxStates] = useState(50);
const [defaultMaxDepth, setDefaultMaxDepth] = useState(5);
const [defaultActionDelayMs, setDefaultActionDelayMs] = useState(500);
const [defaultExcludedPaths, setDefaultExcludedPaths] = useState('');
const [configSaving, setConfigSaving] = useState(false);
const [configSaved, setConfigSaved] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
const [apiKeyCopied, setApiKeyCopied] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'schedules'>('general');
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [showNewSchedule, setShowNewSchedule] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
api.getConfig()
.then((c) => {
setConfig(c);
setSlackUrl(c.slackWebhookUrl ?? '');
setMinSeverity(c.notifyMinSeverity);
setDefaultMaxStates(c.defaultMaxStates);
setDefaultMaxDepth(c.defaultMaxDepth);
setDefaultActionDelayMs(c.defaultActionDelayMs);
setDefaultExcludedPaths((c.defaultExcludedPaths ?? []).join(', '));
})
.catch((err) => {
setLoadError(err instanceof Error ? err.message : 'Failed to load config');
})
.finally(() => setLoading(false));
}, []);
function loadSchedules() {
api.getSchedules().then(setSchedules).catch(() => null);
}
useEffect(() => {
if (activeTab === 'schedules') loadSchedules();
}, [activeTab]);
async function handleCopyApiKey() {
try {
await navigator.clipboard.writeText(MOCK_API_KEY);
setApiKeyCopied(true);
setTimeout(() => setApiKeyCopied(false), 2000);
} catch { /* ignore */ }
}
async function handleSaveNotifications(e: React.FormEvent) {
e.preventDefault();
setNotifySaving(true); setNotifySaved(false); setNotifyError(null);
try {
const updated = await api.patchConfig({ slackWebhookUrl: slackUrl || null, notifyMinSeverity: minSeverity });
setConfig(updated); setNotifySaved(true);
setTimeout(() => setNotifySaved(false), 3000);
} catch (err) {
setNotifyError(err instanceof Error ? err.message : 'Failed to save');
} finally { setNotifySaving(false); }
}
async function handleSaveDefaultConfig(e: React.FormEvent) {
e.preventDefault();
setConfigSaving(true); setConfigSaved(false); setConfigError(null);
try {
const parsedPaths = defaultExcludedPaths
? defaultExcludedPaths.split(',').map((s) => s.trim()).filter(Boolean)
: [];
const updated = await api.patchConfig({ defaultMaxStates, defaultMaxDepth, defaultActionDelayMs, defaultExcludedPaths: parsedPaths });
setConfig(updated); setConfigSaved(true);
setTimeout(() => setConfigSaved(false), 3000);
} catch (err) {
setConfigError(err instanceof Error ? err.message : 'Failed to save');
} finally { setConfigSaving(false); }
}
async function handleToggle(schedule: Schedule) {
setTogglingId(schedule.id);
try { await api.patchSchedule(schedule.id, { enabled: !schedule.enabled }); loadSchedules(); }
catch { /* ignore */ }
finally { setTogglingId(null); }
}
async function handleDelete(id: string) {
if (!confirm('Delete this schedule?')) return;
setDeletingId(id);
try { await api.deleteSchedule(id); loadSchedules(); }
catch { /* ignore */ }
finally { setDeletingId(null); }
}
const inputClass = 'w-full bg-gray-700 text-white rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500';
const labelClass = 'block text-sm text-gray-400 mb-1';
const sectionClass = 'bg-gray-800 rounded-lg p-6 space-y-4';
return (
<div className="max-w-3xl mx-auto px-4 py-8 space-y-8">
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-gray-400 text-sm mt-1">ABE configuration</p>
</div>
<Link to="/" className="text-blue-400 hover:text-blue-300 text-sm transition-colors"> Dashboard</Link>
</header>
{/* Tab Bar */}
<div className="flex gap-1 bg-gray-800 rounded-lg p-1">
{(['general', 'schedules'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-2 text-sm rounded font-medium transition-colors capitalize ${
activeTab === tab ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
{tab}
</button>
))}
</div>
{/* ── General Tab ── */}
{activeTab === 'general' && (
<>
{loading && <p className="text-gray-400 text-sm">Loading configuration</p>}
{loadError && (
<div className="bg-red-900/40 border border-red-700 rounded p-3 text-sm text-red-300">
{loadError} some fields may show defaults.
</div>
)}
<div className={sectionClass}>
<h2 className="text-base font-semibold text-white">API Key</h2>
<p className="text-sm text-gray-400">
Set via <code className="bg-gray-700 px-1 rounded text-gray-200">ABE_API_KEY</code> environment variable.
</p>
<div className="flex items-center gap-3">
<code className="flex-1 bg-gray-900 text-gray-300 text-xs font-mono px-3 py-2 rounded truncate">{MOCK_API_KEY}</code>
<button
type="button"
onClick={handleCopyApiKey}
className="bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium px-4 py-2 rounded transition-colors shrink-0"
>
{apiKeyCopied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<form onSubmit={handleSaveNotifications} className={sectionClass}>
<h2 className="text-base font-semibold text-white">Notifications</h2>
<p className="text-sm text-gray-400">Configure Slack alerts for detected anomalies.</p>
<div>
<label className={labelClass}>Slack Webhook URL</label>
<input type="url" value={slackUrl} onChange={(e) => setSlackUrl(e.target.value)} placeholder="https://hooks.slack.com/services/..." className={inputClass} />
</div>
<div>
<label className={labelClass}>Minimum Severity to Notify</label>
<select value={minSeverity} onChange={(e) => setMinSeverity(e.target.value as NotifyMinSeverity)} className={inputClass}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
{notifyError && <p className="text-red-400 text-sm">{notifyError}</p>}
{notifySaved && <p className="text-green-400 text-sm">Saved successfully.</p>}
<button type="submit" disabled={notifySaving} className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded transition-colors">
{notifySaving ? 'Saving…' : 'Save Notifications'}
</button>
</form>
<form onSubmit={handleSaveDefaultConfig} className={sectionClass}>
<h2 className="text-base font-semibold text-white">Default Exploration Config</h2>
<p className="text-sm text-gray-400">These values pre-fill the New Exploration form.</p>
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>Default Max States</label>
<input type="number" value={defaultMaxStates} onChange={(e) => setDefaultMaxStates(Number(e.target.value))} className={inputClass} />
</div>
<div>
<label className={labelClass}>Default Max Depth</label>
<input type="number" value={defaultMaxDepth} onChange={(e) => setDefaultMaxDepth(Number(e.target.value))} className={inputClass} />
</div>
<div>
<label className={labelClass}>Action Delay (ms)</label>
<input type="number" value={defaultActionDelayMs} onChange={(e) => setDefaultActionDelayMs(Number(e.target.value))} className={inputClass} />
</div>
</div>
<div>
<label className={labelClass}>Default Excluded Paths <span className="text-gray-500">(comma-separated)</span></label>
<input type="text" value={defaultExcludedPaths} onChange={(e) => setDefaultExcludedPaths(e.target.value)} placeholder="/logout, /admin" className={inputClass} />
</div>
{configError && <p className="text-red-400 text-sm">{configError}</p>}
{configSaved && <p className="text-green-400 text-sm">Saved successfully.</p>}
<button type="submit" disabled={configSaving} className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded transition-colors">
{configSaving ? 'Saving…' : 'Save Config'}
</button>
</form>
<div className={sectionClass}>
<h2 className="text-base font-semibold text-white">About</h2>
<div className="space-y-1 text-sm text-gray-400">
<p><span className="text-gray-200 font-medium">ABE</span> Autonomous Bug Explorer</p>
<p>Version <span className="text-gray-200 font-mono">0.1.0</span></p>
<p className="text-gray-500 text-xs mt-2">An open-source framework that autonomously explores web apps, provokes failures, and generates reproducible bug reports.</p>
</div>
</div>
</>
)}
{/* ── Schedules Tab ── */}
{activeTab === 'schedules' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-400">Automated scheduled explorations using cron expressions.</p>
<button
onClick={() => setShowNewSchedule(true)}
className="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded transition-colors"
>
+ New Schedule
</button>
</div>
{schedules.length === 0 ? (
<div className="bg-gray-800 rounded-lg p-8 text-center text-gray-500 text-sm">
No schedules yet. Create one to start continuous monitoring.
</div>
) : (
<div className="space-y-2">
{schedules.map((sched) => (
<div key={sched.id} className="bg-gray-800 rounded-lg p-4 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-white font-medium text-sm">{sched.name}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${sched.enabled ? 'bg-green-900/50 text-green-300' : 'bg-gray-700 text-gray-400'}`}>
{sched.enabled ? 'enabled' : 'disabled'}
</span>
</div>
<p className="text-gray-400 text-xs mt-0.5 truncate">{sched.url}</p>
<code className="text-gray-500 text-xs">{sched.cronExpression}</code>
{sched.lastRunAt && (
<p className="text-gray-600 text-xs mt-0.5">Last run: {new Date(sched.lastRunAt).toLocaleString()}</p>
)}
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => handleToggle(sched)}
disabled={togglingId === sched.id}
className="text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-200 transition-colors disabled:opacity-50"
>
{togglingId === sched.id ? '…' : sched.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDelete(sched.id)}
disabled={deletingId === sched.id}
className="text-xs px-3 py-1.5 rounded bg-red-900/40 hover:bg-red-800/50 text-red-300 transition-colors disabled:opacity-50"
>
{deletingId === sched.id ? '…' : 'Delete'}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{showNewSchedule && (
<NewScheduleModal onCreated={loadSchedules} onClose={() => setShowNewSchedule(false)} />
)}
</div>
);
}

View File

@@ -0,0 +1,281 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../hooks/useApi';
import type { VisualComparison, ComparisonStatus } from '../types';
const STATUS_LABEL: Record<ComparisonStatus, string> = {
new_state: 'New State',
failed: 'Regression',
passed: 'Passed',
pending: 'Pending',
};
const STATUS_COLORS: Record<ComparisonStatus, string> = {
new_state: 'bg-blue-900/50 text-blue-300 border-blue-700',
failed: 'bg-red-900/50 text-red-300 border-red-700',
passed: 'bg-green-900/50 text-green-300 border-green-700',
pending: 'bg-gray-700 text-gray-300 border-gray-600',
};
function screenshotUrl(path: string | null): string | null {
if (!path) return null;
// Backend serves screenshots from /api/screenshots/<filename>
const parts = path.replace(/\\/g, '/').split('/');
return `/api/screenshots/${parts[parts.length - 1]}`;
}
interface ComparisonModalProps {
comparison: VisualComparison;
onApprove: () => void;
onReject: () => void;
onClose: () => void;
}
function ComparisonModal({ comparison, onApprove, onReject, onClose }: ComparisonModalProps) {
const [acting, setActing] = useState<'approving' | 'rejecting' | null>(null);
async function handleApprove() {
setActing('approving');
try { await api.approveBaseline(comparison.id); onApprove(); }
finally { setActing(null); }
}
async function handleReject() {
setActing('rejecting');
try { await api.rejectBaseline(comparison.id); onReject(); }
finally { setActing(null); }
}
const currentUrl = screenshotUrl(comparison.current_screenshot_path);
const diffUrl = screenshotUrl(comparison.diff_screenshot_path);
return (
<div
className="fixed inset-0 bg-black/70 flex items-start justify-center z-50 overflow-y-auto py-8"
onClick={onClose}
>
<div
className="bg-gray-800 rounded-lg w-full max-w-5xl mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-700">
<div>
<h3 className="text-base font-semibold text-white">Visual Review</h3>
<p className="text-xs text-gray-400 mt-0.5">
State: <code className="bg-gray-700 px-1 rounded">{comparison.state_id.slice(0, 12)}</code>
{comparison.diff_percent !== null && (
<span className="ml-3">Diff: <strong className="text-red-400">{comparison.diff_percent.toFixed(2)}%</strong></span>
)}
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white text-xl leading-none">&times;</button>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Baseline */}
<div className="space-y-2">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">Baseline</p>
{comparison.baseline_id ? (
<div className="bg-gray-900 rounded overflow-hidden">
<img src={`/api/visual/baseline-screenshot/${comparison.baseline_id}`} alt="Baseline" className="w-full" />
</div>
) : (
<div className="bg-gray-900 rounded p-8 text-center text-gray-500 text-sm">No baseline</div>
)}
</div>
{/* Current */}
<div className="space-y-2">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">Current</p>
{currentUrl ? (
<div className="bg-gray-900 rounded overflow-hidden">
<img src={currentUrl} alt="Current screenshot" className="w-full" />
</div>
) : (
<div className="bg-gray-900 rounded p-8 text-center text-gray-500 text-sm">No screenshot</div>
)}
</div>
{/* Diff */}
<div className="space-y-2">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">Diff</p>
{diffUrl ? (
<div className="bg-gray-900 rounded overflow-hidden">
<img src={diffUrl} alt="Diff image" className="w-full" />
</div>
) : (
<div className="bg-gray-900 rounded p-8 text-center text-gray-500 text-sm">No diff</div>
)}
</div>
</div>
<div className="px-6 pb-6 flex gap-3">
{comparison.status !== 'passed' && (
<button
onClick={handleApprove}
disabled={!!acting}
className="bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white text-sm font-medium px-5 py-2 rounded transition-colors"
>
{acting === 'approving' ? 'Approving…' : 'Approve as Baseline'}
</button>
)}
{comparison.status === 'failed' && (
<button
onClick={handleReject}
disabled={!!acting}
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-medium px-5 py-2 rounded transition-colors"
>
{acting === 'rejecting' ? 'Rejecting…' : 'Mark as Rejected'}
</button>
)}
<button
onClick={onClose}
className="bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium px-5 py-2 rounded transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}
const ALL_STATUSES: Array<ComparisonStatus | 'all'> = ['all', 'new_state', 'failed', 'passed', 'pending'];
export function VisualReview() {
const [comparisons, setComparisons] = useState<VisualComparison[]>([]);
const [statusFilter, setStatusFilter] = useState<ComparisonStatus | 'all'>('all');
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<VisualComparison | null>(null);
const [approvingAll, setApprovingAll] = useState(false);
const [bulkResult, setBulkResult] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
api
.getVisualComparisons(statusFilter !== 'all' ? { status: statusFilter } : undefined)
.then(setComparisons)
.catch(() => setComparisons([]))
.finally(() => setLoading(false));
}, [statusFilter]);
useEffect(() => { load(); }, [load]);
async function handleApproveAll() {
if (!confirm('Approve all new_state comparisons as baselines?')) return;
setApprovingAll(true);
setBulkResult(null);
try {
const res = await api.approveAllBaselines();
setBulkResult(`Approved ${res.approved} comparison(s) as baselines.`);
load();
} catch (err) {
setBulkResult(`Error: ${err instanceof Error ? err.message : 'unknown'}`);
} finally {
setApprovingAll(false);
}
}
const newStateCount = comparisons.filter((c) => c.status === 'new_state').length;
const failedCount = comparisons.filter((c) => c.status === 'failed').length;
return (
<div className="max-w-6xl mx-auto px-4 py-8 space-y-6">
<header className="flex items-center justify-between">
<div>
<Link to="/" className="text-blue-400 text-sm hover:underline"> Dashboard</Link>
<h1 className="text-2xl font-bold text-white mt-1">Visual Regression Review</h1>
<p className="text-gray-400 text-sm mt-1">
{failedCount > 0 && <span className="text-red-400 font-medium mr-3">{failedCount} regression{failedCount > 1 ? 's' : ''}</span>}
{newStateCount > 0 && <span className="text-blue-400 font-medium">{newStateCount} new state{newStateCount > 1 ? 's' : ''}</span>}
</p>
</div>
{newStateCount > 0 && (
<button
onClick={handleApproveAll}
disabled={approvingAll}
className="bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded transition-colors"
>
{approvingAll ? 'Approving…' : `Approve All New (${newStateCount})`}
</button>
)}
</header>
{bulkResult && (
<div className="bg-gray-800 rounded px-4 py-3 text-sm text-gray-300">
{bulkResult}
</div>
)}
{/* Status filter */}
<div className="flex gap-2">
{ALL_STATUSES.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`text-xs px-3 py-1.5 rounded border transition-colors ${
statusFilter === s
? 'bg-gray-600 border-gray-500 text-white'
: 'bg-transparent border-gray-700 text-gray-400 hover:text-gray-200'
}`}
>
{s === 'all' ? 'All' : STATUS_LABEL[s]}
</button>
))}
</div>
{loading ? (
<p className="text-gray-400 text-sm">Loading comparisons</p>
) : comparisons.length === 0 ? (
<div className="bg-gray-800 rounded-lg p-12 text-center text-gray-500 text-sm">
No visual comparisons found. Run an exploration with visual regression enabled.
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{comparisons.map((cmp) => {
const imgUrl = screenshotUrl(cmp.current_screenshot_path);
return (
<button
key={cmp.id}
onClick={() => setSelected(cmp)}
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all text-left"
>
{imgUrl ? (
<div className="bg-gray-900 aspect-video overflow-hidden">
<img src={imgUrl} alt="Screenshot" className="w-full object-cover object-top" />
</div>
) : (
<div className="bg-gray-900 aspect-video flex items-center justify-center text-gray-600 text-xs">
No image
</div>
)}
<div className="p-3 space-y-1">
<span
className={`inline-block text-xs px-2 py-0.5 rounded border ${STATUS_COLORS[cmp.status]}`}
>
{STATUS_LABEL[cmp.status]}
</span>
<p className="text-gray-400 text-xs truncate">
{cmp.state_id.slice(0, 16)}
</p>
{cmp.diff_percent !== null && (
<p className="text-red-400 text-xs font-medium">{cmp.diff_percent.toFixed(2)}% diff</p>
)}
</div>
</button>
);
})}
</div>
)}
{selected && (
<ComparisonModal
comparison={selected}
onApprove={() => { setSelected(null); load(); }}
onReject={() => { setSelected(null); load(); }}
onClose={() => setSelected(null)}
/>
)}
</div>
);
}

201
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,201 @@
/**
* Frontend type mirrors of the backend interfaces.
* Keep in sync with src/core/interfaces.ts — do NOT import backend code directly.
*/
export type AnomalyType =
| 'http_error'
| 'js_exception'
| 'console_error'
| 'navigation_fail'
| 'element_missing'
| 'timeout'
| 'validation_bypass'
| 'server_error_on_fuzz'
| 'xss_reflection'
| 'visual_regression'
| 'accessibility_violation'
| 'mobile_layout_issue'
| 'performance_degradation'
| 'offline_handling_missing'
| 'slow_network_no_feedback'
| 'external_service_crash';
export type Severity = 'low' | 'medium' | 'high' | 'critical';
export type SessionStatus = 'running' | 'completed' | 'stopped' | 'error';
export type BrowserType = 'chromium' | 'firefox' | 'webkit';
export interface HttpResponse {
url: string;
status: number;
method: string;
durationMs: number;
}
export interface AnomalyEvidence {
screenshotPath?: string;
domSnapshotPath?: string;
httpLog?: HttpResponse[];
rawErrors?: string[];
}
export interface AIEnrichment {
rootCause: string;
userImpact: string;
suggestedFix: string;
debugPrompt: string;
confidence: 'low' | 'medium' | 'high';
generatedAt: number;
provider: string;
model: string;
}
export interface Action {
id: string;
type: string;
selector?: string;
value?: string;
url?: string;
timestamp: number;
seed: number;
stateId: string;
}
export interface Anomaly {
id: string;
sessionId?: string;
type: AnomalyType;
severity: Severity;
observationId: string;
actionTrace: Action[];
description: string;
evidence: AnomalyEvidence;
timestamp: number;
screenshotUrl?: string;
browser?: BrowserType;
browserVersion?: string;
aiEnrichment?: AIEnrichment;
}
export interface AnomalySummary {
id: string;
sessionId?: string;
type: AnomalyType;
severity: Severity;
description: string;
timestamp: number;
screenshotUrl?: string;
browser?: BrowserType;
}
export interface Session {
sessionId: string;
url: string;
status: SessionStatus;
startedAt: string;
finishedAt?: string;
statesVisited: number;
anomaliesFound: number;
seed?: number;
}
export interface Stats {
totalSessions: number;
totalAnomalies: number;
criticalHighCount: number;
runningSessions: number;
}
export type NotifyMinSeverity = 'low' | 'medium' | 'high' | 'critical';
export interface ServerConfig {
slackWebhookUrl: string | null;
notifyMinSeverity: NotifyMinSeverity;
defaultMaxStates: number;
defaultMaxDepth: number;
defaultActionDelayMs: number;
defaultExcludedPaths: string[];
}
export type AuthType = 'none' | 'cookies' | 'headers' | 'login_flow';
export type FuzzingIntensity = 'low' | 'medium' | 'high';
export type NetworkProfile = 'fast-3g' | 'slow-3g' | '2g' | 'offline' | 'none';
export interface ExplorationConfig {
allowedDomains: string[];
maxStates: number;
maxDepth: number;
actionDelayMs: number;
sessionTimeoutMs: number;
excludedPaths: string[];
excludedSelectors: string[];
auth: unknown | null;
fuzzingEnabled: boolean;
fuzzingIntensity: FuzzingIntensity;
browsers: BrowserType[];
mobileDevice: string;
accessibility: { enabled: boolean; minImpact: string; wcagLevel: string };
performance: { enabled: boolean; lcpThresholdMs: number; clsThreshold: number; inpThresholdMs: number; ttfbThresholdMs: number };
visualRegression: { enabled: boolean; threshold: number; screenshotFullPage: boolean; ignoreSelectors: string[] };
networkChaos: { enabled: boolean; profile: NetworkProfile; blockedEndpoints: string[]; slowEndpoints: Array<{ pattern: string; delayMs: number }> };
}
// ─── Schedule ─────────────────────────────────────────────────────────────────
export interface Schedule {
id: string;
name: string;
url: string;
configJson: string;
cronExpression: string;
enabled: boolean;
lastRunAt: number | null;
nextRunAt: number | null;
createdAt: number;
}
// ─── Visual Regression ────────────────────────────────────────────────────────
export type ComparisonStatus = 'passed' | 'failed' | 'new_state' | 'pending';
export interface VisualComparison {
id: string;
session_id: string;
state_id: string;
baseline_id: string | null;
current_screenshot_path: string;
diff_screenshot_path: string | null;
diff_pixels: number | null;
diff_percent: number | null;
status: ComparisonStatus;
created_at: number;
}
// ─── Performance ──────────────────────────────────────────────────────────────
export interface PerformanceMetrics {
id: string;
sessionId: string;
stateId: string;
url: string;
ttfb: number;
domContentLoaded: number;
loadComplete: number;
lcp: number | null;
cls: number | null;
fid: number | null;
inp: number | null;
totalRequests: number;
failedRequests: number;
capturedAt: number;
}
// ─── WebSocket event payloads ──────────────────────────────────────────────────
export interface WsSessionStarted { sessionId: string; url: string }
export interface WsStateDiscovered { sessionId: string; stateId: string; url: string; title: string }
export interface WsActionExecuted { sessionId: string; actionType: string; selector?: string; timestamp: number }
export interface WsAnomalyDetected { sessionId: string; anomalyId: string; type: AnomalyType; severity: Severity; description: string }
export interface WsSessionCompleted { sessionId: string; statesVisited: number; anomaliesFound: number }
export interface WsSessionError { sessionId: string; error: string }
export interface WsAnomalyEnriched { anomalyId: string; enrichment: AIEnrichment }