docs: enterprise refactor plan with ralph specs
This commit is contained in:
35
frontend/src/components/AnomalyCard.tsx
Normal file
35
frontend/src/components/AnomalyCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
frontend/src/components/AnomalyList.tsx
Normal file
179
frontend/src/components/AnomalyList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/LiveFeed.tsx
Normal file
45
frontend/src/components/LiveFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
434
frontend/src/components/NewSessionForm.tsx
Normal file
434
frontend/src/components/NewSessionForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/src/components/SessionList.tsx
Normal file
52
frontend/src/components/SessionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/SeverityBadge.tsx
Normal file
23
frontend/src/components/SeverityBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user