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

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>
);
}