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:
2026-02-19 15:41:15 -05:00
parent d0c9f88268
commit 9b98ab9614
92 changed files with 13042 additions and 1089 deletions

31
frontend/nginx.conf Normal file
View 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";
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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>
&copy; 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
View 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)}`),
};

View 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 &middot; {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>
);
}

View File

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

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

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

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

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

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

View 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; // 0100
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>
);
}

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

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

View 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
View 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;