docs: enterprise refactor plan with ralph specs
This commit is contained in:
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
20
frontend/Dockerfile
Normal file
20
frontend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# ---- Build stage ----
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Production stage ----
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/nginx.conf
Normal file
28
frontend/nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Single-page application fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy REST API to the backend service
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy socket.io with WebSocket upgrade support
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
5404
frontend/package-lock.json
generated
Normal file
5404
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/browser": "^4.0.18",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal 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
22
frontend/src/App.tsx
Normal 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
|
||||
77
frontend/src/__tests__/AnomalyList.test.tsx
Normal file
77
frontend/src/__tests__/AnomalyList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
97
frontend/src/__tests__/NewSessionForm.test.tsx
Normal file
97
frontend/src/__tests__/NewSessionForm.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
34
frontend/src/__tests__/SeverityBadge.test.tsx
Normal file
34
frontend/src/__tests__/SeverityBadge.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/hooks/useApi.ts
Normal file
74
frontend/src/hooks/useApi.ts
Normal 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 }) }),
|
||||
};
|
||||
40
frontend/src/hooks/useSocket.ts
Normal file
40
frontend/src/hooks/useSocket.ts
Normal 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
9
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
370
frontend/src/pages/AnomalyDetail.tsx
Normal file
370
frontend/src/pages/AnomalyDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/pages/Dashboard.tsx
Normal file
91
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
frontend/src/pages/SessionDetail.tsx
Normal file
247
frontend/src/pages/SessionDetail.tsx
Normal 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" /> < 2500ms Good</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-yellow-500 rounded-full inline-block" /> 2500–4000ms Needs improvement</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-red-500 rounded-full inline-block" /> > 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>
|
||||
);
|
||||
}
|
||||
414
frontend/src/pages/Settings.tsx
Normal file
414
frontend/src/pages/Settings.tsx
Normal 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">×</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>
|
||||
);
|
||||
}
|
||||
281
frontend/src/pages/VisualReview.tsx
Normal file
281
frontend/src/pages/VisualReview.tsx
Normal 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">×</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
201
frontend/src/types.ts
Normal 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 }
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
'/socket.io': { target: 'http://localhost:3001', ws: true },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: [],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user