chore: checkpoint all local changes

This commit is contained in:
2026-02-23 14:36:33 -05:00
76 changed files with 34486 additions and 738 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

View File

@@ -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&amp;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&amp;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

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

View File

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

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

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

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

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

@@ -0,0 +1,2 @@
declare module 'cytoscape-dagre';
declare module 'cytoscape-cola';