mirror of
https://github.com/mblanke/ThreatHunt.git
synced 2026-03-01 05:50:21 -05:00
chore: checkpoint all local changes
This commit is contained in:
1708
frontend/package-lock.json
generated
1708
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "threathunt-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -9,12 +9,23 @@
|
||||
"@mui/material": "^7.3.8",
|
||||
"@mui/x-data-grid": "^8.27.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-cola": "^2.5.1",
|
||||
"cytoscape-dagre": "^2.5.0",
|
||||
"dagre": "^0.8.5",
|
||||
"notistack": "^3.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-scripts": "5.0.1",
|
||||
<<<<<<< HEAD
|
||||
"recharts": "^3.7.0"
|
||||
=======
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"yaml": "^2.8.2"
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
@@ -40,6 +51,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"typescript": "^4.9.5"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* ThreatHunt MUI-powered analyst-assist platform.
|
||||
* ThreatHunt — MUI-powered analyst-assist platform.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, Suspense } from 'react';
|
||||
@@ -19,11 +19,25 @@ 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';
|
||||
<<<<<<< HEAD
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import TimelineIcon from '@mui/icons-material/Timeline';
|
||||
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||
import BookmarksIcon from '@mui/icons-material/Bookmarks';
|
||||
import ShieldIcon from '@mui/icons-material/Shield';
|
||||
=======
|
||||
import DevicesIcon from '@mui/icons-material/Devices';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import TimelineIcon from '@mui/icons-material/Timeline';
|
||||
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
|
||||
import ScheduleIcon from '@mui/icons-material/Schedule';
|
||||
import ShieldIcon from '@mui/icons-material/Shield';
|
||||
import BubbleChartIcon from '@mui/icons-material/BubbleChart';
|
||||
import WorkIcon from '@mui/icons-material/Work';
|
||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import theme from './theme';
|
||||
|
||||
@@ -33,11 +47,12 @@ 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 AnalysisPanel from './components/AnalysisPanel';
|
||||
import AnnotationPanel from './components/AnnotationPanel';
|
||||
import HypothesisTracker from './components/HypothesisTracker';
|
||||
import CorrelationView from './components/CorrelationView';
|
||||
import AUPScanner from './components/AUPScanner';
|
||||
<<<<<<< HEAD
|
||||
|
||||
/* -- Lazy imports (heavy: charts, network graph, new feature pages) -- */
|
||||
const NetworkMap = React.lazy(() => import('./components/NetworkMap'));
|
||||
@@ -46,12 +61,27 @@ const MitreMatrix = React.lazy(() => import('./components/MitreMatrix'));
|
||||
const TimelineView = React.lazy(() => import('./components/TimelineView'));
|
||||
const PlaybookManager = React.lazy(() => import('./components/PlaybookManager'));
|
||||
const SavedSearches = React.lazy(() => import('./components/SavedSearches'));
|
||||
=======
|
||||
import NetworkMap from './components/NetworkMap';
|
||||
import NetworkPicture from './components/NetworkPicture';
|
||||
import ProcessTree from './components/ProcessTree';
|
||||
import StorylineGraph from './components/StorylineGraph';
|
||||
import TimelineScrubber from './components/TimelineScrubber';
|
||||
import QueryBar from './components/QueryBar';
|
||||
import MitreMatrix from './components/MitreMatrix';
|
||||
import KnowledgeGraph from './components/KnowledgeGraph';
|
||||
import CaseManager from './components/CaseManager';
|
||||
import AlertPanel from './components/AlertPanel';
|
||||
import InvestigationNotebook from './components/InvestigationNotebook';
|
||||
import PlaybookManager from './components/PlaybookManager';
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
interface NavItem { label: string; path: string; icon: React.ReactNode }
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
<<<<<<< HEAD
|
||||
{ label: 'Dashboard', path: '/', icon: <DashboardIcon /> },
|
||||
{ label: 'Hunts', path: '/hunts', icon: <SearchIcon /> },
|
||||
{ label: 'Datasets', path: '/datasets', icon: <StorageIcon /> },
|
||||
@@ -68,6 +98,30 @@ const NAV: NavItem[] = [
|
||||
{ label: 'Timeline', path: '/timeline', icon: <TimelineIcon /> },
|
||||
{ label: 'Playbooks', path: '/playbooks', icon: <PlaylistAddCheckIcon /> },
|
||||
{ label: 'Saved Searches', path: '/saved-searches', icon: <BookmarksIcon /> },
|
||||
=======
|
||||
{ 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: 'Analysis', path: '/analysis', 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: 'Net Picture', path: '/netpicture', icon: <DevicesIcon /> },
|
||||
{ label: 'Proc Tree', path: '/proctree', icon: <AccountTreeIcon /> },
|
||||
{ label: 'Storyline', path: '/storyline', icon: <TimelineIcon /> },
|
||||
{ label: 'Timeline', path: '/timeline', icon: <ScheduleIcon /> },
|
||||
{ label: 'Search', path: '/search', icon: <ManageSearchIcon /> },
|
||||
{ label: 'MITRE Map', path: '/mitre', icon: <ShieldIcon /> },
|
||||
{ label: 'Knowledge', path: '/knowledge', icon: <BubbleChartIcon /> },
|
||||
{ label: 'Cases', path: '/cases', icon: <WorkIcon /> },
|
||||
{ label: 'Alerts', path: '/alerts', icon: <NotificationsActiveIcon /> },
|
||||
{ label: 'Notebooks', path: '/notebooks', icon: <MenuBookIcon /> },
|
||||
{ label: 'Playbooks', path: '/playbooks', icon: <PlaylistPlayIcon /> },
|
||||
{ label: 'AUP Scanner', path: '/aup', icon: <GppMaybeIcon /> },
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
];
|
||||
|
||||
function LazyFallback() {
|
||||
@@ -131,6 +185,7 @@ function Shell() {
|
||||
ml: open ? 0 : `-${DRAWER_WIDTH}px`,
|
||||
transition: 'margin 225ms cubic-bezier(0,0,0.2,1)',
|
||||
}}>
|
||||
<<<<<<< HEAD
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
@@ -151,6 +206,32 @@ function Shell() {
|
||||
<Route path="/saved-searches" element={<SavedSearches />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
=======
|
||||
<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="/analysis" element={<AnalysisPanel />} />
|
||||
<Route path="/annotations" element={<AnnotationPanel />} />
|
||||
<Route path="/hypotheses" element={<HypothesisTracker />} />
|
||||
<Route path="/correlation" element={<CorrelationView />} />
|
||||
<Route path="/network" element={<NetworkMap />} />
|
||||
<Route path="/netpicture" element={<NetworkPicture />} />
|
||||
<Route path="/proctree" element={<ProcessTree />} />
|
||||
<Route path="/storyline" element={<StorylineGraph />} />
|
||||
<Route path="/timeline" element={<TimelineScrubber />} />
|
||||
<Route path="/search" element={<QueryBar />} />
|
||||
<Route path="/mitre" element={<MitreMatrix />} />
|
||||
<Route path="/knowledge" element={<KnowledgeGraph />} />
|
||||
<Route path="/cases" element={<CaseManager />} />
|
||||
<Route path="/alerts" element={<AlertPanel />} />
|
||||
<Route path="/notebooks" element={<InvestigationNotebook />} />
|
||||
<Route path="/playbooks" element={<PlaybookManager />} />
|
||||
<Route path="/aup" element={<AUPScanner />} />
|
||||
</Routes>
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* ====================================================================
|
||||
ThreatHunt API Client -- mirrors every backend endpoint.
|
||||
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 --
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
let authToken: string | null = localStorage.getItem('th_token');
|
||||
|
||||
@@ -36,7 +36,7 @@ async function api<T = any>(
|
||||
return res.text() as unknown as T;
|
||||
}
|
||||
|
||||
// -- Auth --
|
||||
// ── Auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserPayload {
|
||||
id: string; username: string; email: string;
|
||||
@@ -63,7 +63,7 @@ export const auth = {
|
||||
me: () => api<UserPayload>('/api/auth/me'),
|
||||
};
|
||||
|
||||
// -- Hunts --
|
||||
// ── Hunts ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Hunt {
|
||||
id: string; name: string; description: string | null; status: string;
|
||||
@@ -71,6 +71,7 @@ export interface Hunt {
|
||||
dataset_count: number; hypothesis_count: number;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
export interface HuntProgress {
|
||||
hunt_id: string;
|
||||
status: 'idle' | 'processing' | 'ready';
|
||||
@@ -84,6 +85,10 @@ export interface HuntProgress {
|
||||
network_status: 'none' | 'building' | 'ready';
|
||||
stages: Record<string, any>;
|
||||
}
|
||||
=======
|
||||
/** Alias kept for backward-compat with components that import HuntOut */
|
||||
export type HuntOut = Hunt;
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
|
||||
export const hunts = {
|
||||
list: (skip = 0, limit = 50) =>
|
||||
@@ -97,7 +102,7 @@ export const hunts = {
|
||||
progress: (id: string) => api<HuntProgress>(`/api/hunts/${id}/progress`),
|
||||
};
|
||||
|
||||
// -- Datasets --
|
||||
// ── Datasets ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface DatasetSummary {
|
||||
id: string; name: string; filename: string; source_tool: string | null;
|
||||
@@ -107,8 +112,6 @@ export interface DatasetSummary {
|
||||
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;
|
||||
processing_status?: string; artifact_type?: string | null;
|
||||
error_message?: string | null; file_path?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
@@ -172,7 +175,7 @@ export const datasets = {
|
||||
delete: (id: string) => api(`/api/datasets/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// -- Agent --
|
||||
// ── Agent ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AssistRequest {
|
||||
query: string;
|
||||
@@ -226,7 +229,7 @@ export const agent = {
|
||||
},
|
||||
};
|
||||
|
||||
// -- Annotations --
|
||||
// ── Annotations ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AnnotationData {
|
||||
id: string; row_id: number | null; dataset_id: string | null;
|
||||
@@ -252,7 +255,7 @@ export const annotations = {
|
||||
delete: (id: string) => api(`/api/annotations/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// -- Hypotheses --
|
||||
// ── Hypotheses ───────────────────────────────────────────────────────
|
||||
|
||||
export interface HypothesisData {
|
||||
id: string; hunt_id: string | null; title: string; description: string | null;
|
||||
@@ -277,7 +280,7 @@ export const hypotheses = {
|
||||
delete: (id: string) => api(`/api/hypotheses/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// -- Enrichment --
|
||||
// ── Enrichment ───────────────────────────────────────────────────────
|
||||
|
||||
export interface EnrichmentResult {
|
||||
ioc_value: string; ioc_type: string; source: string; verdict: string;
|
||||
@@ -302,7 +305,7 @@ export const enrichment = {
|
||||
status: () => api<Record<string, any>>('/api/enrichment/status'),
|
||||
};
|
||||
|
||||
// -- Correlation --
|
||||
// ── Correlation ──────────────────────────────────────────────────────
|
||||
|
||||
export interface CorrelationResult {
|
||||
hunt_ids: string[]; summary: string; total_correlations: number;
|
||||
@@ -320,7 +323,7 @@ export const correlation = {
|
||||
api<{ ioc_value: string; occurrences: any[]; total: number }>(`/api/correlation/ioc/${encodeURIComponent(ioc_value)}`),
|
||||
};
|
||||
|
||||
// -- Reports --
|
||||
// ── Reports ──────────────────────────────────────────────────────────
|
||||
|
||||
export const reports = {
|
||||
json: (huntId: string) =>
|
||||
@@ -333,13 +336,256 @@ export const reports = {
|
||||
api<Record<string, any>>(`/api/reports/hunt/${huntId}/summary`),
|
||||
};
|
||||
|
||||
// -- Root / misc --
|
||||
// ── Root / misc ──────────────────────────────────────────────────────
|
||||
|
||||
export const misc = {
|
||||
root: () => api<{ name: string; version: string; status: string }>('/'),
|
||||
};
|
||||
|
||||
// -- AUP Keywords --
|
||||
// ── Network Picture ──────────────────────────────────────────────────
|
||||
|
||||
export interface HostEntry {
|
||||
hostname: string;
|
||||
ips: string[];
|
||||
users: string[];
|
||||
os: string[];
|
||||
mac_addresses: string[];
|
||||
protocols: string[];
|
||||
open_ports: string[];
|
||||
remote_targets: string[];
|
||||
datasets: string[];
|
||||
connection_count: number;
|
||||
first_seen: string | null;
|
||||
last_seen: string | null;
|
||||
}
|
||||
|
||||
export interface PictureSummary {
|
||||
total_hosts: number;
|
||||
total_connections: number;
|
||||
total_unique_ips: number;
|
||||
datasets_scanned: number;
|
||||
}
|
||||
|
||||
export interface NetworkPictureResponse {
|
||||
hosts: HostEntry[];
|
||||
summary: PictureSummary;
|
||||
}
|
||||
|
||||
export const network = {
|
||||
picture: (huntId: string) =>
|
||||
api<NetworkPictureResponse>(`/api/network/picture?hunt_id=${encodeURIComponent(huntId)}`),
|
||||
};
|
||||
|
||||
// ── Analysis (Process Tree / Storyline / Risk) ───────────────────────
|
||||
|
||||
export interface ProcessNodeData {
|
||||
pid: string; ppid: string; name: string; command_line: string;
|
||||
username: string; hostname: string; timestamp: string;
|
||||
dataset_name: string; row_index: number;
|
||||
children: ProcessNodeData[]; extra: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProcessTreeResponse {
|
||||
trees: ProcessNodeData[];
|
||||
total_processes: number;
|
||||
}
|
||||
|
||||
export interface StorylineNode {
|
||||
data: {
|
||||
id: string; label: string; event_type: string; hostname: string;
|
||||
timestamp: string; pid: string; ppid: string; process_name: string;
|
||||
command_line: string; username: string; src_ip: string; dst_ip: string;
|
||||
dst_port: string; file_path: string; severity: string;
|
||||
dataset_id: string; row_index: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StorylineEdge {
|
||||
data: {
|
||||
id: string; source: string; target: string; relationship: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StorylineResponse {
|
||||
nodes: StorylineNode[];
|
||||
edges: StorylineEdge[];
|
||||
summary: {
|
||||
total_events: number; total_edges: number;
|
||||
hosts: string[]; event_types: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RiskHost {
|
||||
hostname: string; score: number; signals: string[];
|
||||
event_count: number; process_count: number;
|
||||
network_count: number; file_count: number;
|
||||
}
|
||||
|
||||
export interface RiskSummaryResponse {
|
||||
hosts: RiskHost[];
|
||||
overall_score: number;
|
||||
total_events: number;
|
||||
severity_breakdown: Record<string, number>;
|
||||
}
|
||||
|
||||
export const analysis = {
|
||||
processTree: (params: { dataset_id?: string; hunt_id?: string; hostname?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.dataset_id) q.set('dataset_id', params.dataset_id);
|
||||
if (params.hunt_id) q.set('hunt_id', params.hunt_id);
|
||||
if (params.hostname) q.set('hostname', params.hostname);
|
||||
return api<ProcessTreeResponse>(`/api/analysis/process-tree?${q}`);
|
||||
},
|
||||
storyline: (params: { dataset_id?: string; hunt_id?: string; hostname?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.dataset_id) q.set('dataset_id', params.dataset_id);
|
||||
if (params.hunt_id) q.set('hunt_id', params.hunt_id);
|
||||
if (params.hostname) q.set('hostname', params.hostname);
|
||||
return api<StorylineResponse>(`/api/analysis/storyline?${q}`);
|
||||
},
|
||||
riskSummary: (huntId?: string) => {
|
||||
const q = huntId ? `?hunt_id=${encodeURIComponent(huntId)}` : '';
|
||||
return api<RiskSummaryResponse>(`/api/analysis/risk-summary${q}`);
|
||||
},
|
||||
llmAnalyze: (params: {
|
||||
dataset_id?: string; hunt_id?: string; question?: string;
|
||||
mode?: 'quick' | 'deep'; focus?: string;
|
||||
}) =>
|
||||
api<LLMAnalysisResult>('/api/analysis/llm-analyze', {
|
||||
method: 'POST', body: JSON.stringify(params),
|
||||
}),
|
||||
|
||||
// Timeline & Search
|
||||
timeline: (params: { dataset_id?: string; hunt_id?: string; bins?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.dataset_id) q.set('dataset_id', params.dataset_id);
|
||||
if (params.hunt_id) q.set('hunt_id', params.hunt_id);
|
||||
if (params.bins) q.set('bins', String(params.bins));
|
||||
return api<TimelineBinsResponse>(`/api/analysis/timeline?${q}`);
|
||||
},
|
||||
fieldStats: (params: { dataset_id?: string; hunt_id?: string; fields?: string; top_n?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.dataset_id) q.set('dataset_id', params.dataset_id);
|
||||
if (params.hunt_id) q.set('hunt_id', params.hunt_id);
|
||||
if (params.fields) q.set('fields', params.fields);
|
||||
if (params.top_n) q.set('top_n', String(params.top_n));
|
||||
return api<FieldStatsResponse>(`/api/analysis/field-stats?${q}`);
|
||||
},
|
||||
searchRows: (params: SearchRowsRequest) =>
|
||||
api<SearchRowsResponse>('/api/analysis/search', {
|
||||
method: 'POST', body: JSON.stringify(params),
|
||||
}),
|
||||
|
||||
// MITRE ATT&CK
|
||||
mitreMap: (params: { dataset_id?: string; hunt_id?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.dataset_id) q.set('dataset_id', params.dataset_id);
|
||||
if (params.hunt_id) q.set('hunt_id', params.hunt_id);
|
||||
return api<MitreMapResponse>(`/api/analysis/mitre-map?${q}`);
|
||||
},
|
||||
knowledgeGraph: (params: { dataset_id?: string; hunt_id?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.dataset_id) q.set('dataset_id', params.dataset_id);
|
||||
if (params.hunt_id) q.set('hunt_id', params.hunt_id);
|
||||
return api<KnowledgeGraphResponse>(`/api/analysis/knowledge-graph?${q}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ── LLM Analysis types ───────────────────────────────────────────────
|
||||
|
||||
export interface LLMAnalysisResult {
|
||||
analysis: string;
|
||||
confidence: number;
|
||||
key_findings: string[];
|
||||
iocs_identified: { type: string; value: string; context: string }[];
|
||||
recommended_actions: string[];
|
||||
mitre_techniques: string[];
|
||||
risk_score: number;
|
||||
model_used: string;
|
||||
node_used: string;
|
||||
latency_ms: number;
|
||||
rows_analyzed: number;
|
||||
dataset_summary: string;
|
||||
}
|
||||
|
||||
// ── Timeline & Search types ──────────────────────────────────────────
|
||||
|
||||
export interface TimelineBin {
|
||||
start: string;
|
||||
end: string;
|
||||
count: number;
|
||||
types: Record<string, number>;
|
||||
}
|
||||
export interface TimelineBinsResponse {
|
||||
bins: TimelineBin[];
|
||||
total: number;
|
||||
time_range: { start: string; end: string };
|
||||
}
|
||||
|
||||
export interface FieldStatEntry {
|
||||
value: string;
|
||||
count: number;
|
||||
pct: number;
|
||||
}
|
||||
export interface FieldStatsResponse {
|
||||
fields: Record<string, { total: number; unique: number; top: FieldStatEntry[] }>;
|
||||
total_rows: number;
|
||||
}
|
||||
|
||||
export interface SearchRowsRequest {
|
||||
dataset_id?: string;
|
||||
hunt_id?: string;
|
||||
query?: string;
|
||||
filters?: Record<string, string>;
|
||||
time_start?: string;
|
||||
time_end?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
export interface SearchRowsResponse {
|
||||
rows: Record<string, any>[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// ── MITRE ATT&CK types ──────────────────────────────────────────────
|
||||
|
||||
export interface MitreTechnique {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
evidence: { row_index: number; field: string; value: string; pattern: string }[];
|
||||
}
|
||||
export interface MitreTactic {
|
||||
id: string;
|
||||
name: string;
|
||||
techniques: MitreTechnique[];
|
||||
total_hits: number;
|
||||
}
|
||||
export interface MitreMapResponse {
|
||||
tactics: MitreTactic[];
|
||||
coverage: {
|
||||
tactics_covered: number;
|
||||
tactics_total: number;
|
||||
techniques_matched: number;
|
||||
total_evidence: number;
|
||||
};
|
||||
total_rows: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeGraphResponse {
|
||||
nodes: { data: { id: string; label: string; type: string; color: string; shape: string; tactic?: string } }[];
|
||||
edges: { data: { source: string; target: string; weight: number; label: string } }[];
|
||||
stats: {
|
||||
total_nodes: number;
|
||||
total_edges: number;
|
||||
entity_counts: Record<string, number>;
|
||||
techniques_found: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ── AUP Keywords ─────────────────────────────────────────────────────
|
||||
|
||||
export interface KeywordOut {
|
||||
id: number; theme_id: string; value: string; is_regex: boolean; created_at: string;
|
||||
@@ -400,208 +646,257 @@ export const keywords = {
|
||||
api<ScanResponse>(`/api/keywords/scan/quick?dataset_id=${encodeURIComponent(datasetId)}`),
|
||||
};
|
||||
|
||||
// ── Case Management ──────────────────────────────────────────────────
|
||||
|
||||
// -- Analysis (Phase 2+) --
|
||||
// ── Alerts & Analyzers ───────────────────────────────────────────────
|
||||
|
||||
export interface TriageResultData {
|
||||
id: string; dataset_id: string; row_start: number; row_end: number;
|
||||
risk_score: number; verdict: string;
|
||||
findings: any[] | null; suspicious_indicators: any[] | null;
|
||||
mitre_techniques: any[] | null;
|
||||
model_used: string | null; node_used: string | null;
|
||||
}
|
||||
|
||||
export interface HostProfileData {
|
||||
id: string; hunt_id: string; hostname: string; fqdn: string | null;
|
||||
risk_score: number; risk_level: string;
|
||||
artifact_summary: Record<string, any> | null;
|
||||
timeline_summary: string | null;
|
||||
suspicious_findings: any[] | null;
|
||||
mitre_techniques: any[] | null;
|
||||
llm_analysis: string | null;
|
||||
model_used: string | null;
|
||||
}
|
||||
|
||||
export interface HuntReportData {
|
||||
id: string; hunt_id: string; status: string;
|
||||
exec_summary: string | null; full_report: string | null;
|
||||
findings: any[] | null; recommendations: any[] | null;
|
||||
mitre_mapping: Record<string, any> | null;
|
||||
ioc_table: any[] | null; host_risk_summary: any[] | null;
|
||||
models_used: any[] | null; generation_time_ms: number | null;
|
||||
}
|
||||
|
||||
export interface AnomalyResultData {
|
||||
id: string; dataset_id: string; row_id: number | null;
|
||||
anomaly_score: number; distance_from_centroid: number | null;
|
||||
cluster_id: number | null; is_outlier: boolean;
|
||||
explanation: string | null;
|
||||
}
|
||||
|
||||
export interface HostGroupData {
|
||||
hostname: string;
|
||||
dataset_count: number;
|
||||
total_rows: number;
|
||||
artifact_types: string[];
|
||||
first_seen: string | null;
|
||||
last_seen: string | null;
|
||||
risk_score: number | null;
|
||||
}
|
||||
|
||||
// -- Job queue types (Phase 10) --
|
||||
|
||||
export interface JobData {
|
||||
export interface AlertData {
|
||||
id: string;
|
||||
job_type: string;
|
||||
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
message: string;
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
started_at: number | null;
|
||||
completed_at: number | null;
|
||||
elapsed_ms: number;
|
||||
params: Record<string, any>;
|
||||
title: string;
|
||||
description: string | null;
|
||||
severity: string;
|
||||
status: string;
|
||||
analyzer: string;
|
||||
score: number;
|
||||
evidence: Record<string, any>[];
|
||||
mitre_technique: string | null;
|
||||
tags: string[];
|
||||
hunt_id: string | null;
|
||||
dataset_id: string | null;
|
||||
case_id: string | null;
|
||||
assignee: string | null;
|
||||
acknowledged_at: string | null;
|
||||
resolved_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface JobStats {
|
||||
export interface AlertStats {
|
||||
total: number;
|
||||
queued: number;
|
||||
by_status: Record<string, number>;
|
||||
workers: number;
|
||||
active_workers: number;
|
||||
severity_counts: Record<string, number>;
|
||||
status_counts: Record<string, number>;
|
||||
analyzer_counts: Record<string, number>;
|
||||
top_mitre: { technique: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface LBNodeStatus {
|
||||
healthy: boolean;
|
||||
active_jobs: number;
|
||||
total_completed: number;
|
||||
total_errors: number;
|
||||
avg_latency_ms: number;
|
||||
last_check: number;
|
||||
export interface AlertRuleData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
analyzer: string;
|
||||
config: Record<string, any> | null;
|
||||
severity_override: string | null;
|
||||
enabled: boolean;
|
||||
hunt_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const analysis = {
|
||||
// Triage
|
||||
triageResults: (datasetId: string, minRisk = 0) =>
|
||||
api<TriageResultData[]>(`/api/analysis/triage/${datasetId}?min_risk=${minRisk}`),
|
||||
triggerTriage: (datasetId: string) =>
|
||||
api<{ status: string; dataset_id: string }>(`/api/analysis/triage/${datasetId}`, { method: 'POST' }),
|
||||
export interface AnalyzerInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Host profiles
|
||||
hostProfiles: (huntId: string, minRisk = 0) =>
|
||||
api<HostProfileData[]>(`/api/analysis/profiles/${huntId}?min_risk=${minRisk}`),
|
||||
triggerAllProfiles: (huntId: string) =>
|
||||
api<{ status: string; hunt_id: string }>(`/api/analysis/profiles/${huntId}`, { method: 'POST' }),
|
||||
triggerHostProfile: (huntId: string, hostname: string) =>
|
||||
api<{ status: string }>(`/api/analysis/profiles/${huntId}/${encodeURIComponent(hostname)}`, { method: 'POST' }),
|
||||
export interface AnalyzeResult {
|
||||
candidates_found: number;
|
||||
alerts_created: number;
|
||||
alerts: AlertData[];
|
||||
summary: {
|
||||
by_severity: Record<string, number>;
|
||||
by_analyzer: Record<string, number>;
|
||||
rows_analyzed: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Reports
|
||||
listReports: (huntId: string) =>
|
||||
api<HuntReportData[]>(`/api/analysis/reports/${huntId}`),
|
||||
getReport: (huntId: string, reportId: string) =>
|
||||
api<HuntReportData>(`/api/analysis/reports/${huntId}/${reportId}`),
|
||||
generateReport: (huntId: string) =>
|
||||
api<{ status: string; hunt_id: string }>(`/api/analysis/reports/${huntId}/generate`, { method: 'POST' }),
|
||||
|
||||
// Anomaly detection
|
||||
anomalies: (datasetId: string, outliersOnly = false) =>
|
||||
api<AnomalyResultData[]>(`/api/analysis/anomalies/${datasetId}?outliers_only=${outliersOnly}`),
|
||||
triggerAnomalyDetection: (datasetId: string, k = 3, threshold = 0.35) =>
|
||||
api<{ status: string; dataset_id: string }>(
|
||||
`/api/analysis/anomalies/${datasetId}?k=${k}&threshold=${threshold}`, { method: 'POST' },
|
||||
),
|
||||
|
||||
// IOC extraction
|
||||
extractIocs: (datasetId: string) =>
|
||||
api<{ dataset_id: string; iocs: Record<string, string[]>; total: number }>(
|
||||
`/api/analysis/iocs/${datasetId}`,
|
||||
),
|
||||
|
||||
// Host grouping
|
||||
hostGroups: (huntId: string) =>
|
||||
api<{ hunt_id: string; hosts: HostGroupData[] }>(
|
||||
`/api/analysis/hosts/${huntId}`,
|
||||
),
|
||||
|
||||
// Data query (Phase 9) - SSE streaming
|
||||
queryStream: async (datasetId: string, question: string, mode: string = 'quick'): Promise<Response> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
return fetch(`${BASE}/api/analysis/query/${datasetId}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ question, mode }),
|
||||
});
|
||||
},
|
||||
|
||||
// Data query (Phase 9) - sync
|
||||
querySync: (datasetId: string, question: string, mode: string = 'quick') =>
|
||||
api<{ dataset_id: string; question: string; answer: string; mode: string }>(
|
||||
`/api/analysis/query/${datasetId}/sync`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ question, mode }),
|
||||
},
|
||||
),
|
||||
|
||||
// Job queue (Phase 10)
|
||||
listJobs: (status?: string, jobType?: string, limit = 50) => {
|
||||
export const alerts = {
|
||||
list: (opts?: { status?: string; severity?: string; analyzer?: string; hunt_id?: string; dataset_id?: string; limit?: number; offset?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (status) q.set('status', status);
|
||||
if (jobType) q.set('job_type', jobType);
|
||||
q.set('limit', String(limit));
|
||||
return api<{ jobs: JobData[]; stats: JobStats }>(`/api/analysis/jobs?${q}`);
|
||||
if (opts?.status) q.set('status', opts.status);
|
||||
if (opts?.severity) q.set('severity', opts.severity);
|
||||
if (opts?.analyzer) q.set('analyzer', opts.analyzer);
|
||||
if (opts?.hunt_id) q.set('hunt_id', opts.hunt_id);
|
||||
if (opts?.dataset_id) q.set('dataset_id', opts.dataset_id);
|
||||
if (opts?.limit) q.set('limit', String(opts.limit));
|
||||
if (opts?.offset) q.set('offset', String(opts.offset));
|
||||
return api<{ alerts: AlertData[]; total: number }>(`/api/alerts?${q}`);
|
||||
},
|
||||
getJob: (jobId: string) =>
|
||||
api<JobData>(`/api/analysis/jobs/${jobId}`),
|
||||
cancelJob: (jobId: string) =>
|
||||
api<{ status: string; job_id: string }>(`/api/analysis/jobs/${jobId}`, { method: 'DELETE' }),
|
||||
submitJob: (jobType: string, params: Record<string, any> = {}) =>
|
||||
api<{ job_id: string; status: string; job_type: string }>(
|
||||
`/api/analysis/jobs/submit/${jobType}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
),
|
||||
|
||||
// Load balancer (Phase 10)
|
||||
lbStatus: () =>
|
||||
api<Record<string, LBNodeStatus>>('/api/analysis/lb/status'),
|
||||
lbCheck: () =>
|
||||
api<Record<string, LBNodeStatus>>('/api/analysis/lb/check', { method: 'POST' }),
|
||||
stats: (huntId?: string) => {
|
||||
const q = huntId ? `?hunt_id=${encodeURIComponent(huntId)}` : '';
|
||||
return api<AlertStats>(`/api/alerts/stats${q}`);
|
||||
},
|
||||
get: (id: string) => api<AlertData>(`/api/alerts/${id}`),
|
||||
update: (id: string, data: { status?: string; severity?: string; assignee?: string; case_id?: string; tags?: string[] }) =>
|
||||
api<AlertData>(`/api/alerts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) =>
|
||||
api(`/api/alerts/${id}`, { method: 'DELETE' }),
|
||||
bulkUpdate: (alertIds: string[], status: string) =>
|
||||
api<{ updated: number }>(`/api/alerts/bulk-update?status=${encodeURIComponent(status)}`, {
|
||||
method: 'POST', body: JSON.stringify(alertIds),
|
||||
}),
|
||||
analyzers: () =>
|
||||
api<{ analyzers: AnalyzerInfo[] }>('/api/alerts/analyzers/list'),
|
||||
analyze: (params: { dataset_id?: string; hunt_id?: string; analyzers?: string[]; config?: Record<string, any>; auto_create?: boolean }) =>
|
||||
api<AnalyzeResult>('/api/alerts/analyze', {
|
||||
method: 'POST', body: JSON.stringify(params),
|
||||
}),
|
||||
listRules: (enabled?: boolean) => {
|
||||
const q = enabled !== undefined ? `?enabled=${enabled}` : '';
|
||||
return api<{ rules: AlertRuleData[] }>(`/api/alerts/rules/list${q}`);
|
||||
},
|
||||
createRule: (data: { name: string; description?: string; analyzer: string; config?: Record<string, any>; severity_override?: string; enabled?: boolean; hunt_id?: string }) =>
|
||||
api<AlertRuleData>('/api/alerts/rules', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateRule: (id: string, data: Partial<AlertRuleData>) =>
|
||||
api<AlertRuleData>(`/api/alerts/rules/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteRule: (id: string) =>
|
||||
api(`/api/alerts/rules/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// -- Network Topology --
|
||||
// ── Case Management (continued) ──────────────────────────────────────
|
||||
|
||||
export interface InventoryHost {
|
||||
export interface CaseData {
|
||||
id: string;
|
||||
hostname: string;
|
||||
fqdn: string;
|
||||
client_id: string;
|
||||
ips: string[];
|
||||
os: string;
|
||||
users: string[];
|
||||
datasets: string[];
|
||||
row_count: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
severity: string;
|
||||
tlp: string;
|
||||
pap: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
assignee: string | null;
|
||||
tags: string[];
|
||||
hunt_id: string | null;
|
||||
owner_id: string | null;
|
||||
mitre_techniques: string[];
|
||||
iocs: { type: string; value: string; description?: string }[];
|
||||
started_at: string | null;
|
||||
resolved_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tasks: CaseTaskData[];
|
||||
}
|
||||
export interface CaseTaskData {
|
||||
id: string;
|
||||
case_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
assignee: string | null;
|
||||
order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface ActivityLogEntry {
|
||||
id: number;
|
||||
action: string;
|
||||
details: Record<string, any> | null;
|
||||
user_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface InventoryConnection {
|
||||
export const cases = {
|
||||
list: (opts?: { status?: string; hunt_id?: string; limit?: number; offset?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts?.status) q.set('status', opts.status);
|
||||
if (opts?.hunt_id) q.set('hunt_id', opts.hunt_id);
|
||||
if (opts?.limit) q.set('limit', String(opts.limit));
|
||||
if (opts?.offset) q.set('offset', String(opts.offset));
|
||||
return api<{ cases: CaseData[]; total: number }>(`/api/cases?${q}`);
|
||||
},
|
||||
get: (id: string) => api<CaseData>(`/api/cases/${id}`),
|
||||
create: (data: Partial<CaseData>) =>
|
||||
api<CaseData>('/api/cases', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: Partial<CaseData>) =>
|
||||
api<CaseData>(`/api/cases/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) =>
|
||||
api(`/api/cases/${id}`, { method: 'DELETE' }),
|
||||
addTask: (caseId: string, data: { title: string; description?: string; assignee?: string }) =>
|
||||
api<CaseTaskData>(`/api/cases/${caseId}/tasks`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateTask: (caseId: string, taskId: string, data: Partial<CaseTaskData>) =>
|
||||
api<CaseTaskData>(`/api/cases/${caseId}/tasks/${taskId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteTask: (caseId: string, taskId: string) =>
|
||||
api(`/api/cases/${caseId}/tasks/${taskId}`, { method: 'DELETE' }),
|
||||
activity: (caseId: string, limit = 50) =>
|
||||
api<{ logs: ActivityLogEntry[] }>(`/api/cases/${caseId}/activity?limit=${limit}`),
|
||||
};
|
||||
|
||||
// ── Notebooks & Playbooks ────────────────────────────────────────────
|
||||
|
||||
export interface NotebookCell {
|
||||
id: string;
|
||||
cell_type: string;
|
||||
source: string;
|
||||
target: string;
|
||||
target_ip: string;
|
||||
port: string;
|
||||
count: number;
|
||||
output: string | null;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
export interface NotebookData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
cells: NotebookCell[];
|
||||
hunt_id: string | null;
|
||||
case_id: string | null;
|
||||
owner_id: string | null;
|
||||
tags: string[];
|
||||
cell_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface PlaybookTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
step_count: number;
|
||||
}
|
||||
export interface PlaybookStep {
|
||||
order: number;
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
action_config: Record<string, any>;
|
||||
expected_outcome: string;
|
||||
}
|
||||
export interface PlaybookTemplateDetail extends PlaybookTemplate {
|
||||
steps: PlaybookStep[];
|
||||
}
|
||||
export interface PlaybookRunData {
|
||||
id: string;
|
||||
playbook_name: string;
|
||||
status: string;
|
||||
current_step: number;
|
||||
total_steps: number;
|
||||
step_results: { step: number; status: string; notes: string | null; completed_at: string }[];
|
||||
hunt_id: string | null;
|
||||
case_id: string | null;
|
||||
started_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
steps?: PlaybookStep[];
|
||||
}
|
||||
|
||||
export interface InventoryStats {
|
||||
total_hosts: number;
|
||||
total_datasets_scanned: number;
|
||||
datasets_with_hosts: number;
|
||||
total_rows_scanned: number;
|
||||
hosts_with_ips: number;
|
||||
hosts_with_users: number;
|
||||
}
|
||||
export const notebooks = {
|
||||
list: (opts?: { hunt_id?: string; limit?: number; offset?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts?.hunt_id) q.set('hunt_id', opts.hunt_id);
|
||||
if (opts?.limit) q.set('limit', String(opts.limit));
|
||||
if (opts?.offset) q.set('offset', String(opts.offset));
|
||||
return api<{ notebooks: NotebookData[]; total: number }>(`/api/notebooks?${q}`);
|
||||
},
|
||||
get: (id: string) => api<NotebookData>(`/api/notebooks/${id}`),
|
||||
create: (data: { title: string; description?: string; cells?: Partial<NotebookCell>[]; hunt_id?: string; case_id?: string; tags?: string[] }) =>
|
||||
api<NotebookData>('/api/notebooks', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: { title?: string; description?: string; cells?: Partial<NotebookCell>[]; tags?: string[] }) =>
|
||||
api<NotebookData>(`/api/notebooks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
upsertCell: (notebookId: string, cell: { cell_id: string; cell_type?: string; source?: string; output?: string; metadata?: Record<string, any> }) =>
|
||||
api<NotebookData>(`/api/notebooks/${notebookId}/cells`, { method: 'POST', body: JSON.stringify(cell) }),
|
||||
deleteCell: (notebookId: string, cellId: string) =>
|
||||
api(`/api/notebooks/${notebookId}/cells/${cellId}`, { method: 'DELETE' }),
|
||||
delete: (id: string) =>
|
||||
api(`/api/notebooks/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
<<<<<<< HEAD
|
||||
export interface HostInventory {
|
||||
hosts: InventoryHost[];
|
||||
connections: InventoryConnection[];
|
||||
@@ -818,3 +1113,27 @@ export const stixExport = {
|
||||
},
|
||||
};
|
||||
|
||||
=======
|
||||
export const playbooks = {
|
||||
templates: () =>
|
||||
api<{ templates: PlaybookTemplate[] }>('/api/notebooks/playbooks/templates'),
|
||||
templateDetail: (name: string) =>
|
||||
api<PlaybookTemplateDetail>(`/api/notebooks/playbooks/templates/${encodeURIComponent(name)}`),
|
||||
start: (data: { playbook_name: string; hunt_id?: string; case_id?: string; started_by?: string }) =>
|
||||
api<PlaybookRunData>('/api/notebooks/playbooks/start', { method: 'POST', body: JSON.stringify(data) }),
|
||||
listRuns: (opts?: { status?: string; hunt_id?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts?.status) q.set('status', opts.status);
|
||||
if (opts?.hunt_id) q.set('hunt_id', opts.hunt_id);
|
||||
return api<{ runs: PlaybookRunData[] }>(`/api/notebooks/playbooks/runs?${q}`);
|
||||
},
|
||||
getRun: (runId: string) =>
|
||||
api<PlaybookRunData>(`/api/notebooks/playbooks/runs/${runId}`),
|
||||
completeStep: (runId: string, data: { notes?: string; status?: string }) =>
|
||||
api<PlaybookRunData>(`/api/notebooks/playbooks/runs/${runId}/complete-step`, {
|
||||
method: 'POST', body: JSON.stringify(data),
|
||||
}),
|
||||
abortRun: (runId: string) =>
|
||||
api<PlaybookRunData>(`/api/notebooks/playbooks/runs/${runId}/abort`, { method: 'POST' }),
|
||||
};
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
|
||||
556
frontend/src/components/AlertPanel.tsx
Normal file
556
frontend/src/components/AlertPanel.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* AlertPanel — Security alert management with analyzer controls.
|
||||
*
|
||||
* Features:
|
||||
* - Alert list with severity/status filtering and sorting
|
||||
* - Run analyzers on demand against datasets/hunts
|
||||
* - Alert detail drill-down with evidence viewer
|
||||
* - Bulk acknowledge/resolve/false-positive actions
|
||||
* - Alert rules management (auto-trigger analyzers)
|
||||
* - Stats dashboard (severity/status/analyzer breakdowns)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, Chip, IconButton, Tooltip,
|
||||
Select, MenuItem, FormControl, InputLabel, Stack, Divider,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
||||
LinearProgress, Alert as MuiAlert, Card, CardContent, Tabs, Tab,
|
||||
Checkbox, FormControlLabel, Switch, Badge,
|
||||
} from '@mui/material';
|
||||
import { DataGrid, GridColDef } from '@mui/x-data-grid';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ReportProblemIcon from '@mui/icons-material/ReportProblem';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import RuleIcon from '@mui/icons-material/Rule';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
alerts, hunts, datasets,
|
||||
AlertData, AlertStats, AlertRuleData, AnalyzerInfo,
|
||||
Hunt, DatasetSummary,
|
||||
} from '../api/client';
|
||||
|
||||
const SEV_COLORS: Record<string, 'error' | 'warning' | 'info' | 'success' | 'default'> = {
|
||||
critical: 'error', high: 'warning', medium: 'info', low: 'success', info: 'default',
|
||||
};
|
||||
const STATUS_COLORS: Record<string, 'error' | 'warning' | 'info' | 'success' | 'default'> = {
|
||||
new: 'error', acknowledged: 'warning', 'in-progress': 'info',
|
||||
resolved: 'success', 'false-positive': 'default',
|
||||
};
|
||||
|
||||
export default function AlertPanel() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
// Tab state
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
// Alerts list
|
||||
const [alertList, setAlertList] = useState<AlertData[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sevFilter, setSevFilter] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
// Stats
|
||||
const [stats, setStats] = useState<AlertStats | null>(null);
|
||||
|
||||
// Hunt/dataset selectors
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [dsList, setDsList] = useState<DatasetSummary[]>([]);
|
||||
const [huntId, setHuntId] = useState('');
|
||||
const [datasetId, setDatasetId] = useState('');
|
||||
|
||||
// Analyzers
|
||||
const [analyzerList, setAnalyzerList] = useState<AnalyzerInfo[]>([]);
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
|
||||
// Rules
|
||||
const [rules, setRules] = useState<AlertRuleData[]>([]);
|
||||
const [ruleDialog, setRuleDialog] = useState(false);
|
||||
const [ruleForm, setRuleForm] = useState({ name: '', description: '', analyzer: '', enabled: true });
|
||||
|
||||
// Detail dialog
|
||||
const [detailAlert, setDetailAlert] = useState<AlertData | null>(null);
|
||||
|
||||
// ── Load data ──────────────────────────────────────────────────────
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const opts: any = { limit: 200 };
|
||||
if (sevFilter) opts.severity = sevFilter;
|
||||
if (statusFilter) opts.status = statusFilter;
|
||||
if (huntId) opts.hunt_id = huntId;
|
||||
if (datasetId) opts.dataset_id = datasetId;
|
||||
const res = await alerts.list(opts);
|
||||
setAlertList(res.alerts);
|
||||
setTotal(res.total);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sevFilter, statusFilter, huntId, datasetId, enqueueSnackbar]);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const s = await alerts.stats(huntId || undefined);
|
||||
setStats(s);
|
||||
} catch {}
|
||||
}, [huntId]);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
try {
|
||||
const res = await alerts.listRules();
|
||||
setRules(res.rules);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
hunts.list().then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
datasets.list(0, 500).then(r => setDsList(r.datasets)).catch(() => {});
|
||||
alerts.analyzers().then(r => setAnalyzerList(r.analyzers)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadAlerts(); loadStats(); }, [loadAlerts, loadStats]);
|
||||
useEffect(() => { loadRules(); }, [loadRules]);
|
||||
|
||||
// ── Analyze ────────────────────────────────────────────────────────
|
||||
|
||||
const runAnalysis = async () => {
|
||||
if (!datasetId && !huntId) {
|
||||
enqueueSnackbar('Select a hunt or dataset first', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
const res = await alerts.analyze({
|
||||
dataset_id: datasetId || undefined,
|
||||
hunt_id: huntId || undefined,
|
||||
auto_create: true,
|
||||
});
|
||||
enqueueSnackbar(
|
||||
`Analysis complete: ${res.candidates_found} findings, ${res.alerts_created} alerts created`,
|
||||
{ variant: 'success' },
|
||||
);
|
||||
loadAlerts();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Bulk actions ───────────────────────────────────────────────────
|
||||
|
||||
const bulkAction = async (status: string) => {
|
||||
if (!selected.length) return;
|
||||
try {
|
||||
await alerts.bulkUpdate(selected, status);
|
||||
enqueueSnackbar(`${selected.length} alerts → ${status}`, { variant: 'success' });
|
||||
setSelected([]);
|
||||
loadAlerts();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Single alert actions ───────────────────────────────────────────
|
||||
|
||||
const updateStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
await alerts.update(id, { status });
|
||||
loadAlerts();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAlert = async (id: string) => {
|
||||
try {
|
||||
await alerts.delete(id);
|
||||
loadAlerts();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Rules ──────────────────────────────────────────────────────────
|
||||
|
||||
const createRule = async () => {
|
||||
if (!ruleForm.name || !ruleForm.analyzer) return;
|
||||
try {
|
||||
await alerts.createRule({
|
||||
name: ruleForm.name,
|
||||
description: ruleForm.description,
|
||||
analyzer: ruleForm.analyzer,
|
||||
enabled: ruleForm.enabled,
|
||||
});
|
||||
enqueueSnackbar('Rule created', { variant: 'success' });
|
||||
setRuleDialog(false);
|
||||
setRuleForm({ name: '', description: '', analyzer: '', enabled: true });
|
||||
loadRules();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRule = async (rule: AlertRuleData) => {
|
||||
try {
|
||||
await alerts.updateRule(rule.id, { enabled: !rule.enabled } as any);
|
||||
loadRules();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (id: string) => {
|
||||
try {
|
||||
await alerts.deleteRule(id);
|
||||
loadRules();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── DataGrid columns ──────────────────────────────────────────────
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'severity', headerName: 'Sev', width: 90,
|
||||
renderCell: (p) => <Chip label={p.value} size="small" color={SEV_COLORS[p.value] || 'default'} />,
|
||||
},
|
||||
{
|
||||
field: 'status', headerName: 'Status', width: 110,
|
||||
renderCell: (p) => <Chip label={p.value} size="small" color={STATUS_COLORS[p.value] || 'default'} variant="outlined" />,
|
||||
},
|
||||
{ field: 'title', headerName: 'Title', flex: 1, minWidth: 250 },
|
||||
{ field: 'analyzer', headerName: 'Analyzer', width: 140 },
|
||||
{
|
||||
field: 'score', headerName: 'Score', width: 80, type: 'number',
|
||||
renderCell: (p) => <Typography variant="body2" fontWeight="bold">{Math.round(p.value)}</Typography>,
|
||||
},
|
||||
{ field: 'mitre_technique', headerName: 'MITRE', width: 90 },
|
||||
{
|
||||
field: 'created_at', headerName: 'Created', width: 150,
|
||||
valueFormatter: (value: string) => value ? new Date(value).toLocaleString() : '',
|
||||
},
|
||||
{
|
||||
field: 'actions', headerName: '', width: 160, sortable: false,
|
||||
renderCell: (p) => (
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="View"><IconButton size="small" onClick={() => setDetailAlert(p.row)}><VisibilityIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Acknowledge"><IconButton size="small" color="warning" onClick={() => updateStatus(p.row.id, 'acknowledged')}><CheckCircleIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Resolve"><IconButton size="small" color="success" onClick={() => updateStatus(p.row.id, 'resolved')}><CheckCircleIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => deleteAlert(p.row.id)}><DeleteIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Stats cards ────────────────────────────────────────────────────
|
||||
|
||||
const StatsView = () => {
|
||||
if (!stats) return <Typography color="text.secondary">No stats available</Typography>;
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 2 }}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">Total Alerts</Typography>
|
||||
<Typography variant="h3">{stats.total}</Typography>
|
||||
</CardContent></Card>
|
||||
|
||||
{['critical', 'high', 'medium', 'low', 'info'].map(sev => (
|
||||
<Card key={sev}><CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">{sev.toUpperCase()}</Typography>
|
||||
<Typography variant="h4" color={SEV_COLORS[sev] === 'error' ? 'error' : SEV_COLORS[sev] === 'warning' ? 'warning.main' : 'text.primary'}>
|
||||
{stats.severity_counts[sev] || 0}
|
||||
</Typography>
|
||||
</CardContent></Card>
|
||||
))}
|
||||
|
||||
<Card sx={{ gridColumn: 'span 2' }}><CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>By Status</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{Object.entries(stats.status_counts).map(([s, c]) => (
|
||||
<Chip key={s} label={`${s}: ${c}`} color={STATUS_COLORS[s] || 'default'} size="small" />
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent></Card>
|
||||
|
||||
<Card sx={{ gridColumn: 'span 2' }}><CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>By Analyzer</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{Object.entries(stats.analyzer_counts).map(([a, c]) => (
|
||||
<Chip key={a} label={`${a}: ${c}`} size="small" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent></Card>
|
||||
|
||||
{stats.top_mitre.length > 0 && (
|
||||
<Card sx={{ gridColumn: 'span 2' }}><CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>Top MITRE Techniques</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{stats.top_mitre.map(m => (
|
||||
<Chip key={m.technique} label={`${m.technique} (${m.count})`} size="small" color="error" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Rules view ─────────────────────────────────────────────────────
|
||||
|
||||
const RulesView = () => (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">Alert Rules ({rules.length})</Typography>
|
||||
<Button variant="contained" startIcon={<RuleIcon />} onClick={() => setRuleDialog(true)}>
|
||||
New Rule
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{rules.map(rule => (
|
||||
<Paper key={rule.id} sx={{ p: 2, mb: 1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
{rule.name}
|
||||
<Chip label={rule.analyzer} size="small" sx={{ ml: 1 }} />
|
||||
{rule.severity_override && <Chip label={rule.severity_override} size="small" color={SEV_COLORS[rule.severity_override] || 'default'} sx={{ ml: 0.5 }} />}
|
||||
</Typography>
|
||||
{rule.description && <Typography variant="body2" color="text.secondary">{rule.description}</Typography>}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Switch checked={rule.enabled} onChange={() => toggleRule(rule)} size="small" />
|
||||
<IconButton size="small" color="error" onClick={() => deleteRule(rule.id)}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<Typography color="text.secondary" textAlign="center" py={4}>
|
||||
No alert rules configured. Create a rule to auto-trigger analyzers.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h5">
|
||||
<Badge badgeContent={stats?.severity_counts?.critical || 0} color="error" sx={{ mr: 2 }}>
|
||||
<ReportProblemIcon />
|
||||
</Badge>
|
||||
Alerts & Analyzers
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={() => { loadAlerts(); loadStats(); }}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Selector bar */}
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select value={huntId} label="Hunt" onChange={e => { setHuntId(e.target.value); setDatasetId(''); }}>
|
||||
<MenuItem value="">All hunts</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select value={datasetId} label="Dataset" onChange={e => setDatasetId(e.target.value)}>
|
||||
<MenuItem value="">All datasets</MenuItem>
|
||||
{dsList.map(d => <MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={runAnalysis}
|
||||
disabled={analyzing || (!huntId && !datasetId)}
|
||||
>
|
||||
{analyzing ? 'Analyzing…' : 'Run Analyzers'}
|
||||
</Button>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Severity</InputLabel>
|
||||
<Select value={sevFilter} label="Severity" onChange={e => setSevFilter(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{['critical', 'high', 'medium', 'low', 'info'].map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select value={statusFilter} label="Status" onChange={e => setStatusFilter(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{['new', 'acknowledged', 'in-progress', 'resolved', 'false-positive'].map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
{analyzing && <LinearProgress sx={{ mt: 1 }} />}
|
||||
</Paper>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)}>
|
||||
<Tab label={`Alerts (${total})`} />
|
||||
<Tab icon={<BarChartIcon />} label="Stats" />
|
||||
<Tab icon={<RuleIcon />} label={`Rules (${rules.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{/* Tab panels */}
|
||||
{tab === 0 && (
|
||||
<Box>
|
||||
{/* Bulk actions */}
|
||||
{selected.length > 0 && (
|
||||
<Paper sx={{ p: 1, mb: 1, bgcolor: 'action.hover' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2">{selected.length} selected</Typography>
|
||||
<Button size="small" onClick={() => bulkAction('acknowledged')}>Acknowledge</Button>
|
||||
<Button size="small" onClick={() => bulkAction('resolved')}>Resolve</Button>
|
||||
<Button size="small" color="error" onClick={() => bulkAction('false-positive')}>False Positive</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper sx={{ height: 600 }}>
|
||||
{loading && <LinearProgress />}
|
||||
<DataGrid
|
||||
rows={alertList}
|
||||
columns={columns}
|
||||
density="compact"
|
||||
checkboxSelection
|
||||
onRowSelectionModelChange={(model) => setSelected(Array.from(model.ids) as string[])}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 50 } } }}
|
||||
sx={{ border: 'none' }}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && <StatsView />}
|
||||
{tab === 2 && <RulesView />}
|
||||
|
||||
{/* Detail dialog */}
|
||||
<Dialog open={!!detailAlert} onClose={() => setDetailAlert(null)} maxWidth="md" fullWidth>
|
||||
{detailAlert && (
|
||||
<>
|
||||
<DialogTitle>
|
||||
<Chip label={detailAlert.severity} color={SEV_COLORS[detailAlert.severity] || 'default'} size="small" sx={{ mr: 1 }} />
|
||||
{detailAlert.title}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="body1" gutterBottom>{detailAlert.description}</Typography>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Stack direction="row" spacing={2} mb={2}>
|
||||
<Chip label={`Analyzer: ${detailAlert.analyzer}`} variant="outlined" />
|
||||
<Chip label={`Score: ${Math.round(detailAlert.score)}`} variant="outlined" />
|
||||
{detailAlert.mitre_technique && <Chip label={detailAlert.mitre_technique} color="error" variant="outlined" />}
|
||||
<Chip label={detailAlert.status} color={STATUS_COLORS[detailAlert.status] || 'default'} />
|
||||
</Stack>
|
||||
|
||||
{detailAlert.tags?.length > 0 && (
|
||||
<Stack direction="row" spacing={0.5} mb={2}>
|
||||
{detailAlert.tags.map((t, i) => <Chip key={i} label={t} size="small" />)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>Evidence</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, maxHeight: 300, overflow: 'auto' }}>
|
||||
<pre style={{ margin: 0, fontSize: '0.8rem', whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(detailAlert.evidence, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
|
||||
<Stack direction="row" spacing={2} mt={2}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Created: {new Date(detailAlert.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
{detailAlert.acknowledged_at && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Acknowledged: {new Date(detailAlert.acknowledged_at).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
{detailAlert.resolved_at && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Resolved: {new Date(detailAlert.resolved_at).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { updateStatus(detailAlert.id, 'acknowledged'); setDetailAlert(null); }}>
|
||||
Acknowledge
|
||||
</Button>
|
||||
<Button onClick={() => { updateStatus(detailAlert.id, 'resolved'); setDetailAlert(null); }} color="success">
|
||||
Resolve
|
||||
</Button>
|
||||
<Button onClick={() => { updateStatus(detailAlert.id, 'false-positive'); setDetailAlert(null); }}>
|
||||
False Positive
|
||||
</Button>
|
||||
<Button onClick={() => setDetailAlert(null)}>Close</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* Create rule dialog */}
|
||||
<Dialog open={ruleDialog} onClose={() => setRuleDialog(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create Alert Rule</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} mt={1}>
|
||||
<TextField
|
||||
label="Rule Name" fullWidth required
|
||||
value={ruleForm.name} onChange={e => setRuleForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Description" fullWidth multiline rows={2}
|
||||
value={ruleForm.description} onChange={e => setRuleForm(f => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Analyzer</InputLabel>
|
||||
<Select value={ruleForm.analyzer} label="Analyzer" onChange={e => setRuleForm(f => ({ ...f, analyzer: e.target.value }))}>
|
||||
{analyzerList.map(a => <MenuItem key={a.name} value={a.name}>{a.name} — {a.description}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={ruleForm.enabled} onChange={e => setRuleForm(f => ({ ...f, enabled: e.target.checked }))} />}
|
||||
label="Enabled"
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRuleDialog(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={createRule} disabled={!ruleForm.name || !ruleForm.analyzer}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
315
frontend/src/components/AnalysisPanel.tsx
Normal file
315
frontend/src/components/AnalysisPanel.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* AnalysisPanel — LLM-powered threat analysis of datasets.
|
||||
*
|
||||
* Replaces the old EnrichmentPanel (which required VT/AbuseIPDB/Shodan API
|
||||
* keys). Uses Wile (70B) and Roadrunner (fast) to perform deep threat analysis
|
||||
* of uploaded forensic data, returning structured findings, IOCs, MITRE
|
||||
* techniques, and actionable recommendations — all rendered in markdown.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Stack, Alert, CircularProgress, Chip,
|
||||
FormControl, InputLabel, Select, MenuItem, TextField, Button,
|
||||
Divider, LinearProgress, Grid, ToggleButton, ToggleButtonGroup,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import BugReportIcon from '@mui/icons-material/BugReport';
|
||||
import TravelExploreIcon from '@mui/icons-material/TravelExplore';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
analysis, hunts, datasets, type Hunt, type DatasetSummary,
|
||||
type LLMAnalysisResult,
|
||||
} from '../api/client';
|
||||
|
||||
const FOCUS_OPTIONS = [
|
||||
{ value: '', label: 'General', icon: <SecurityIcon fontSize="small" /> },
|
||||
{ value: 'threats', label: 'Threats', icon: <BugReportIcon fontSize="small" /> },
|
||||
{ value: 'anomalies', label: 'Anomalies', icon: <TravelExploreIcon fontSize="small" /> },
|
||||
{ value: 'lateral_movement', label: 'Lateral Mvmt', icon: <SecurityIcon fontSize="small" /> },
|
||||
{ value: 'exfil', label: 'Exfiltration', icon: <SecurityIcon fontSize="small" /> },
|
||||
{ value: 'persistence', label: 'Persistence', icon: <SecurityIcon fontSize="small" /> },
|
||||
{ value: 'recon', label: 'Recon', icon: <TravelExploreIcon fontSize="small" /> },
|
||||
];
|
||||
|
||||
const SEV_COLORS: Record<string, string> = {
|
||||
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6', info: '#6b7280',
|
||||
};
|
||||
|
||||
function RiskGauge({ score }: { score: number }) {
|
||||
const color = score >= 75 ? '#ef4444' : score >= 50 ? '#f97316' : score >= 25 ? '#eab308' : '#10b981';
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<CircularProgress variant="determinate" value={score} size={80}
|
||||
thickness={5} sx={{ color }} />
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color }}>{score}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="caption" display="block" color="text.secondary">Risk Score</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalysisPanel() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [datasetList, setDatasetList] = useState<DatasetSummary[]>([]);
|
||||
const [selectedHunt, setSelectedHunt] = useState('');
|
||||
const [selectedDataset, setSelectedDataset] = useState('');
|
||||
const [question, setQuestion] = useState('');
|
||||
const [mode, setMode] = useState<'quick' | 'deep'>('deep');
|
||||
const [focus, setFocus] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<LLMAnalysisResult | null>(null);
|
||||
|
||||
/* load hunts + datasets */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [h, d] = await Promise.all([
|
||||
hunts.list(0, 100),
|
||||
datasets.list(0, 200),
|
||||
]);
|
||||
setHuntList(h.hunts);
|
||||
setDatasetList(d.datasets);
|
||||
if (h.hunts.length > 0) setSelectedHunt(h.hunts[0].id);
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const huntDatasets = selectedHunt
|
||||
? datasetList.filter(d => d.hunt_id === selectedHunt)
|
||||
: datasetList;
|
||||
|
||||
const runAnalysis = useCallback(async () => {
|
||||
if (!selectedHunt && !selectedDataset) {
|
||||
enqueueSnackbar('Select a hunt or dataset', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await analysis.llmAnalyze({
|
||||
dataset_id: selectedDataset || undefined,
|
||||
hunt_id: selectedDataset ? undefined : selectedHunt,
|
||||
question: question || undefined,
|
||||
mode,
|
||||
focus: focus || undefined,
|
||||
});
|
||||
setResult(res);
|
||||
enqueueSnackbar(`Analysis complete in ${(res.latency_ms / 1000).toFixed(1)}s`, { variant: 'success' });
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message || 'Analysis failed', { variant: 'error' });
|
||||
}
|
||||
setLoading(false);
|
||||
}, [selectedHunt, selectedDataset, question, mode, focus, enqueueSnackbar]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>LLM Threat Analysis</Typography>
|
||||
|
||||
{/* Controls */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={selectedHunt}
|
||||
onChange={e => { setSelectedHunt(e.target.value); setSelectedDataset(''); }}>
|
||||
{huntList.map(h => (
|
||||
<MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select label="Dataset" value={selectedDataset}
|
||||
onChange={e => setSelectedDataset(e.target.value)}>
|
||||
<MenuItem value="">All in hunt</MenuItem>
|
||||
{huntDatasets.map(d => (
|
||||
<MenuItem key={d.id} value={d.id}>
|
||||
{d.name} ({d.row_count} rows)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<ToggleButtonGroup size="small" value={mode} exclusive
|
||||
onChange={(_, v) => v && setMode(v)}>
|
||||
<ToggleButton value="quick">
|
||||
<Tooltip title="Quick (Roadrunner fast model)"><SpeedIcon fontSize="small" /></Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="deep">
|
||||
<Tooltip title="Deep (Wile 70B model)"><PsychologyIcon fontSize="small" /></Tooltip>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Focus</InputLabel>
|
||||
<Select label="Focus" value={focus} onChange={e => setFocus(e.target.value)}>
|
||||
{FOCUS_OPTIONS.map(f => (
|
||||
<MenuItem key={f.value} value={f.value}>{f.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<TextField
|
||||
size="small" fullWidth
|
||||
label="Ask a specific question (optional)"
|
||||
placeholder="e.g. Is there evidence of lateral movement via PsExec?"
|
||||
value={question}
|
||||
onChange={e => setQuestion(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && runAnalysis()}
|
||||
/>
|
||||
<Button
|
||||
variant="contained" startIcon={loading ? <CircularProgress size={16} /> : <PlayArrowIcon />}
|
||||
onClick={runAnalysis} disabled={loading || (!selectedHunt && !selectedDataset)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{loading ? 'Analyzing...' : 'Analyze'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{loading && <LinearProgress sx={{ mt: 1 }} />}
|
||||
</Paper>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<Grid container spacing={2}>
|
||||
{/* Left: main analysis */}
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Paper sx={{ p: 2.5, maxHeight: 'calc(100vh - 320px)', overflow: 'auto' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<RiskGauge score={result.risk_score} />
|
||||
<Box sx={{ flex: 1, ml: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{result.rows_analyzed.toLocaleString()} rows analyzed • {result.model_used} on {result.node_used}
|
||||
• {(result.latency_ms / 1000).toFixed(1)}s
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Confidence: {(result.confidence * 100).toFixed(0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Box sx={{
|
||||
'& h1': { fontSize: '1.4rem', mt: 2, mb: 1 },
|
||||
'& h2': { fontSize: '1.2rem', mt: 2, mb: 1 },
|
||||
'& h3': { fontSize: '1rem', mt: 1.5, mb: 0.5 },
|
||||
'& p': { mb: 1 },
|
||||
'& ul, & ol': { pl: 3, mb: 1 },
|
||||
'& code': { bgcolor: 'action.hover', px: 0.5, borderRadius: 0.5, fontFamily: 'monospace', fontSize: '0.85em' },
|
||||
'& pre': { bgcolor: 'background.default', p: 1.5, borderRadius: 1, overflow: 'auto' },
|
||||
'& table': { width: '100%', borderCollapse: 'collapse', mb: 2 },
|
||||
'& th, & td': { border: '1px solid', borderColor: 'divider', p: 0.5, fontSize: '0.85rem' },
|
||||
}}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{result.analysis}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Right: structured findings */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Stack spacing={2}>
|
||||
{/* Key findings */}
|
||||
{result.key_findings.length > 0 && (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Key Findings</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{result.key_findings.map((f, i) => (
|
||||
<Alert key={i} severity="warning" variant="outlined" sx={{ py: 0 }}>
|
||||
<Typography variant="body2">{f}</Typography>
|
||||
</Alert>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* IOCs identified */}
|
||||
{result.iocs_identified.length > 0 && (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>IOCs Identified</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{result.iocs_identified.map((ioc, i) => (
|
||||
<Stack key={i} direction="row" spacing={0.5} alignItems="center">
|
||||
<Chip label={ioc.type} size="small" color="error" variant="outlined" />
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{ioc.value}
|
||||
</Typography>
|
||||
{ioc.context && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
— {ioc.context}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* MITRE techniques */}
|
||||
{result.mitre_techniques.length > 0 && (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>MITRE ATT&CK</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{result.mitre_techniques.map((t, i) => (
|
||||
<Chip key={i} label={t} size="small" color="info" variant="outlined"
|
||||
sx={{ fontSize: 11 }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Recommended actions */}
|
||||
{result.recommended_actions.length > 0 && (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Recommended Actions</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{result.recommended_actions.map((a, i) => (
|
||||
<Typography key={i} variant="body2">
|
||||
{i + 1}. {a}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!result && !loading && (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<PsychologyIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Select a dataset and click Analyze
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Uses Wile (70B) or Roadrunner for AI-powered threat analysis of your forensic data.
|
||||
No external API keys required.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
409
frontend/src/components/CaseManager.tsx
Normal file
409
frontend/src/components/CaseManager.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* CaseManager — case management with Kanban task board, TLP/PAP badges,
|
||||
* activity timeline, MITRE technique tags, and IOC lists.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Chip, Button, TextField,
|
||||
FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
||||
Alert, IconButton, Dialog, DialogTitle, DialogContent,
|
||||
DialogActions, Divider, Tooltip, Card, CardContent, CardActions,
|
||||
Grid, Badge,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
cases, type CaseData, type CaseTaskData, type ActivityLogEntry,
|
||||
} from '../api/client';
|
||||
|
||||
const STATUSES = ['open', 'in-progress', 'resolved', 'closed'];
|
||||
const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
|
||||
const TLPS = ['white', 'green', 'amber', 'red'];
|
||||
const PRIORITIES = [
|
||||
{ value: 1, label: 'P1 — Urgent' },
|
||||
{ value: 2, label: 'P2 — High' },
|
||||
{ value: 3, label: 'P3 — Medium' },
|
||||
{ value: 4, label: 'P4 — Low' },
|
||||
];
|
||||
|
||||
const SEV_COLORS: Record<string, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
info: 'info', low: 'success', medium: 'warning', high: 'error', critical: 'error',
|
||||
};
|
||||
const TLP_COLORS: Record<string, string> = {
|
||||
white: '#fff', green: '#22c55e', amber: '#f59e0b', red: '#ef4444',
|
||||
};
|
||||
const TASK_COLUMNS = ['todo', 'in-progress', 'done'];
|
||||
const TASK_COL_LABELS: Record<string, string> = { 'todo': 'To Do', 'in-progress': 'In Progress', 'done': 'Done' };
|
||||
|
||||
export default function CaseManager() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [caseList, setCaseList] = useState<CaseData[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [selectedCase, setSelectedCase] = useState<CaseData | null>(null);
|
||||
const [activityLog, setActivityLog] = useState<ActivityLogEntry[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [taskDlgOpen, setTaskDlgOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', severity: 'medium', tlp: 'amber', pap: 'amber', priority: 2,
|
||||
assignee: '', tags: '',
|
||||
});
|
||||
const [taskForm, setTaskForm] = useState({ title: '', description: '', assignee: '' });
|
||||
|
||||
const loadCases = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await cases.list({ status: filterStatus || undefined, limit: 100 });
|
||||
setCaseList(r.cases);
|
||||
setTotal(r.total);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setLoading(false);
|
||||
}, [filterStatus, enqueueSnackbar]);
|
||||
|
||||
useEffect(() => { loadCases(); }, [loadCases]);
|
||||
|
||||
const loadCaseDetail = async (id: string) => {
|
||||
try {
|
||||
const [c, a] = await Promise.all([cases.get(id), cases.activity(id)]);
|
||||
setSelectedCase(c);
|
||||
setActivityLog(a.logs);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const tags = form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
await cases.create({
|
||||
title: form.title,
|
||||
description: form.description || undefined,
|
||||
severity: form.severity,
|
||||
tlp: form.tlp,
|
||||
pap: form.pap,
|
||||
priority: form.priority,
|
||||
assignee: form.assignee || undefined,
|
||||
tags,
|
||||
} as any);
|
||||
enqueueSnackbar('Case created', { variant: 'success' });
|
||||
setCreateOpen(false);
|
||||
loadCases();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleStatusChange = async (caseId: string, newStatus: string) => {
|
||||
try {
|
||||
await cases.update(caseId, { status: newStatus } as any);
|
||||
enqueueSnackbar(`Status → ${newStatus}`, { variant: 'info' });
|
||||
if (selectedCase?.id === caseId) loadCaseDetail(caseId);
|
||||
loadCases();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Delete this case?')) return;
|
||||
try {
|
||||
await cases.delete(id);
|
||||
enqueueSnackbar('Case deleted', { variant: 'info' });
|
||||
if (selectedCase?.id === id) setSelectedCase(null);
|
||||
loadCases();
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleAddTask = async () => {
|
||||
if (!selectedCase) return;
|
||||
try {
|
||||
await cases.addTask(selectedCase.id, {
|
||||
title: taskForm.title,
|
||||
description: taskForm.description || undefined,
|
||||
assignee: taskForm.assignee || undefined,
|
||||
});
|
||||
enqueueSnackbar('Task added', { variant: 'success' });
|
||||
setTaskDlgOpen(false);
|
||||
loadCaseDetail(selectedCase.id);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleTaskStatusChange = async (taskId: string, newStatus: string) => {
|
||||
if (!selectedCase) return;
|
||||
try {
|
||||
await cases.updateTask(selectedCase.id, taskId, { status: newStatus } as any);
|
||||
loadCaseDetail(selectedCase.id);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
if (!selectedCase) return;
|
||||
try {
|
||||
await cases.deleteTask(selectedCase.id, taskId);
|
||||
loadCaseDetail(selectedCase.id);
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ p: 4 }}><CircularProgress /></Box>;
|
||||
|
||||
// ── Case Detail View ───────────────────────────────────────────────
|
||||
|
||||
if (selectedCase) {
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
|
||||
<IconButton onClick={() => setSelectedCase(null)}><ArrowBackIcon /></IconButton>
|
||||
<Typography variant="h5">{selectedCase.title}</Typography>
|
||||
<Chip label={selectedCase.severity} size="small" color={SEV_COLORS[selectedCase.severity] || 'default'} />
|
||||
<Chip label={`TLP:${selectedCase.tlp.toUpperCase()}`} size="small"
|
||||
sx={{ background: TLP_COLORS[selectedCase.tlp], color: selectedCase.tlp === 'white' ? '#000' : '#fff' }} />
|
||||
<Chip label={selectedCase.status} size="small" variant="outlined" />
|
||||
</Stack>
|
||||
|
||||
{selectedCase.description && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">{selectedCase.description}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Status control */}
|
||||
<Paper sx={{ p: 1.5, mb: 2 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="caption" fontWeight={700}>Status:</Typography>
|
||||
{STATUSES.map(s => (
|
||||
<Button key={s} size="small"
|
||||
variant={selectedCase.status === s ? 'contained' : 'outlined'}
|
||||
onClick={() => handleStatusChange(selectedCase.id, s)}>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{selectedCase.assignee && (
|
||||
<Chip label={`Assigned: ${selectedCase.assignee}`} size="small" variant="outlined" />
|
||||
)}
|
||||
<Chip label={`P${selectedCase.priority}`} size="small" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Tags + MITRE */}
|
||||
{(selectedCase.tags.length > 0 || selectedCase.mitre_techniques.length > 0) && (
|
||||
<Paper sx={{ p: 1.5, mb: 2 }}>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{selectedCase.tags.map(t => <Chip key={t} label={t} size="small" variant="outlined" />)}
|
||||
{selectedCase.mitre_techniques.map(t => (
|
||||
<Chip key={t} label={t} size="small" color="error" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Kanban Task Board */}
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Typography variant="h6">Tasks</Typography>
|
||||
<IconButton size="small" onClick={() => {
|
||||
setTaskForm({ title: '', description: '', assignee: '' });
|
||||
setTaskDlgOpen(true);
|
||||
}}><AddIcon /></IconButton>
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
{TASK_COLUMNS.map(col => {
|
||||
const colTasks = (selectedCase.tasks || []).filter(t => t.status === col);
|
||||
return (
|
||||
<Grid key={col} size={{ xs: 12, md: 4 }}>
|
||||
<Paper sx={{ p: 1, minHeight: 200, background: 'rgba(255,255,255,0.02)' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, textAlign: 'center' }}>
|
||||
{TASK_COL_LABELS[col]}
|
||||
<Badge badgeContent={colTasks.length} color="primary" sx={{ ml: 1 }} />
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{colTasks.map(task => (
|
||||
<Card key={task.id} variant="outlined" sx={{ position: 'relative' }}>
|
||||
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="body2" fontWeight={600}>{task.title}</Typography>
|
||||
{task.description && (
|
||||
<Typography variant="caption" color="text.secondary">{task.description}</Typography>
|
||||
)}
|
||||
{task.assignee && (
|
||||
<Chip label={task.assignee} size="small" sx={{ mt: 0.5 }} />
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions sx={{ py: 0, px: 1 }}>
|
||||
{col !== 'todo' && (
|
||||
<Button size="small" onClick={() =>
|
||||
handleTaskStatusChange(task.id, col === 'done' ? 'in-progress' : 'todo')}>
|
||||
←
|
||||
</Button>
|
||||
)}
|
||||
{col !== 'done' && (
|
||||
<Button size="small" onClick={() =>
|
||||
handleTaskStatusChange(task.id, col === 'todo' ? 'in-progress' : 'done')}>
|
||||
→
|
||||
</Button>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton size="small" color="error" onClick={() => handleDeleteTask(task.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{/* Activity Log */}
|
||||
{activityLog.length > 0 && (
|
||||
<>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Activity</Typography>
|
||||
<Paper sx={{ p: 1.5, maxHeight: 200, overflow: 'auto' }}>
|
||||
{activityLog.map(l => (
|
||||
<Stack key={l.id} direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>
|
||||
{l.created_at ? new Date(l.created_at).toLocaleString() : ''}
|
||||
</Typography>
|
||||
<Chip label={l.action} size="small" variant="outlined" />
|
||||
{l.details && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{JSON.stringify(l.details).slice(0, 100)}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Task create dialog */}
|
||||
<Dialog open={taskDlgOpen} onClose={() => setTaskDlgOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>New Task</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Title" fullWidth value={taskForm.title}
|
||||
onChange={e => setTaskForm(f => ({ ...f, title: e.target.value }))} />
|
||||
<TextField label="Description" fullWidth multiline rows={2} value={taskForm.description}
|
||||
onChange={e => setTaskForm(f => ({ ...f, description: e.target.value }))} />
|
||||
<TextField label="Assignee" fullWidth value={taskForm.assignee}
|
||||
onChange={e => setTaskForm(f => ({ ...f, assignee: e.target.value }))} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTaskDlgOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleAddTask} disabled={!taskForm.title.trim()}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Case List View ─────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Cases ({total})</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />}
|
||||
onClick={() => {
|
||||
setForm({ title: '', description: '', severity: 'medium', tlp: 'amber', pap: 'amber', priority: 2, assignee: '', tags: '' });
|
||||
setCreateOpen(true);
|
||||
}}>New Case</Button>
|
||||
</Stack>
|
||||
|
||||
<Paper sx={{ p: 1.5, mb: 2 }}>
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<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>
|
||||
|
||||
<Stack spacing={1}>
|
||||
{caseList.map(c => (
|
||||
<Paper key={c.id} sx={{ p: 1.5, cursor: 'pointer', '&:hover': { borderColor: 'primary.main' } }}
|
||||
variant="outlined"
|
||||
onClick={() => loadCaseDetail(c.id)}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="body1" fontWeight={600} sx={{ flex: 1 }}>{c.title}</Typography>
|
||||
<Chip label={c.severity} size="small" color={SEV_COLORS[c.severity] || 'default'} />
|
||||
<Chip label={`TLP:${c.tlp.toUpperCase()}`} size="small"
|
||||
sx={{ background: TLP_COLORS[c.tlp], color: c.tlp === 'white' ? '#000' : '#fff', fontSize: '0.65rem' }} />
|
||||
<Chip label={c.status} size="small" variant="outlined" />
|
||||
<Chip label={`P${c.priority}`} size="small" />
|
||||
{c.assignee && <Chip label={c.assignee} size="small" variant="outlined" />}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{c.tasks.length} tasks
|
||||
</Typography>
|
||||
<IconButton size="small" color="error" onClick={e => { e.stopPropagation(); handleDelete(c.id); }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
{caseList.length === 0 && (
|
||||
<Alert severity="info">No cases found. Create one to get started.</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Create case dialog */}
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>New Case</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 }))} />
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth size="small">
|
||||
<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 size="small">
|
||||
<InputLabel>Priority</InputLabel>
|
||||
<Select label="Priority" value={form.priority}
|
||||
onChange={e => setForm(f => ({ ...f, priority: Number(e.target.value) }))}>
|
||||
{PRIORITIES.map(p => <MenuItem key={p.value} value={p.value}>{p.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>TLP</InputLabel>
|
||||
<Select label="TLP" value={form.tlp}
|
||||
onChange={e => setForm(f => ({ ...f, tlp: e.target.value }))}>
|
||||
{TLPS.map(t => <MenuItem key={t} value={t}>{t.toUpperCase()}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>PAP</InputLabel>
|
||||
<Select label="PAP" value={form.pap}
|
||||
onChange={e => setForm(f => ({ ...f, pap: e.target.value }))}>
|
||||
{TLPS.map(t => <MenuItem key={t} value={t}>{t.toUpperCase()}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<TextField label="Assignee" fullWidth value={form.assignee}
|
||||
onChange={e => setForm(f => ({ ...f, assignee: e.target.value }))} />
|
||||
<TextField label="Tags (comma-separated)" fullWidth value={form.tags}
|
||||
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={!form.title.trim()}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
278
frontend/src/components/ContextMenu.tsx
Normal file
278
frontend/src/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* ContextMenu — right-click radial menu for analyst actions on cells / rows.
|
||||
* Re-usable across DatasetViewer, NetworkMap, ProcessTree, StorylineGraph, etc.
|
||||
*
|
||||
* Actions:
|
||||
* - Annotate (open annotation dialog)
|
||||
* - Copy value
|
||||
* - Search for value (navigates to Search page)
|
||||
* - Enrich IOC (stub for future)
|
||||
* - Add to hypothesis
|
||||
* - Mark as suspicious / benign
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Menu, MenuItem, ListItemIcon, ListItemText, Divider,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Button, TextField, FormControl, InputLabel, Select,
|
||||
Stack, Typography, Chip,
|
||||
} from '@mui/material';
|
||||
import BookmarkAddIcon from '@mui/icons-material/BookmarkAdd';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import FlagIcon from '@mui/icons-material/Flag';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import ScienceIcon from '@mui/icons-material/Science';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { annotations } from '../api/client';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ContextTarget {
|
||||
/** The cell / node value the user right-clicked on */
|
||||
value: string;
|
||||
/** The field / column name */
|
||||
field?: string;
|
||||
/** Dataset ID if applicable */
|
||||
datasetId?: string;
|
||||
/** Row index if applicable */
|
||||
rowIndex?: number;
|
||||
/** Extra context for display */
|
||||
extra?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
anchorPosition: { top: number; left: number } | null;
|
||||
target: ContextTarget | null;
|
||||
onClose: () => void;
|
||||
/** Optional callback after an annotation is created */
|
||||
onAnnotated?: () => void;
|
||||
}
|
||||
|
||||
// ── Severity + Tag options ───────────────────────────────────────────
|
||||
|
||||
const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'] as const;
|
||||
const TAGS = ['suspicious', 'benign', 'needs-review', 'false-positive', 'true-positive', 'escalate'] as const;
|
||||
const SEV_COLORS: Record<string, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
info: 'info', low: 'success', medium: 'warning', high: 'error', critical: 'error',
|
||||
};
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export default function ContextMenu({ anchorPosition, target, onClose, onAnnotated }: ContextMenuProps) {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const navigate = useNavigate();
|
||||
const [annOpen, setAnnOpen] = useState(false);
|
||||
const [form, setForm] = useState({ text: '', severity: 'medium', tag: 'suspicious' });
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (target?.value) {
|
||||
navigator.clipboard.writeText(target.value);
|
||||
enqueueSnackbar('Copied to clipboard', { variant: 'info' });
|
||||
}
|
||||
onClose();
|
||||
}, [target, enqueueSnackbar, onClose]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (target?.value) {
|
||||
// Navigate to search page with the value pre-loaded via query param
|
||||
navigate(`/search?q=${encodeURIComponent(target.value)}`);
|
||||
}
|
||||
onClose();
|
||||
}, [target, navigate, onClose]);
|
||||
|
||||
const handleQuickAnnotate = useCallback(async (tag: string, severity: string) => {
|
||||
if (!target) return;
|
||||
try {
|
||||
const text = target.field
|
||||
? `${tag}: ${target.field}="${target.value}"`
|
||||
: `${tag}: "${target.value}"`;
|
||||
await annotations.create({
|
||||
text,
|
||||
severity,
|
||||
tag,
|
||||
dataset_id: target.datasetId,
|
||||
row_id: target.rowIndex,
|
||||
});
|
||||
enqueueSnackbar(`Marked as ${tag}`, { variant: 'success' });
|
||||
onAnnotated?.();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
onClose();
|
||||
}, [target, enqueueSnackbar, onClose, onAnnotated]);
|
||||
|
||||
const handleAnnotateOpen = () => {
|
||||
setForm({
|
||||
text: target?.field
|
||||
? `${target.field}="${target.value}"`
|
||||
: target?.value || '',
|
||||
severity: 'medium',
|
||||
tag: 'suspicious',
|
||||
});
|
||||
setAnnOpen(true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAnnotateSubmit = async () => {
|
||||
if (!target) return;
|
||||
try {
|
||||
await annotations.create({
|
||||
text: form.text,
|
||||
severity: form.severity,
|
||||
tag: form.tag,
|
||||
dataset_id: target.datasetId,
|
||||
row_id: target.rowIndex,
|
||||
});
|
||||
enqueueSnackbar('Annotation created', { variant: 'success' });
|
||||
onAnnotated?.();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
setAnnOpen(false);
|
||||
};
|
||||
|
||||
const handleHypothesis = () => {
|
||||
// Navigate to hypotheses page — user can create there
|
||||
navigate('/hypotheses');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Context menu */}
|
||||
<Menu
|
||||
open={!!anchorPosition}
|
||||
onClose={onClose}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={anchorPosition ?? undefined}
|
||||
slotProps={{ paper: { sx: { minWidth: 220 } } }}
|
||||
>
|
||||
{target && (
|
||||
<MenuItem disabled sx={{ opacity: '1 !important', py: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: 260 }}>
|
||||
{target.field ? `${target.field}: ` : ''}
|
||||
<strong>{String(target.value).slice(0, 60)}</strong>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={handleCopy}>
|
||||
<ListItemIcon><ContentCopyIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Copy value</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleSearch}>
|
||||
<ListItemIcon><SearchIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Search for this</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={handleAnnotateOpen}>
|
||||
<ListItemIcon><BookmarkAddIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Annotate…</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => handleQuickAnnotate('suspicious', 'high')}>
|
||||
<ListItemIcon><WarningAmberIcon fontSize="small" color="warning" /></ListItemIcon>
|
||||
<ListItemText>Mark suspicious</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => handleQuickAnnotate('benign', 'info')}>
|
||||
<ListItemIcon><CheckCircleOutlineIcon fontSize="small" color="success" /></ListItemIcon>
|
||||
<ListItemText>Mark benign</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => handleQuickAnnotate('escalate', 'critical')}>
|
||||
<ListItemIcon><FlagIcon fontSize="small" color="error" /></ListItemIcon>
|
||||
<ListItemText>Escalate</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={handleHypothesis}>
|
||||
<ListItemIcon><ScienceIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Add to hypothesis</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Full annotation dialog */}
|
||||
<Dialog open={annOpen} onClose={() => setAnnOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Annotate
|
||||
{target?.field && (
|
||||
<Chip label={target.field} size="small" sx={{ ml: 1 }} />
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Annotation text" fullWidth multiline rows={3}
|
||||
value={form.text}
|
||||
onChange={e => setForm(f => ({ ...f, text: e.target.value }))}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth size="small">
|
||||
<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}>
|
||||
<Chip label={s} size="small" color={SEV_COLORS[s] || 'default'} variant="outlined" sx={{ mr: 1 }} />
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Tag</InputLabel>
|
||||
<Select label="Tag" value={form.tag}
|
||||
onChange={e => setForm(f => ({ ...f, tag: e.target.value }))}>
|
||||
{TAGS.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
{target?.datasetId && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Dataset: {target.datasetId}
|
||||
{target.rowIndex != null && ` · Row: ${target.rowIndex}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAnnOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleAnnotateSubmit} disabled={!form.text.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hook for easy integration ────────────────────────────────────────
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [menuTarget, setMenuTarget] = useState<ContextTarget | null>(null);
|
||||
|
||||
const openMenu = useCallback((e: React.MouseEvent, target: ContextTarget) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuPos({ top: e.clientY, left: e.clientX });
|
||||
setMenuTarget(target);
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuPos(null);
|
||||
setMenuTarget(null);
|
||||
}, []);
|
||||
|
||||
return { menuPos, menuTarget, openMenu, closeMenu };
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
/**
|
||||
<<<<<<< HEAD
|
||||
* Dashboard - overview cards with hunt stats, cluster health, recent activity.
|
||||
* Symmetrical 4-column grid layout, empty-state onboarding, auto-refresh.
|
||||
=======
|
||||
* Dashboard — CrowdScore-style triage overview with risk scoring,
|
||||
* severity breakdown, top riskiest hosts, node health, and recent hunts.
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Grid, Paper, Typography, Chip, CircularProgress,
|
||||
<<<<<<< HEAD
|
||||
Stack, Alert, Button, Divider,
|
||||
=======
|
||||
Stack, Alert, LinearProgress, Divider,
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
} from '@mui/material';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
@@ -14,6 +23,7 @@ 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';
|
||||
<<<<<<< HEAD
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
@@ -34,14 +44,80 @@ function StatCard({ title, value, icon, color }: {
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h4" noWrap>{value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>{title}</Typography>
|
||||
=======
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import ShieldIcon from '@mui/icons-material/Shield';
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
|
||||
Tooltip as RechartsTooltip, Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
hunts, datasets, hypotheses, agent, misc, analysis,
|
||||
type Hunt, type DatasetSummary, type HealthInfo, type RiskSummaryResponse,
|
||||
} from '../api/client';
|
||||
|
||||
/* ── Severity palette ─────────────────────────────────────────────── */
|
||||
const SEV_COLORS: Record<string, string> = {
|
||||
critical: '#ef4444',
|
||||
high: '#f97316',
|
||||
medium: '#eab308',
|
||||
low: '#3b82f6',
|
||||
info: '#6b7280',
|
||||
};
|
||||
|
||||
/* ── CrowdScore gauge component ───────────────────────────────────── */
|
||||
function CrowdScore({ score }: { score: number }) {
|
||||
const color = score >= 75 ? '#ef4444' : score >= 50 ? '#f97316' : score >= 25 ? '#eab308' : '#10b981';
|
||||
const label = score >= 75 ? 'CRITICAL' : score >= 50 ? 'HIGH' : score >= 25 ? 'MODERATE' : 'LOW';
|
||||
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<CircularProgress
|
||||
variant="determinate" value={score} size={140}
|
||||
thickness={6} sx={{ color, '& .MuiCircularProgress-circle': { strokeLinecap: 'round' } }}
|
||||
/>
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color, lineHeight: 1 }}>
|
||||
{score}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color, fontWeight: 600, mt: 0.5 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Overall Threat Score
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stat card ────────────────────────────────────────────────────── */
|
||||
function StatCard({ title, value, icon, color }: { title: string; value: string | number; icon: React.ReactNode; color: string }) {
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Box sx={{ color, fontSize: 36, display: 'flex' }}>{icon}</Box>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600 }}>{value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{title}</Typography>
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Node Status */
|
||||
|
||||
=======
|
||||
/* ── Node status chip ─────────────────────────────────────────────── */
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
function NodeStatus({ label, available }: { label: string; available: boolean }) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
@@ -86,10 +162,16 @@ export default function Dashboard() {
|
||||
const [huntList, setHunts] = useState<Hunt[]>([]);
|
||||
const [datasetList, setDatasets] = useState<DatasetSummary[]>([]);
|
||||
const [hypoCount, setHypoCount] = useState(0);
|
||||
<<<<<<< HEAD
|
||||
const [apiInfo, setApiInfo] = useState<{ name?: string; version?: string; status?: string; service?: string } | null>(null);
|
||||
=======
|
||||
const [apiInfo, setApiInfo] = useState<{ name?: string; version?: string; status?: string } | null>(null);
|
||||
const [risk, setRisk] = useState<RiskSummaryResponse | null>(null);
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
const [error, setError] = useState('');
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
|
||||
<<<<<<< HEAD
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [h, ht, ds, hy, info] = await Promise.all([
|
||||
@@ -111,6 +193,36 @@ export default function Dashboard() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
=======
|
||||
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);
|
||||
|
||||
// Fetch risk for the first active hunt
|
||||
const activeHunt = ht.hunts.find((h: Hunt) => h.status === 'active');
|
||||
if (activeHunt) {
|
||||
const riskData = await analysis.riskSummary(activeHunt.id).catch(() => null);
|
||||
setRisk(riskData);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
@@ -129,6 +241,16 @@ export default function Dashboard() {
|
||||
const totalRows = datasetList.reduce((s, d) => s + d.row_count, 0);
|
||||
const isEmpty = huntList.length === 0 && datasetList.length === 0;
|
||||
|
||||
/* severity pie data */
|
||||
const sevData = risk?.severity_breakdown
|
||||
? Object.entries(risk.severity_breakdown)
|
||||
.filter(([_, v]) => v > 0)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
|
||||
/* top 10 riskiest hosts */
|
||||
const topHosts = risk?.hosts?.slice(0, 10) || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
@@ -141,6 +263,7 @@ export default function Dashboard() {
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<<<<<<< HEAD
|
||||
{/* Stat cards - symmetrical 4-column */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }} columns={12}>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
@@ -254,6 +377,186 @@ export default function Dashboard() {
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
=======
|
||||
{/* Row 1: CrowdScore + stats */}
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Paper sx={{ height: '100%' }}>
|
||||
<CrowdScore score={risk?.overall_score || 0} />
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 9 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<StatCard title="Active Hunts" value={activeHunts} icon={<SearchIcon fontSize="inherit" />} color="#60a5fa" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<StatCard title="Datasets" value={datasetList.length} icon={<StorageIcon fontSize="inherit" />} color="#f472b6" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<StatCard title="Total Rows" value={totalRows.toLocaleString()} icon={<SecurityIcon fontSize="inherit" />} color="#10b981" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<StatCard title="Hypotheses" value={hypoCount} icon={<ScienceIcon fontSize="inherit" />} color="#f59e0b" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Alert signal summary */}
|
||||
{risk && risk.hosts.length > 0 && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<WarningAmberIcon sx={{ color: '#f97316' }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{risk.hosts.filter(h => h.score >= 50).length} high-risk hosts
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{risk.total_events.toLocaleString()} events analyzed
|
||||
</Typography>
|
||||
{risk.hosts.slice(0, 3).flatMap(h => h.signals.slice(0, 2)).filter((v, i, a) => a.indexOf(v) === i).map(s => (
|
||||
<Chip key={s} label={s} size="small" color="warning" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Row 2: Severity pie + Top riskiest hosts */}
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Paper sx={{ p: 2, height: 320 }}>
|
||||
<Typography variant="h6" gutterBottom>Severity Breakdown</Typography>
|
||||
{sevData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={sevData} dataKey="value" nameKey="name" cx="50%" cy="50%"
|
||||
outerRadius={90} innerRadius={45} paddingAngle={2} label={({ name, value }) => `${name}: ${value}`}>
|
||||
{sevData.map((entry) => (
|
||||
<Cell key={entry.name} fill={SEV_COLORS[entry.name] || '#6b7280'} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 250 }}>
|
||||
<Typography color="text.secondary">No data</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Paper sx={{ p: 2, height: 320 }}>
|
||||
<Typography variant="h6" gutterBottom>Top 10 Riskiest Hosts</Typography>
|
||||
{topHosts.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={topHosts} layout="vertical" margin={{ left: 80 }}>
|
||||
<XAxis type="number" domain={[0, 100]} />
|
||||
<YAxis type="category" dataKey="hostname" width={70} tick={{ fontSize: 11 }} />
|
||||
<RechartsTooltip
|
||||
formatter={(value: any, name: any) => [`${value}/100`, 'Risk Score']}
|
||||
labelFormatter={(label: any) => `Host: ${label}`}
|
||||
/>
|
||||
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
|
||||
{topHosts.map((entry, idx) => (
|
||||
<Cell key={idx}
|
||||
fill={entry.score >= 75 ? '#ef4444' : entry.score >= 50 ? '#f97316'
|
||||
: entry.score >= 25 ? '#eab308' : '#10b981'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 250 }}>
|
||||
<Typography color="text.secondary">No risk data — select a hunt with datasets</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Row 3: Node health + Recent hunts */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<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>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{apiInfo ? `${apiInfo.name || 'ThreatHunt'} v${apiInfo.version || '?'}` : 'API unreachable'} — {apiInfo?.status ?? 'unknown'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" gutterBottom>Recent Hunts</Typography>
|
||||
{huntList.length > 0 ? (
|
||||
<Stack spacing={1}>
|
||||
{huntList.slice(0, 6).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, flex: 1 }}>{h.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{h.dataset_count}ds · {h.hypothesis_count}hyp
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary">No hunts yet</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Risk signal details for top hosts */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<ShieldIcon sx={{ color: '#60a5fa' }} />
|
||||
<Typography variant="h6">Risk Signals</Typography>
|
||||
</Stack>
|
||||
{topHosts.filter(h => h.signals.length > 0).slice(0, 5).map(h => (
|
||||
<Box key={h.hostname} sx={{ mb: 1.5 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 100 }}>
|
||||
{h.hostname}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate" value={h.score}
|
||||
sx={{ flex: 1, height: 6, borderRadius: 3,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: h.score >= 75 ? '#ef4444' : h.score >= 50 ? '#f97316'
|
||||
: h.score >= 25 ? '#eab308' : '#10b981' } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, minWidth: 30 }}>
|
||||
{h.score}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }} flexWrap="wrap">
|
||||
{h.signals.map(s => (
|
||||
<Chip key={s} label={s} size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20 }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
{topHosts.filter(h => h.signals.length > 0).length === 0 && (
|
||||
<Typography color="text.secondary" variant="body2">No risk signals detected</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
import ContextMenu, { useContextMenu, type ContextTarget } from './ContextMenu';
|
||||
|
||||
export default function DatasetViewer() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
@@ -24,6 +25,7 @@ export default function DatasetViewer() {
|
||||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({ page: 0, pageSize: 50 });
|
||||
const [rowLoading, setRowLoading] = useState(false);
|
||||
const [enriching, setEnriching] = useState(false);
|
||||
const { menuPos, menuTarget, openMenu, closeMenu } = useContextMenu();
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -169,7 +171,19 @@ export default function DatasetViewer() {
|
||||
|
||||
{/* Data grid */}
|
||||
{selected ? (
|
||||
<Paper sx={{ height: 520 }}>
|
||||
<Paper
|
||||
sx={{ height: 520 }}
|
||||
onContextMenu={e => {
|
||||
// Find cell value from the DataGrid event target
|
||||
const cell = (e.target as HTMLElement).closest('.MuiDataGrid-cell');
|
||||
if (!cell) return;
|
||||
const field = cell.getAttribute('data-field') || '';
|
||||
const value = cell.textContent || '';
|
||||
const rowEl = cell.closest('.MuiDataGrid-row');
|
||||
const rowIdx = rowEl ? parseInt(rowEl.getAttribute('data-rowindex') || '0', 10) : undefined;
|
||||
openMenu(e, { value, field, datasetId: selected.id, rowIndex: rowIdx });
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
@@ -183,7 +197,7 @@ export default function DatasetViewer() {
|
||||
density="compact"
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell': { fontSize: '0.8rem' },
|
||||
'& .MuiDataGrid-cell': { fontSize: '0.8rem', cursor: 'context-menu' },
|
||||
'& .MuiDataGrid-columnHeader': { fontWeight: 700 },
|
||||
// IOC column highlights
|
||||
...Object.fromEntries(
|
||||
@@ -201,6 +215,9 @@ export default function DatasetViewer() {
|
||||
) : (
|
||||
<Alert severity="info">Upload a CSV to get started.</Alert>
|
||||
)}
|
||||
|
||||
{/* Right-click context menu */}
|
||||
<ContextMenu anchorPosition={menuPos} target={menuTarget} onClose={closeMenu} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
372
frontend/src/components/InvestigationNotebook.tsx
Normal file
372
frontend/src/components/InvestigationNotebook.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* InvestigationNotebook — Cell-based investigation documentation.
|
||||
*
|
||||
* Features:
|
||||
* - Create/list/open notebooks
|
||||
* - Markdown + query cells with add/edit/delete
|
||||
* - Real-time save on cell changes
|
||||
* - Link notebooks to hunts/cases
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, IconButton, TextField, Chip,
|
||||
Stack, Divider, Select, MenuItem, FormControl, InputLabel,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Card, CardContent, CardActions, Tooltip, ToggleButton, ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {
|
||||
notebooks, hunts,
|
||||
NotebookData, NotebookCell, Hunt,
|
||||
} from '../api/client';
|
||||
|
||||
const CELL_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
markdown: <NotesIcon fontSize="small" />,
|
||||
query: <SearchIcon fontSize="small" />,
|
||||
code: <CodeIcon fontSize="small" />,
|
||||
};
|
||||
|
||||
export default function InvestigationNotebook() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
// List view state
|
||||
const [nbList, setNbList] = useState<NotebookData[]>([]);
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [huntFilter, setHuntFilter] = useState('');
|
||||
|
||||
// Detail view state
|
||||
const [activeNb, setActiveNb] = useState<NotebookData | null>(null);
|
||||
const [editingCell, setEditingCell] = useState<string | null>(null);
|
||||
const [cellSource, setCellSource] = useState('');
|
||||
|
||||
// Create dialog
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newHunt, setNewHunt] = useState('');
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
try {
|
||||
const opts: any = {};
|
||||
if (huntFilter) opts.hunt_id = huntFilter;
|
||||
const res = await notebooks.list(opts);
|
||||
setNbList(res.notebooks);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
}, [huntFilter, enqueueSnackbar]);
|
||||
|
||||
useEffect(() => {
|
||||
hunts.list().then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadList(); }, [loadList]);
|
||||
|
||||
// ── Create ─────────────────────────────────────────────────────────
|
||||
|
||||
const createNotebook = async () => {
|
||||
if (!newTitle) return;
|
||||
try {
|
||||
const nb = await notebooks.create({
|
||||
title: newTitle,
|
||||
description: newDesc || undefined,
|
||||
hunt_id: newHunt || undefined,
|
||||
});
|
||||
enqueueSnackbar('Notebook created', { variant: 'success' });
|
||||
setCreateOpen(false);
|
||||
setNewTitle(''); setNewDesc(''); setNewHunt('');
|
||||
setActiveNb(nb);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Open ───────────────────────────────────────────────────────────
|
||||
|
||||
const openNotebook = async (id: string) => {
|
||||
try {
|
||||
const nb = await notebooks.get(id);
|
||||
setActiveNb(nb);
|
||||
setEditingCell(null);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const refreshNotebook = async () => {
|
||||
if (!activeNb) return;
|
||||
try {
|
||||
const nb = await notebooks.get(activeNb.id);
|
||||
setActiveNb(nb);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// ── Cell operations ────────────────────────────────────────────────
|
||||
|
||||
const addCell = async (type: string) => {
|
||||
if (!activeNb) return;
|
||||
const cellId = `cell-${Date.now()}`;
|
||||
const placeholder = type === 'markdown'
|
||||
? '## New section\n\nWrite your notes here...'
|
||||
: type === 'query'
|
||||
? '# Search query\nprocess_name:powershell.exe'
|
||||
: '# Code cell\n';
|
||||
try {
|
||||
const nb = await notebooks.upsertCell(activeNb.id, {
|
||||
cell_id: cellId,
|
||||
cell_type: type,
|
||||
source: placeholder,
|
||||
});
|
||||
setActiveNb(nb);
|
||||
setEditingCell(cellId);
|
||||
setCellSource(placeholder);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (cellId: string) => {
|
||||
if (!activeNb) return;
|
||||
try {
|
||||
const nb = await notebooks.upsertCell(activeNb.id, {
|
||||
cell_id: cellId,
|
||||
source: cellSource,
|
||||
});
|
||||
setActiveNb(nb);
|
||||
setEditingCell(null);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCell = async (cellId: string) => {
|
||||
if (!activeNb) return;
|
||||
try {
|
||||
await notebooks.deleteCell(activeNb.id, cellId);
|
||||
refreshNotebook();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const moveCell = async (cellId: string, direction: 'up' | 'down') => {
|
||||
if (!activeNb) return;
|
||||
const cells = [...activeNb.cells];
|
||||
const idx = cells.findIndex(c => c.id === cellId);
|
||||
if (idx < 0) return;
|
||||
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (targetIdx < 0 || targetIdx >= cells.length) return;
|
||||
[cells[idx], cells[targetIdx]] = [cells[targetIdx], cells[idx]];
|
||||
try {
|
||||
const nb = await notebooks.update(activeNb.id, { cells });
|
||||
setActiveNb(nb);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotebook = async (id: string) => {
|
||||
try {
|
||||
await notebooks.delete(id);
|
||||
enqueueSnackbar('Notebook deleted', { variant: 'success' });
|
||||
if (activeNb?.id === id) setActiveNb(null);
|
||||
loadList();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Cell renderer ──────────────────────────────────────────────────
|
||||
|
||||
const renderCell = (cell: NotebookCell, index: number) => {
|
||||
const isEditing = editingCell === cell.id;
|
||||
return (
|
||||
<Paper key={cell.id} variant="outlined" sx={{ mb: 1, position: 'relative' }}>
|
||||
{/* Cell header */}
|
||||
<Stack direction="row" alignItems="center" sx={{ px: 1, py: 0.5, bgcolor: 'action.hover' }}>
|
||||
{CELL_TYPE_ICONS[cell.cell_type] || <NotesIcon fontSize="small" />}
|
||||
<Typography variant="caption" sx={{ ml: 0.5, flexGrow: 1 }}>
|
||||
{cell.cell_type} • #{index + 1}
|
||||
</Typography>
|
||||
<Tooltip title="Move up"><IconButton size="small" onClick={() => moveCell(cell.id, 'up')} disabled={index === 0}><ArrowUpwardIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Move down"><IconButton size="small" onClick={() => moveCell(cell.id, 'down')} disabled={index === (activeNb?.cells.length || 0) - 1}><ArrowDownwardIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{!isEditing && (
|
||||
<Tooltip title="Edit"><IconButton size="small" onClick={() => { setEditingCell(cell.id); setCellSource(cell.source); }}><EditIcon fontSize="small" /></IconButton></Tooltip>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Tooltip title="Save"><IconButton size="small" color="primary" onClick={() => saveCell(cell.id)}><SaveIcon fontSize="small" /></IconButton></Tooltip>
|
||||
)}
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => deleteCell(cell.id)}><DeleteIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</Stack>
|
||||
<Divider />
|
||||
|
||||
{/* Cell body */}
|
||||
<Box sx={{ p: 2 }}>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
multiline fullWidth
|
||||
minRows={4}
|
||||
value={cellSource}
|
||||
onChange={e => setCellSource(e.target.value)}
|
||||
onKeyDown={e => { if (e.ctrlKey && e.key === 's') { e.preventDefault(); saveCell(cell.id); } }}
|
||||
placeholder="Type here... (Ctrl+S to save)"
|
||||
sx={{ fontFamily: cell.cell_type !== 'markdown' ? 'monospace' : undefined }}
|
||||
/>
|
||||
) : cell.cell_type === 'markdown' ? (
|
||||
<Box sx={{ '& h1,& h2,& h3': { mt: 1, mb: 0.5 }, '& p': { mb: 1 }, '& code': { bgcolor: 'action.hover', px: 0.5, borderRadius: 0.5 } }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cell.source}</ReactMarkdown>
|
||||
</Box>
|
||||
) : (
|
||||
<pre style={{ margin: 0, fontFamily: 'monospace', fontSize: '0.85rem', whiteSpace: 'pre-wrap', color: '#e0e0e0', backgroundColor: '#1e1e1e', padding: '12px', borderRadius: '4px' }}>
|
||||
{cell.source}
|
||||
</pre>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Cell output */}
|
||||
{cell.output && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1, bgcolor: 'grey.900' }}>
|
||||
<Typography variant="caption" color="text.secondary">Output:</Typography>
|
||||
<pre style={{ margin: 0, fontSize: '0.8rem', whiteSpace: 'pre-wrap' }}>{cell.output}</pre>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Detail view ────────────────────────────────────────────────────
|
||||
|
||||
if (activeNb) {
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => { setActiveNb(null); loadList(); }}>
|
||||
Back
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ flexGrow: 1 }}>{activeNb.title}</Typography>
|
||||
<Chip label={`${activeNb.cells.length} cells`} size="small" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Updated: {new Date(activeNb.updated_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{activeNb.description && (
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>{activeNb.description}</Typography>
|
||||
)}
|
||||
|
||||
{/* Cells */}
|
||||
{activeNb.cells.map((cell, i) => renderCell(cell, i))}
|
||||
|
||||
{/* Add cell toolbar */}
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, textAlign: 'center' }}>
|
||||
<Stack direction="row" spacing={1} justifyContent="center">
|
||||
<Button size="small" startIcon={<NotesIcon />} onClick={() => addCell('markdown')}>
|
||||
+ Markdown
|
||||
</Button>
|
||||
<Button size="small" startIcon={<SearchIcon />} onClick={() => addCell('query')}>
|
||||
+ Query
|
||||
</Button>
|
||||
<Button size="small" startIcon={<CodeIcon />} onClick={() => addCell('code')}>
|
||||
+ Code
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List view ──────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h5">Investigation Notebooks</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New Notebook
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2} mb={2}>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Filter by Hunt</InputLabel>
|
||||
<Select value={huntFilter} label="Filter by Hunt" onChange={e => setHuntFilter(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 2 }}>
|
||||
{nbList.map(nb => (
|
||||
<Card key={nb.id} variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>{nb.title}</Typography>
|
||||
{nb.description && (
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>{nb.description}</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip label={`${nb.cell_count} cells`} size="small" />
|
||||
{nb.tags?.map((t, i) => <Chip key={i} label={t} size="small" variant="outlined" />)}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
|
||||
Updated: {new Date(nb.updated_at).toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small" onClick={() => openNotebook(nb.id)}>Open</Button>
|
||||
<IconButton size="small" color="error" onClick={() => deleteNotebook(nb.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{nbList.length === 0 && (
|
||||
<Typography color="text.secondary" textAlign="center" py={6}>
|
||||
No notebooks yet. Create one to start documenting your investigation.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create Notebook</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} mt={1}>
|
||||
<TextField label="Title" fullWidth required value={newTitle} onChange={e => setNewTitle(e.target.value)} />
|
||||
<TextField label="Description" fullWidth multiline rows={2} value={newDesc} onChange={e => setNewDesc(e.target.value)} />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Linked Hunt</InputLabel>
|
||||
<Select value={newHunt} label="Linked Hunt" onChange={e => setNewHunt(e.target.value)}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={createNotebook} disabled={!newTitle}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
273
frontend/src/components/KnowledgeGraph.tsx
Normal file
273
frontend/src/components/KnowledgeGraph.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* KnowledgeGraph — entity-to-technique knowledge graph visualization
|
||||
* using Cytoscape.js with cola (force-directed) layout.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, FormControl, InputLabel, Select,
|
||||
MenuItem, CircularProgress, Alert, Chip, ToggleButton,
|
||||
ToggleButtonGroup, IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
import FitScreenIcon from '@mui/icons-material/FitScreen';
|
||||
import cytoscape from 'cytoscape';
|
||||
import dagre from 'cytoscape-dagre';
|
||||
import cola from 'cytoscape-cola';
|
||||
import {
|
||||
hunts, datasets, analysis,
|
||||
type HuntOut, type DatasetSummary, type KnowledgeGraphResponse,
|
||||
} from '../api/client';
|
||||
|
||||
cytoscape.use(dagre);
|
||||
cytoscape.use(cola);
|
||||
|
||||
const CY_STYLE: cytoscape.StylesheetStyle[] = [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
label: 'data(label)',
|
||||
'font-size': 9,
|
||||
'text-wrap': 'wrap',
|
||||
'text-max-width': '80px',
|
||||
color: '#ddd',
|
||||
'text-outline-color': '#111',
|
||||
'text-outline-width': 1,
|
||||
'background-color': 'data(color)',
|
||||
shape: 'data(shape)' as any,
|
||||
width: 30,
|
||||
height: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node[type="technique"]',
|
||||
style: {
|
||||
width: 40,
|
||||
height: 26,
|
||||
'font-size': 7,
|
||||
'background-color': '#ef4444',
|
||||
shape: 'round-tag' as any,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 'mapData(weight, 1, 20, 1, 4)',
|
||||
'line-color': 'rgba(150,150,150,0.4)',
|
||||
'curve-style': 'bezier',
|
||||
'target-arrow-shape': 'none',
|
||||
label: 'data(label)',
|
||||
'font-size': 7,
|
||||
color: '#888',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge[weight > 3]',
|
||||
style: {
|
||||
'line-color': 'rgba(239,68,68,0.4)',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ':selected',
|
||||
style: {
|
||||
'border-width': 3,
|
||||
'border-color': '#f59e0b',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function KnowledgeGraph() {
|
||||
const cyRef = useRef<cytoscape.Core | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [huntList, setHuntList] = useState<HuntOut[]>([]);
|
||||
const [dsList, setDsList] = useState<DatasetSummary[]>([]);
|
||||
const [activeHunt, setActiveHunt] = useState('');
|
||||
const [activeDs, setActiveDs] = useState('');
|
||||
const [data, setData] = useState<KnowledgeGraphResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [layout, setLayout] = useState<'cola' | 'dagre'>('cola');
|
||||
const [selectedNode, setSelectedNode] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hunts.list(0, 200).then(r => {
|
||||
setHuntList(r.hunts);
|
||||
if (r.hunts.length > 0) setActiveHunt(r.hunts[0].id);
|
||||
}).catch(() => {});
|
||||
datasets.list(0, 200).then(r => setDsList(r.datasets)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!activeDs && !activeHunt) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const r = await analysis.knowledgeGraph({
|
||||
dataset_id: activeDs || undefined,
|
||||
hunt_id: activeHunt || undefined,
|
||||
});
|
||||
setData(r);
|
||||
} catch (e: any) { setError(e.message); }
|
||||
setLoading(false);
|
||||
}, [activeDs, activeHunt]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// Render Cytoscape
|
||||
useEffect(() => {
|
||||
if (!data || !containerRef.current) return;
|
||||
if (data.nodes.length === 0) return;
|
||||
|
||||
if (cyRef.current) cyRef.current.destroy();
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements: { nodes: data.nodes, edges: data.edges },
|
||||
style: CY_STYLE,
|
||||
layout: layout === 'cola'
|
||||
? { name: 'cola', animate: false, nodeSpacing: 20, edgeLength: 120 } as any
|
||||
: { name: 'dagre', rankDir: 'LR', nodeSep: 40, edgeSep: 10, rankSep: 80 } as any,
|
||||
});
|
||||
|
||||
cy.on('tap', 'node', (e) => {
|
||||
setSelectedNode(e.target.data());
|
||||
});
|
||||
cy.on('tap', (e) => {
|
||||
if (e.target === cy) setSelectedNode(null);
|
||||
});
|
||||
|
||||
cyRef.current = cy;
|
||||
return () => { cy.destroy(); };
|
||||
}, [data, layout]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Knowledge Graph</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={activeHunt}
|
||||
onChange={e => { setActiveHunt(e.target.value); setActiveDs(''); }}>
|
||||
<MenuItem value="">— none —</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select label="Dataset" value={activeDs}
|
||||
onChange={e => setActiveDs(e.target.value)}>
|
||||
<MenuItem value="">— all datasets —</MenuItem>
|
||||
{dsList.map(d => <MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<ToggleButtonGroup size="small" exclusive value={layout}
|
||||
onChange={(_, v) => { if (v) setLayout(v); }}>
|
||||
<ToggleButton value="cola">Force</ToggleButton>
|
||||
<ToggleButton value="dagre">Hierarchy</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{data?.stats && (
|
||||
<>
|
||||
<Chip label={`${data.stats.total_nodes} nodes`} size="small" color="primary" variant="outlined" />
|
||||
<Chip label={`${data.stats.total_edges} edges`} size="small" variant="outlined" />
|
||||
<Chip label={`${data.stats.techniques_found} techniques`} size="small" color="error" variant="outlined" />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{loading && <CircularProgress sx={{ display: 'block', mx: 'auto', my: 4 }} />}
|
||||
|
||||
{!loading && data && data.nodes.length > 0 && (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Paper sx={{ flex: 1, position: 'relative' }}>
|
||||
{/* Controls */}
|
||||
<Stack direction="row" spacing={0.5} sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}>
|
||||
<Tooltip title="Zoom in">
|
||||
<IconButton size="small" onClick={() => cyRef.current?.zoom(cyRef.current.zoom() * 1.2)}>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Zoom out">
|
||||
<IconButton size="small" onClick={() => cyRef.current?.zoom(cyRef.current.zoom() / 1.2)}>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Fit">
|
||||
<IconButton size="small" onClick={() => cyRef.current?.fit()}>
|
||||
<FitScreenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box ref={containerRef} sx={{ width: '100%', height: 520 }} />
|
||||
</Paper>
|
||||
|
||||
{/* Detail panel + Legend */}
|
||||
<Paper sx={{ width: 260, p: 2, maxHeight: 560, overflow: 'auto' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Legend</Typography>
|
||||
<Stack spacing={0.5} sx={{ mb: 2 }}>
|
||||
{[
|
||||
{ type: 'host', color: '#3b82f6', shape: 'rect' },
|
||||
{ type: 'user', color: '#10b981', shape: 'circle' },
|
||||
{ type: 'ip', color: '#8b5cf6', shape: 'diamond' },
|
||||
{ type: 'process', color: '#f59e0b', shape: 'hexagon' },
|
||||
{ type: 'technique', color: '#ef4444', shape: 'tag' },
|
||||
].map(e => (
|
||||
<Stack key={e.type} direction="row" alignItems="center" spacing={1}>
|
||||
<Box sx={{
|
||||
width: 14, height: 14, borderRadius: e.shape === 'circle' ? '50%' : 2,
|
||||
background: e.color,
|
||||
}} />
|
||||
<Typography variant="caption">{e.type}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{data.stats.entity_counts && (
|
||||
<>
|
||||
<Typography variant="subtitle2" gutterBottom>Entity Counts</Typography>
|
||||
<Stack spacing={0.3} sx={{ mb: 2 }}>
|
||||
{Object.entries(data.stats.entity_counts).map(([k, v]) => (
|
||||
<Stack key={k} direction="row" justifyContent="space-between">
|
||||
<Typography variant="caption">{k}</Typography>
|
||||
<Typography variant="caption" fontWeight={700}>{v}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNode && (
|
||||
<>
|
||||
<Typography variant="subtitle2" gutterBottom>Selected</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
<Chip label={selectedNode.type} size="small"
|
||||
sx={{ background: selectedNode.color, color: '#fff' }} />
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
|
||||
{selectedNode.label}
|
||||
</Typography>
|
||||
{selectedNode.tactic && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Tactic: {selectedNode.tactic}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!loading && data && data.nodes.length === 0 && (
|
||||
<Alert severity="info">No entities or techniques found in the selected data.</Alert>
|
||||
)}
|
||||
{!loading && !data && !error && (
|
||||
<Alert severity="info">Select a hunt or dataset to build the knowledge graph.</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
<<<<<<< HEAD
|
||||
* MitreMatrix Interactive MITRE ATT&CK technique heat map.
|
||||
* Aggregates detected techniques from triage, host profiles, and hypotheses.
|
||||
*/
|
||||
@@ -181,9 +182,243 @@ export default function MitreMatrix() {
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
=======
|
||||
* MitreMatrix — ATT&CK heat-map matrix + evidence drill-down.
|
||||
* Shows tactics as columns, techniques as cells with hit-count heat coloring.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, FormControl, InputLabel, Select,
|
||||
MenuItem, CircularProgress, Alert, Chip, Tooltip, Dialog,
|
||||
DialogTitle, DialogContent, DialogActions, Button, Table,
|
||||
TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
hunts, datasets, analysis,
|
||||
type HuntOut, type DatasetSummary, type MitreMapResponse,
|
||||
type MitreTactic, type MitreTechnique,
|
||||
} from '../api/client';
|
||||
|
||||
function heatColor(count: number, max: number): string {
|
||||
if (count === 0) return 'transparent';
|
||||
const ratio = Math.min(count / Math.max(max, 1), 1);
|
||||
if (ratio > 0.7) return 'rgba(239,68,68,0.7)'; // red
|
||||
if (ratio > 0.4) return 'rgba(249,115,22,0.6)'; // orange
|
||||
if (ratio > 0.15) return 'rgba(234,179,8,0.5)'; // yellow
|
||||
return 'rgba(59,130,246,0.4)'; // blue
|
||||
}
|
||||
|
||||
export default function MitreMatrix() {
|
||||
const [huntList, setHuntList] = useState<HuntOut[]>([]);
|
||||
const [dsList, setDsList] = useState<DatasetSummary[]>([]);
|
||||
const [activeHunt, setActiveHunt] = useState('');
|
||||
const [activeDs, setActiveDs] = useState('');
|
||||
const [data, setData] = useState<MitreMapResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedTech, setSelectedTech] = useState<MitreTechnique | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hunts.list(0, 200).then(r => {
|
||||
setHuntList(r.hunts);
|
||||
if (r.hunts.length > 0) setActiveHunt(r.hunts[0].id);
|
||||
}).catch(() => {});
|
||||
datasets.list(0, 200).then(r => setDsList(r.datasets)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!activeDs && !activeHunt) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const r = await analysis.mitreMap({
|
||||
dataset_id: activeDs || undefined,
|
||||
hunt_id: activeHunt || undefined,
|
||||
});
|
||||
setData(r);
|
||||
} catch (e: any) { setError(e.message); }
|
||||
setLoading(false);
|
||||
}, [activeDs, activeHunt]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const maxHits = data ? Math.max(...data.tactics.flatMap(t => t.techniques.map(te => te.count)), 1) : 1;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>MITRE ATT&CK Matrix</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={activeHunt}
|
||||
onChange={e => { setActiveHunt(e.target.value); setActiveDs(''); }}>
|
||||
<MenuItem value="">— none —</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select label="Dataset" value={activeDs}
|
||||
onChange={e => setActiveDs(e.target.value)}>
|
||||
<MenuItem value="">— all datasets —</MenuItem>
|
||||
{dsList.map(d => <MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{data && (
|
||||
<>
|
||||
<Chip label={`${data.coverage.tactics_covered}/${data.coverage.tactics_total} tactics`} size="small" color="info" variant="outlined" />
|
||||
<Chip label={`${data.coverage.techniques_matched} techniques`} size="small" color="warning" variant="outlined" />
|
||||
<Chip label={`${data.coverage.total_evidence} evidence hits`} size="small" color="error" variant="outlined" />
|
||||
<Chip label={`${data.total_rows.toLocaleString()} rows scanned`} size="small" variant="outlined" />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{loading && <CircularProgress sx={{ display: 'block', mx: 'auto', my: 4 }} />}
|
||||
|
||||
{/* ATT&CK Matrix Grid */}
|
||||
{data && !loading && (
|
||||
<Paper sx={{ p: 1, overflow: 'auto' }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ minWidth: data.tactics.length * 140 }}>
|
||||
{data.tactics.map(tactic => (
|
||||
<Box key={tactic.id} sx={{ flex: '0 0 140px', minWidth: 140 }}>
|
||||
{/* Tactic header */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
mb: 0.5,
|
||||
background: tactic.total_hits > 0
|
||||
? 'rgba(99,102,241,0.2)'
|
||||
: 'rgba(100,100,100,0.1)',
|
||||
textAlign: 'center',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: tactic.total_hits > 0 ? 'primary.main' : 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" fontWeight={700} sx={{ fontSize: '0.65rem', lineHeight: 1.2 }}>
|
||||
{tactic.name}
|
||||
</Typography>
|
||||
{tactic.total_hits > 0 && (
|
||||
<Typography variant="caption" display="block" color="warning.main" sx={{ fontSize: '0.6rem' }}>
|
||||
{tactic.total_hits} hits
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Technique cells */}
|
||||
<Stack spacing={0.3}>
|
||||
{tactic.techniques.map(tech => (
|
||||
<Tooltip
|
||||
key={tech.id}
|
||||
title={`${tech.id}: ${tech.name} — ${tech.count} matches`}
|
||||
arrow
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
onClick={() => setSelectedTech(tech)}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
cursor: 'pointer',
|
||||
background: heatColor(tech.count, maxHits),
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
transform: 'scale(1.02)',
|
||||
},
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" fontWeight={600}
|
||||
sx={{ fontSize: '0.6rem', lineHeight: 1.1, display: 'block' }}>
|
||||
{tech.id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary"
|
||||
sx={{ fontSize: '0.55rem', lineHeight: 1.1 }}>
|
||||
{tech.name}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min((tech.count / maxHits) * 100, 100)}
|
||||
sx={{ height: 2, mt: 0.3, borderRadius: 1 }}
|
||||
color={tech.count > maxHits * 0.5 ? 'error' : 'primary'}
|
||||
/>
|
||||
</Paper>
|
||||
</Tooltip>
|
||||
))}
|
||||
{tactic.techniques.length === 0 && (
|
||||
<Typography variant="caption" color="text.disabled"
|
||||
sx={{ fontSize: '0.6rem', textAlign: 'center', py: 1 }}>
|
||||
No matches
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!loading && !data && !error && (
|
||||
<Alert severity="info">Select a hunt or dataset to map to MITRE ATT&CK.</Alert>
|
||||
)}
|
||||
|
||||
{/* Evidence drill-down dialog */}
|
||||
<Dialog open={!!selectedTech} onClose={() => setSelectedTech(null)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{selectedTech?.id}: {selectedTech?.name}
|
||||
<Chip label={`${selectedTech?.count} hits`} size="small" color="warning" sx={{ ml: 1 }} />
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Evidence samples (up to 5 shown)
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Row</TableCell>
|
||||
<TableCell>Field</TableCell>
|
||||
<TableCell>Value</TableCell>
|
||||
<TableCell>Pattern</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{selectedTech?.evidence.map((ev, i) => (
|
||||
<TableRow key={i} hover>
|
||||
<TableCell>{ev.row_index}</TableCell>
|
||||
<TableCell><Chip label={ev.field || '—'} size="small" /></TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300, wordBreak: 'break-all' }}>
|
||||
{ev.value}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" fontFamily="monospace">{ev.pattern}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSelectedTech(null)}>Close</Button>
|
||||
</DialogActions>
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
|
||||
|
||||
=======
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
403
frontend/src/components/NetworkPicture.tsx
Normal file
403
frontend/src/components/NetworkPicture.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* NetworkPicture — deduplicated host inventory view.
|
||||
*
|
||||
* Select a hunt → server scans all datasets, groups by hostname,
|
||||
* returns one row per unique host with IPs, users, OS, MAC, ports.
|
||||
* No duplicates — sets handle dedup. All unique values shown.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Alert, Chip, TextField, Tooltip,
|
||||
LinearProgress, FormControl, InputLabel, Select, MenuItem,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
TableSortLabel, Collapse, IconButton,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
import RouterIcon from '@mui/icons-material/Router';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import {
|
||||
hunts, network,
|
||||
type Hunt, type HostEntry, type PictureSummary,
|
||||
} from '../api/client';
|
||||
|
||||
// ── Colour palette (matches NetworkMap) ──────────────────────────────
|
||||
|
||||
const CHIP_COLORS = {
|
||||
ip: '#3b82f6',
|
||||
user: '#22c55e',
|
||||
os: '#eab308',
|
||||
mac: '#8b5cf6',
|
||||
port: '#f43f5e',
|
||||
proto: '#06b6d4',
|
||||
};
|
||||
|
||||
// ── Collapsible chip list — show first N, expand for all ─────────────
|
||||
|
||||
function ChipList({ items, color, max = 5 }: { items: string[]; color: string; max?: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (items.length === 0) return <Typography variant="body2" color="text.secondary">—</Typography>;
|
||||
const show = expanded ? items : items.slice(0, max);
|
||||
const more = items.length - max;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||
{show.map(v => (
|
||||
<Chip
|
||||
key={v} label={v} size="small" variant="outlined"
|
||||
sx={{ borderColor: color, color, fontSize: '0.75rem', height: 22 }}
|
||||
/>
|
||||
))}
|
||||
{more > 0 && !expanded && (
|
||||
<Chip
|
||||
label={`+${more} more`} size="small"
|
||||
onClick={() => setExpanded(true)}
|
||||
sx={{
|
||||
bgcolor: color, color: '#fff', fontSize: '0.7rem', height: 22,
|
||||
cursor: 'pointer', '&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{expanded && more > 0 && (
|
||||
<Chip
|
||||
label="less" size="small"
|
||||
onClick={() => setExpanded(false)}
|
||||
sx={{
|
||||
fontSize: '0.7rem', height: 22, cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stat card ────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) {
|
||||
return (
|
||||
<Paper elevation={1} sx={{ px: 2, py: 1.5, minWidth: 140, textAlign: 'center' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
|
||||
{icon}
|
||||
<Typography variant="h5" fontWeight={700}>{value}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sort helpers ─────────────────────────────────────────────────────
|
||||
|
||||
type SortKey = 'hostname' | 'ips' | 'users' | 'connection_count';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function sortHosts(hosts: HostEntry[], key: SortKey, dir: SortDir): HostEntry[] {
|
||||
const cmp = (a: HostEntry, b: HostEntry): number => {
|
||||
switch (key) {
|
||||
case 'hostname': return a.hostname.localeCompare(b.hostname);
|
||||
case 'ips': return a.ips.length - b.ips.length;
|
||||
case 'users': return a.users.length - b.users.length;
|
||||
case 'connection_count': return a.connection_count - b.connection_count;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
const sorted = [...hosts].sort(cmp);
|
||||
return dir === 'desc' ? sorted.reverse() : sorted;
|
||||
}
|
||||
|
||||
// ── Expanded row detail panel ────────────────────────────────────────
|
||||
|
||||
function HostDetail({ host }: { host: HostEntry }) {
|
||||
return (
|
||||
<Box sx={{ p: 2, bgcolor: 'background.default' }}>
|
||||
<Stack spacing={1.5}>
|
||||
{host.remote_targets.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>Remote Targets ({host.remote_targets.length})</Typography>
|
||||
<ChipList items={host.remote_targets} color={CHIP_COLORS.ip} max={50} />
|
||||
</Box>
|
||||
)}
|
||||
{host.open_ports.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>All Open Ports ({host.open_ports.length})</Typography>
|
||||
<ChipList items={host.open_ports} color={CHIP_COLORS.port} max={50} />
|
||||
</Box>
|
||||
)}
|
||||
{host.protocols.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>Protocols</Typography>
|
||||
<ChipList items={host.protocols} color={CHIP_COLORS.proto} max={20} />
|
||||
</Box>
|
||||
)}
|
||||
{host.mac_addresses.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>MAC Addresses</Typography>
|
||||
<ChipList items={host.mac_addresses} color={CHIP_COLORS.mac} max={20} />
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>Datasets</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{host.datasets.join(', ') || '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{(host.first_seen || host.last_seen) && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>Time Range</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{host.first_seen || '?'} → {host.last_seen || '?'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkPicture() {
|
||||
// Hunt selector
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [selectedHunt, setSelectedHunt] = useState('');
|
||||
|
||||
// Data
|
||||
const [hosts, setHosts] = useState<HostEntry[]>([]);
|
||||
const [summary, setSummary] = useState<PictureSummary | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Table state
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('connection_count');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
|
||||
// Load hunts on mount
|
||||
useEffect(() => {
|
||||
hunts.list(0, 200).then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Load network picture when hunt changes
|
||||
const loadPicture = useCallback(async (huntId: string) => {
|
||||
if (!huntId) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setHosts([]);
|
||||
setSummary(null);
|
||||
setExpandedRow(null);
|
||||
try {
|
||||
const resp = await network.picture(huntId);
|
||||
setHosts(resp.hosts);
|
||||
setSummary(resp.summary);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to load network picture');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedHunt) loadPicture(selectedHunt);
|
||||
}, [selectedHunt, loadPicture]);
|
||||
|
||||
// Filter + sort
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
let list = hosts;
|
||||
if (q) {
|
||||
list = hosts.filter(h =>
|
||||
h.hostname.toLowerCase().includes(q) ||
|
||||
h.ips.some(ip => ip.includes(q)) ||
|
||||
h.users.some(u => u.toLowerCase().includes(q)) ||
|
||||
h.os.some(o => o.toLowerCase().includes(q)) ||
|
||||
h.mac_addresses.some(m => m.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return sortHosts(list, sortKey, sortDir);
|
||||
}, [hosts, search, sortKey, sortDir]);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir(key === 'connection_count' ? 'desc' : 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (hostname: string) => {
|
||||
setExpandedRow(prev => prev === hostname ? null : hostname);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom fontWeight={700}>
|
||||
Network Picture
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Deduplicated host inventory — one row per machine. Hostname, IPs, users, OS, MACs aggregated from all datasets.
|
||||
</Typography>
|
||||
|
||||
{/* Hunt selector */}
|
||||
<Stack direction="row" spacing={2} sx={{ mt: 2, mb: 2 }} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 300 }}>
|
||||
<InputLabel>Select Hunt</InputLabel>
|
||||
<Select
|
||||
value={selectedHunt}
|
||||
label="Select Hunt"
|
||||
onChange={e => setSelectedHunt(e.target.value)}
|
||||
>
|
||||
{huntList.map(h => (
|
||||
<MenuItem key={h.id} value={h.id}>
|
||||
{h.name} ({h.dataset_count} datasets)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedHunt && (
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton onClick={() => loadPicture(selectedHunt)} size="small">
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
size="small" placeholder="Search hostname, IP, user, OS, MAC…"
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 280 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{/* Summary stats */}
|
||||
{summary && !loading && (
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }} flexWrap="wrap" useFlexGap>
|
||||
<StatCard label="Hosts" value={summary.total_hosts} icon={<ComputerIcon color="primary" />} />
|
||||
<StatCard label="Unique IPs" value={summary.total_unique_ips} icon={<RouterIcon color="secondary" />} />
|
||||
<StatCard label="Connections" value={summary.total_connections.toLocaleString()} icon={<DnsIcon color="info" />} />
|
||||
<StatCard label="Datasets Scanned" value={summary.datasets_scanned} icon={<PeopleIcon color="success" />} />
|
||||
{search && (
|
||||
<Paper elevation={1} sx={{ px: 2, py: 1.5, textAlign: 'center' }}>
|
||||
<Typography variant="h5" fontWeight={700}>{filtered.length}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Matching filter</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Host table */}
|
||||
{!loading && hosts.length > 0 && (
|
||||
<TableContainer component={Paper} sx={{ maxHeight: 'calc(100vh - 320px)' }}>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width={32} />
|
||||
<TableCell>
|
||||
<TableSortLabel
|
||||
active={sortKey === 'hostname'} direction={sortKey === 'hostname' ? sortDir : 'asc'}
|
||||
onClick={() => handleSort('hostname')}
|
||||
>
|
||||
Hostname
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableSortLabel
|
||||
active={sortKey === 'ips'} direction={sortKey === 'ips' ? sortDir : 'asc'}
|
||||
onClick={() => handleSort('ips')}
|
||||
>
|
||||
IPs
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableSortLabel
|
||||
active={sortKey === 'users'} direction={sortKey === 'users' ? sortDir : 'asc'}
|
||||
onClick={() => handleSort('users')}
|
||||
>
|
||||
Users
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>OS</TableCell>
|
||||
<TableCell>MAC</TableCell>
|
||||
<TableCell>
|
||||
<TableSortLabel
|
||||
active={sortKey === 'connection_count'} direction={sortKey === 'connection_count' ? sortDir : 'asc'}
|
||||
onClick={() => handleSort('connection_count')}
|
||||
>
|
||||
Connections
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>Ports</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map(host => {
|
||||
const isExpanded = expandedRow === host.hostname;
|
||||
return (
|
||||
<React.Fragment key={host.hostname}>
|
||||
<TableRow
|
||||
hover
|
||||
sx={{ cursor: 'pointer', '& > *': { borderBottom: isExpanded ? 'none' : undefined } }}
|
||||
onClick={() => toggleExpand(host.hostname)}
|
||||
>
|
||||
<TableCell>
|
||||
<IconButton size="small">
|
||||
{isExpanded ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={700}>{host.hostname}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><ChipList items={host.ips} color={CHIP_COLORS.ip} /></TableCell>
|
||||
<TableCell><ChipList items={host.users} color={CHIP_COLORS.user} /></TableCell>
|
||||
<TableCell>
|
||||
{host.os.length > 0
|
||||
? host.os.join(', ')
|
||||
: <Typography variant="body2" color="text.secondary">—</Typography>}
|
||||
</TableCell>
|
||||
<TableCell><ChipList items={host.mac_addresses} color={CHIP_COLORS.mac} /></TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={host.connection_count.toLocaleString()}
|
||||
size="small" color="primary" variant="outlined"
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell><ChipList items={host.open_ports.slice(0, 5)} color={CHIP_COLORS.port} max={5} /></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ p: 0, border: 0 }}>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<HostDetail host={host} />
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && selectedHunt && hosts.length === 0 && !error && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
No hosts found. Upload datasets with hostname/IP columns to this hunt.
|
||||
</Alert>
|
||||
)}
|
||||
{!selectedHunt && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Select a hunt to view the network picture.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
<<<<<<< HEAD
|
||||
* PlaybookManager - Investigation playbook workflow wizard.
|
||||
* Create/load playbooks from templates, track step completion, navigate to target views.
|
||||
*/
|
||||
@@ -53,11 +54,108 @@ export default function PlaybookManager() {
|
||||
try {
|
||||
const d = await playbooks.get(id);
|
||||
setActive(d);
|
||||
=======
|
||||
* PlaybookManager — Pre-defined investigation playbooks.
|
||||
*
|
||||
* Features:
|
||||
* - Browse built-in playbook templates
|
||||
* - Start a playbook run linked to a hunt/case
|
||||
* - Step-by-step execution with notes and status tracking
|
||||
* - View past runs and their results
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, Chip, Stack, Divider,
|
||||
Card, CardContent, CardActions, Stepper, Step, StepLabel, StepContent,
|
||||
TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
FormControl, InputLabel, Select, MenuItem, Tabs, Tab,
|
||||
LinearProgress, IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import SkipNextIcon from '@mui/icons-material/SkipNext';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
playbooks, hunts,
|
||||
PlaybookTemplate, PlaybookTemplateDetail, PlaybookRunData, Hunt,
|
||||
} from '../api/client';
|
||||
|
||||
const CATEGORY_COLORS: Record<string, 'error' | 'primary' | 'secondary' | 'warning' | 'info' | 'success'> = {
|
||||
incident_response: 'error',
|
||||
threat_hunting: 'primary',
|
||||
compliance: 'info',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, 'warning' | 'success' | 'error' | 'default'> = {
|
||||
'in-progress': 'warning',
|
||||
completed: 'success',
|
||||
aborted: 'error',
|
||||
};
|
||||
|
||||
export default function PlaybookManager() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
// Templates
|
||||
const [templates, setTemplates] = useState<PlaybookTemplate[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<PlaybookTemplateDetail | null>(null);
|
||||
|
||||
// Runs
|
||||
const [runs, setRuns] = useState<PlaybookRunData[]>([]);
|
||||
const [activeRun, setActiveRun] = useState<PlaybookRunData | null>(null);
|
||||
|
||||
// Start dialog
|
||||
const [startDialog, setStartDialog] = useState(false);
|
||||
const [startTemplate, setStartTemplate] = useState('');
|
||||
const [startHunt, setStartHunt] = useState('');
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
|
||||
// Step notes
|
||||
const [stepNotes, setStepNotes] = useState('');
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await playbooks.templates();
|
||||
setTemplates(res.templates);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
const res = await playbooks.listRuns();
|
||||
setRuns(res.runs);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
loadRuns();
|
||||
hunts.list().then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, [loadTemplates, loadRuns]);
|
||||
|
||||
// ── Template detail ────────────────────────────────────────────────
|
||||
|
||||
const viewTemplate = async (name: string) => {
|
||||
try {
|
||||
const detail = await playbooks.templateDetail(name);
|
||||
setSelectedTemplate(detail);
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
<<<<<<< HEAD
|
||||
const toggleStep = async (stepId: number, current: boolean) => {
|
||||
if (!active) return;
|
||||
try {
|
||||
@@ -85,11 +183,29 @@ export default function PlaybookManager() {
|
||||
enqueueSnackbar('Playbook created from template', { variant: 'success' });
|
||||
loadList();
|
||||
setActive(pb);
|
||||
=======
|
||||
// ── Start run ──────────────────────────────────────────────────────
|
||||
|
||||
const startRun = async () => {
|
||||
if (!startTemplate) return;
|
||||
try {
|
||||
const run = await playbooks.start({
|
||||
playbook_name: startTemplate,
|
||||
hunt_id: startHunt || undefined,
|
||||
});
|
||||
enqueueSnackbar('Playbook started!', { variant: 'success' });
|
||||
setStartDialog(false);
|
||||
setStartTemplate(''); setStartHunt('');
|
||||
setActiveRun(run);
|
||||
setTab(1);
|
||||
loadRuns();
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
<<<<<<< HEAD
|
||||
const createCustom = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
@@ -104,22 +220,61 @@ export default function PlaybookManager() {
|
||||
setNewDesc('');
|
||||
loadList();
|
||||
setActive(pb);
|
||||
=======
|
||||
// ── Open run detail ────────────────────────────────────────────────
|
||||
|
||||
const openRun = async (runId: string) => {
|
||||
try {
|
||||
const run = await playbooks.getRun(runId);
|
||||
setActiveRun(run);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Complete step ──────────────────────────────────────────────────
|
||||
|
||||
const completeStep = async (status: string = 'completed') => {
|
||||
if (!activeRun) return;
|
||||
try {
|
||||
const run = await playbooks.completeStep(activeRun.id, {
|
||||
notes: stepNotes || undefined,
|
||||
status,
|
||||
});
|
||||
setActiveRun(run);
|
||||
setStepNotes('');
|
||||
loadRuns();
|
||||
if (run.status === 'completed') {
|
||||
enqueueSnackbar('Playbook completed!', { variant: 'success' });
|
||||
}
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
<<<<<<< HEAD
|
||||
const deletePlaybook = async (id: string) => {
|
||||
try {
|
||||
await playbooks.delete(id);
|
||||
enqueueSnackbar('Playbook deleted', { variant: 'success' });
|
||||
if (active?.id === id) setActive(null);
|
||||
loadList();
|
||||
=======
|
||||
const abortRun = async () => {
|
||||
if (!activeRun) return;
|
||||
try {
|
||||
const run = await playbooks.abortRun(activeRun.id);
|
||||
setActiveRun(run);
|
||||
loadRuns();
|
||||
enqueueSnackbar('Playbook aborted', { variant: 'warning' });
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
<<<<<<< HEAD
|
||||
const completedCount = active?.steps.filter(s => s.is_completed).length || 0;
|
||||
const totalSteps = active?.steps.length || 1;
|
||||
const progress = Math.round((completedCount / totalSteps) * 100);
|
||||
@@ -229,9 +384,299 @@ export default function PlaybookManager() {
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={createCustom} disabled={!newName.trim()}>Create</Button>
|
||||
=======
|
||||
// ── Active run view ────────────────────────────────────────────────
|
||||
|
||||
if (activeRun) {
|
||||
const steps = activeRun.steps || [];
|
||||
const currentIdx = activeRun.current_step - 1;
|
||||
const isActive = activeRun.status === 'in-progress';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
||||
<Button onClick={() => setActiveRun(null)}>Back</Button>
|
||||
<Typography variant="h5" sx={{ flexGrow: 1 }}>{activeRun.playbook_name}</Typography>
|
||||
<Chip label={activeRun.status} color={STATUS_COLORS[activeRun.status] || 'default'} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Step {activeRun.current_step} / {activeRun.total_steps}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(activeRun.step_results.length / activeRun.total_steps) * 100}
|
||||
sx={{ mb: 3, height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
|
||||
<Stepper activeStep={currentIdx} orientation="vertical">
|
||||
{steps.map((step, i) => {
|
||||
const result = activeRun.step_results.find(r => r.step === step.order);
|
||||
const isCurrent = i === currentIdx && isActive;
|
||||
|
||||
return (
|
||||
<Step key={step.order} completed={!!result}>
|
||||
<StepLabel
|
||||
optional={
|
||||
result ? (
|
||||
<Chip
|
||||
label={result.status}
|
||||
size="small"
|
||||
color={result.status === 'completed' ? 'success' : 'default'}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={isCurrent ? 'bold' : 'normal'}>
|
||||
{step.title}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
<StepContent>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{step.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 1, mb: 1 }}>
|
||||
<Chip label={`Action: ${step.action}`} size="small" variant="outlined" sx={{ mr: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
|
||||
Expected: {step.expected_outcome}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{result?.notes && (
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="caption" color="text.secondary">Notes:</Typography>
|
||||
<Typography variant="body2">{result.notes}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
label="Step notes (optional)"
|
||||
multiline rows={2} fullWidth size="small"
|
||||
value={stepNotes}
|
||||
onChange={e => setStepNotes(e.target.value)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained" size="small"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
onClick={() => completeStep('completed')}
|
||||
>
|
||||
Complete Step
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined" size="small"
|
||||
startIcon={<SkipNextIcon />}
|
||||
onClick={() => completeStep('skipped')}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined" color="error" size="small"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={abortRun}
|
||||
>
|
||||
Abort
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</StepContent>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
|
||||
{activeRun.status === 'completed' && (
|
||||
<Paper sx={{ p: 2, mt: 2, bgcolor: 'success.dark', color: 'white' }}>
|
||||
<Typography variant="h6">Playbook Completed</Typography>
|
||||
<Typography variant="body2">
|
||||
All {activeRun.total_steps} steps finished at {activeRun.completed_at ? new Date(activeRun.completed_at).toLocaleString() : 'N/A'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main view ──────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h5">Playbooks</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={() => setStartDialog(true)}
|
||||
>
|
||||
Start Playbook
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label={`Templates (${templates.length})`} />
|
||||
<Tab label={`Runs (${runs.length})`} icon={<HistoryIcon />} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{/* Templates tab */}
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: 2 }}>
|
||||
{templates.map(t => (
|
||||
<Card key={t.name} variant="outlined">
|
||||
<CardContent>
|
||||
<Stack direction="row" alignItems="center" spacing={1} mb={1}>
|
||||
<Typography variant="h6">{t.name}</Typography>
|
||||
<Chip
|
||||
label={t.category.replace('_', ' ')}
|
||||
size="small"
|
||||
color={CATEGORY_COLORS[t.category] || 'default'}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{t.description}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
<Chip label={`${t.step_count} steps`} size="small" variant="outlined" />
|
||||
{t.tags.map((tag, i) => <Chip key={i} label={tag} size="small" />)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small" onClick={() => viewTemplate(t.name)}>View Steps</Button>
|
||||
<Button
|
||||
size="small" variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={() => { setStartTemplate(t.name); setStartDialog(true); }}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Runs tab */}
|
||||
{tab === 1 && (
|
||||
<Box>
|
||||
{runs.map(run => (
|
||||
<Paper key={run.id} sx={{ p: 2, mb: 1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
{run.playbook_name}
|
||||
<Chip label={run.status} size="small" color={STATUS_COLORS[run.status] || 'default'} sx={{ ml: 1 }} />
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Step {run.current_step}/{run.total_steps} • Started {new Date(run.created_at).toLocaleString()}
|
||||
{run.started_by && ` by ${run.started_by}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(run.step_results.length / run.total_steps) * 100}
|
||||
sx={{ width: 100, height: 8, borderRadius: 4, alignSelf: 'center' }}
|
||||
/>
|
||||
<Tooltip title="Open">
|
||||
<IconButton onClick={() => openRun(run.id)}>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
{runs.length === 0 && (
|
||||
<Typography color="text.secondary" textAlign="center" py={4}>
|
||||
No playbook runs yet. Start one from the Templates tab.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Template detail dialog */}
|
||||
<Dialog open={!!selectedTemplate} onClose={() => setSelectedTemplate(null)} maxWidth="md" fullWidth>
|
||||
{selectedTemplate && (
|
||||
<>
|
||||
<DialogTitle>
|
||||
{selectedTemplate.name}
|
||||
<Chip label={selectedTemplate.category.replace('_', ' ')} size="small" color={CATEGORY_COLORS[selectedTemplate.category] || 'default'} sx={{ ml: 1 }} />
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="body1" gutterBottom>{selectedTemplate.description}</Typography>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom>Steps ({selectedTemplate.steps?.length || 0})</Typography>
|
||||
<Stepper orientation="vertical">
|
||||
{(selectedTemplate.steps || []).map((step, i) => (
|
||||
<Step key={i} active>
|
||||
<StepLabel>
|
||||
<Typography variant="subtitle2">{step.title}</Typography>
|
||||
</StepLabel>
|
||||
<StepContent>
|
||||
<Typography variant="body2" color="text.secondary">{step.description}</Typography>
|
||||
<Chip label={`Action: ${step.action}`} size="small" variant="outlined" sx={{ mt: 0.5 }} />
|
||||
<Typography variant="caption" display="block" mt={0.5}>
|
||||
Expected: {step.expected_outcome}
|
||||
</Typography>
|
||||
</StepContent>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSelectedTemplate(null)}>Close</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={() => {
|
||||
setStartTemplate(selectedTemplate.name);
|
||||
setSelectedTemplate(null);
|
||||
setStartDialog(true);
|
||||
}}
|
||||
>
|
||||
Start This Playbook
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* Start run dialog */}
|
||||
<Dialog open={startDialog} onClose={() => setStartDialog(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Start Playbook Run</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} mt={1}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Playbook</InputLabel>
|
||||
<Select value={startTemplate} label="Playbook" onChange={e => setStartTemplate(e.target.value)}>
|
||||
{templates.map(t => <MenuItem key={t.name} value={t.name}>{t.name} ({t.step_count} steps)</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Linked Hunt</InputLabel>
|
||||
<Select value={startHunt} label="Linked Hunt" onChange={e => setStartHunt(e.target.value)}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setStartDialog(false)}>Cancel</Button>
|
||||
<Button variant="contained" startIcon={<PlayArrowIcon />} onClick={startRun} disabled={!startTemplate}>
|
||||
Start
|
||||
</Button>
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
|
||||
=======
|
||||
>>>>>>> 7c454036c7ef6a3d6517f98cbee643fd0238e0b2
|
||||
|
||||
526
frontend/src/components/ProcessTree.tsx
Normal file
526
frontend/src/components/ProcessTree.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* ProcessTree — interactive hierarchical process tree view.
|
||||
*
|
||||
* Key UX improvements:
|
||||
* - Hunt dropdown auto-fetches the full tree to extract hostnames.
|
||||
* - Host dropdown lets the user pick a single host (server-side filter).
|
||||
* - Detail panel is absolutely positioned so it never re-flows the graph.
|
||||
* - ResizeObserver keeps Cytoscape in sync with the container.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Stack, Alert, CircularProgress,
|
||||
FormControl, InputLabel, Select, MenuItem, Chip, TextField,
|
||||
IconButton, Tooltip, Divider, Autocomplete,
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong';
|
||||
import cytoscape, { Core, NodeSingular } from 'cytoscape';
|
||||
// @ts-ignore
|
||||
import dagre from 'cytoscape-dagre';
|
||||
import {
|
||||
analysis, hunts, datasets, type Hunt, type DatasetSummary,
|
||||
type ProcessNodeData,
|
||||
} from '../api/client';
|
||||
|
||||
cytoscape.use(dagre);
|
||||
|
||||
/* ── helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** Recursively collect unique hostnames from process tree roots */
|
||||
function collectHostnames(trees: ProcessNodeData[]): string[] {
|
||||
const set = new Set<string>();
|
||||
const walk = (n: ProcessNodeData) => {
|
||||
if (n.hostname) set.add(n.hostname);
|
||||
n.children.forEach(walk);
|
||||
};
|
||||
trees.forEach(walk);
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
/** Recursively count processes */
|
||||
function countNodes(trees: ProcessNodeData[]): number {
|
||||
let c = 0;
|
||||
const walk = (n: ProcessNodeData) => { c++; n.children.forEach(walk); };
|
||||
trees.forEach(walk);
|
||||
return c;
|
||||
}
|
||||
|
||||
/* ── flatten tree for Cytoscape elements ──────────────────────────── */
|
||||
function flattenTree(
|
||||
node: ProcessNodeData,
|
||||
parentId: string | null,
|
||||
nodes: any[],
|
||||
edges: any[],
|
||||
idSet: Set<string>,
|
||||
) {
|
||||
let id = `${node.hostname}_${node.pid}`;
|
||||
// deduplicate: if PID appears multiple times, append row_index
|
||||
if (idSet.has(id)) id = `${id}_${node.row_index}`;
|
||||
idSet.add(id);
|
||||
|
||||
const cmd = node.command_line || '';
|
||||
const isSuspicious = /powershell\s+-enc|certutil|mimikatz|psexec|mshta|cobalt|meterpreter/i.test(cmd);
|
||||
|
||||
nodes.push({
|
||||
data: {
|
||||
id,
|
||||
label: `${node.name || 'unknown'}\nPID ${node.pid}`,
|
||||
pid: node.pid,
|
||||
ppid: node.ppid,
|
||||
name: node.name,
|
||||
command_line: node.command_line,
|
||||
username: node.username,
|
||||
hostname: node.hostname,
|
||||
timestamp: node.timestamp,
|
||||
dataset_name: node.dataset_name,
|
||||
severity: isSuspicious ? 'high' : 'info',
|
||||
extra: node.extra,
|
||||
},
|
||||
});
|
||||
|
||||
if (parentId) {
|
||||
edges.push({
|
||||
data: { id: `e_${parentId}_${id}`, source: parentId, target: id },
|
||||
});
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
flattenTree(child, id, nodes, edges, idSet);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cytoscape stylesheet ─────────────────────────────────────────── */
|
||||
const CY_STYLE: cytoscape.StylesheetStyle[] = [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
label: 'data(label)',
|
||||
'text-wrap': 'wrap' as any,
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'font-size': '11px',
|
||||
color: '#e2e8f0',
|
||||
'background-color': '#334155',
|
||||
'border-width': 2,
|
||||
'border-color': '#475569',
|
||||
width: 160,
|
||||
height: 50,
|
||||
shape: 'roundrectangle',
|
||||
'text-max-width': '140px',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node[severity = "high"], node[severity = "critical"]',
|
||||
style: {
|
||||
'background-color': '#7f1d1d',
|
||||
'border-color': '#ef4444',
|
||||
'border-width': 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node[severity = "medium"]',
|
||||
style: {
|
||||
'background-color': '#713f12',
|
||||
'border-color': '#eab308',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-color': '#60a5fa',
|
||||
'border-width': 3,
|
||||
'background-color': '#1e3a5f',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 2,
|
||||
'line-color': '#475569',
|
||||
'target-arrow-color': '#475569',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 0.8,
|
||||
},
|
||||
},
|
||||
{ selector: '.dimmed', style: { opacity: 0.15 } },
|
||||
{ selector: '.highlighted', style: { 'border-color': '#22d3ee', 'border-width': 3 } },
|
||||
];
|
||||
|
||||
/* ================================================================== */
|
||||
|
||||
export default function ProcessTree() {
|
||||
/* refs */
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cyDivRef = useRef<HTMLDivElement>(null);
|
||||
const cyRef = useRef<Core | null>(null);
|
||||
|
||||
/* data */
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [datasetList, setDatasetList] = useState<DatasetSummary[]>([]);
|
||||
|
||||
/* selections */
|
||||
const [selectedHunt, setSelectedHunt] = useState('');
|
||||
const [selectedDataset, setSelectedDataset] = useState('');
|
||||
const [hostnames, setHostnames] = useState<string[]>([]);
|
||||
const [selectedHost, setSelectedHost] = useState('');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
/* ui */
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hostsLoading, setHostsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [totalProcs, setTotalProcs] = useState(0);
|
||||
const [selectedNode, setSelectedNode] = useState<Record<string, any> | null>(null);
|
||||
|
||||
/* ── load hunts + datasets on mount ─────────────────────────────── */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [h, d] = await Promise.all([
|
||||
hunts.list(0, 100),
|
||||
datasets.list(0, 200),
|
||||
]);
|
||||
setHuntList(h.hunts);
|
||||
setDatasetList(d.datasets);
|
||||
if (h.hunts.length > 0) setSelectedHunt(h.hunts[0].id);
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/* ── when hunt changes, fetch all trees to extract hostnames ────── */
|
||||
useEffect(() => {
|
||||
if (!selectedHunt) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setHostsLoading(true);
|
||||
try {
|
||||
const res = await analysis.processTree({ hunt_id: selectedHunt });
|
||||
if (cancelled) return;
|
||||
const hosts = collectHostnames(res.trees);
|
||||
setHostnames(hosts);
|
||||
setTotalProcs(res.total_processes);
|
||||
// auto-pick first host so the user sees something reasonable
|
||||
if (hosts.length > 0) {
|
||||
setSelectedHost(hosts[0]);
|
||||
} else {
|
||||
setSelectedHost('');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setHostnames([]);
|
||||
}
|
||||
setHostsLoading(false);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedHunt]);
|
||||
|
||||
/* filter datasets when hunt changes */
|
||||
const huntDatasets = selectedHunt
|
||||
? datasetList.filter(d => d.hunt_id === selectedHunt)
|
||||
: datasetList;
|
||||
|
||||
/* ── fetch tree (per host) ──────────────────────────────────────── */
|
||||
const fetchTree = useCallback(async (host?: string) => {
|
||||
const huntId = selectedDataset ? undefined : selectedHunt;
|
||||
const dsId = selectedDataset || undefined;
|
||||
if (!huntId && !dsId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSelectedNode(null);
|
||||
|
||||
try {
|
||||
const hostname = (host ?? selectedHost) || undefined;
|
||||
const res = await analysis.processTree({
|
||||
dataset_id: dsId,
|
||||
hunt_id: huntId,
|
||||
hostname,
|
||||
});
|
||||
|
||||
setTotalProcs(res.total_processes);
|
||||
|
||||
/* build Cytoscape elements */
|
||||
const nodes: any[] = [];
|
||||
const edges: any[] = [];
|
||||
const idSet = new Set<string>();
|
||||
for (const tree of res.trees) {
|
||||
flattenTree(tree, null, nodes, edges, idSet);
|
||||
}
|
||||
|
||||
/* init or update Cytoscape */
|
||||
if (cyRef.current) {
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
if (!cyDivRef.current) return;
|
||||
|
||||
/* choose layout based on whether there are actual parent→child edges */
|
||||
const hasEdges = edges.length > nodes.length * 0.1; // at least 10% connected
|
||||
const layoutConfig = hasEdges
|
||||
? {
|
||||
name: 'dagre',
|
||||
rankDir: 'TB',
|
||||
nodeSep: 40,
|
||||
rankSep: 70,
|
||||
padding: 40,
|
||||
}
|
||||
: {
|
||||
name: 'grid',
|
||||
rows: Math.max(1, Math.ceil(Math.sqrt(nodes.length))),
|
||||
cols: Math.max(1, Math.ceil(Math.sqrt(nodes.length))),
|
||||
padding: 30,
|
||||
avoidOverlap: true,
|
||||
};
|
||||
|
||||
const cy = cytoscape({
|
||||
container: cyDivRef.current,
|
||||
elements: [...nodes, ...edges],
|
||||
style: CY_STYLE as any,
|
||||
layout: layoutConfig as any,
|
||||
minZoom: 0.05,
|
||||
maxZoom: 5,
|
||||
wheelSensitivity: 0.3,
|
||||
});
|
||||
|
||||
/* tap handlers */
|
||||
cy.on('tap', 'node', (evt) => {
|
||||
const nd = (evt.target as NodeSingular).data();
|
||||
setSelectedNode(nd);
|
||||
});
|
||||
cy.on('tap', (evt) => {
|
||||
if (evt.target === cy) setSelectedNode(null);
|
||||
});
|
||||
|
||||
cyRef.current = cy;
|
||||
|
||||
/* fit with padding after layout settles */
|
||||
requestAnimationFrame(() => {
|
||||
cy.resize();
|
||||
cy.fit(undefined, 40);
|
||||
});
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to load process tree');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [selectedHunt, selectedDataset, selectedHost]);
|
||||
|
||||
/* re-fetch when host changes */
|
||||
useEffect(() => {
|
||||
if (selectedHost) fetchTree(selectedHost);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedHost, selectedDataset]);
|
||||
|
||||
/* ── keep Cytoscape sized correctly ─────────────────────────────── */
|
||||
useEffect(() => {
|
||||
const el = cyDivRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => cyRef.current?.resize());
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
/* cleanup Cytoscape on unmount */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cyRef.current) {
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── search highlight ───────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
const cy = cyRef.current;
|
||||
if (!cy) return;
|
||||
cy.elements().removeClass('dimmed highlighted');
|
||||
if (!searchText.trim()) return;
|
||||
const q = searchText.toLowerCase();
|
||||
const matched = cy.nodes().filter(n => {
|
||||
const d = n.data();
|
||||
return (d.name || '').toLowerCase().includes(q)
|
||||
|| (d.pid || '').toLowerCase().includes(q)
|
||||
|| (d.command_line || '').toLowerCase().includes(q)
|
||||
|| (d.username || '').toLowerCase().includes(q);
|
||||
});
|
||||
if (matched.length === 0) return;
|
||||
cy.elements().addClass('dimmed');
|
||||
matched.removeClass('dimmed').addClass('highlighted');
|
||||
matched.connectedEdges().removeClass('dimmed');
|
||||
}, [searchText]);
|
||||
|
||||
/* controls */
|
||||
const zoomIn = () => { const cy = cyRef.current; if (cy) cy.zoom({ level: cy.zoom() * 1.3, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } }); };
|
||||
const zoomOut = () => { const cy = cyRef.current; if (cy) cy.zoom({ level: cy.zoom() / 1.3, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } }); };
|
||||
const fitAll = () => cyRef.current?.fit(undefined, 40);
|
||||
|
||||
const GRAPH_HEIGHT = 'calc(100vh - 230px)';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Process Tree</Typography>
|
||||
|
||||
{/* ── Toolbar ──────────────────────────────────────────────── */}
|
||||
<Paper sx={{ p: 1.5, mb: 2 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
{/* Hunt */}
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={selectedHunt}
|
||||
onChange={e => {
|
||||
setSelectedHunt(e.target.value);
|
||||
setSelectedDataset('');
|
||||
setSelectedHost('');
|
||||
setHostnames([]);
|
||||
}}>
|
||||
{huntList.map(h => (
|
||||
<MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Dataset (optional) */}
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Dataset (optional)</InputLabel>
|
||||
<Select label="Dataset (optional)" value={selectedDataset}
|
||||
onChange={e => setSelectedDataset(e.target.value)}>
|
||||
<MenuItem value="">All in hunt</MenuItem>
|
||||
{huntDatasets.map(d => (
|
||||
<MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Host dropdown */}
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ width: 220 }}
|
||||
options={hostnames}
|
||||
value={selectedHost || null}
|
||||
loading={hostsLoading}
|
||||
onChange={(_e, v) => setSelectedHost(v || '')}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Host" placeholder={hostsLoading ? 'Loading hosts…' : 'Pick a host'} />
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<TextField size="small" label="Search process…" value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
sx={{ width: 170 }}
|
||||
/>
|
||||
|
||||
<Tooltip title="Refresh"><IconButton onClick={() => fetchTree()}><RefreshIcon /></IconButton></Tooltip>
|
||||
<Tooltip title="Zoom In"><IconButton onClick={zoomIn}><ZoomInIcon /></IconButton></Tooltip>
|
||||
<Tooltip title="Zoom Out"><IconButton onClick={zoomOut}><ZoomOutIcon /></IconButton></Tooltip>
|
||||
<Tooltip title="Fit"><IconButton onClick={fitAll}><CenterFocusStrongIcon /></IconButton></Tooltip>
|
||||
|
||||
<Chip label={`${totalProcs.toLocaleString()} processes`} size="small" color="info" variant="outlined" />
|
||||
{hostnames.length > 0 && (
|
||||
<Chip label={`${hostnames.length} hosts`} size="small" variant="outlined" />
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{/* ── Graph + overlay Detail panel ─────────────────────────── */}
|
||||
<Box ref={containerRef} sx={{ position: 'relative', height: GRAPH_HEIGHT, minHeight: 400 }}>
|
||||
{/* Cytoscape canvas — dedicated div with NO React children */}
|
||||
<div
|
||||
ref={cyDivRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: '#0f172a', borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Overlays — sibling elements, not children of Cytoscape div */}
|
||||
{loading && (
|
||||
<Box sx={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', zIndex: 5 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{!loading && !selectedHost && hostnames.length > 0 && (
|
||||
<Box sx={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', textAlign: 'center', zIndex: 5 }}>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Select a host from the dropdown to view its process tree
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{hostnames.length} hosts available with {totalProcs.toLocaleString()} total processes
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Detail panel — overlays graph on the right, doesn't affect layout */}
|
||||
{selectedNode && (
|
||||
<Paper sx={{
|
||||
position: 'absolute', top: 8, right: 8, bottom: 8,
|
||||
width: 340, p: 2, overflow: 'auto', zIndex: 10,
|
||||
bgcolor: 'rgba(15,23,42,0.95)', backdropFilter: 'blur(8px)',
|
||||
border: '1px solid', borderColor: 'divider',
|
||||
}}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h6">
|
||||
{selectedNode.name || 'Process'}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setSelectedNode(null)}><CloseIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack spacing={0.5}>
|
||||
<DetailRow label="PID" value={selectedNode.pid} />
|
||||
<DetailRow label="PPID" value={selectedNode.ppid} />
|
||||
<DetailRow label="Host" value={selectedNode.hostname} />
|
||||
<DetailRow label="User" value={selectedNode.username} />
|
||||
<DetailRow label="Timestamp" value={selectedNode.timestamp} />
|
||||
<DetailRow label="Dataset" value={selectedNode.dataset_name} />
|
||||
</Stack>
|
||||
{selectedNode.command_line && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Command Line</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 0.5, fontFamily: 'monospace', fontSize: 12,
|
||||
wordBreak: 'break-all', bgcolor: 'background.default' }}>
|
||||
{selectedNode.command_line}
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
{selectedNode.extra && Object.keys(selectedNode.extra).length > 0 && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Extra Fields</Typography>
|
||||
<Stack spacing={0.3} sx={{ mt: 0.5 }}>
|
||||
{Object.entries(selectedNode.extra as Record<string, string>).map(([k, v]) => (
|
||||
<DetailRow key={k} label={k} value={v} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
<Chip
|
||||
label={selectedNode.severity || 'info'}
|
||||
size="small" sx={{ mt: 1.5 }}
|
||||
color={
|
||||
selectedNode.severity === 'critical' || selectedNode.severity === 'high' ? 'error'
|
||||
: selectedNode.severity === 'medium' ? 'warning'
|
||||
: 'default'
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value?: string }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
<strong>{label}:</strong>{' '}
|
||||
<span style={{ color: '#94a3b8' }}>{value}</span>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
255
frontend/src/components/QueryBar.tsx
Normal file
255
frontend/src/components/QueryBar.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* QueryBar — free-text search with field filters, time-range picker,
|
||||
* and result DataGrid. Works across all datasets in a hunt.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, TextField, Button, IconButton,
|
||||
FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
||||
Alert, Chip, Tooltip, Collapse,
|
||||
} from '@mui/material';
|
||||
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import {
|
||||
hunts, datasets, analysis,
|
||||
type HuntOut, type DatasetSummary, type SearchRowsResponse,
|
||||
} from '../api/client';
|
||||
|
||||
interface ActiveFilter { field: string; value: string }
|
||||
|
||||
export default function QueryBar() {
|
||||
const [huntList, setHuntList] = useState<HuntOut[]>([]);
|
||||
const [dsList, setDsList] = useState<DatasetSummary[]>([]);
|
||||
const [activeHunt, setActiveHunt] = useState('');
|
||||
const [activeDs, setActiveDs] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [filters, setFilters] = useState<ActiveFilter[]>([]);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [timeStart, setTimeStart] = useState('');
|
||||
const [timeEnd, setTimeEnd] = useState('');
|
||||
const [results, setResults] = useState<SearchRowsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [availableFields, setAvailableFields] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load hunts + datasets
|
||||
useEffect(() => {
|
||||
hunts.list(0, 200).then(r => {
|
||||
setHuntList(r.hunts);
|
||||
if (r.hunts.length > 0) setActiveHunt(r.hunts[0].id);
|
||||
}).catch(() => {});
|
||||
datasets.list(0, 200).then(r => setDsList(r.datasets)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Load field names from field-stats
|
||||
useEffect(() => {
|
||||
if (!activeDs && !activeHunt) return;
|
||||
analysis.fieldStats({
|
||||
dataset_id: activeDs || undefined,
|
||||
hunt_id: activeHunt || undefined,
|
||||
top_n: 5,
|
||||
}).then(r => setAvailableFields(Object.keys(r.fields))).catch(() => {});
|
||||
}, [activeDs, activeHunt]);
|
||||
|
||||
const doSearch = useCallback(async (offset = 0) => {
|
||||
if (!activeDs && !activeHunt) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const filterMap: Record<string, string> = {};
|
||||
filters.forEach(f => { if (f.field && f.value) filterMap[f.field] = f.value; });
|
||||
const r = await analysis.searchRows({
|
||||
dataset_id: activeDs || undefined,
|
||||
hunt_id: activeHunt || undefined,
|
||||
query,
|
||||
filters: Object.keys(filterMap).length > 0 ? filterMap : undefined,
|
||||
time_start: timeStart || undefined,
|
||||
time_end: timeEnd || undefined,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
setResults(r);
|
||||
} catch (e: any) { setError(e.message); }
|
||||
setLoading(false);
|
||||
}, [activeDs, activeHunt, query, filters, timeStart, timeEnd, pageSize]);
|
||||
|
||||
const handleSearch = () => { setPage(0); doSearch(0); };
|
||||
const handlePageChange = (model: { page: number; pageSize: number }) => {
|
||||
setPage(model.page);
|
||||
setPageSize(model.pageSize);
|
||||
doSearch(model.page * model.pageSize);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
};
|
||||
|
||||
// Filter management
|
||||
const addFilter = () => setFilters(prev => [...prev, { field: '', value: '' }]);
|
||||
const removeFilter = (i: number) => setFilters(prev => prev.filter((_, idx) => idx !== i));
|
||||
const updateFilter = (i: number, key: 'field' | 'value', val: string) =>
|
||||
setFilters(prev => prev.map((f, idx) => idx === i ? { ...f, [key]: val } : f));
|
||||
|
||||
const clearAll = () => {
|
||||
setQuery('');
|
||||
setFilters([]);
|
||||
setTimeStart('');
|
||||
setTimeEnd('');
|
||||
setResults(null);
|
||||
};
|
||||
|
||||
// Build columns from result rows
|
||||
const columns: GridColDef[] = results && results.rows.length > 0
|
||||
? Object.keys(results.rows[0]).filter(k => k !== '__id').map(k => ({
|
||||
field: k, headerName: k, flex: 1, minWidth: 120,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const gridRows = results?.rows.map((r, i) => ({ __id: `r-${page}-${i}`, ...r })) || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Search & Query</Typography>
|
||||
|
||||
{/* Source selectors */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={activeHunt}
|
||||
onChange={e => { setActiveHunt(e.target.value); setActiveDs(''); }}>
|
||||
<MenuItem value="">— none —</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select label="Dataset" value={activeDs}
|
||||
onChange={e => setActiveDs(e.target.value)}>
|
||||
<MenuItem value="">— all datasets —</MenuItem>
|
||||
{dsList.map(d => <MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Search bar */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
size="small" fullWidth
|
||||
placeholder="Free-text search across all fields…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
InputProps={{
|
||||
startAdornment: <SearchIcon sx={{ color: 'text.secondary', mr: 0.5 }} />,
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="Field filters">
|
||||
<IconButton size="small" onClick={() => setShowFilters(s => !s)}
|
||||
color={showFilters ? 'primary' : 'default'}>
|
||||
<FilterListIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button variant="contained" size="small" onClick={handleSearch}
|
||||
disabled={loading || (!activeDs && !activeHunt)}>
|
||||
Search
|
||||
</Button>
|
||||
<Tooltip title="Clear all">
|
||||
<IconButton size="small" onClick={clearAll}><ClearIcon /></IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{/* Time range */}
|
||||
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
size="small" label="Time start" type="datetime-local"
|
||||
value={timeStart} onChange={e => setTimeStart(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }} sx={{ width: 220 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small" label="Time end" type="datetime-local"
|
||||
value={timeEnd} onChange={e => setTimeEnd(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }} sx={{ width: 220 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Field filters */}
|
||||
<Collapse in={showFilters}>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption" fontWeight={700}>Field Filters</Typography>
|
||||
<IconButton size="small" onClick={addFilter}><AddIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
{filters.map((f, i) => (
|
||||
<Stack key={i} direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Field</InputLabel>
|
||||
<Select label="Field" value={f.field}
|
||||
onChange={e => updateFilter(i, 'field', e.target.value)}>
|
||||
{availableFields.map(af => (
|
||||
<MenuItem key={af} value={af}>{af}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField size="small" placeholder="Contains…" value={f.value}
|
||||
onChange={e => updateFilter(i, 'value', e.target.value)}
|
||||
sx={{ flex: 1 }} />
|
||||
<IconButton size="small" onClick={() => removeFilter(i)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{loading && <CircularProgress sx={{ display: 'block', mx: 'auto', my: 4 }} />}
|
||||
|
||||
{/* Results */}
|
||||
{results && (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Chip label={`${results.total.toLocaleString()} results`} size="small" color="primary" variant="outlined" />
|
||||
{query && <Chip label={`"${query}"`} size="small" onDelete={() => setQuery('')} />}
|
||||
{filters.filter(f => f.field && f.value).map((f, i) => (
|
||||
<Chip key={i} label={`${f.field}: ${f.value}`} size="small"
|
||||
onDelete={() => removeFilter(i)} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Paper sx={{ height: 480 }}>
|
||||
<DataGrid
|
||||
rows={gridRows}
|
||||
columns={columns}
|
||||
getRowId={r => r.__id}
|
||||
rowCount={results.total}
|
||||
loading={loading}
|
||||
paginationMode="server"
|
||||
paginationModel={{ page, pageSize }}
|
||||
onPaginationModelChange={handlePageChange}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
density="compact"
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell': { fontSize: '0.8rem' },
|
||||
'& .MuiDataGrid-columnHeader': { fontWeight: 700 },
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
395
frontend/src/components/StorylineGraph.tsx
Normal file
395
frontend/src/components/StorylineGraph.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* StorylineGraph — CrowdStrike-style attack storyline visualization.
|
||||
*
|
||||
* Renders events as a directed graph using Cytoscape.js with cola layout.
|
||||
* Nodes are colour-coded by event type (process/network/file/registry).
|
||||
* Edges show spawned (parent→child) and temporal relationships.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, Stack, Alert, CircularProgress,
|
||||
FormControl, InputLabel, Select, MenuItem, Chip, TextField,
|
||||
IconButton, Tooltip, Divider, ToggleButton, ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||
import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import TimelineIcon from '@mui/icons-material/Timeline';
|
||||
import cytoscape, { Core, NodeSingular } from 'cytoscape';
|
||||
// @ts-ignore
|
||||
import dagre from 'cytoscape-dagre';
|
||||
// @ts-ignore
|
||||
import cola from 'cytoscape-cola';
|
||||
import {
|
||||
analysis, hunts, datasets, type Hunt, type DatasetSummary,
|
||||
type StorylineResponse,
|
||||
} from '../api/client';
|
||||
|
||||
cytoscape.use(dagre);
|
||||
cytoscape.use(cola);
|
||||
|
||||
/* ── colour palette by event type ──────────────────────────────────── */
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
process: '#3b82f6',
|
||||
network: '#8b5cf6',
|
||||
file: '#10b981',
|
||||
registry: '#f59e0b',
|
||||
other: '#6b7280',
|
||||
};
|
||||
|
||||
const SEVERITY_BG: Record<string, string> = {
|
||||
critical: '#7f1d1d',
|
||||
high: '#7f1d1d',
|
||||
medium: '#713f12',
|
||||
low: '#1e3a5f',
|
||||
info: '#1e293b',
|
||||
};
|
||||
|
||||
const SEVERITY_BORDER: Record<string, string> = {
|
||||
critical: '#ef4444',
|
||||
high: '#f97316',
|
||||
medium: '#eab308',
|
||||
low: '#3b82f6',
|
||||
info: '#475569',
|
||||
};
|
||||
|
||||
/* ── shapes per event type ─────────────────────────────────────────── */
|
||||
const EVENT_SHAPES: Record<string, string> = {
|
||||
process: 'roundrectangle',
|
||||
network: 'diamond',
|
||||
file: 'hexagon',
|
||||
registry: 'octagon',
|
||||
other: 'ellipse',
|
||||
};
|
||||
|
||||
/* ── Cytoscape stylesheet ─────────────────────────────────────────── */
|
||||
const CY_STYLE: cytoscape.StylesheetStyle[] = [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
label: 'data(label)',
|
||||
'text-wrap': 'wrap' as any,
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'font-size': '9px',
|
||||
color: '#e2e8f0',
|
||||
'background-color': '#334155',
|
||||
'border-width': 2,
|
||||
'border-color': '#475569',
|
||||
width: 130,
|
||||
height: 45,
|
||||
shape: 'roundrectangle',
|
||||
'text-max-width': '115px',
|
||||
},
|
||||
},
|
||||
/* per event type colours */
|
||||
...Object.entries(EVENT_COLORS).map(([type, color]) => ({
|
||||
selector: `node[event_type = "${type}"]`,
|
||||
style: {
|
||||
'border-color': color,
|
||||
shape: EVENT_SHAPES[type] as any,
|
||||
},
|
||||
})),
|
||||
/* per severity background */
|
||||
...Object.entries(SEVERITY_BG).map(([sev, bg]) => ({
|
||||
selector: `node[severity = "${sev}"]`,
|
||||
style: {
|
||||
'background-color': bg,
|
||||
'border-color': SEVERITY_BORDER[sev],
|
||||
'border-width': sev === 'critical' || sev === 'high' ? 3 : 2,
|
||||
},
|
||||
})),
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-color': '#60a5fa',
|
||||
'border-width': 3,
|
||||
'background-color': '#1e3a5f',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 1.5,
|
||||
'line-color': '#475569',
|
||||
'target-arrow-color': '#475569',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 0.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge[relationship = "spawned"]',
|
||||
style: {
|
||||
'line-color': '#3b82f6',
|
||||
'target-arrow-color': '#3b82f6',
|
||||
width: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge[relationship = "temporal"]',
|
||||
style: {
|
||||
'line-color': '#334155',
|
||||
'line-style': 'dashed',
|
||||
width: 1,
|
||||
'target-arrow-shape': 'vee',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type LayoutName = 'dagre' | 'cola';
|
||||
|
||||
export default function StorylineGraph() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cyRef = useRef<Core | null>(null);
|
||||
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [datasetList, setDatasetList] = useState<DatasetSummary[]>([]);
|
||||
const [selectedHunt, setSelectedHunt] = useState('');
|
||||
const [selectedDataset, setSelectedDataset] = useState('');
|
||||
const [hostFilter, setHostFilter] = useState('');
|
||||
const [layout, setLayout] = useState<LayoutName>('dagre');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [storyline, setStoryline] = useState<StorylineResponse | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Record<string, any> | null>(null);
|
||||
|
||||
/* load hunts + datasets */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [h, d] = await Promise.all([
|
||||
hunts.list(0, 100),
|
||||
datasets.list(0, 200),
|
||||
]);
|
||||
setHuntList(h.hunts);
|
||||
setDatasetList(d.datasets);
|
||||
if (h.hunts.length > 0) setSelectedHunt(h.hunts[0].id);
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const huntDatasets = selectedHunt
|
||||
? datasetList.filter(d => d.hunt_id === selectedHunt)
|
||||
: datasetList;
|
||||
|
||||
/* fetch storyline */
|
||||
const fetchStoryline = useCallback(async () => {
|
||||
if (!selectedHunt && !selectedDataset) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSelectedNode(null);
|
||||
|
||||
try {
|
||||
const res = await analysis.storyline({
|
||||
dataset_id: selectedDataset || undefined,
|
||||
hunt_id: selectedDataset ? undefined : selectedHunt,
|
||||
hostname: hostFilter || undefined,
|
||||
});
|
||||
setStoryline(res);
|
||||
renderGraph(res);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to load storyline');
|
||||
}
|
||||
setLoading(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedHunt, selectedDataset, hostFilter, layout]);
|
||||
|
||||
/* render Cytoscape */
|
||||
const renderGraph = useCallback((data: StorylineResponse) => {
|
||||
if (cyRef.current) cyRef.current.destroy();
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const elements = [...data.nodes, ...data.edges];
|
||||
|
||||
const layoutConfig: any = layout === 'dagre'
|
||||
? { name: 'dagre', rankDir: 'LR', nodeSep: 25, rankSep: 50, padding: 30 }
|
||||
: { name: 'cola', nodeSpacing: 40, edgeLength: 120, animate: false, padding: 30, maxSimulationTime: 3000 };
|
||||
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements,
|
||||
style: CY_STYLE as any,
|
||||
layout: layoutConfig,
|
||||
minZoom: 0.05,
|
||||
maxZoom: 4,
|
||||
wheelSensitivity: 0.3,
|
||||
});
|
||||
|
||||
cy.on('tap', 'node', (evt) => {
|
||||
setSelectedNode((evt.target as NodeSingular).data());
|
||||
});
|
||||
cy.on('tap', (evt) => {
|
||||
if (evt.target === cy) setSelectedNode(null);
|
||||
});
|
||||
|
||||
cyRef.current = cy;
|
||||
}, [layout]);
|
||||
|
||||
/* refetch on param change */
|
||||
useEffect(() => {
|
||||
if (selectedHunt || selectedDataset) fetchStoryline();
|
||||
}, [selectedHunt, selectedDataset, fetchStoryline]);
|
||||
|
||||
/* re-render on layout change */
|
||||
useEffect(() => {
|
||||
if (storyline) renderGraph(storyline);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layout]);
|
||||
|
||||
/* controls */
|
||||
const zoomIn = () => cyRef.current?.zoom(cyRef.current.zoom() * 1.3);
|
||||
const zoomOut = () => cyRef.current?.zoom(cyRef.current.zoom() / 1.3);
|
||||
const fitAll = () => cyRef.current?.fit(undefined, 40);
|
||||
|
||||
const summary = storyline?.summary;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Storyline Attack Graph</Typography>
|
||||
|
||||
{/* Controls */}
|
||||
<Paper sx={{ p: 1.5, mb: 2 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select label="Hunt" value={selectedHunt}
|
||||
onChange={e => { setSelectedHunt(e.target.value); setSelectedDataset(''); }}>
|
||||
{huntList.map(h => (
|
||||
<MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Dataset (optional)</InputLabel>
|
||||
<Select label="Dataset (optional)" value={selectedDataset}
|
||||
onChange={e => setSelectedDataset(e.target.value)}>
|
||||
<MenuItem value="">All in hunt</MenuItem>
|
||||
{huntDatasets.map(d => (
|
||||
<MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField size="small" label="Hostname" value={hostFilter}
|
||||
onChange={e => setHostFilter(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && fetchStoryline()}
|
||||
sx={{ width: 160 }}
|
||||
/>
|
||||
|
||||
<ToggleButtonGroup size="small" value={layout} exclusive
|
||||
onChange={(_, v) => v && setLayout(v)}>
|
||||
<ToggleButton value="dagre"><Tooltip title="Hierarchical"><AccountTreeIcon fontSize="small" /></Tooltip></ToggleButton>
|
||||
<ToggleButton value="cola"><Tooltip title="Force-directed"><TimelineIcon fontSize="small" /></Tooltip></ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Tooltip title="Refresh"><IconButton onClick={fetchStoryline}><RefreshIcon /></IconButton></Tooltip>
|
||||
<Tooltip title="Zoom In"><IconButton onClick={zoomIn}><ZoomInIcon /></IconButton></Tooltip>
|
||||
<Tooltip title="Zoom Out"><IconButton onClick={zoomOut}><ZoomOutIcon /></IconButton></Tooltip>
|
||||
<Tooltip title="Fit"><IconButton onClick={fitAll}><CenterFocusStrongIcon /></IconButton></Tooltip>
|
||||
</Stack>
|
||||
|
||||
{/* Legend + stats */}
|
||||
{summary && (
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }} flexWrap="wrap" alignItems="center">
|
||||
<Chip label={`${summary.total_events} events`} size="small" variant="outlined" />
|
||||
<Chip label={`${summary.total_edges} edges`} size="small" variant="outlined" />
|
||||
<Chip label={`${summary.hosts?.length || 0} hosts`} size="small" variant="outlined" />
|
||||
<Divider orientation="vertical" flexItem />
|
||||
{Object.entries(EVENT_COLORS).map(([type, color]) => (
|
||||
<Chip
|
||||
key={type}
|
||||
label={`${type} (${summary.event_types?.[type] || 0})`}
|
||||
size="small"
|
||||
sx={{ borderColor: color, color }}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{/* Graph + Detail */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Paper
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
height: 'calc(100vh - 280px)',
|
||||
minHeight: 400,
|
||||
bgcolor: '#0f172a',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<Box sx={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{selectedNode && (
|
||||
<Paper sx={{ width: 340, p: 2, maxHeight: 'calc(100vh - 280px)', overflow: 'auto' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{selectedNode.process_name || selectedNode.label || 'Event'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={selectedNode.event_type}
|
||||
size="small"
|
||||
sx={{ borderColor: EVENT_COLORS[selectedNode.event_type] || '#6b7280',
|
||||
color: EVENT_COLORS[selectedNode.event_type] || '#6b7280', mb: 1 }}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={selectedNode.severity}
|
||||
size="small"
|
||||
sx={{ ml: 0.5, mb: 1 }}
|
||||
color={
|
||||
selectedNode.severity === 'critical' || selectedNode.severity === 'high' ? 'error'
|
||||
: selectedNode.severity === 'medium' ? 'warning'
|
||||
: 'default'
|
||||
}
|
||||
/>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack spacing={0.5}>
|
||||
<DetailRow label="Hostname" value={selectedNode.hostname} />
|
||||
<DetailRow label="PID" value={selectedNode.pid} />
|
||||
<DetailRow label="PPID" value={selectedNode.ppid} />
|
||||
<DetailRow label="User" value={selectedNode.username} />
|
||||
<DetailRow label="Timestamp" value={selectedNode.timestamp} />
|
||||
<DetailRow label="Src IP" value={selectedNode.src_ip} />
|
||||
<DetailRow label="Dst IP" value={selectedNode.dst_ip} />
|
||||
<DetailRow label="Dst Port" value={selectedNode.dst_port} />
|
||||
<DetailRow label="File" value={selectedNode.file_path} />
|
||||
</Stack>
|
||||
{selectedNode.command_line && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Command Line</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 0.5, fontFamily: 'monospace', fontSize: 12,
|
||||
wordBreak: 'break-all', bgcolor: 'background.default' }}>
|
||||
{selectedNode.command_line}
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value?: string }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
<strong>{label}:</strong>{' '}
|
||||
<span style={{ color: '#94a3b8' }}>{value}</span>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
283
frontend/src/components/TimelineScrubber.tsx
Normal file
283
frontend/src/components/TimelineScrubber.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* TimelineScrubber — interactive temporal histogram with brush selection,
|
||||
* event-type stacking, and field-stats sidebar.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, FormControl, InputLabel, Select,
|
||||
MenuItem, CircularProgress, Alert, Chip, Tooltip, IconButton,
|
||||
ToggleButton, ToggleButtonGroup, Slider, List, ListItem,
|
||||
ListItemText, LinearProgress,
|
||||
} from '@mui/material';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import PauseIcon from '@mui/icons-material/Pause';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import StackedBarChartIcon from '@mui/icons-material/StackedBarChart';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip as RTooltip,
|
||||
ResponsiveContainer, Brush, BarChart, Bar, Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
hunts, datasets, analysis,
|
||||
type HuntOut, type DatasetSummary, type TimelineBin,
|
||||
} from '../api/client';
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
process: '#3b82f6',
|
||||
network: '#8b5cf6',
|
||||
file: '#10b981',
|
||||
registry: '#f59e0b',
|
||||
authentication: '#ef4444',
|
||||
dns: '#06b6d4',
|
||||
other: '#6b7280',
|
||||
};
|
||||
|
||||
function shortTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(0, 19);
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
export default function TimelineScrubber() {
|
||||
const [huntList, setHuntList] = useState<HuntOut[]>([]);
|
||||
const [dsList, setDsList] = useState<DatasetSummary[]>([]);
|
||||
const [activeHunt, setActiveHunt] = useState<string>('');
|
||||
const [activeDs, setActiveDs] = useState<string>('');
|
||||
const [bins, setBins] = useState<TimelineBin[]>([]);
|
||||
const [fieldStats, setFieldStats] = useState<Record<string, { total: number; unique: number; top: { value: string; count: number; pct: number }[] }>>({});
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [chartType, setChartType] = useState<'area' | 'bar'>('area');
|
||||
const [numBins, setNumBins] = useState(80);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playIdx, setPlayIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Load hunts and datasets
|
||||
useEffect(() => {
|
||||
hunts.list(0, 200).then(r => {
|
||||
setHuntList(r.hunts);
|
||||
if (r.hunts.length > 0) setActiveHunt(r.hunts[0].id);
|
||||
}).catch(() => {});
|
||||
datasets.list(0, 200).then(r => {
|
||||
setDsList(r.datasets);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Fetch timeline bins
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!activeDs && !activeHunt) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [tl, fs] = await Promise.all([
|
||||
analysis.timeline({ dataset_id: activeDs || undefined, hunt_id: activeHunt || undefined, bins: numBins }),
|
||||
analysis.fieldStats({ dataset_id: activeDs || undefined, hunt_id: activeHunt || undefined }),
|
||||
]);
|
||||
setBins(tl.bins);
|
||||
setTotalRows(tl.total);
|
||||
setFieldStats(fs.fields);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [activeDs, activeHunt, numBins]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// Playback animation
|
||||
useEffect(() => {
|
||||
if (playing && bins.length > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setPlayIdx(prev => {
|
||||
if (prev >= bins.length - 1) {
|
||||
setPlaying(false);
|
||||
return 0;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [playing, bins.length]);
|
||||
|
||||
// Collect all event types across bins
|
||||
const eventTypes = Array.from(new Set(bins.flatMap(b => Object.keys(b.types || {}))));
|
||||
|
||||
// Transform bins for recharts
|
||||
const chartData = bins.map((b, i) => ({
|
||||
name: shortTime(b.start),
|
||||
total: b.count,
|
||||
...b.types,
|
||||
_idx: i,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Timeline Scrubber</Typography>
|
||||
|
||||
{/* Selectors */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select
|
||||
label="Hunt" value={activeHunt}
|
||||
onChange={e => { setActiveHunt(e.target.value); setActiveDs(''); }}
|
||||
>
|
||||
<MenuItem value="">— none —</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||
<InputLabel>Dataset</InputLabel>
|
||||
<Select
|
||||
label="Dataset" value={activeDs}
|
||||
onChange={e => setActiveDs(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">— all datasets —</MenuItem>
|
||||
{dsList.map(d => <MenuItem key={d.id} value={d.id}>{d.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ width: 140, px: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">Bins: {numBins}</Typography>
|
||||
<Slider size="small" min={20} max={200} step={10} value={numBins}
|
||||
onChange={(_, v) => setNumBins(v as number)} />
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup size="small" exclusive value={chartType}
|
||||
onChange={(_, v) => { if (v) setChartType(v); }}>
|
||||
<ToggleButton value="area"><BarChartIcon fontSize="small" /></ToggleButton>
|
||||
<ToggleButton value="bar"><StackedBarChartIcon fontSize="small" /></ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Chip label={`${totalRows.toLocaleString()} events`} size="small" color="primary" variant="outlined" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{loading && <CircularProgress sx={{ display: 'block', mx: 'auto', my: 4 }} />}
|
||||
|
||||
{!loading && bins.length > 0 && (
|
||||
<Stack direction="row" spacing={2}>
|
||||
{/* Main chart */}
|
||||
<Paper sx={{ flex: 1, p: 2 }}>
|
||||
{/* Playback controls */}
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Tooltip title={playing ? 'Pause' : 'Play animation'}>
|
||||
<IconButton size="small" onClick={() => setPlaying(p => !p)}>
|
||||
{playing ? <PauseIcon /> : <PlayArrowIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Reset">
|
||||
<IconButton size="small" onClick={() => { setPlaying(false); setPlayIdx(0); }}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{playing && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Bin {playIdx + 1} / {bins.length}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
{chartType === 'area' ? (
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<RTooltip
|
||||
contentStyle={{ background: '#1e1e1e', border: '1px solid #444', fontSize: 12 }}
|
||||
labelStyle={{ color: '#aaa' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{eventTypes.map(t => (
|
||||
<Area
|
||||
key={t} type="monotone" dataKey={t} stackId="1"
|
||||
fill={EVENT_COLORS[t] || EVENT_COLORS.other}
|
||||
stroke={EVENT_COLORS[t] || EVENT_COLORS.other}
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
))}
|
||||
<Brush
|
||||
dataKey="name" height={28} stroke="#666"
|
||||
startIndex={0}
|
||||
endIndex={playing ? Math.min(playIdx, bins.length - 1) : undefined}
|
||||
fill="#1a1a1a"
|
||||
travellerWidth={8}
|
||||
/>
|
||||
</AreaChart>
|
||||
) : (
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<RTooltip
|
||||
contentStyle={{ background: '#1e1e1e', border: '1px solid #444', fontSize: 12 }}
|
||||
labelStyle={{ color: '#aaa' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{eventTypes.map(t => (
|
||||
<Bar
|
||||
key={t} dataKey={t} stackId="1"
|
||||
fill={EVENT_COLORS[t] || EVENT_COLORS.other}
|
||||
/>
|
||||
))}
|
||||
<Brush dataKey="name" height={28} stroke="#666" fill="#1a1a1a" />
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</Paper>
|
||||
|
||||
{/* Field stats sidebar */}
|
||||
<Paper sx={{ width: 320, p: 2, maxHeight: 440, overflow: 'auto' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Field Statistics</Typography>
|
||||
{Object.entries(fieldStats).slice(0, 12).map(([field, stat]) => (
|
||||
<Box key={field} sx={{ mb: 1.5 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="caption" fontWeight={700}>{field}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{stat.unique} unique
|
||||
</Typography>
|
||||
</Stack>
|
||||
<List dense disablePadding>
|
||||
{stat.top.slice(0, 5).map(v => (
|
||||
<ListItem key={v.value} disablePadding sx={{ py: 0 }}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 140 }}>
|
||||
{v.value || '(empty)'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{v.count}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
secondary={
|
||||
<LinearProgress
|
||||
variant="determinate" value={v.pct}
|
||||
sx={{ height: 3, borderRadius: 1, mt: 0.3 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!loading && bins.length === 0 && !error && (
|
||||
<Alert severity="info">Select a hunt or dataset to view the timeline.</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
2
frontend/src/declarations.d.ts
vendored
Normal file
2
frontend/src/declarations.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module 'cytoscape-dagre';
|
||||
declare module 'cytoscape-cola';
|
||||
Reference in New Issue
Block a user