mirror of
https://github.com/mblanke/ThreatHunt.git
synced 2026-03-01 05:50:21 -05:00
feat: interactive network map, IOC highlighting, AUP hunt selector, type filters
- NetworkMap: hunt-scoped force-directed graph with click-to-inspect popover - NetworkMap: zoom/pan (wheel, drag, buttons), viewport transform - NetworkMap: clickable IP/Host/Domain/URL legend chips to filter node types - NetworkMap: brighter colors, 20% smaller nodes - DatasetViewer: IOC columns highlighted with colored headers + cell tinting - AUPScanner: hunt dropdown replacing dataset checkboxes, auto-select all - Rename 'Social Media (Personal)' theme to 'Social Media' with DB migration - Fix /api/hunts timeout: Dataset.rows lazy='noload' (was selectin cascade) - Add OS column mapping to normalizer - Full backend services, DB models, alembic migrations, new routes - New components: Dashboard, HuntManager, FileUpload, NetworkMap, etc. - Docker Compose deployment with nginx reverse proxy
This commit is contained in:
31
frontend/nginx.conf
Normal file
31
frontend/nginx.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Allow large CSV uploads (matches backend 500 MB limit)
|
||||
client_max_body_size 500M;
|
||||
|
||||
# Proxy API requests to the backend service
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# SPA fallback — serve index.html for all non-file routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location /static/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
1947
frontend/package-lock.json
generated
1947
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,16 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@mui/x-data-grid": "^8.27.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"notistack": "^3.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#1976d2" />
|
||||
<meta
|
||||
name="description"
|
||||
content="ThreatHunt - Analyst-assist threat hunting platform with agent guidance"
|
||||
/>
|
||||
<title>ThreatHunt - Threat Hunting with Agent Assistance</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0b0c0d" />
|
||||
<meta name="description" content="ThreatHunt - Analyst-assist threat hunting platform with agent guidance" />
|
||||
<title>ThreatHunt Command Deck</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,123 +1,137 @@
|
||||
/**
|
||||
* Main ThreatHunt application entry point.
|
||||
* ThreatHunt — MUI-powered analyst-assist platform.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import "./App.css";
|
||||
import AgentPanel from "./components/AgentPanel";
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ThemeProvider, CssBaseline, Box, AppBar, Toolbar, Typography, IconButton,
|
||||
Drawer, List, ListItemButton, ListItemIcon, ListItemText, Divider, Chip } from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import ScienceIcon from '@mui/icons-material/Science';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import GppMaybeIcon from '@mui/icons-material/GppMaybe';
|
||||
import HubIcon from '@mui/icons-material/Hub';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import theme from './theme';
|
||||
|
||||
function App() {
|
||||
// Sample state for demonstration
|
||||
const [currentDataset] = useState("FileList-2025-12-26");
|
||||
const [currentHost] = useState("DESKTOP-ABC123");
|
||||
const [currentArtifact] = useState("FileList");
|
||||
const [dataDescription] = useState(
|
||||
"File listing from system scan showing recent modifications"
|
||||
);
|
||||
import Dashboard from './components/Dashboard';
|
||||
import HuntManager from './components/HuntManager';
|
||||
import DatasetViewer from './components/DatasetViewer';
|
||||
import FileUpload from './components/FileUpload';
|
||||
import AgentPanel from './components/AgentPanel';
|
||||
import EnrichmentPanel from './components/EnrichmentPanel';
|
||||
import AnnotationPanel from './components/AnnotationPanel';
|
||||
import HypothesisTracker from './components/HypothesisTracker';
|
||||
import CorrelationView from './components/CorrelationView';
|
||||
import AUPScanner from './components/AUPScanner';
|
||||
import NetworkMap from './components/NetworkMap';
|
||||
|
||||
const handleAnalysisAction = (action: string) => {
|
||||
console.log("Analysis action triggered:", action);
|
||||
// In a real app, this would update the analysis view or apply filters
|
||||
};
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
interface NavItem { label: string; path: string; icon: React.ReactNode }
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ label: 'Dashboard', path: '/', icon: <DashboardIcon /> },
|
||||
{ label: 'Hunts', path: '/hunts', icon: <SearchIcon /> },
|
||||
{ label: 'Datasets', path: '/datasets', icon: <StorageIcon /> },
|
||||
{ label: 'Upload', path: '/upload', icon: <UploadFileIcon /> },
|
||||
{ label: 'Agent', path: '/agent', icon: <SmartToyIcon /> },
|
||||
{ label: 'Enrichment', path: '/enrichment', icon: <SecurityIcon /> },
|
||||
{ label: 'Annotations', path: '/annotations', icon: <BookmarkIcon /> },
|
||||
{ label: 'Hypotheses', path: '/hypotheses', icon: <ScienceIcon /> },
|
||||
{ label: 'Correlation', path: '/correlation', icon: <CompareArrowsIcon /> },
|
||||
{ label: 'Network Map', path: '/network', icon: <HubIcon /> },
|
||||
{ label: 'AUP Scanner', path: '/aup', icon: <GppMaybeIcon /> },
|
||||
];
|
||||
|
||||
function Shell() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const toggle = useCallback(() => setOpen(o => !o), []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>ThreatHunt - Analyst-Assist Platform</h1>
|
||||
<p className="subtitle">
|
||||
Powered by agent guidance for faster threat hunting
|
||||
</p>
|
||||
</header>
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
|
||||
{/* App bar */}
|
||||
<AppBar position="fixed" sx={{ zIndex: t => t.zIndex.drawer + 1 }}>
|
||||
<Toolbar variant="dense">
|
||||
<IconButton edge="start" color="inherit" onClick={toggle} sx={{ mr: 1 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>
|
||||
ThreatHunt
|
||||
</Typography>
|
||||
<Chip label="v0.3.0" size="small" color="primary" variant="outlined" />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<main className="app-main">
|
||||
<div className="app-content">
|
||||
<section className="main-panel">
|
||||
<h2>Analysis Dashboard</h2>
|
||||
<p className="placeholder-text">
|
||||
[Main analysis interface would display here]
|
||||
</p>
|
||||
<div className="data-view">
|
||||
<table className="sample-data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Modified</th>
|
||||
<th>Size</th>
|
||||
<th>Hash</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>System32\drivers\etc\hosts</td>
|
||||
<td>2025-12-20 14:32</td>
|
||||
<td>456 B</td>
|
||||
<td>d41d8cd98f00b204...</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows\Temp\cache.bin</td>
|
||||
<td>2025-12-26 09:15</td>
|
||||
<td>2.3 MB</td>
|
||||
<td>5d41402abc4b2a76...</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Users\Admin\AppData\Roaming\config.xml</td>
|
||||
<td>2025-12-25 16:45</td>
|
||||
<td>12.4 KB</td>
|
||||
<td>e99a18c428cb38d5...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/* Sidebar drawer */}
|
||||
<Drawer
|
||||
variant="persistent"
|
||||
open={open}
|
||||
sx={{
|
||||
width: open ? DRAWER_WIDTH : 0,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': { width: DRAWER_WIDTH, boxSizing: 'border-box', mt: '48px' },
|
||||
}}
|
||||
>
|
||||
<List dense>
|
||||
{NAV.map(item => (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</Drawer>
|
||||
|
||||
<aside className="agent-sidebar">
|
||||
<AgentPanel
|
||||
dataset_name={currentDataset}
|
||||
artifact_type={currentArtifact}
|
||||
host_identifier={currentHost}
|
||||
data_summary={dataDescription}
|
||||
onAnalysisAction={handleAnalysisAction}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
{/* Main content */}
|
||||
<Box component="main" sx={{
|
||||
flexGrow: 1, p: 2, mt: '48px',
|
||||
ml: open ? 0 : `-${DRAWER_WIDTH}px`,
|
||||
transition: 'margin 225ms cubic-bezier(0,0,0.2,1)',
|
||||
}}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/hunts" element={<HuntManager />} />
|
||||
<Route path="/datasets" element={<DatasetViewer />} />
|
||||
<Route path="/upload" element={<FileUpload />} />
|
||||
<Route path="/agent" element={<AgentPanel />} />
|
||||
<Route path="/enrichment" element={<EnrichmentPanel />} />
|
||||
<Route path="/annotations" element={<AnnotationPanel />} />
|
||||
<Route path="/hypotheses" element={<HypothesisTracker />} />
|
||||
<Route path="/correlation" element={<CorrelationView />} />
|
||||
<Route path="/network" element={<NetworkMap />} />
|
||||
<Route path="/aup" element={<AUPScanner />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
<footer className="app-footer">
|
||||
<div className="footer-content">
|
||||
<div className="footer-section">
|
||||
<h4>About Analyst-Assist Agent</h4>
|
||||
<p>
|
||||
The agent provides advisory guidance on artifact data, analytical
|
||||
pivots, and hypotheses. All decisions remain with the analyst.
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Capabilities</h4>
|
||||
<ul>
|
||||
<li>Interpret CSV artifact data</li>
|
||||
<li>Suggest analytical directions</li>
|
||||
<li>Highlight anomalies</li>
|
||||
<li>Propose investigative steps</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Governance</h4>
|
||||
<ul>
|
||||
<li>Read-only guidance</li>
|
||||
<li>No tool execution</li>
|
||||
<li>No autonomous actions</li>
|
||||
<li>Analyst controls decisions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<p>
|
||||
© 2025 ThreatHunt. Agent guidance is advisory only. All
|
||||
analytical decisions remain with the analyst.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
||||
<BrowserRouter>
|
||||
<Shell />
|
||||
</BrowserRouter>
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
370
frontend/src/api/client.ts
Normal file
370
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/* ====================================================================
|
||||
ThreatHunt API Client — mirrors every backend endpoint.
|
||||
All requests go through the CRA proxy (see package.json "proxy").
|
||||
==================================================================== */
|
||||
|
||||
const BASE = ''; // proxied to http://localhost:8000 by CRA
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
let authToken: string | null = localStorage.getItem('th_token');
|
||||
|
||||
export function setToken(t: string | null) {
|
||||
authToken = t;
|
||||
if (t) localStorage.setItem('th_token', t);
|
||||
else localStorage.removeItem('th_token');
|
||||
}
|
||||
export function getToken() { return authToken; }
|
||||
|
||||
async function api<T = any>(
|
||||
path: string,
|
||||
opts: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...(opts.headers as Record<string, string> || {}),
|
||||
};
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
if (!(opts.body instanceof FormData)) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { ...opts, headers });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) return res.json();
|
||||
return res.text() as unknown as T;
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserPayload {
|
||||
id: string; username: string; email: string;
|
||||
display_name: string | null; role: string; is_active: boolean; created_at: string;
|
||||
}
|
||||
export interface AuthPayload {
|
||||
user: UserPayload;
|
||||
tokens: { access_token: string; refresh_token: string; token_type: string };
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
register: (username: string, email: string, password: string, display_name?: string) =>
|
||||
api<AuthPayload>('/api/auth/register', {
|
||||
method: 'POST', body: JSON.stringify({ username, email, password, display_name }),
|
||||
}),
|
||||
login: (username: string, password: string) =>
|
||||
api<AuthPayload>('/api/auth/login', {
|
||||
method: 'POST', body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
refresh: (refresh_token: string) =>
|
||||
api<AuthPayload>('/api/auth/refresh', {
|
||||
method: 'POST', body: JSON.stringify({ refresh_token }),
|
||||
}),
|
||||
me: () => api<UserPayload>('/api/auth/me'),
|
||||
};
|
||||
|
||||
// ── Hunts ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Hunt {
|
||||
id: string; name: string; description: string | null; status: string;
|
||||
owner_id: string | null; created_at: string; updated_at: string;
|
||||
dataset_count: number; hypothesis_count: number;
|
||||
}
|
||||
|
||||
export const hunts = {
|
||||
list: (skip = 0, limit = 50) =>
|
||||
api<{ hunts: Hunt[]; total: number }>(`/api/hunts?skip=${skip}&limit=${limit}`),
|
||||
get: (id: string) => api<Hunt>(`/api/hunts/${id}`),
|
||||
create: (name: string, description?: string) =>
|
||||
api<Hunt>('/api/hunts', { method: 'POST', body: JSON.stringify({ name, description }) }),
|
||||
update: (id: string, data: Partial<{ name: string; description: string; status: string }>) =>
|
||||
api<Hunt>(`/api/hunts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => api(`/api/hunts/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ── Datasets ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface DatasetSummary {
|
||||
id: string; name: string; filename: string; source_tool: string | null;
|
||||
row_count: number; column_schema: Record<string, string> | null;
|
||||
normalized_columns: Record<string, string> | null;
|
||||
ioc_columns: Record<string, string[]> | null;
|
||||
file_size_bytes: number; encoding: string | null; delimiter: string | null;
|
||||
time_range_start: string | null; time_range_end: string | null;
|
||||
hunt_id: string | null; created_at: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string; name: string; row_count: number; columns: string[];
|
||||
column_types: Record<string, string>; normalized_columns: Record<string, string>;
|
||||
ioc_columns: Record<string, string[]>; message: string;
|
||||
}
|
||||
|
||||
export const datasets = {
|
||||
list: (skip = 0, limit = 50, huntId?: string) => {
|
||||
let qs = `/api/datasets?skip=${skip}&limit=${limit}`;
|
||||
if (huntId) qs += `&hunt_id=${encodeURIComponent(huntId)}`;
|
||||
return api<{ datasets: DatasetSummary[]; total: number }>(qs);
|
||||
},
|
||||
get: (id: string) => api<DatasetSummary>(`/api/datasets/${id}`),
|
||||
rows: (id: string, offset = 0, limit = 100) =>
|
||||
api<{ rows: Record<string, any>[]; total: number; offset: number; limit: number }>(
|
||||
`/api/datasets/${id}/rows?offset=${offset}&limit=${limit}`,
|
||||
),
|
||||
upload: (file: File, huntId?: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const qs = huntId ? `?hunt_id=${encodeURIComponent(huntId)}` : '';
|
||||
return api<UploadResult>(`/api/datasets/upload${qs}`, { method: 'POST', body: fd });
|
||||
},
|
||||
/** Upload with real progress percentage via XMLHttpRequest. */
|
||||
uploadWithProgress: (
|
||||
file: File,
|
||||
huntId?: string,
|
||||
onProgress?: (pct: number) => void,
|
||||
): Promise<UploadResult> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const qs = huntId ? `?hunt_id=${encodeURIComponent(huntId)}` : '';
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
reject(new Error(body.detail || `HTTP ${xhr.status}`));
|
||||
} catch { reject(new Error(`HTTP ${xhr.status}`)); }
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
|
||||
|
||||
xhr.open('POST', `${BASE}/api/datasets/upload${qs}`);
|
||||
if (authToken) xhr.setRequestHeader('Authorization', `Bearer ${authToken}`);
|
||||
xhr.send(fd);
|
||||
});
|
||||
},
|
||||
delete: (id: string) => api(`/api/datasets/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ── Agent ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AssistRequest {
|
||||
query: string;
|
||||
dataset_name?: string; artifact_type?: string; host_identifier?: string;
|
||||
data_summary?: string; conversation_history?: { role: string; content: string }[];
|
||||
active_hypotheses?: string[]; annotations_summary?: string;
|
||||
enrichment_summary?: string; mode?: 'quick' | 'deep' | 'debate';
|
||||
model_override?: string; conversation_id?: string; hunt_id?: string;
|
||||
}
|
||||
|
||||
export interface AssistResponse {
|
||||
guidance: string; confidence: number; suggested_pivots: string[];
|
||||
suggested_filters: string[]; caveats: string | null; reasoning: string | null;
|
||||
sans_references: string[]; model_used: string; node_used: string;
|
||||
latency_ms: number; perspectives: Record<string, any>[] | null;
|
||||
conversation_id: string | null;
|
||||
}
|
||||
|
||||
export interface NodeInfo { url: string; available: boolean }
|
||||
export interface HealthInfo {
|
||||
status: string;
|
||||
nodes: { wile: NodeInfo; roadrunner: NodeInfo; cluster: NodeInfo };
|
||||
rag: { available: boolean; url: string; model: string };
|
||||
default_models: Record<string, string>;
|
||||
config: { max_tokens: number; temperature: number };
|
||||
}
|
||||
|
||||
export const agent = {
|
||||
assist: (req: AssistRequest) =>
|
||||
api<AssistResponse>('/api/agent/assist', { method: 'POST', body: JSON.stringify(req) }),
|
||||
health: () => api<HealthInfo>('/api/agent/health'),
|
||||
models: () => api<Record<string, any>>('/api/agent/models'),
|
||||
/** Returns a ReadableStream for SSE streaming */
|
||||
assistStream: async (req: AssistRequest): Promise<Response> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
return fetch(`${BASE}/api/agent/assist/stream`, {
|
||||
method: 'POST', headers, body: JSON.stringify(req),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Annotations ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AnnotationData {
|
||||
id: string; row_id: number | null; dataset_id: string | null;
|
||||
author_id: string | null; text: string; severity: string;
|
||||
tag: string | null; highlight_color: string | null;
|
||||
created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
export const annotations = {
|
||||
list: (params?: { dataset_id?: string; severity?: string; tag?: string; skip?: number; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.dataset_id) q.set('dataset_id', params.dataset_id);
|
||||
if (params?.severity) q.set('severity', params.severity);
|
||||
if (params?.tag) q.set('tag', params.tag);
|
||||
if (params?.skip) q.set('skip', String(params.skip));
|
||||
if (params?.limit) q.set('limit', String(params.limit));
|
||||
return api<{ annotations: AnnotationData[]; total: number }>(`/api/annotations?${q}`);
|
||||
},
|
||||
create: (data: { row_id?: number; dataset_id?: string; text: string; severity?: string; tag?: string; highlight_color?: string }) =>
|
||||
api<AnnotationData>('/api/annotations', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: Partial<{ text: string; severity: string; tag: string; highlight_color: string }>) =>
|
||||
api<AnnotationData>(`/api/annotations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => api(`/api/annotations/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ── Hypotheses ───────────────────────────────────────────────────────
|
||||
|
||||
export interface HypothesisData {
|
||||
id: string; hunt_id: string | null; title: string; description: string | null;
|
||||
mitre_technique: string | null; status: string;
|
||||
evidence_row_ids: number[] | null; evidence_notes: string | null;
|
||||
created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
export const hypotheses = {
|
||||
list: (params?: { hunt_id?: string; status?: string; skip?: number; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.hunt_id) q.set('hunt_id', params.hunt_id);
|
||||
if (params?.status) q.set('status', params.status);
|
||||
if (params?.skip) q.set('skip', String(params.skip));
|
||||
if (params?.limit) q.set('limit', String(params.limit));
|
||||
return api<{ hypotheses: HypothesisData[]; total: number }>(`/api/hypotheses?${q}`);
|
||||
},
|
||||
create: (data: { hunt_id?: string; title: string; description?: string; mitre_technique?: string; status?: string }) =>
|
||||
api<HypothesisData>('/api/hypotheses', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: Partial<{ title: string; description: string; mitre_technique: string; status: string; evidence_row_ids: number[]; evidence_notes: string }>) =>
|
||||
api<HypothesisData>(`/api/hypotheses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => api(`/api/hypotheses/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ── Enrichment ───────────────────────────────────────────────────────
|
||||
|
||||
export interface EnrichmentResult {
|
||||
ioc_value: string; ioc_type: string; source: string; verdict: string;
|
||||
score: number; tags: string[]; country: string; asn: string; org: string;
|
||||
last_seen: string; raw_data: Record<string, any>; error: string;
|
||||
latency_ms: number;
|
||||
}
|
||||
|
||||
export const enrichment = {
|
||||
ioc: (ioc_value: string, ioc_type: string, skip_cache = false) =>
|
||||
api<{ ioc_value: string; ioc_type: string; results: EnrichmentResult[]; overall_verdict: string; overall_score: number }>(
|
||||
'/api/enrichment/ioc', { method: 'POST', body: JSON.stringify({ ioc_value, ioc_type, skip_cache }) },
|
||||
),
|
||||
batch: (iocs: { value: string; type: string }[]) =>
|
||||
api<{ results: Record<string, EnrichmentResult[]>; total_enriched: number }>(
|
||||
'/api/enrichment/batch', { method: 'POST', body: JSON.stringify({ iocs }) },
|
||||
),
|
||||
dataset: (datasetId: string) =>
|
||||
api<{ dataset_id: string; iocs_found: number; enriched: number; results: Record<string, any> }>(
|
||||
`/api/enrichment/dataset/${datasetId}`, { method: 'POST' },
|
||||
),
|
||||
status: () => api<Record<string, any>>('/api/enrichment/status'),
|
||||
};
|
||||
|
||||
// ── Correlation ──────────────────────────────────────────────────────
|
||||
|
||||
export interface CorrelationResult {
|
||||
hunt_ids: string[]; summary: string; total_correlations: number;
|
||||
ioc_overlaps: any[]; time_overlaps: any[]; technique_overlaps: any[];
|
||||
host_overlaps: any[];
|
||||
}
|
||||
|
||||
export const correlation = {
|
||||
analyze: (hunt_ids: string[]) =>
|
||||
api<CorrelationResult>('/api/correlation/analyze', {
|
||||
method: 'POST', body: JSON.stringify({ hunt_ids }),
|
||||
}),
|
||||
all: () => api<CorrelationResult>('/api/correlation/all'),
|
||||
ioc: (ioc_value: string) =>
|
||||
api<{ ioc_value: string; occurrences: any[]; total: number }>(`/api/correlation/ioc/${encodeURIComponent(ioc_value)}`),
|
||||
};
|
||||
|
||||
// ── Reports ──────────────────────────────────────────────────────────
|
||||
|
||||
export const reports = {
|
||||
json: (huntId: string) =>
|
||||
api<Record<string, any>>(`/api/reports/hunt/${huntId}?format=json`),
|
||||
html: (huntId: string) =>
|
||||
api<string>(`/api/reports/hunt/${huntId}?format=html`),
|
||||
csv: (huntId: string) =>
|
||||
api<string>(`/api/reports/hunt/${huntId}?format=csv`),
|
||||
summary: (huntId: string) =>
|
||||
api<Record<string, any>>(`/api/reports/hunt/${huntId}/summary`),
|
||||
};
|
||||
|
||||
// ── Root / misc ──────────────────────────────────────────────────────
|
||||
|
||||
export const misc = {
|
||||
root: () => api<{ name: string; version: string; status: string }>('/'),
|
||||
};
|
||||
|
||||
// ── AUP Keywords ─────────────────────────────────────────────────────
|
||||
|
||||
export interface KeywordOut {
|
||||
id: number; theme_id: string; value: string; is_regex: boolean; created_at: string;
|
||||
}
|
||||
export interface ThemeOut {
|
||||
id: string; name: string; color: string; enabled: boolean; is_builtin: boolean;
|
||||
created_at: string; keyword_count: number; keywords: KeywordOut[];
|
||||
}
|
||||
export interface ScanHit {
|
||||
theme_name: string; theme_color: string; keyword: string;
|
||||
source_type: string; source_id: string | number; field: string;
|
||||
matched_value: string; row_index: number | null; dataset_name: string | null;
|
||||
}
|
||||
export interface ScanResponse {
|
||||
total_hits: number; hits: ScanHit[]; themes_scanned: number;
|
||||
keywords_scanned: number; rows_scanned: number;
|
||||
}
|
||||
|
||||
export const keywords = {
|
||||
// Theme CRUD
|
||||
listThemes: () =>
|
||||
api<{ themes: ThemeOut[]; total: number }>('/api/keywords/themes'),
|
||||
createTheme: (name: string, color?: string, enabled?: boolean) =>
|
||||
api<ThemeOut>('/api/keywords/themes', {
|
||||
method: 'POST', body: JSON.stringify({ name, color, enabled }),
|
||||
}),
|
||||
updateTheme: (id: string, data: Partial<{ name: string; color: string; enabled: boolean }>) =>
|
||||
api<ThemeOut>(`/api/keywords/themes/${id}`, {
|
||||
method: 'PUT', body: JSON.stringify(data),
|
||||
}),
|
||||
deleteTheme: (id: string) =>
|
||||
api(`/api/keywords/themes/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Keyword CRUD
|
||||
addKeyword: (themeId: string, value: string, is_regex = false) =>
|
||||
api<KeywordOut>(`/api/keywords/themes/${themeId}/keywords`, {
|
||||
method: 'POST', body: JSON.stringify({ value, is_regex }),
|
||||
}),
|
||||
addKeywordsBulk: (themeId: string, values: string[], is_regex = false) =>
|
||||
api<{ added: number; theme_id: string }>(`/api/keywords/themes/${themeId}/keywords/bulk`, {
|
||||
method: 'POST', body: JSON.stringify({ values, is_regex }),
|
||||
}),
|
||||
deleteKeyword: (keywordId: number) =>
|
||||
api(`/api/keywords/keywords/${keywordId}`, { method: 'DELETE' }),
|
||||
|
||||
// Scanning
|
||||
scan: (opts: {
|
||||
dataset_ids?: string[]; theme_ids?: string[];
|
||||
scan_hunts?: boolean; scan_annotations?: boolean; scan_messages?: boolean;
|
||||
}) =>
|
||||
api<ScanResponse>('/api/keywords/scan', {
|
||||
method: 'POST', body: JSON.stringify(opts),
|
||||
}),
|
||||
quickScan: (datasetId: string) =>
|
||||
api<ScanResponse>(`/api/keywords/scan/quick?dataset_id=${encodeURIComponent(datasetId)}`),
|
||||
};
|
||||
431
frontend/src/components/AUPScanner.tsx
Normal file
431
frontend/src/components/AUPScanner.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* AUPScanner — Acceptable Use Policy keyword scanner.
|
||||
*
|
||||
* Three-panel layout:
|
||||
* Left — Theme manager (add/delete themes, expand to see/add keywords)
|
||||
* Right — Scan controls + results DataGrid
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Button, Chip, TextField, IconButton,
|
||||
Accordion, AccordionSummary, AccordionDetails, Switch, FormControlLabel,
|
||||
CircularProgress, Alert,
|
||||
Tooltip, Checkbox, FormGroup, LinearProgress,
|
||||
FormControl, InputLabel, Select, MenuItem,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
keywords,
|
||||
datasets,
|
||||
hunts,
|
||||
type Hunt,
|
||||
type ThemeOut,
|
||||
type ScanResponse,
|
||||
type DatasetSummary,
|
||||
} from '../api/client';
|
||||
|
||||
// ── Theme Manager (left panel) ───────────────────────────────────────
|
||||
|
||||
interface ThemeManagerProps {
|
||||
themes: ThemeOut[];
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
function ThemeManager({ themes, onReload }: ThemeManagerProps) {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [newThemeName, setNewThemeName] = useState('');
|
||||
const [newThemeColor, setNewThemeColor] = useState('#9e9e9e');
|
||||
const [newKw, setNewKw] = useState<Record<string, string>>({});
|
||||
|
||||
const addTheme = useCallback(async () => {
|
||||
if (!newThemeName.trim()) return;
|
||||
try {
|
||||
await keywords.createTheme(newThemeName.trim(), newThemeColor);
|
||||
enqueueSnackbar(`Theme "${newThemeName}" created`, { variant: 'success' });
|
||||
setNewThemeName('');
|
||||
setNewThemeColor('#9e9e9e');
|
||||
onReload();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
}, [newThemeName, newThemeColor, enqueueSnackbar, onReload]);
|
||||
|
||||
const deleteTheme = useCallback(async (id: string, name: string) => {
|
||||
if (!window.confirm(`Delete theme "${name}" and all its keywords?`)) return;
|
||||
try {
|
||||
await keywords.deleteTheme(id);
|
||||
enqueueSnackbar(`Theme "${name}" deleted`, { variant: 'info' });
|
||||
onReload();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
}, [enqueueSnackbar, onReload]);
|
||||
|
||||
const toggleTheme = useCallback(async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await keywords.updateTheme(id, { enabled });
|
||||
onReload();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
}, [enqueueSnackbar, onReload]);
|
||||
|
||||
const addKeyword = useCallback(async (themeId: string) => {
|
||||
const val = (newKw[themeId] || '').trim();
|
||||
if (!val) return;
|
||||
try {
|
||||
// Support comma-separated bulk add
|
||||
const values = val.split(',').map(v => v.trim()).filter(Boolean);
|
||||
if (values.length > 1) {
|
||||
await keywords.addKeywordsBulk(themeId, values);
|
||||
enqueueSnackbar(`Added ${values.length} keywords`, { variant: 'success' });
|
||||
} else {
|
||||
await keywords.addKeyword(themeId, values[0]);
|
||||
enqueueSnackbar(`Added "${values[0]}"`, { variant: 'success' });
|
||||
}
|
||||
setNewKw(prev => ({ ...prev, [themeId]: '' }));
|
||||
onReload();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
}, [newKw, enqueueSnackbar, onReload]);
|
||||
|
||||
const deleteKeyword = useCallback(async (kwId: number) => {
|
||||
try {
|
||||
await keywords.deleteKeyword(kwId);
|
||||
onReload();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
}, [enqueueSnackbar, onReload]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, height: '100%', overflow: 'auto' }}>
|
||||
<Typography variant="h6" gutterBottom>Keyword Themes</Typography>
|
||||
|
||||
{/* Add new theme */}
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }} alignItems="center">
|
||||
<TextField
|
||||
size="small" label="New theme" value={newThemeName}
|
||||
onChange={e => setNewThemeName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addTheme()}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<input
|
||||
type="color" value={newThemeColor}
|
||||
onChange={e => setNewThemeColor(e.target.value)}
|
||||
style={{ width: 36, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }}
|
||||
/>
|
||||
<IconButton color="primary" onClick={addTheme} size="small"><AddIcon /></IconButton>
|
||||
</Stack>
|
||||
|
||||
{/* Theme list */}
|
||||
{themes.map(theme => (
|
||||
<Accordion key={theme.id} defaultExpanded={false} disableGutters
|
||||
sx={{ '&:before': { display: 'none' }, mb: 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%', pr: 1 }}>
|
||||
<Chip
|
||||
label={theme.name}
|
||||
size="small"
|
||||
sx={{ bgcolor: theme.color, color: '#fff', fontWeight: 600 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
|
||||
{theme.keyword_count} keywords
|
||||
</Typography>
|
||||
<Switch
|
||||
size="small" checked={theme.enabled}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={(_, checked) => toggleTheme(theme.id, checked)}
|
||||
/>
|
||||
<IconButton
|
||||
size="small" color="error"
|
||||
onClick={e => { e.stopPropagation(); deleteTheme(theme.id, theme.name); }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
{/* Keywords list */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
|
||||
{theme.keywords.map(kw => (
|
||||
<Chip
|
||||
key={kw.id}
|
||||
label={kw.value}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onDelete={() => deleteKeyword(kw.id)}
|
||||
sx={{ borderColor: theme.color }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{/* Add keyword */}
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<TextField
|
||||
size="small" fullWidth
|
||||
placeholder="Add keyword (comma-separated for bulk)"
|
||||
value={newKw[theme.id] || ''}
|
||||
onChange={e => setNewKw(prev => ({ ...prev, [theme.id]: e.target.value }))}
|
||||
onKeyDown={e => e.key === 'Enter' && addKeyword(theme.id)}
|
||||
/>
|
||||
<IconButton size="small" color="primary" onClick={() => addKeyword(theme.id)}>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scan Controls + Results (right panel) ────────────────────────────
|
||||
|
||||
const RESULT_COLUMNS: GridColDef[] = [
|
||||
{
|
||||
field: 'theme_name', headerName: 'Theme', width: 140,
|
||||
renderCell: (params) => (
|
||||
<Chip label={params.value} size="small"
|
||||
sx={{ bgcolor: params.row.theme_color, color: '#fff', fontWeight: 600 }} />
|
||||
),
|
||||
},
|
||||
{ field: 'keyword', headerName: 'Keyword', width: 140 },
|
||||
{ field: 'source_type', headerName: 'Source', width: 120 },
|
||||
{ field: 'dataset_name', headerName: 'Dataset', width: 150 },
|
||||
{ field: 'field', headerName: 'Field', width: 130 },
|
||||
{ field: 'matched_value', headerName: 'Matched Value', flex: 1, minWidth: 200 },
|
||||
{ field: 'row_index', headerName: 'Row #', width: 80, type: 'number' },
|
||||
];
|
||||
|
||||
export default function AUPScanner() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
// State
|
||||
const [themes, setThemes] = useState<ThemeOut[]>([]);
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [selectedHuntId, setSelectedHuntId] = useState('');
|
||||
const [dsList, setDsList] = useState<DatasetSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<ScanResponse | null>(null);
|
||||
|
||||
// Scan options
|
||||
const [selectedDs, setSelectedDs] = useState<Set<string>>(new Set());
|
||||
const [selectedThemes, setSelectedThemes] = useState<Set<string>>(new Set());
|
||||
const [scanHunts, setScanHunts] = useState(true);
|
||||
const [scanAnnotations, setScanAnnotations] = useState(true);
|
||||
const [scanMessages, setScanMessages] = useState(true);
|
||||
|
||||
// Load themes + hunts
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tRes, hRes] = await Promise.all([
|
||||
keywords.listThemes(),
|
||||
hunts.list(0, 200),
|
||||
]);
|
||||
setThemes(tRes.themes);
|
||||
setHuntList(hRes.hunts);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setLoading(false);
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
// When hunt changes, load its datasets and auto-select all
|
||||
useEffect(() => {
|
||||
if (!selectedHuntId) { setDsList([]); setSelectedDs(new Set()); return; }
|
||||
let cancelled = false;
|
||||
datasets.list(0, 500, selectedHuntId).then(res => {
|
||||
if (cancelled) return;
|
||||
setDsList(res.datasets);
|
||||
setSelectedDs(new Set(res.datasets.map(d => d.id)));
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedHuntId]);
|
||||
|
||||
// Toggle helpers
|
||||
const toggleThemeSelect = (id: string) => setSelectedThemes(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Run scan
|
||||
const runScan = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setScanResult(null);
|
||||
try {
|
||||
const res = await keywords.scan({
|
||||
dataset_ids: selectedDs.size > 0 ? Array.from(selectedDs) : undefined,
|
||||
theme_ids: selectedThemes.size > 0 ? Array.from(selectedThemes) : undefined,
|
||||
scan_hunts: scanHunts,
|
||||
scan_annotations: scanAnnotations,
|
||||
scan_messages: scanMessages,
|
||||
});
|
||||
setScanResult(res);
|
||||
enqueueSnackbar(`Scan complete — ${res.total_hits} hits found`, {
|
||||
variant: res.total_hits > 0 ? 'warning' : 'success',
|
||||
});
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setScanning(false);
|
||||
}, [selectedDs, selectedThemes, scanHunts, scanAnnotations, scanMessages, enqueueSnackbar]);
|
||||
|
||||
if (loading) return <Box sx={{ p: 4, textAlign: 'center' }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>AUP Keyword Scanner</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, height: 'calc(100vh - 140px)' }}>
|
||||
{/* Left — Theme Manager */}
|
||||
<Box sx={{ width: 380, minWidth: 320, flexShrink: 0, overflow: 'auto' }}>
|
||||
<ThemeManager themes={themes} onReload={loadData} />
|
||||
</Box>
|
||||
|
||||
{/* Right — Controls + Results */}
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
{/* Scan controls */}
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Stack direction="row" spacing={3} alignItems="flex-start" flexWrap="wrap">
|
||||
{/* Hunt → Dataset selector */}
|
||||
<Box sx={{ minWidth: 220 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Hunt</Typography>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="aup-hunt-label">Select hunt</InputLabel>
|
||||
<Select
|
||||
labelId="aup-hunt-label"
|
||||
value={selectedHuntId}
|
||||
label="Select hunt"
|
||||
onChange={e => setSelectedHuntId(e.target.value)}
|
||||
>
|
||||
{huntList.map(h => (
|
||||
<MenuItem key={h.id} value={h.id}>
|
||||
{h.name} ({h.dataset_count} datasets)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedHuntId && dsList.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{dsList.length} datasets · {dsList.reduce((sum, d) => sum + d.row_count, 0).toLocaleString()} rows
|
||||
</Typography>
|
||||
)}
|
||||
{selectedHuntId && dsList.length === 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
No datasets in this hunt
|
||||
</Typography>
|
||||
)}
|
||||
{!selectedHuntId && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
All datasets will be scanned if no hunt is selected
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Theme selector */}
|
||||
<Box sx={{ minWidth: 200 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="subtitle2">Themes</Typography>
|
||||
{(() => { const enabled = themes.filter(t => t.enabled); return (
|
||||
<Button size="small" sx={{ textTransform: 'none', minWidth: 0, px: 0.5, fontSize: '0.7rem' }}
|
||||
onClick={() => {
|
||||
if (selectedThemes.size === enabled.length) setSelectedThemes(new Set());
|
||||
else setSelectedThemes(new Set(enabled.map(t => t.id)));
|
||||
}}>
|
||||
{selectedThemes.size === enabled.length && enabled.length > 0 ? 'Clear all' : 'Select all'}
|
||||
</Button>
|
||||
); })()}
|
||||
</Stack>
|
||||
<FormGroup sx={{ maxHeight: 120, overflow: 'auto' }}>
|
||||
{themes.filter(t => t.enabled).map(t => (
|
||||
<FormControlLabel key={t.id} control={
|
||||
<Checkbox size="small" checked={selectedThemes.has(t.id)}
|
||||
onChange={() => toggleThemeSelect(t.id)} />
|
||||
} label={
|
||||
<Chip label={t.name} size="small"
|
||||
sx={{ bgcolor: t.color, color: '#fff', fontSize: '0.75rem' }} />
|
||||
} />
|
||||
))}
|
||||
</FormGroup>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{selectedThemes.size === 0 ? 'All enabled themes' : `${selectedThemes.size} selected`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Extra sources */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>Also scan</Typography>
|
||||
<FormGroup>
|
||||
<FormControlLabel control={
|
||||
<Checkbox size="small" checked={scanHunts} onChange={(_, c) => setScanHunts(c)} />
|
||||
} label={<Typography variant="body2">Hunts</Typography>} />
|
||||
<FormControlLabel control={
|
||||
<Checkbox size="small" checked={scanAnnotations} onChange={(_, c) => setScanAnnotations(c)} />
|
||||
} label={<Typography variant="body2">Annotations</Typography>} />
|
||||
<FormControlLabel control={
|
||||
<Checkbox size="small" checked={scanMessages} onChange={(_, c) => setScanMessages(c)} />
|
||||
} label={<Typography variant="body2">Messages</Typography>} />
|
||||
</FormGroup>
|
||||
</Box>
|
||||
|
||||
{/* Scan button */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, pt: 2 }}>
|
||||
<Button
|
||||
variant="contained" color="warning" size="large"
|
||||
startIcon={scanning ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={runScan} disabled={scanning}
|
||||
>
|
||||
{scanning ? 'Scanning…' : 'Run Scan'}
|
||||
</Button>
|
||||
<Tooltip title="Reload themes & datasets">
|
||||
<IconButton onClick={loadData}><RefreshIcon /></IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Scan progress */}
|
||||
{scanning && <LinearProgress color="warning" />}
|
||||
|
||||
{/* Results summary */}
|
||||
{scanResult && (
|
||||
<Alert severity={scanResult.total_hits > 0 ? 'warning' : 'success'} sx={{ py: 0.5 }}>
|
||||
<strong>{scanResult.total_hits}</strong> hits across{' '}
|
||||
<strong>{scanResult.rows_scanned}</strong> rows |{' '}
|
||||
{scanResult.themes_scanned} themes, {scanResult.keywords_scanned} keywords scanned
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Results DataGrid */}
|
||||
{scanResult && (
|
||||
<Paper sx={{ flexGrow: 1, minHeight: 300 }}>
|
||||
<DataGrid
|
||||
rows={scanResult.hits.map((h, i) => ({ id: i, ...h }))}
|
||||
columns={RESULT_COLUMNS}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
|
||||
density="compact"
|
||||
sx={{
|
||||
border: 0,
|
||||
'& .MuiDataGrid-cell': { fontSize: '0.8rem' },
|
||||
'& .MuiDataGrid-columnHeader': { fontWeight: 700 },
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!scanResult && !scanning && (
|
||||
<Paper sx={{ p: 4, textAlign: 'center', flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="h6" color="text.secondary">No scan results yet</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Select datasets and themes, then click "Run Scan" to check for AUP violations.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +1,246 @@
|
||||
/**
|
||||
* Analyst-assist agent chat panel component.
|
||||
* Provides context-aware guidance on artifact data and analysis.
|
||||
* AgentPanel — analyst-assist chat with quick / deep / debate modes,
|
||||
* streaming support, SANS references, and conversation persistence.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import "./AgentPanel.css";
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
requestAgentAssistance,
|
||||
AssistResponse,
|
||||
AssistRequest,
|
||||
} from "../utils/agentApi";
|
||||
Box, Typography, Paper, TextField, Button, Stack, Chip,
|
||||
ToggleButtonGroup, ToggleButton, CircularProgress, Alert,
|
||||
Accordion, AccordionSummary, AccordionDetails, Divider, Select,
|
||||
MenuItem, FormControl, InputLabel, LinearProgress,
|
||||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import SchoolIcon from '@mui/icons-material/School';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import ForumIcon from '@mui/icons-material/Forum';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
agent, datasets, hunts, type AssistRequest, type AssistResponse,
|
||||
type DatasetSummary, type Hunt,
|
||||
} from '../api/client';
|
||||
|
||||
export interface AgentPanelProps {
|
||||
/** Name of the current dataset */
|
||||
dataset_name?: string;
|
||||
/** Type of artifact (e.g., FileList, ProcessList) */
|
||||
artifact_type?: string;
|
||||
/** Host name, IP, or identifier */
|
||||
host_identifier?: string;
|
||||
/** Summary of the uploaded data */
|
||||
data_summary?: string;
|
||||
/** Callback when user needs to execute analysis based on suggestions */
|
||||
onAnalysisAction?: (action: string) => void;
|
||||
}
|
||||
interface Message { role: 'user' | 'assistant'; content: string; meta?: AssistResponse }
|
||||
|
||||
interface Message {
|
||||
role: "user" | "agent";
|
||||
content: string;
|
||||
response?: AssistResponse;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const AgentPanel: React.FC<AgentPanelProps> = ({
|
||||
dataset_name,
|
||||
artifact_type,
|
||||
host_identifier,
|
||||
data_summary,
|
||||
onAnalysisAction,
|
||||
}) => {
|
||||
export default function AgentPanel() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [query, setQuery] = useState('');
|
||||
const [mode, setMode] = useState<'quick' | 'deep' | 'debate'>('quick');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [datasetList, setDatasets] = useState<DatasetSummary[]>([]);
|
||||
const [huntList, setHunts] = useState<Hunt[]>([]);
|
||||
const [selectedDataset, setSelectedDataset] = useState('');
|
||||
const [selectedHunt, setSelectedHunt] = useState('');
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
datasets.list(0, 100).then(r => setDatasets(r.datasets)).catch(() => {});
|
||||
hunts.list(0, 100).then(r => setHunts(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
const send = useCallback(async () => {
|
||||
if (!query.trim() || loading) return;
|
||||
const userMsg: Message = { role: 'user', content: query };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setQuery('');
|
||||
setLoading(true);
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
role: "user",
|
||||
content: query,
|
||||
timestamp: new Date(),
|
||||
const ds = datasetList.find(d => d.id === selectedDataset);
|
||||
const req: AssistRequest = {
|
||||
query,
|
||||
mode,
|
||||
conversation_id: conversationId || undefined,
|
||||
hunt_id: selectedHunt || undefined,
|
||||
dataset_name: ds?.name,
|
||||
data_summary: ds ? `${ds.row_count} rows, columns: ${Object.keys(ds.column_schema || {}).join(', ')}` : undefined,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setQuery("");
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Build conversation history for context
|
||||
const conversation_history = messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}));
|
||||
|
||||
// Request guidance from agent
|
||||
const response = await requestAgentAssistance({
|
||||
query: query,
|
||||
dataset_name,
|
||||
artifact_type,
|
||||
host_identifier,
|
||||
data_summary,
|
||||
conversation_history,
|
||||
});
|
||||
|
||||
// Add agent response
|
||||
const agentMessage: Message = {
|
||||
role: "agent",
|
||||
content: response.guidance,
|
||||
response,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, agentMessage]);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to get guidance";
|
||||
setError(errorMessage);
|
||||
|
||||
// Add error message
|
||||
const errorMsg: Message = {
|
||||
role: "agent",
|
||||
content: `Error: ${errorMessage}. The agent service may be unavailable.`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const resp = await agent.assist(req);
|
||||
setConversationId(resp.conversation_id || null);
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: resp.guidance, meta: resp }]);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${e.message}` }]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [query, mode, loading, conversationId, selectedDataset, selectedHunt, datasetList, enqueueSnackbar]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
};
|
||||
|
||||
const newConversation = () => { setMessages([]); setConversationId(null); };
|
||||
|
||||
return (
|
||||
<div className="agent-panel">
|
||||
<div className="agent-panel-header">
|
||||
<h3>Analyst Assist Agent</h3>
|
||||
<div className="agent-context">
|
||||
{host_identifier && (
|
||||
<span className="context-badge">Host: {host_identifier}</span>
|
||||
)}
|
||||
{artifact_type && (
|
||||
<span className="context-badge">Artifact: {artifact_type}</span>
|
||||
)}
|
||||
{dataset_name && (
|
||||
<span className="context-badge">Dataset: {dataset_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Typography variant="h5">Agent Assist</Typography>
|
||||
<Button size="small" onClick={newConversation}>New Conversation</Button>
|
||||
</Stack>
|
||||
|
||||
<div className="agent-messages">
|
||||
{messages.length === 0 ? (
|
||||
<div className="agent-welcome">
|
||||
<p className="welcome-title">Welcome to Analyst Assist</p>
|
||||
<p className="welcome-text">
|
||||
Ask questions about your artifact data. I can help you:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Interpret and explain data patterns</li>
|
||||
<li>Suggest analytical pivots and filters</li>
|
||||
<li>Help form and test hypotheses</li>
|
||||
<li>Highlight anomalies and points of interest</li>
|
||||
</ul>
|
||||
<p className="welcome-note">
|
||||
💡 This agent provides guidance only. All analytical decisions
|
||||
remain with you.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, idx) => (
|
||||
<div key={idx} className={`message ${msg.role}`}>
|
||||
<div className="message-header">
|
||||
<span className="message-role">
|
||||
{msg.role === "user" ? "You" : "Agent"}
|
||||
</span>
|
||||
<span className="message-time">
|
||||
{msg.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
{/* Controls */}
|
||||
<Paper sx={{ p: 1.5, mb: 1 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||
<ToggleButtonGroup
|
||||
size="small" exclusive value={mode}
|
||||
onChange={(_, v) => v && setMode(v)}
|
||||
>
|
||||
<ToggleButton value="quick"><SpeedIcon sx={{ mr: 0.5, fontSize: 18 }} />Quick</ToggleButton>
|
||||
<ToggleButton value="deep"><PsychologyIcon sx={{ mr: 0.5, fontSize: 18 }} />Deep</ToggleButton>
|
||||
<ToggleButton value="debate"><ForumIcon sx={{ mr: 0.5, fontSize: 18 }} />Debate</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<div className="message-content">{msg.content}</div>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select label="Dataset" value={selectedDataset} onChange={e => setSelectedDataset(e.target.value)}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{datasetList.map(d => <MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{msg.response && (
|
||||
<div className="message-details">
|
||||
{msg.response.suggested_pivots.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h5>Suggested Pivots:</h5>
|
||||
<ul>
|
||||
{msg.response.suggested_pivots.map((pivot, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
className="pivot-button"
|
||||
onClick={() =>
|
||||
onAnalysisAction && onAnalysisAction(pivot)
|
||||
}
|
||||
>
|
||||
{pivot}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={selectedHunt} onChange={e => setSelectedHunt(e.target.value)}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{msg.response.suggested_filters.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h5>Suggested Filters:</h5>
|
||||
<ul>
|
||||
{msg.response.suggested_filters.map((filter, i) => (
|
||||
<li key={i}>
|
||||
<code>{filter}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg.response.caveats && (
|
||||
<div className="detail-section caveats">
|
||||
<h5>⚠️ Caveats:</h5>
|
||||
<p>{msg.response.caveats}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg.response.confidence && (
|
||||
<div className="detail-section">
|
||||
<span className="confidence">
|
||||
Confidence: {(msg.response.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
{/* Messages */}
|
||||
<Paper sx={{ flex: 1, overflow: 'auto', p: 2, mb: 1, minHeight: 300 }}>
|
||||
{messages.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', mt: 8 }}>
|
||||
<PsychologyIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 1 }} />
|
||||
<Typography color="text.secondary">
|
||||
Ask a question about your threat hunt data.
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
The agent provides advisory guidance — all decisions remain with the analyst.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<Box key={i} sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={700}>
|
||||
{m.role === 'user' ? 'You' : 'Agent'}
|
||||
</Typography>
|
||||
<Paper sx={{
|
||||
p: 1.5, mt: 0.5,
|
||||
bgcolor: m.role === 'user' ? 'primary.dark' : 'background.default',
|
||||
borderColor: m.role === 'user' ? 'primary.main' : 'divider',
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{m.content}</Typography>
|
||||
</Paper>
|
||||
|
||||
{loading && (
|
||||
<div className="message agent loading">
|
||||
<div className="loading-indicator">
|
||||
<span className="dot"></span>
|
||||
<span className="dot"></span>
|
||||
<span className="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Response metadata */}
|
||||
{m.meta && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" sx={{ mb: 0.5 }}>
|
||||
<Chip label={`${m.meta.confidence * 100}% confidence`} size="small"
|
||||
color={m.meta.confidence >= 0.7 ? 'success' : m.meta.confidence >= 0.4 ? 'warning' : 'error'} variant="outlined" />
|
||||
<Chip label={m.meta.model_used} size="small" variant="outlined" />
|
||||
<Chip label={m.meta.node_used} size="small" variant="outlined" />
|
||||
<Chip label={`${m.meta.latency_ms}ms`} size="small" variant="outlined" />
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<div className="message agent error">
|
||||
<p className="error-text">⚠️ {error}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Pivots & Filters */}
|
||||
{(m.meta.suggested_pivots.length > 0 || m.meta.suggested_filters.length > 0) && (
|
||||
<Accordion disableGutters sx={{ mt: 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">Pivots & Filters</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{m.meta.suggested_pivots.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" fontWeight={600}>Pivots</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" sx={{ mb: 1 }}>
|
||||
{m.meta.suggested_pivots.map((p, j) => <Chip key={j} label={p} size="small" color="info" />)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
{m.meta.suggested_filters.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" fontWeight={600}>Filters</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{m.meta.suggested_filters.map((f, j) => <Chip key={j} label={f} size="small" color="secondary" />)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{/* SANS references */}
|
||||
{m.meta.sans_references.length > 0 && (
|
||||
<Accordion disableGutters sx={{ mt: 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<SchoolIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption">SANS References ({m.meta.sans_references.length})</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{m.meta.sans_references.map((r, j) => (
|
||||
<Typography key={j} variant="body2" sx={{ mb: 0.5 }}>• {r}</Typography>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="agent-input-form">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Ask about your data, patterns, or next steps..."
|
||||
{/* Debate perspectives */}
|
||||
{m.meta.perspectives && m.meta.perspectives.length > 0 && (
|
||||
<Accordion disableGutters sx={{ mt: 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">Debate Perspectives ({m.meta.perspectives.length})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{m.meta.perspectives.map((p: any, j: number) => (
|
||||
<Box key={j} sx={{ mb: 1 }}>
|
||||
<Chip label={p.role || `Perspective ${j + 1}`} size="small" color="primary" sx={{ mb: 0.5 }} />
|
||||
<Typography variant="body2">{p.argument || p.content || JSON.stringify(p)}</Typography>
|
||||
<Divider sx={{ mt: 1 }} />
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Caveats */}
|
||||
{m.meta.caveats && (
|
||||
<Alert severity="warning" sx={{ mt: 0.5, py: 0 }}>
|
||||
<Typography variant="caption">{m.meta.caveats}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{loading && <LinearProgress sx={{ mb: 1 }} />}
|
||||
<div ref={bottomRef} />
|
||||
</Paper>
|
||||
|
||||
{/* Input */}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth size="small" multiline maxRows={4}
|
||||
placeholder="Ask the agent..."
|
||||
value={query} onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={loading}
|
||||
className="agent-input"
|
||||
/>
|
||||
<button type="submit" disabled={loading || !query.trim()}>
|
||||
{loading ? "Thinking..." : "Ask"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="agent-footer">
|
||||
<p className="footer-note">
|
||||
ℹ️ Agent provides guidance only. All decisions remain with the analyst.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="contained" onClick={send} disabled={loading || !query.trim()}>
|
||||
{loading ? <CircularProgress size={20} /> : <SendIcon />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentPanel;
|
||||
}
|
||||
|
||||
185
frontend/src/components/AnnotationPanel.tsx
Normal file
185
frontend/src/components/AnnotationPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* AnnotationPanel — create / list / filter annotations on dataset rows.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Chip, Button, TextField,
|
||||
Select, MenuItem, FormControl, InputLabel, CircularProgress,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, IconButton,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { annotations, datasets, type AnnotationData, type DatasetSummary } from '../api/client';
|
||||
|
||||
const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
|
||||
const TAGS = ['suspicious', 'benign', 'needs-review'];
|
||||
const SEV_COLORS: Record<string, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
info: 'info', low: 'success', medium: 'warning', high: 'error', critical: 'error',
|
||||
};
|
||||
|
||||
export default function AnnotationPanel() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [list, setList] = useState<AnnotationData[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterSeverity, setFilterSeverity] = useState('');
|
||||
const [filterTag, setFilterTag] = useState('');
|
||||
const [filterDataset, setFilterDataset] = useState('');
|
||||
const [datasetList, setDatasetList] = useState<DatasetSummary[]>([]);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [form, setForm] = useState({ text: '', severity: 'info', tag: '', dataset_id: '', row_id: '' });
|
||||
|
||||
useEffect(() => {
|
||||
datasets.list(0, 200).then(r => setDatasetList(r.datasets)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await annotations.list({
|
||||
severity: filterSeverity || undefined,
|
||||
tag: filterTag || undefined,
|
||||
dataset_id: filterDataset || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
setList(r.annotations); setTotal(r.total);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setLoading(false);
|
||||
}, [filterSeverity, filterTag, filterDataset, enqueueSnackbar]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await annotations.create({
|
||||
text: form.text,
|
||||
severity: form.severity,
|
||||
tag: form.tag || undefined,
|
||||
dataset_id: form.dataset_id || undefined,
|
||||
row_id: form.row_id ? parseInt(form.row_id, 10) : undefined,
|
||||
});
|
||||
enqueueSnackbar('Annotation created', { variant: 'success' });
|
||||
setDlgOpen(false); load();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await annotations.delete(id);
|
||||
enqueueSnackbar('Deleted', { variant: 'info' }); load();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ p: 4 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Annotations ({total})</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />}
|
||||
onClick={() => { setForm({ text: '', severity: 'info', tag: '', dataset_id: '', row_id: '' }); setDlgOpen(true); }}>
|
||||
New
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Filters */}
|
||||
<Paper sx={{ p: 1.5, mb: 2 }}>
|
||||
<Stack direction="row" spacing={1.5} flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Severity</InputLabel>
|
||||
<Select label="Severity" value={filterSeverity} onChange={e => setFilterSeverity(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{SEVERITIES.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Tag</InputLabel>
|
||||
<Select label="Tag" value={filterTag} onChange={e => setFilterTag(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{TAGS.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select label="Dataset" value={filterDataset} onChange={e => setFilterDataset(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{datasetList.map(d => <MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Severity</TableCell>
|
||||
<TableCell>Tag</TableCell>
|
||||
<TableCell>Text</TableCell>
|
||||
<TableCell>Row</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list.map(a => (
|
||||
<TableRow key={a.id} hover>
|
||||
<TableCell>
|
||||
<Chip label={a.severity} size="small" color={SEV_COLORS[a.severity] || 'default'} variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>{a.tag || '—'}</TableCell>
|
||||
<TableCell><Typography variant="body2" sx={{ maxWidth: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.text}</Typography></TableCell>
|
||||
<TableCell>{a.row_id ?? '—'}</TableCell>
|
||||
<TableCell><Typography variant="caption">{new Date(a.created_at).toLocaleString()}</Typography></TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(a.id)}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<TableRow><TableCell colSpan={6} align="center"><Typography color="text.secondary">No annotations</Typography></TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={dlgOpen} onClose={() => setDlgOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>New Annotation</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Text" fullWidth multiline rows={3} value={form.text} onChange={e => setForm(f => ({ ...f, text: e.target.value }))} />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Severity</InputLabel>
|
||||
<Select label="Severity" value={form.severity} onChange={e => setForm(f => ({ ...f, severity: e.target.value }))}>
|
||||
{SEVERITIES.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Tag</InputLabel>
|
||||
<Select label="Tag" value={form.tag} onChange={e => setForm(f => ({ ...f, tag: e.target.value }))}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{TAGS.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select label="Dataset" value={form.dataset_id} onChange={e => setForm(f => ({ ...f, dataset_id: e.target.value }))}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{datasetList.map(d => <MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Row Index" type="number" value={form.row_id} onChange={e => setForm(f => ({ ...f, row_id: e.target.value }))} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDlgOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={!form.text.trim()}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
178
frontend/src/components/CorrelationView.tsx
Normal file
178
frontend/src/components/CorrelationView.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* CorrelationView — cross-hunt correlation analysis with IOC, time,
|
||||
* technique, and host overlap visualisation.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Chip, Button, CircularProgress,
|
||||
Alert, Table, TableBody, TableCell, TableContainer, TableHead,
|
||||
TableRow, TextField,
|
||||
} from '@mui/material';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { correlation, hunts, type Hunt, type CorrelationResult } from '../api/client';
|
||||
|
||||
export default function CorrelationView() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<CorrelationResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [iocSearch, setIocSearch] = useState('');
|
||||
const [iocHits, setIocHits] = useState<any[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hunts.list(0, 100).then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHunt = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const runAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = selectedIds.length >= 2
|
||||
? await correlation.analyze(selectedIds)
|
||||
: await correlation.all();
|
||||
setResult(r);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setLoading(false);
|
||||
}, [selectedIds, enqueueSnackbar]);
|
||||
|
||||
const searchIoc = useCallback(async () => {
|
||||
if (!iocSearch.trim()) return;
|
||||
try {
|
||||
const r = await correlation.ioc(iocSearch.trim());
|
||||
setIocHits(r.occurrences);
|
||||
if (r.occurrences.length === 0) enqueueSnackbar('No occurrences found', { variant: 'info' });
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
}, [iocSearch, enqueueSnackbar]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Cross-Hunt Correlation</Typography>
|
||||
|
||||
{/* Hunt selector */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Select hunts to correlate (min 2, or leave empty for all):</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" sx={{ mb: 1.5 }}>
|
||||
{huntList.map(h => (
|
||||
<Chip
|
||||
key={h.id} label={h.name} size="small"
|
||||
color={selectedIds.includes(h.id) ? 'primary' : 'default'}
|
||||
onClick={() => toggleHunt(h.id)}
|
||||
variant={selectedIds.includes(h.id) ? 'filled' : 'outlined'}
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Button
|
||||
variant="contained" startIcon={loading ? <CircularProgress size={16} /> : <CompareArrowsIcon />}
|
||||
onClick={runAnalysis} disabled={loading}
|
||||
>
|
||||
{selectedIds.length >= 2 ? `Correlate ${selectedIds.length} Hunts` : 'Correlate All'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* IOC Search */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Search IOC across all hunts:</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField size="small" fullWidth placeholder="e.g. 192.168.1.100" value={iocSearch}
|
||||
onChange={e => setIocSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && searchIoc()} />
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={searchIoc}>Search</Button>
|
||||
</Stack>
|
||||
{iocHits && iocHits.length > 0 && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="body2" fontWeight={600}>Found in {iocHits.length} location(s):</Typography>
|
||||
{iocHits.map((hit: any, i: number) => (
|
||||
<Chip key={i} label={`${hit.hunt_name || hit.hunt_id} / ${hit.dataset_name || hit.dataset_id}`}
|
||||
size="small" sx={{ mr: 0.5, mt: 0.5 }} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{result.summary} — {result.total_correlations} total correlation(s) across {result.hunt_ids.length} hunts
|
||||
</Alert>
|
||||
|
||||
{/* IOC overlaps */}
|
||||
{result.ioc_overlaps.length > 0 && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>IOC Overlaps ({result.ioc_overlaps.length})</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>IOC</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Shared Hunts</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{result.ioc_overlaps.map((o: any, i: number) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Typography variant="body2" fontFamily="monospace">{o.ioc_value}</Typography></TableCell>
|
||||
<TableCell><Chip label={o.ioc_type || 'unknown'} size="small" /></TableCell>
|
||||
<TableCell>
|
||||
{(o.hunt_ids || []).map((hid: string, j: number) => (
|
||||
<Chip key={j} label={huntList.find(h => h.id === hid)?.name || hid} size="small" sx={{ mr: 0.5 }} />
|
||||
))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Technique overlaps */}
|
||||
{result.technique_overlaps.length > 0 && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>MITRE Technique Overlaps</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{result.technique_overlaps.map((t: any, i: number) => (
|
||||
<Chip key={i} label={t.technique || t.mitre_technique} color="secondary" size="small" />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Time overlaps */}
|
||||
{result.time_overlaps.length > 0 && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Time Overlaps</Typography>
|
||||
{result.time_overlaps.map((t: any, i: number) => (
|
||||
<Typography key={i} variant="body2" sx={{ mb: 0.5 }}>
|
||||
{t.hunt_a || 'Hunt A'} ↔ {t.hunt_b || 'Hunt B'}: {t.overlap_start} — {t.overlap_end}
|
||||
</Typography>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Host overlaps */}
|
||||
{result.host_overlaps.length > 0 && (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Host Overlaps</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{result.host_overlaps.map((h: any, i: number) => (
|
||||
<Chip key={i} label={typeof h === 'string' ? h : h.hostname || JSON.stringify(h)} size="small" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
152
frontend/src/components/Dashboard.tsx
Normal file
152
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Dashboard — overview cards with hunt stats, node health, recent activity.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box, Grid, Paper, Typography, Chip, CircularProgress,
|
||||
Stack, Alert,
|
||||
} from '@mui/material';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import ScienceIcon from '@mui/icons-material/Science';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import { hunts, datasets, hypotheses, agent, misc, type Hunt, type DatasetSummary, type HealthInfo } from '../api/client';
|
||||
|
||||
function StatCard({ title, value, icon, color }: { title: string; value: string | number; icon: React.ReactNode; color: string }) {
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Box sx={{ color, fontSize: 40, display: 'flex' }}>{icon}</Box>
|
||||
<Box>
|
||||
<Typography variant="h4">{value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{title}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeStatus({ label, available }: { label: string; available: boolean }) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
{available
|
||||
? <CheckCircleIcon sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
: <ErrorIcon sx={{ color: 'error.main', fontSize: 20 }} />
|
||||
}
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
<Chip label={available ? 'Online' : 'Offline'} size="small"
|
||||
color={available ? 'success' : 'error'} variant="outlined" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [health, setHealth] = useState<HealthInfo | null>(null);
|
||||
const [huntList, setHunts] = useState<Hunt[]>([]);
|
||||
const [datasetList, setDatasets] = useState<DatasetSummary[]>([]);
|
||||
const [hypoCount, setHypoCount] = useState(0);
|
||||
const [apiInfo, setApiInfo] = useState<{ name: string; version: string; status: string } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [h, ht, ds, hy, info] = await Promise.all([
|
||||
agent.health().catch(() => null),
|
||||
hunts.list(0, 100).catch(() => ({ hunts: [], total: 0 })),
|
||||
datasets.list(0, 100).catch(() => ({ datasets: [], total: 0 })),
|
||||
hypotheses.list({ limit: 1 }).catch(() => ({ hypotheses: [], total: 0 })),
|
||||
misc.root().catch(() => null),
|
||||
]);
|
||||
setHealth(h);
|
||||
setHunts(ht.hunts);
|
||||
setDatasets(ds.datasets);
|
||||
setHypoCount(hy.total);
|
||||
setApiInfo(info);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (loading) return <Box sx={{ p: 4 }}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
|
||||
const activeHunts = huntList.filter(h => h.status === 'active').length;
|
||||
const totalRows = datasetList.reduce((s, d) => s + d.row_count, 0);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Dashboard</Typography>
|
||||
|
||||
{/* Stat cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Active Hunts" value={activeHunts} icon={<SearchIcon fontSize="inherit" />} color="#60a5fa" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Datasets" value={datasetList.length} icon={<StorageIcon fontSize="inherit" />} color="#f472b6" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Total Rows" value={totalRows.toLocaleString()} icon={<SecurityIcon fontSize="inherit" />} color="#10b981" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Hypotheses" value={hypoCount} icon={<ScienceIcon fontSize="inherit" />} color="#f59e0b" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Node health + API info */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" gutterBottom>LLM Cluster Health</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<NodeStatus label="Wile (100.110.190.12)" available={health?.nodes?.wile?.available ?? false} />
|
||||
<NodeStatus label="Roadrunner (100.110.190.11)" available={health?.nodes?.roadrunner?.available ?? false} />
|
||||
<NodeStatus label="SANS RAG (Open WebUI)" available={health?.rag?.available ?? false} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" gutterBottom>API Status</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{apiInfo ? `${apiInfo.name} — ${apiInfo.version}` : 'Unreachable'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Status: {apiInfo?.status ?? 'unknown'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Recent hunts */}
|
||||
{huntList.length > 0 && (
|
||||
<Paper sx={{ p: 2.5, mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Recent Hunts</Typography>
|
||||
<Stack spacing={1}>
|
||||
{huntList.slice(0, 5).map(h => (
|
||||
<Stack key={h.id} direction="row" alignItems="center" spacing={1}>
|
||||
<Chip label={h.status} size="small"
|
||||
color={h.status === 'active' ? 'success' : h.status === 'closed' ? 'default' : 'warning'}
|
||||
variant="outlined" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{h.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{h.dataset_count} datasets · {h.hypothesis_count} hypotheses
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
200
frontend/src/components/DatasetViewer.tsx
Normal file
200
frontend/src/components/DatasetViewer.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* DatasetViewer — list datasets, browse rows with MUI DataGrid.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Chip, CircularProgress,
|
||||
Alert, Button, IconButton, Select, MenuItem, FormControl,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import { DataGrid, type GridColDef, type GridPaginationModel } from '@mui/x-data-grid';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { datasets, enrichment, type DatasetSummary } from '../api/client';
|
||||
|
||||
export default function DatasetViewer() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [list, setList] = useState<DatasetSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<DatasetSummary | null>(null);
|
||||
const [rows, setRows] = useState<Record<string, any>[]>([]);
|
||||
const [rowTotal, setRowTotal] = useState(0);
|
||||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({ page: 0, pageSize: 50 });
|
||||
const [rowLoading, setRowLoading] = useState(false);
|
||||
const [enriching, setEnriching] = useState(false);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await datasets.list(0, 200);
|
||||
setList(r.datasets);
|
||||
if (r.datasets.length > 0 && !selected) setSelected(r.datasets[0]);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setLoading(false);
|
||||
}, [enqueueSnackbar, selected]);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
setRowLoading(true);
|
||||
try {
|
||||
const r = await datasets.rows(selected.id, paginationModel.page * paginationModel.pageSize, paginationModel.pageSize);
|
||||
setRows(r.rows.map((rw, i) => ({ __id: `${paginationModel.page}-${i}`, ...rw })));
|
||||
setRowTotal(r.total);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setRowLoading(false);
|
||||
}, [selected, paginationModel, enqueueSnackbar]);
|
||||
|
||||
useEffect(() => { loadList(); }, [loadList]);
|
||||
useEffect(() => { loadRows(); }, [loadRows]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Delete this dataset?')) return;
|
||||
try {
|
||||
await datasets.delete(id);
|
||||
enqueueSnackbar('Dataset deleted', { variant: 'info' });
|
||||
if (selected?.id === id) setSelected(null);
|
||||
loadList();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleEnrich = async () => {
|
||||
if (!selected) return;
|
||||
setEnriching(true);
|
||||
try {
|
||||
const r = await enrichment.dataset(selected.id);
|
||||
enqueueSnackbar(`Enriched ${r.enriched} IOCs from ${r.iocs_found} found`, { variant: 'success' });
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setEnriching(false);
|
||||
};
|
||||
|
||||
// IOC type → colour mapping (matches NetworkMap)
|
||||
const IOC_COLORS: Record<string, { bg: string; text: string; header: string }> = {
|
||||
ip: { bg: 'rgba(59,130,246,0.08)', text: '#3b82f6', header: 'rgba(59,130,246,0.18)' },
|
||||
hostname: { bg: 'rgba(34,197,94,0.08)', text: '#22c55e', header: 'rgba(34,197,94,0.18)' },
|
||||
domain: { bg: 'rgba(234,179,8,0.08)', text: '#eab308', header: 'rgba(234,179,8,0.18)' },
|
||||
url: { bg: 'rgba(139,92,246,0.08)', text: '#8b5cf6', header: 'rgba(139,92,246,0.18)' },
|
||||
hash_md5: { bg: 'rgba(244,63,94,0.08)', text: '#f43f5e', header: 'rgba(244,63,94,0.18)' },
|
||||
hash_sha1:{ bg: 'rgba(244,63,94,0.08)', text: '#f43f5e', header: 'rgba(244,63,94,0.18)' },
|
||||
hash_sha256:{ bg: 'rgba(244,63,94,0.08)',text: '#f43f5e', header: 'rgba(244,63,94,0.18)' },
|
||||
};
|
||||
const DEFAULT_IOC_STYLE = { bg: 'rgba(251,191,36,0.08)', text: '#fbbf24', header: 'rgba(251,191,36,0.18)' };
|
||||
|
||||
// Resolve IOC type for a column (first type in the array)
|
||||
const iocMap = selected?.ioc_columns ?? {};
|
||||
const iocTypeFor = (col: string): string | null => {
|
||||
const types = iocMap[col];
|
||||
if (!types || types.length === 0) return null;
|
||||
return Array.isArray(types) ? types[0] : (types as any);
|
||||
};
|
||||
|
||||
// Build DataGrid columns from the first row, highlighting IOC columns
|
||||
const columns: GridColDef[] = rows.length > 0
|
||||
? Object.keys(rows[0]).filter(k => k !== '__id').map(k => {
|
||||
const iocType = iocTypeFor(k);
|
||||
const style = iocType ? (IOC_COLORS[iocType] || DEFAULT_IOC_STYLE) : null;
|
||||
return {
|
||||
field: k,
|
||||
headerName: iocType ? `${k} ◆ ${iocType.toUpperCase()}` : k,
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
...(style ? {
|
||||
headerClassName: `ioc-header-${iocType}`,
|
||||
cellClassName: `ioc-cell-${iocType}`,
|
||||
} : {}),
|
||||
} as GridColDef;
|
||||
})
|
||||
: [];
|
||||
|
||||
if (loading) return <Box sx={{ p: 4 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Datasets ({list.length})</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" startIcon={<RefreshIcon />} onClick={loadList}>Refresh</Button>
|
||||
{selected && (
|
||||
<Button size="small" variant="outlined" onClick={handleEnrich} disabled={enriching}>
|
||||
{enriching ? 'Enriching...' : 'Auto-Enrich IOCs'}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Dataset selector */}
|
||||
{list.length > 0 && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select
|
||||
label="Dataset"
|
||||
value={selected?.id || ''}
|
||||
onChange={e => setSelected(list.find(d => d.id === e.target.value) || null)}
|
||||
>
|
||||
{list.map(d => (
|
||||
<MenuItem key={d.id} value={d.id}>{d.name} ({d.row_count} rows)</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selected && (
|
||||
<>
|
||||
<Chip label={`${selected.row_count} rows`} size="small" />
|
||||
<Chip label={selected.encoding || 'utf-8'} size="small" variant="outlined" />
|
||||
{selected.source_tool && <Chip label={selected.source_tool} size="small" color="info" variant="outlined" />}
|
||||
{selected.ioc_columns && Object.keys(selected.ioc_columns).length > 0 && (
|
||||
<Chip label={`${Object.keys(selected.ioc_columns).length} IOC columns`} size="small" color="warning" variant="outlined" />
|
||||
)}
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(selected.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
{selected?.time_range_start && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Time range: {selected.time_range_start} — {selected.time_range_end}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Data grid */}
|
||||
{selected ? (
|
||||
<Paper sx={{ height: 520 }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowId={r => r.__id}
|
||||
rowCount={rowTotal}
|
||||
loading={rowLoading}
|
||||
paginationMode="server"
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
density="compact"
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell': { fontSize: '0.8rem' },
|
||||
'& .MuiDataGrid-columnHeader': { fontWeight: 700 },
|
||||
// IOC column highlights
|
||||
...Object.fromEntries(
|
||||
Object.entries(IOC_COLORS).flatMap(([type, c]) => [
|
||||
[`& .ioc-header-${type}`, { backgroundColor: c.header, '& .MuiDataGrid-columnHeaderTitle': { color: c.text, fontWeight: 800 } }],
|
||||
[`& .ioc-cell-${type}`, { backgroundColor: c.bg, borderLeft: `2px solid ${c.text}` }],
|
||||
]),
|
||||
),
|
||||
// Default IOC fallback
|
||||
'& [class*="ioc-header-"]': { backgroundColor: DEFAULT_IOC_STYLE.header },
|
||||
'& [class*="ioc-cell-"]': { backgroundColor: DEFAULT_IOC_STYLE.bg },
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
) : (
|
||||
<Alert severity="info">Upload a CSV to get started.</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
119
frontend/src/components/EnrichmentPanel.tsx
Normal file
119
frontend/src/components/EnrichmentPanel.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* EnrichmentPanel — manual IOC lookup + batch enrichment results.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, TextField, Stack, Button, Chip,
|
||||
Select, MenuItem, FormControl, InputLabel, CircularProgress,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { enrichment, type EnrichmentResult } from '../api/client';
|
||||
|
||||
const IOC_TYPES = ['ip', 'domain', 'hash_md5', 'hash_sha1', 'hash_sha256', 'url'];
|
||||
|
||||
const VERDICT_COLORS: Record<string, 'error' | 'warning' | 'success' | 'default' | 'info'> = {
|
||||
malicious: 'error', suspicious: 'warning', clean: 'success', unknown: 'default', error: 'info',
|
||||
};
|
||||
|
||||
export default function EnrichmentPanel() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [iocValue, setIocValue] = useState('');
|
||||
const [iocType, setIocType] = useState('ip');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<EnrichmentResult[]>([]);
|
||||
const [overallVerdict, setOverallVerdict] = useState('');
|
||||
const [overallScore, setOverallScore] = useState(0);
|
||||
|
||||
const lookup = useCallback(async () => {
|
||||
if (!iocValue.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await enrichment.ioc(iocValue.trim(), iocType);
|
||||
setResults(r.results);
|
||||
setOverallVerdict(r.overall_verdict);
|
||||
setOverallScore(r.overall_score);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
setLoading(false);
|
||||
}, [iocValue, iocType, enqueueSnackbar]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>IOC Enrichment</Typography>
|
||||
|
||||
{/* Lookup form */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select label="Type" value={iocType} onChange={e => setIocType(e.target.value)}>
|
||||
{IOC_TYPES.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small" fullWidth label="IOC Value"
|
||||
placeholder="e.g. 1.2.3.4 or evil.com"
|
||||
value={iocValue}
|
||||
onChange={e => setIocValue(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && lookup()}
|
||||
/>
|
||||
<Button variant="contained" startIcon={loading ? <CircularProgress size={16} /> : <SearchIcon />}
|
||||
onClick={lookup} disabled={loading || !iocValue.trim()}>
|
||||
Lookup
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Overall verdict */}
|
||||
{overallVerdict && (
|
||||
<Alert severity={overallVerdict === 'malicious' ? 'error' : overallVerdict === 'suspicious' ? 'warning' : 'info'} sx={{ mb: 2 }}>
|
||||
Overall verdict: <strong>{overallVerdict}</strong> — Score: {overallScore.toFixed(1)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Results table */}
|
||||
{results.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>Verdict</TableCell>
|
||||
<TableCell>Score</TableCell>
|
||||
<TableCell>Country</TableCell>
|
||||
<TableCell>Org</TableCell>
|
||||
<TableCell>Tags</TableCell>
|
||||
<TableCell>Latency</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{results.map((r, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{r.source}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={r.verdict} size="small"
|
||||
color={VERDICT_COLORS[r.verdict] || 'default'} variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>{r.score.toFixed(1)}</TableCell>
|
||||
<TableCell>{r.country || '—'}</TableCell>
|
||||
<TableCell>{r.org || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{r.tags.slice(0, 5).map((t, j) => <Chip key={j} label={t} size="small" />)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>{r.latency_ms}ms</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
223
frontend/src/components/FileUpload.tsx
Normal file
223
frontend/src/components/FileUpload.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* FileUpload — multi-file drag-and-drop CSV upload with per-file progress bars.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Chip, LinearProgress,
|
||||
Select, MenuItem, FormControl, InputLabel, IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { datasets, hunts, type UploadResult, type Hunt } from '../api/client';
|
||||
|
||||
interface FileJob {
|
||||
file: File;
|
||||
status: 'queued' | 'uploading' | 'done' | 'error';
|
||||
progress: number; // 0–100
|
||||
result?: UploadResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function FileUpload() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [jobs, setJobs] = useState<FileJob[]>([]);
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [huntId, setHuntId] = useState('');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
hunts.list(0, 100).then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Process the queue sequentially
|
||||
const processQueue = useCallback(async (queue: FileJob[]) => {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
if (queue[i].status !== 'queued') continue;
|
||||
|
||||
// Mark uploading
|
||||
setJobs(prev => prev.map((j, idx) =>
|
||||
idx === i ? { ...j, status: 'uploading' as const, progress: 0 } : j
|
||||
));
|
||||
|
||||
try {
|
||||
const result = await datasets.uploadWithProgress(
|
||||
queue[i].file,
|
||||
huntId || undefined,
|
||||
(pct) => {
|
||||
setJobs(prev => prev.map((j, idx) =>
|
||||
idx === i ? { ...j, progress: pct } : j
|
||||
));
|
||||
},
|
||||
);
|
||||
setJobs(prev => prev.map((j, idx) =>
|
||||
idx === i ? { ...j, status: 'done' as const, progress: 100, result } : j
|
||||
));
|
||||
enqueueSnackbar(
|
||||
`${queue[i].file.name}: ${result.row_count} rows, ${result.columns.length} columns`,
|
||||
{ variant: 'success' },
|
||||
);
|
||||
} catch (e: any) {
|
||||
setJobs(prev => prev.map((j, idx) =>
|
||||
idx === i ? { ...j, status: 'error' as const, error: e.message } : j
|
||||
));
|
||||
enqueueSnackbar(`${queue[i].file.name}: ${e.message}`, { variant: 'error' });
|
||||
}
|
||||
}
|
||||
busyRef.current = false;
|
||||
}, [huntId, enqueueSnackbar]);
|
||||
|
||||
const enqueueFiles = useCallback((files: FileList | File[]) => {
|
||||
const newJobs: FileJob[] = Array.from(files).map(file => ({
|
||||
file, status: 'queued' as const, progress: 0,
|
||||
}));
|
||||
setJobs(prev => {
|
||||
const merged = [...prev, ...newJobs];
|
||||
// kick off processing with the full merged list
|
||||
setTimeout(() => processQueue(merged), 0);
|
||||
return merged;
|
||||
});
|
||||
}, [processQueue]);
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault(); setDragOver(false);
|
||||
if (e.dataTransfer.files.length > 0) enqueueFiles(e.dataTransfer.files);
|
||||
}, [enqueueFiles]);
|
||||
|
||||
const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
enqueueFiles(e.target.files);
|
||||
e.target.value = ''; // reset so same file can be re-selected
|
||||
}
|
||||
}, [enqueueFiles]);
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
setJobs(prev => prev.filter(j => j.status === 'queued' || j.status === 'uploading'));
|
||||
}, []);
|
||||
|
||||
const overallDone = jobs.filter(j => j.status === 'done').length;
|
||||
const overallErr = jobs.filter(j => j.status === 'error').length;
|
||||
const overallTotal = jobs.length;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Upload Datasets</Typography>
|
||||
|
||||
{/* Hunt association */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 300 }}>
|
||||
<InputLabel>Associate with Hunt (optional)</InputLabel>
|
||||
<Select label="Associate with Hunt (optional)" value={huntId}
|
||||
onChange={e => setHuntId(e.target.value)}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
|
||||
{/* Drop zone */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 6, textAlign: 'center', cursor: 'pointer',
|
||||
border: '2px dashed',
|
||||
borderColor: dragOver ? 'primary.main' : 'divider',
|
||||
bgcolor: dragOver ? 'action.hover' : 'background.paper',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<input ref={fileRef} type="file" accept=".csv,.tsv,.txt" hidden multiple onChange={onFileChange} />
|
||||
<CloudUploadIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 1 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Drag & drop CSV / TSV files here
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
or click to browse — multiple files supported — max 100 MB each
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Overall progress summary */}
|
||||
{overallTotal > 0 && (
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{overallDone + overallErr} / {overallTotal} files processed
|
||||
{overallErr > 0 && ` (${overallErr} failed)`}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
{overallDone + overallErr === overallTotal && overallTotal > 0 && (
|
||||
<Tooltip title="Clear completed">
|
||||
<IconButton size="small" onClick={clearCompleted}><ClearIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Per-file progress list */}
|
||||
{jobs.map((job, i) => (
|
||||
<Paper key={`${job.file.name}-${i}`} sx={{ p: 2, mt: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
{job.status === 'done' && <CheckCircleIcon color="success" fontSize="small" />}
|
||||
{job.status === 'error' && <ErrorIcon color="error" fontSize="small" />}
|
||||
{(job.status === 'queued' || job.status === 'uploading') && (
|
||||
<Box sx={{ width: 20, height: 20 }} />
|
||||
)}
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="body2" noWrap sx={{ fontWeight: 600 }}>
|
||||
{job.file.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({(job.file.size / 1024 / 1024).toFixed(1)} MB)
|
||||
</Typography>
|
||||
{job.status === 'queued' && (
|
||||
<Chip label="Queued" size="small" variant="outlined" />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Progress bar */}
|
||||
{job.status === 'uploading' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
||||
<LinearProgress
|
||||
variant="determinate" value={job.progress}
|
||||
sx={{ flexGrow: 1, height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ minWidth: 36 }}>
|
||||
{job.progress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{job.status === 'error' && (
|
||||
<Typography variant="caption" color="error">{job.error}</Typography>
|
||||
)}
|
||||
|
||||
{/* Success details */}
|
||||
{job.status === 'done' && job.result && (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" sx={{ mt: 0.5 }}>
|
||||
<Chip label={`${job.result.row_count} rows`} size="small" color="primary" />
|
||||
<Chip label={`${job.result.columns.length} cols`} size="small" />
|
||||
{Object.keys(job.result.ioc_columns).length > 0 && (
|
||||
<Chip label={`${Object.keys(job.result.ioc_columns).length} IOC cols`}
|
||||
size="small" color="warning" />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
156
frontend/src/components/HuntManager.tsx
Normal file
156
frontend/src/components/HuntManager.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* HuntManager — create, list, and manage hunts.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, TextField, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, Chip, Stack, IconButton, Table,
|
||||
TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
CircularProgress, Select, MenuItem, FormControl, InputLabel,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { hunts, reports, type Hunt } from '../api/client';
|
||||
|
||||
const STATUS_COLORS: Record<string, 'success' | 'default' | 'warning'> = {
|
||||
active: 'success', closed: 'default', archived: 'warning',
|
||||
};
|
||||
|
||||
export default function HuntManager() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [list, setList] = useState<Hunt[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [editHunt, setEditHunt] = useState<Hunt | null>(null);
|
||||
const [form, setForm] = useState({ name: '', description: '', status: 'active' });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await hunts.list(0, 100);
|
||||
setList(r.hunts); setTotal(r.total);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setLoading(false);
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => { setEditHunt(null); setForm({ name: '', description: '', status: 'active' }); setDlgOpen(true); };
|
||||
const openEdit = (h: Hunt) => { setEditHunt(h); setForm({ name: h.name, description: h.description || '', status: h.status }); setDlgOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (editHunt) {
|
||||
await hunts.update(editHunt.id, form);
|
||||
enqueueSnackbar('Hunt updated', { variant: 'success' });
|
||||
} else {
|
||||
await hunts.create(form.name, form.description || undefined);
|
||||
enqueueSnackbar('Hunt created', { variant: 'success' });
|
||||
}
|
||||
setDlgOpen(false); load();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Delete this hunt?')) return;
|
||||
try {
|
||||
await hunts.delete(id);
|
||||
enqueueSnackbar('Hunt deleted', { variant: 'info' }); load();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleExport = async (id: string, fmt: 'json' | 'html' | 'csv') => {
|
||||
try {
|
||||
const data = fmt === 'json' ? JSON.stringify(await reports.json(id), null, 2)
|
||||
: fmt === 'html' ? await reports.html(id)
|
||||
: await reports.csv(id);
|
||||
const blob = new Blob([data], { type: fmt === 'json' ? 'application/json' : fmt === 'html' ? 'text/html' : 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = `hunt_${id}.${fmt}`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
enqueueSnackbar(`Report exported as ${fmt.toUpperCase()}`, { variant: 'success' });
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ p: 4 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Hunts ({total})</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>New Hunt</Button>
|
||||
</Stack>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Datasets</TableCell>
|
||||
<TableCell>Hypotheses</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{list.map(h => (
|
||||
<TableRow key={h.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={600}>{h.name}</Typography>
|
||||
{h.description && <Typography variant="caption" color="text.secondary">{h.description}</Typography>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={h.status} size="small" color={STATUS_COLORS[h.status] || 'default'} variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>{h.dataset_count}</TableCell>
|
||||
<TableCell>{h.hypothesis_count}</TableCell>
|
||||
<TableCell><Typography variant="caption">{new Date(h.created_at).toLocaleDateString()}</Typography></TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(h)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" onClick={() => handleExport(h.id, 'html')} title="Export HTML"><DownloadIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(h.id)}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<TableRow><TableCell colSpan={6} align="center"><Typography color="text.secondary">No hunts yet</Typography></TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Create / Edit dialog */}
|
||||
<Dialog open={dlgOpen} onClose={() => setDlgOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editHunt ? 'Edit Hunt' : 'New Hunt'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Name" fullWidth value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
||||
<TextField label="Description" fullWidth multiline rows={3} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
|
||||
{editHunt && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select label="Status" value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="closed">Closed</MenuItem>
|
||||
<MenuItem value="archived">Archived</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDlgOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={!form.name.trim()}>
|
||||
{editHunt ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
202
frontend/src/components/HypothesisTracker.tsx
Normal file
202
frontend/src/components/HypothesisTracker.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* HypothesisTracker — create, track status, link MITRE techniques.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Chip, Button, TextField,
|
||||
Select, MenuItem, FormControl, InputLabel, CircularProgress,
|
||||
IconButton, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Card, CardContent, CardActions, Grid,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { hypotheses, hunts, type HypothesisData, type Hunt } from '../api/client';
|
||||
|
||||
const STATUSES = ['draft', 'active', 'confirmed', 'rejected'];
|
||||
const STATUS_COLORS: Record<string, 'default' | 'info' | 'success' | 'error'> = {
|
||||
draft: 'default', active: 'info', confirmed: 'success', rejected: 'error',
|
||||
};
|
||||
|
||||
export default function HypothesisTracker() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [list, setList] = useState<HypothesisData[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [filterHunt, setFilterHunt] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [editItem, setEditItem] = useState<HypothesisData | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', mitre_technique: '', status: 'draft',
|
||||
hunt_id: '', evidence_notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
hunts.list(0, 100).then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await hypotheses.list({
|
||||
hunt_id: filterHunt || undefined,
|
||||
status: filterStatus || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
setList(r.hypotheses); setTotal(r.total);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setLoading(false);
|
||||
}, [filterHunt, filterStatus, enqueueSnackbar]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditItem(null);
|
||||
setForm({ title: '', description: '', mitre_technique: '', status: 'draft', hunt_id: '', evidence_notes: '' });
|
||||
setDlgOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (h: HypothesisData) => {
|
||||
setEditItem(h);
|
||||
setForm({
|
||||
title: h.title, description: h.description || '', mitre_technique: h.mitre_technique || '',
|
||||
status: h.status, hunt_id: h.hunt_id || '', evidence_notes: h.evidence_notes || '',
|
||||
});
|
||||
setDlgOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (editItem) {
|
||||
await hypotheses.update(editItem.id, {
|
||||
title: form.title, description: form.description || undefined,
|
||||
mitre_technique: form.mitre_technique || undefined, status: form.status,
|
||||
evidence_notes: form.evidence_notes || undefined,
|
||||
});
|
||||
enqueueSnackbar('Hypothesis updated', { variant: 'success' });
|
||||
} else {
|
||||
await hypotheses.create({
|
||||
title: form.title, description: form.description || undefined,
|
||||
mitre_technique: form.mitre_technique || undefined,
|
||||
hunt_id: form.hunt_id || undefined, status: form.status,
|
||||
});
|
||||
enqueueSnackbar('Hypothesis created', { variant: 'success' });
|
||||
}
|
||||
setDlgOpen(false); load();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Delete this hypothesis?')) return;
|
||||
try {
|
||||
await hypotheses.delete(id);
|
||||
enqueueSnackbar('Deleted', { variant: 'info' }); load();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ p: 4 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Hypotheses ({total})</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>New Hypothesis</Button>
|
||||
</Stack>
|
||||
|
||||
<Paper sx={{ p: 1.5, mb: 2 }}>
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={filterHunt} onChange={e => setFilterHunt(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select label="Status" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{STATUSES.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{list.map(h => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={h.id}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Chip label={h.status} size="small" color={STATUS_COLORS[h.status] || 'default'} />
|
||||
{h.mitre_technique && <Chip label={h.mitre_technique} size="small" variant="outlined" color="info" />}
|
||||
</Stack>
|
||||
<Typography variant="subtitle1" fontWeight={600}>{h.title}</Typography>
|
||||
{h.description && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{h.description}</Typography>}
|
||||
{h.evidence_notes && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Evidence: {h.evidence_notes}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<IconButton size="small" onClick={() => openEdit(h)}><EditIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(h.id)}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<Grid size={12}>
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary" gutterBottom>No hypotheses yet. Create one to track your investigation.</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Hypotheses let you document what you think is happening (e.g. "Attacker used T1059.001 PowerShell
|
||||
to exfiltrate data"), link them to a hunt and MITRE ATT&CK technique, then update their status
|
||||
as evidence confirms or rejects them.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Dialog */}
|
||||
<Dialog open={dlgOpen} onClose={() => setDlgOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editItem ? 'Edit Hypothesis' : 'New Hypothesis'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Title" fullWidth value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} />
|
||||
<TextField label="Description" fullWidth multiline rows={3} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
|
||||
<TextField label="MITRE Technique" fullWidth placeholder="e.g. T1059.001" value={form.mitre_technique} onChange={e => setForm(f => ({ ...f, mitre_technique: e.target.value }))} />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select label="Status" value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
|
||||
{STATUSES.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{!editItem && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={form.hunt_id} onChange={e => setForm(f => ({ ...f, hunt_id: e.target.value }))}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<TextField label="Evidence Notes" fullWidth multiline rows={2} value={form.evidence_notes} onChange={e => setForm(f => ({ ...f, evidence_notes: e.target.value }))} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDlgOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={!form.title.trim()}>
|
||||
{editItem ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
777
frontend/src/components/NetworkMap.tsx
Normal file
777
frontend/src/components/NetworkMap.tsx
Normal file
@@ -0,0 +1,777 @@
|
||||
/**
|
||||
* NetworkMap — interactive hunt-scoped force-directed network graph.
|
||||
*
|
||||
* • Select a hunt → loads only that hunt's datasets
|
||||
* • Nodes = unique IPs / hostnames / domains pulled from IOC columns
|
||||
* • Edges = "seen together in the same row" co-occurrence
|
||||
* • Click a node → popover showing hostname, IP, OS, dataset sources, connections
|
||||
* • Responsive canvas with ResizeObserver
|
||||
* • Zero extra npm dependencies
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Alert, Chip, Button, TextField,
|
||||
LinearProgress, FormControl, InputLabel, Select, MenuItem,
|
||||
Popover, Divider, IconButton,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong';
|
||||
import { datasets, hunts, type Hunt, type DatasetSummary } from '../api/client';
|
||||
|
||||
// ── Graph primitives ─────────────────────────────────────────────────
|
||||
|
||||
type NodeType = 'ip' | 'hostname' | 'domain' | 'url';
|
||||
|
||||
interface NodeMeta {
|
||||
hostnames: Set<string>;
|
||||
ips: Set<string>;
|
||||
os: Set<string>;
|
||||
datasets: Set<string>;
|
||||
type: NodeType;
|
||||
}
|
||||
|
||||
interface GNode {
|
||||
id: string; label: string; x: number; y: number;
|
||||
vx: number; vy: number; radius: number; color: string; count: number;
|
||||
meta: { hostnames: string[]; ips: string[]; os: string[]; datasets: string[]; type: NodeType };
|
||||
}
|
||||
interface GEdge { source: string; target: string; weight: number }
|
||||
interface Graph { nodes: GNode[]; edges: GEdge[] }
|
||||
|
||||
const TYPE_COLORS: Record<NodeType, string> = {
|
||||
ip: '#3b82f6', hostname: '#22c55e', domain: '#eab308', url: '#8b5cf6',
|
||||
};
|
||||
|
||||
// ── Helpers: find context columns from dataset schema ────────────────
|
||||
|
||||
/** Best-effort detection of hostname, IP, and OS columns from raw column names + normalized mapping. */
|
||||
function findContextColumns(ds: DatasetSummary) {
|
||||
const norm = ds.normalized_columns || {};
|
||||
const schema = ds.column_schema || {};
|
||||
const rawCols = Object.keys(schema).length > 0 ? Object.keys(schema) : Object.keys(norm);
|
||||
|
||||
const hostCols: string[] = [];
|
||||
const ipCols: string[] = [];
|
||||
const osCols: string[] = [];
|
||||
|
||||
for (const raw of rawCols) {
|
||||
const canonical = norm[raw] || '';
|
||||
const lower = raw.toLowerCase();
|
||||
// Hostname columns
|
||||
if (canonical === 'hostname' || /^(hostname|host|fqdn|computer_?name|system_?name|machinename)$/i.test(lower)) {
|
||||
hostCols.push(raw);
|
||||
}
|
||||
// IP columns
|
||||
if (['src_ip', 'dst_ip', 'ip_address'].includes(canonical) || /^(ip|ip_?address|src_?ip|dst_?ip|source_?ip|dest_?ip)$/i.test(lower)) {
|
||||
ipCols.push(raw);
|
||||
}
|
||||
// OS columns (best-effort — raw name scan + normalized canonical)
|
||||
if (canonical === 'os' || /^(os|operating_?system|os_?version|os_?name|platform|os_?type)$/i.test(lower)) {
|
||||
osCols.push(raw);
|
||||
}
|
||||
}
|
||||
return { hostCols, ipCols, osCols };
|
||||
}
|
||||
|
||||
function cleanVal(v: any): string {
|
||||
const s = (v ?? '').toString().trim();
|
||||
return (s && s !== '-' && s !== '0.0.0.0' && s !== '::') ? s : '';
|
||||
}
|
||||
|
||||
// ── Build graph with per-node metadata ───────────────────────────────
|
||||
|
||||
interface RowBatch {
|
||||
rows: Record<string, any>[];
|
||||
iocColumns: Record<string, any>;
|
||||
dsName: string;
|
||||
ds: DatasetSummary;
|
||||
}
|
||||
|
||||
function buildGraph(allBatches: RowBatch[], canvasW: number, canvasH: number): Graph {
|
||||
const countMap = new Map<string, number>();
|
||||
const edgeMap = new Map<string, number>();
|
||||
const metaMap = new Map<string, NodeMeta>();
|
||||
|
||||
const getOrCreateMeta = (id: string, type: NodeType): NodeMeta => {
|
||||
let m = metaMap.get(id);
|
||||
if (!m) { m = { hostnames: new Set(), ips: new Set(), os: new Set(), datasets: new Set(), type }; metaMap.set(id, m); }
|
||||
return m;
|
||||
};
|
||||
|
||||
for (const { rows, iocColumns, dsName, ds } of allBatches) {
|
||||
// IOC columns that produce graph nodes
|
||||
const iocEntries = Object.entries(iocColumns).filter(([, t]) => {
|
||||
const typ = Array.isArray(t) ? t[0] : t;
|
||||
return typ === 'ip' || typ === 'hostname' || typ === 'domain' || typ === 'url';
|
||||
}).map(([col, t]) => {
|
||||
const typ = (Array.isArray(t) ? t[0] : t) as NodeType;
|
||||
return { col, typ };
|
||||
});
|
||||
|
||||
if (iocEntries.length === 0) continue;
|
||||
|
||||
// Context columns for enrichment
|
||||
const ctx = findContextColumns(ds);
|
||||
|
||||
for (const row of rows) {
|
||||
// Collect IOC values for this row (nodes + edges)
|
||||
const vals: { v: string; typ: NodeType }[] = [];
|
||||
for (const { col, typ } of iocEntries) {
|
||||
const v = cleanVal(row[col]);
|
||||
if (v) vals.push({ v, typ });
|
||||
}
|
||||
const unique = [...new Map(vals.map(x => [x.v, x])).values()];
|
||||
|
||||
// Count occurrences
|
||||
for (const { v } of unique) countMap.set(v, (countMap.get(v) ?? 0) + 1);
|
||||
|
||||
// Create edges (co-occurrence)
|
||||
for (let i = 0; i < unique.length; i++) {
|
||||
for (let j = i + 1; j < unique.length; j++) {
|
||||
const key = [unique[i].v, unique[j].v].sort().join('||');
|
||||
edgeMap.set(key, (edgeMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract context values from this row
|
||||
const rowHosts = ctx.hostCols.map(c => cleanVal(row[c])).filter(Boolean);
|
||||
const rowIps = ctx.ipCols.map(c => cleanVal(row[c])).filter(Boolean);
|
||||
const rowOs = ctx.osCols.map(c => cleanVal(row[c])).filter(Boolean);
|
||||
|
||||
// Attach context to each node in this row
|
||||
for (const { v, typ } of unique) {
|
||||
const meta = getOrCreateMeta(v, typ);
|
||||
meta.datasets.add(dsName);
|
||||
for (const h of rowHosts) meta.hostnames.add(h);
|
||||
for (const ip of rowIps) meta.ips.add(ip);
|
||||
for (const o of rowOs) meta.os.add(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: GNode[] = [...countMap.entries()].map(([id, count]) => {
|
||||
const raw = metaMap.get(id);
|
||||
const type: NodeType = raw?.type || 'ip';
|
||||
return {
|
||||
id, label: id, count,
|
||||
x: canvasW / 2 + (Math.random() - 0.5) * canvasW * 0.75,
|
||||
y: canvasH / 2 + (Math.random() - 0.5) * canvasH * 0.65,
|
||||
vx: 0, vy: 0,
|
||||
radius: Math.max(5, Math.min(18, 4 + Math.sqrt(count) * 1.6)),
|
||||
color: TYPE_COLORS[type],
|
||||
meta: {
|
||||
hostnames: [...(raw?.hostnames ?? [])],
|
||||
ips: [...(raw?.ips ?? [])],
|
||||
os: [...(raw?.os ?? [])],
|
||||
datasets: [...(raw?.datasets ?? [])],
|
||||
type,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const edges: GEdge[] = [...edgeMap.entries()].map(([key, weight]) => {
|
||||
const [source, target] = key.split('||');
|
||||
return { source, target, weight };
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ── Force simulation ─────────────────────────────────────────────────
|
||||
|
||||
function simulate(graph: Graph, cx: number, cy: number, steps = 120) {
|
||||
const { nodes, edges } = graph;
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
const k = 80;
|
||||
const repulsion = 6000;
|
||||
const damping = 0.85;
|
||||
|
||||
for (let step = 0; step < steps; step++) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i], b = nodes[j];
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy));
|
||||
const force = repulsion / (dist * dist);
|
||||
const fx = (dx / dist) * force, fy = (dy / dist) * force;
|
||||
a.vx -= fx; a.vy -= fy;
|
||||
b.vx += fx; b.vy += fy;
|
||||
}
|
||||
}
|
||||
for (const e of edges) {
|
||||
const a = nodeMap.get(e.source), b = nodeMap.get(e.target);
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy));
|
||||
const force = (dist - k) * 0.05;
|
||||
const fx = (dx / dist) * force, fy = (dy / dist) * force;
|
||||
a.vx += fx; a.vy += fy;
|
||||
b.vx -= fx; b.vy -= fy;
|
||||
}
|
||||
for (const n of nodes) {
|
||||
n.vx += (cx - n.x) * 0.001;
|
||||
n.vy += (cy - n.y) * 0.001;
|
||||
n.vx *= damping; n.vy *= damping;
|
||||
n.x += n.vx; n.y += n.vy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Viewport (zoom / pan) ────────────────────────────────────────────
|
||||
|
||||
interface Viewport { x: number; y: number; scale: number }
|
||||
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
|
||||
// ── Canvas renderer ──────────────────────────────────────────────────
|
||||
|
||||
function drawGraph(
|
||||
ctx: CanvasRenderingContext2D, graph: Graph,
|
||||
hovered: string | null, selected: string | null, search: string,
|
||||
vp: Viewport,
|
||||
) {
|
||||
const { nodes, edges } = graph;
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
const matchSet = new Set<string>();
|
||||
if (search) {
|
||||
const lc = search.toLowerCase();
|
||||
for (const n of nodes) if (n.label.toLowerCase().includes(lc)) matchSet.add(n.id);
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
ctx.save();
|
||||
ctx.translate(vp.x, vp.y);
|
||||
ctx.scale(vp.scale, vp.scale);
|
||||
|
||||
// Edges
|
||||
for (const e of edges) {
|
||||
const a = nodeMap.get(e.source), b = nodeMap.get(e.target);
|
||||
if (!a || !b) continue;
|
||||
const isActive = (hovered && (e.source === hovered || e.target === hovered))
|
||||
|| (selected && (e.source === selected || e.target === selected));
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = isActive ? 'rgba(96,165,250,0.7)' : 'rgba(100,116,139,0.25)';
|
||||
ctx.lineWidth = Math.min(4, 0.5 + e.weight * 0.3) / vp.scale;
|
||||
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const n of nodes) {
|
||||
const highlighted = hovered === n.id || selected === n.id || (search && matchSet.has(n.id));
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = highlighted ? '#fff' : n.color;
|
||||
ctx.globalAlpha = (search && !matchSet.has(n.id)) ? 0.15 : 1;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
if (highlighted) { ctx.strokeStyle = n.color; ctx.lineWidth = 2.5 / vp.scale; ctx.stroke(); }
|
||||
}
|
||||
|
||||
// Labels — show more labels when zoomed in
|
||||
const labelThreshold = Math.max(1, Math.round(3 / vp.scale));
|
||||
const fontSize = Math.max(8, Math.round(11 / vp.scale));
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
for (const n of nodes) {
|
||||
const show = hovered === n.id || selected === n.id
|
||||
|| (search && matchSet.has(n.id)) || n.count >= labelThreshold;
|
||||
if (!show) continue;
|
||||
ctx.fillStyle = (search && !matchSet.has(n.id)) ? 'rgba(241,245,249,0.15)' : '#f1f5f9';
|
||||
ctx.fillText(n.label, n.x, n.y - n.radius - 5);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Hit-test helper (viewport-aware) ─────────────────────────────────
|
||||
|
||||
function screenToWorld(
|
||||
canvas: HTMLCanvasElement, clientX: number, clientY: number, vp: Viewport,
|
||||
): { wx: number; wy: number } {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cssToCanvas_x = canvas.width / rect.width;
|
||||
const cssToCanvas_y = canvas.height / rect.height;
|
||||
const cx = (clientX - rect.left) * cssToCanvas_x;
|
||||
const cy = (clientY - rect.top) * cssToCanvas_y;
|
||||
return { wx: (cx - vp.x) / vp.scale, wy: (cy - vp.y) / vp.scale };
|
||||
}
|
||||
|
||||
function hitTest(
|
||||
graph: Graph, canvas: HTMLCanvasElement, clientX: number, clientY: number,
|
||||
vp: Viewport,
|
||||
): GNode | null {
|
||||
const { wx, wy } = screenToWorld(canvas, clientX, clientY, vp);
|
||||
for (const n of graph.nodes) {
|
||||
const dx = n.x - wx, dy = n.y - wy;
|
||||
if (dx * dx + dy * dy < (n.radius + 4) ** 2) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkMap() {
|
||||
// Hunt selector
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [selectedHuntId, setSelectedHuntId] = useState('');
|
||||
|
||||
// Graph state
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [graph, setGraph] = useState<Graph | null>(null);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<GNode | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [dsCount, setDsCount] = useState(0);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
|
||||
// Node type filters
|
||||
const [visibleTypes, setVisibleTypes] = useState<Set<NodeType>>(
|
||||
new Set<NodeType>(['ip', 'hostname', 'domain', 'url']),
|
||||
);
|
||||
|
||||
// Canvas sizing
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 900, h: 600 });
|
||||
|
||||
// Viewport (zoom / pan)
|
||||
const vpRef = useRef<Viewport>({ x: 0, y: 0, scale: 1 });
|
||||
const [vpScale, setVpScale] = useState(1); // for UI display only
|
||||
const isPanning = useRef(false);
|
||||
const panStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Popover anchor
|
||||
const [popoverAnchor, setPopoverAnchor] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// ── Load hunts on mount ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
hunts.list(0, 200).then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Resize observer ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const w = Math.round(entry.contentRect.width);
|
||||
if (w > 100) setCanvasSize({ w, h: Math.max(450, Math.round(w * 0.55)) });
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// ── Load graph for selected hunt ──────────────────────────────────
|
||||
const loadGraph = useCallback(async (huntId: string) => {
|
||||
if (!huntId) return;
|
||||
setLoading(true); setError(''); setGraph(null);
|
||||
setSelectedNode(null); setPopoverAnchor(null);
|
||||
try {
|
||||
setProgress('Fetching datasets for hunt…');
|
||||
const dsRes = await datasets.list(0, 500, huntId);
|
||||
const dsList = dsRes.datasets;
|
||||
setDsCount(dsList.length);
|
||||
|
||||
if (dsList.length === 0) {
|
||||
setError('This hunt has no datasets. Upload CSV files to this hunt first.');
|
||||
setLoading(false); setProgress('');
|
||||
return;
|
||||
}
|
||||
|
||||
const allBatches: RowBatch[] = [];
|
||||
let rowTotal = 0;
|
||||
|
||||
for (let i = 0; i < dsList.length; i++) {
|
||||
const ds = dsList[i];
|
||||
setProgress(`Loading ${ds.name} (${i + 1}/${dsList.length})…`);
|
||||
try {
|
||||
const detail = await datasets.get(ds.id);
|
||||
const ioc = detail.ioc_columns || {};
|
||||
const hasIoc = Object.values(ioc).some(t => {
|
||||
const typ = Array.isArray(t) ? t[0] : t;
|
||||
return typ === 'ip' || typ === 'hostname' || typ === 'domain' || typ === 'url';
|
||||
});
|
||||
if (hasIoc) {
|
||||
const r = await datasets.rows(ds.id, 0, 5000);
|
||||
allBatches.push({ rows: r.rows, iocColumns: ioc, dsName: ds.name, ds: detail });
|
||||
rowTotal += r.rows.length;
|
||||
}
|
||||
} catch { /* skip failed datasets */ }
|
||||
}
|
||||
|
||||
setTotalRows(rowTotal);
|
||||
|
||||
if (allBatches.length === 0) {
|
||||
setError('No datasets in this hunt contain IP/hostname/domain IOC columns.');
|
||||
setLoading(false); setProgress('');
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress('Building graph…');
|
||||
const g = buildGraph(allBatches, canvasSize.w, canvasSize.h);
|
||||
if (g.nodes.length === 0) {
|
||||
setError('No network nodes found in the data.');
|
||||
} else {
|
||||
simulate(g, canvasSize.w / 2, canvasSize.h / 2);
|
||||
setGraph(g);
|
||||
}
|
||||
} catch (e: any) { setError(e.message); }
|
||||
setLoading(false); setProgress('');
|
||||
}, [canvasSize]);
|
||||
|
||||
// When hunt changes, load graph
|
||||
useEffect(() => {
|
||||
if (selectedHuntId) loadGraph(selectedHuntId);
|
||||
}, [selectedHuntId, loadGraph]);
|
||||
|
||||
// Reset viewport when graph changes
|
||||
useEffect(() => {
|
||||
vpRef.current = { x: 0, y: 0, scale: 1 };
|
||||
setVpScale(1);
|
||||
}, [graph]);
|
||||
|
||||
// Filtered graph — only visible node types + edges between them
|
||||
const filteredGraph = useMemo<Graph | null>(() => {
|
||||
if (!graph) return null;
|
||||
const nodes = graph.nodes.filter(n => visibleTypes.has(n.meta.type));
|
||||
const nodeIds = new Set(nodes.map(n => n.id));
|
||||
const edges = graph.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target));
|
||||
return { nodes, edges };
|
||||
}, [graph, visibleTypes]);
|
||||
|
||||
// Toggle a node type filter
|
||||
const toggleType = useCallback((t: NodeType) => {
|
||||
setVisibleTypes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(t)) {
|
||||
// Don't allow all to be hidden
|
||||
if (next.size > 1) next.delete(t);
|
||||
} else {
|
||||
next.add(t);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Redraw helper — uses filteredGraph
|
||||
const redraw = useCallback(() => {
|
||||
if (!filteredGraph || !canvasRef.current) return;
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (ctx) drawGraph(ctx, filteredGraph, hovered, selectedNode?.id ?? null, search, vpRef.current);
|
||||
}, [filteredGraph, hovered, selectedNode, search]);
|
||||
|
||||
// Redraw on every render-affecting state change
|
||||
useEffect(() => { redraw(); }, [redraw]);
|
||||
|
||||
// ── Mouse wheel → zoom ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const vp = vpRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cssToCanvasX = canvas.width / rect.width;
|
||||
const cssToCanvasY = canvas.height / rect.height;
|
||||
// Mouse position in canvas pixel coords
|
||||
const mx = (e.clientX - rect.left) * cssToCanvasX;
|
||||
const my = (e.clientY - rect.top) * cssToCanvasY;
|
||||
|
||||
const zoomFactor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, vp.scale * zoomFactor));
|
||||
// Zoom toward cursor: adjust offset so world-point under cursor stays fixed
|
||||
vp.x = mx - (mx - vp.x) * (newScale / vp.scale);
|
||||
vp.y = my - (my - vp.y) * (newScale / vp.scale);
|
||||
vp.scale = newScale;
|
||||
setVpScale(newScale);
|
||||
// Immediate redraw (bypass React state for smoothness)
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx && filteredGraph) drawGraph(ctx, filteredGraph, hovered, selectedNode?.id ?? null, search, vp);
|
||||
};
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => canvas.removeEventListener('wheel', onWheel);
|
||||
}, [filteredGraph, hovered, selectedNode, search]);
|
||||
|
||||
// ── Mouse drag → pan ───────────────────────────────────────────────
|
||||
const onMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!filteredGraph || !canvasRef.current) return;
|
||||
const node = hitTest(filteredGraph, canvasRef.current, e.clientX, e.clientY, vpRef.current);
|
||||
if (!node) {
|
||||
isPanning.current = true;
|
||||
panStart.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}, [filteredGraph]);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!filteredGraph || !canvasRef.current) return;
|
||||
|
||||
if (isPanning.current) {
|
||||
const vp = vpRef.current;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const cssToCanvasX = canvasRef.current.width / rect.width;
|
||||
const cssToCanvasY = canvasRef.current.height / rect.height;
|
||||
vp.x += (e.clientX - panStart.current.x) * cssToCanvasX;
|
||||
vp.y += (e.clientY - panStart.current.y) * cssToCanvasY;
|
||||
panStart.current = { x: e.clientX, y: e.clientY };
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
const node = hitTest(filteredGraph, canvasRef.current, e.clientX, e.clientY, vpRef.current);
|
||||
setHovered(node?.id ?? null);
|
||||
}, [filteredGraph, redraw]);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
isPanning.current = false;
|
||||
}, []);
|
||||
|
||||
// ── Mouse click → select node + show popover ─────────────────────
|
||||
const onClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!filteredGraph || !canvasRef.current) return;
|
||||
const node = hitTest(filteredGraph, canvasRef.current, e.clientX, e.clientY, vpRef.current);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
setPopoverAnchor({ top: e.clientY, left: e.clientX });
|
||||
} else {
|
||||
setSelectedNode(null);
|
||||
setPopoverAnchor(null);
|
||||
}
|
||||
}, [filteredGraph]);
|
||||
|
||||
const closePopover = () => { setSelectedNode(null); setPopoverAnchor(null); };
|
||||
|
||||
// ── Zoom controls ──────────────────────────────────────────────────
|
||||
const zoomBy = useCallback((factor: number) => {
|
||||
const vp = vpRef.current;
|
||||
const cw = canvasSize.w, ch = canvasSize.h;
|
||||
const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, vp.scale * factor));
|
||||
// Zoom toward canvas center
|
||||
vp.x = cw / 2 - (cw / 2 - vp.x) * (newScale / vp.scale);
|
||||
vp.y = ch / 2 - (ch / 2 - vp.y) * (newScale / vp.scale);
|
||||
vp.scale = newScale;
|
||||
setVpScale(newScale);
|
||||
redraw();
|
||||
}, [canvasSize, redraw]);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
vpRef.current = { x: 0, y: 0, scale: 1 };
|
||||
setVpScale(1);
|
||||
redraw();
|
||||
}, [redraw]);
|
||||
|
||||
// Count connections for selected node
|
||||
const connectionCount = selectedNode && filteredGraph
|
||||
? filteredGraph.edges.filter(e => e.source === selectedNode.id || e.target === selectedNode.id).length
|
||||
: 0;
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Box>
|
||||
{/* Header row */}
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }} flexWrap="wrap" gap={1}>
|
||||
<Typography variant="h5">Network Map</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||
<InputLabel id="hunt-selector-label">Hunt</InputLabel>
|
||||
<Select
|
||||
labelId="hunt-selector-label"
|
||||
value={selectedHuntId}
|
||||
label="Hunt"
|
||||
onChange={e => setSelectedHuntId(e.target.value)}
|
||||
>
|
||||
{huntList.map(h => (
|
||||
<MenuItem key={h.id} value={h.id}>
|
||||
{h.name} ({h.dataset_count} datasets)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField size="small" placeholder="Search node…" value={search}
|
||||
onChange={e => setSearch(e.target.value)} sx={{ width: 200 }} />
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />}
|
||||
onClick={() => loadGraph(selectedHuntId)}
|
||||
disabled={loading || !selectedHuntId} size="small">
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>{progress}</Typography>
|
||||
<LinearProgress />
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="warning" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{/* Legend — clickable type filters */}
|
||||
{graph && filteredGraph && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" gap={0.5} alignItems="center">
|
||||
<Chip label={`${dsCount} datasets`} size="small" variant="outlined" />
|
||||
<Chip label={`${totalRows.toLocaleString()} rows`} size="small" variant="outlined" />
|
||||
<Chip label={`${filteredGraph.nodes.length} nodes`} size="small" color="primary" variant="outlined" />
|
||||
<Chip label={`${filteredGraph.edges.length} edges`} size="small" color="secondary" variant="outlined" />
|
||||
<Divider orientation="vertical" flexItem />
|
||||
{([['ip', 'IP'], ['hostname', 'Host'], ['domain', 'Domain'], ['url', 'URL']] as [NodeType, string][]).map(([type, label]) => {
|
||||
const active = visibleTypes.has(type);
|
||||
const count = graph.nodes.filter(n => n.meta.type === type).length;
|
||||
return (
|
||||
<Chip
|
||||
key={type}
|
||||
label={`${label} (${count})`}
|
||||
size="small"
|
||||
onClick={() => toggleType(type)}
|
||||
sx={{
|
||||
bgcolor: active ? TYPE_COLORS[type] : 'transparent',
|
||||
color: active ? '#fff' : TYPE_COLORS[type],
|
||||
border: `2px solid ${TYPE_COLORS[type]}`,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
opacity: active ? 1 : 0.5,
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': { opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
{filteredGraph && (
|
||||
<Paper ref={wrapperRef} sx={{ p: 1, position: 'relative', backgroundColor: '#0f172a' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={canvasSize.w} height={canvasSize.h}
|
||||
style={{
|
||||
width: '100%', height: canvasSize.h,
|
||||
cursor: isPanning.current ? 'grabbing' : hovered ? 'pointer' : 'grab',
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={() => { isPanning.current = false; setHovered(null); }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
{/* Zoom controls overlay */}
|
||||
<Stack
|
||||
direction="column" spacing={0.5}
|
||||
sx={{ position: 'absolute', top: 12, right: 12, zIndex: 2 }}
|
||||
>
|
||||
<IconButton size="small" onClick={() => zoomBy(1.3)}
|
||||
sx={{ bgcolor: 'rgba(30,41,59,0.85)', color: '#f1f5f9', '&:hover': { bgcolor: 'rgba(51,65,85,0.95)' } }}
|
||||
aria-label="Zoom in"><ZoomInIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" onClick={() => zoomBy(1 / 1.3)}
|
||||
sx={{ bgcolor: 'rgba(30,41,59,0.85)', color: '#f1f5f9', '&:hover': { bgcolor: 'rgba(51,65,85,0.95)' } }}
|
||||
aria-label="Zoom out"><ZoomOutIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" onClick={resetView}
|
||||
sx={{ bgcolor: 'rgba(30,41,59,0.85)', color: '#f1f5f9', '&:hover': { bgcolor: 'rgba(51,65,85,0.95)' } }}
|
||||
aria-label="Reset view"><CenterFocusStrongIcon fontSize="small" /></IconButton>
|
||||
<Chip label={`${Math.round(vpScale * 100)}%`} size="small"
|
||||
sx={{ bgcolor: 'rgba(30,41,59,0.85)', color: '#94a3b8', fontSize: 11, height: 22 }} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Node detail popover */}
|
||||
<Popover
|
||||
open={Boolean(selectedNode && popoverAnchor)}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={popoverAnchor ?? undefined}
|
||||
onClose={closePopover}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
slotProps={{ paper: { sx: { p: 2, minWidth: 280, maxWidth: 400 } } }}
|
||||
>
|
||||
{selectedNode && (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>{selectedNode.label}</Typography>
|
||||
<Chip label={selectedNode.meta.type.toUpperCase()} size="small"
|
||||
sx={{ bgcolor: TYPE_COLORS[selectedNode.meta.type], color: '#fff', fontWeight: 600, fontSize: 11 }} />
|
||||
</Stack>
|
||||
<IconButton size="small" onClick={closePopover} aria-label="close"><CloseIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
{/* Hostnames */}
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600}>Hostname</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{selectedNode.meta.hostnames.length > 0
|
||||
? selectedNode.meta.hostnames.join(', ')
|
||||
: <em>Unknown</em>}
|
||||
</Typography>
|
||||
|
||||
{/* IPs */}
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600}>IP Address</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontFamily: 'monospace' }}>
|
||||
{selectedNode.meta.ips.length > 0
|
||||
? selectedNode.meta.ips.join(', ')
|
||||
: (selectedNode.meta.type === 'ip' ? selectedNode.label : <em>Unknown</em>)}
|
||||
</Typography>
|
||||
|
||||
{/* OS */}
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600}>Operating System</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{selectedNode.meta.os.length > 0
|
||||
? selectedNode.meta.os.join(', ')
|
||||
: <em>Unknown</em>}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Stats */}
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={0.5}>
|
||||
<Chip label={`${selectedNode.count} occurrences`} size="small" variant="outlined" />
|
||||
<Chip label={`${connectionCount} connections`} size="small" variant="outlined" />
|
||||
</Stack>
|
||||
|
||||
{/* Datasets */}
|
||||
{selectedNode.meta.datasets.length > 0 && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600}>Seen in datasets</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5} sx={{ mt: 0.5 }}>
|
||||
{selectedNode.meta.datasets.map(d => (
|
||||
<Chip key={d} label={d} size="small" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Empty states */}
|
||||
{!selectedHuntId && !loading && (
|
||||
<Paper ref={wrapperRef} sx={{ p: 6, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Select a hunt to visualize its network
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Choose a hunt from the dropdown above. The map will display IP addresses,
|
||||
hostnames, and domains found across the hunt's datasets, with connections
|
||||
showing co-occurrence in the same log rows.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{selectedHuntId && !graph && !loading && !error && (
|
||||
<Paper sx={{ p: 6, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">
|
||||
No network data to display. Upload datasets with IP/hostname columns to this hunt.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
86
frontend/src/theme.ts
Normal file
86
frontend/src/theme.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#60a5fa', // blue-400
|
||||
light: '#93c5fd',
|
||||
dark: '#2563eb',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f472b6', // pink-400
|
||||
light: '#f9a8d4',
|
||||
dark: '#db2777',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
},
|
||||
info: {
|
||||
main: '#06b6d4',
|
||||
},
|
||||
background: {
|
||||
default: '#0f172a', // slate-900
|
||||
paper: '#1e293b', // slate-800
|
||||
},
|
||||
text: {
|
||||
primary: '#f1f5f9', // slate-100
|
||||
secondary: '#94a3b8', // slate-400
|
||||
},
|
||||
divider: '#334155', // slate-700
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Roboto", "Helvetica Neue", Arial, sans-serif',
|
||||
h4: { fontWeight: 700 },
|
||||
h5: { fontWeight: 600 },
|
||||
h6: { fontWeight: 600 },
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiPaper: {
|
||||
defaultProps: { elevation: 0 },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
border: '1px solid',
|
||||
borderColor: '#334155',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: { textTransform: 'none', fontWeight: 600 },
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { fontWeight: 500 },
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: { borderRight: '1px solid #334155' },
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
backgroundColor: '#1e293b',
|
||||
borderBottom: '1px solid #334155',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
Reference in New Issue
Block a user