feat: host-centric network map, analysis dashboard, deduped inventory

- Rewrote NetworkMap to use deduplicated host inventory (163 hosts from 394K rows)
- New host_inventory.py service: scans datasets, groups by FQDN/ClientId, extracts IPs/users/OS
- New /api/network/host-inventory endpoint
- Added AnalysisDashboard with 6 tabs (IOC, anomaly, host profile, query, triage, reports)
- Added 16 analysis API endpoints with job queue and load balancer
- Added 4 AI/analysis ORM models (ProcessingJob, AnalysisResult, HostProfile, IOCEntry)
- Filters system accounts (DWM-*, UMFD-*, LOCAL/NETWORK SERVICE)
- Infers OS from hostname patterns (W10-* -> Windows 10)
- Canvas 2D force-directed graph with host/external-IP node types
- Click popover shows hostname, FQDN, IPs, OS, users, datasets, connections
This commit is contained in:
2026-02-20 07:16:17 -05:00
parent 9b98ab9614
commit 04a9946891
24 changed files with 4774 additions and 620 deletions

View File

@@ -15,10 +15,10 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_read_timeout 300s;
}
# SPA fallback serve index.html for all non-file routes
# SPA fallback serve index.html for all non-file routes
location / {
try_files $uri $uri/ /index.html;
}
@@ -28,4 +28,4 @@ server {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

View File

@@ -1,5 +1,5 @@
/**
* ThreatHunt MUI-powered analyst-assist platform.
* ThreatHunt MUI-powered analyst-assist platform.
*/
import React, { useState, useCallback } from 'react';
@@ -18,6 +18,7 @@ import ScienceIcon from '@mui/icons-material/Science';
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
import GppMaybeIcon from '@mui/icons-material/GppMaybe';
import HubIcon from '@mui/icons-material/Hub';
import AssessmentIcon from '@mui/icons-material/Assessment';
import { SnackbarProvider } from 'notistack';
import theme from './theme';
@@ -32,6 +33,7 @@ import HypothesisTracker from './components/HypothesisTracker';
import CorrelationView from './components/CorrelationView';
import AUPScanner from './components/AUPScanner';
import NetworkMap from './components/NetworkMap';
import AnalysisDashboard from './components/AnalysisDashboard';
const DRAWER_WIDTH = 240;
@@ -42,13 +44,14 @@ const NAV: NavItem[] = [
{ label: 'Hunts', path: '/hunts', icon: <SearchIcon /> },
{ label: 'Datasets', path: '/datasets', icon: <StorageIcon /> },
{ label: 'Upload', path: '/upload', icon: <UploadFileIcon /> },
{ label: 'AI Analysis', path: '/analysis', icon: <AssessmentIcon /> },
{ label: 'Agent', path: '/agent', icon: <SmartToyIcon /> },
{ label: 'Enrichment', path: '/enrichment', icon: <SecurityIcon /> },
{ label: 'Annotations', path: '/annotations', icon: <BookmarkIcon /> },
{ label: 'Hypotheses', path: '/hypotheses', icon: <ScienceIcon /> },
{ label: 'Correlation', path: '/correlation', icon: <CompareArrowsIcon /> },
{ label: 'Network Map', path: '/network', icon: <HubIcon /> },
{ label: 'AUP Scanner', path: '/aup', icon: <GppMaybeIcon /> },
{ label: 'Network Map', path: '/network', icon: <HubIcon /> },
{ label: 'AUP Scanner', path: '/aup', icon: <GppMaybeIcon /> },
];
function Shell() {
@@ -109,6 +112,7 @@ function Shell() {
<Route path="/hunts" element={<HuntManager />} />
<Route path="/datasets" element={<DatasetViewer />} />
<Route path="/upload" element={<FileUpload />} />
<Route path="/analysis" element={<AnalysisDashboard />} />
<Route path="/agent" element={<AgentPanel />} />
<Route path="/enrichment" element={<EnrichmentPanel />} />
<Route path="/annotations" element={<AnnotationPanel />} />
@@ -135,4 +139,4 @@ function App() {
);
}
export default App;
export default App;

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;
@@ -82,7 +82,7 @@ export const hunts = {
delete: (id: string) => api(`/api/hunts/${id}`, { method: 'DELETE' }),
};
// ── Datasets ─────────────────────────────────────────────────────────
// -- Datasets --
export interface DatasetSummary {
id: string; name: string; filename: string; source_tool: string | null;
@@ -92,6 +92,8 @@ 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 {
@@ -155,7 +157,7 @@ export const datasets = {
delete: (id: string) => api(`/api/datasets/${id}`, { method: 'DELETE' }),
};
// ── Agent ────────────────────────────────────────────────────────────
// -- Agent --
export interface AssistRequest {
query: string;
@@ -198,7 +200,7 @@ export const agent = {
},
};
// ── Annotations ──────────────────────────────────────────────────────
// -- Annotations --
export interface AnnotationData {
id: string; row_id: number | null; dataset_id: string | null;
@@ -224,7 +226,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;
@@ -249,7 +251,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;
@@ -274,7 +276,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;
@@ -292,7 +294,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) =>
@@ -305,13 +307,13 @@ 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 ─────────────────────────────────────────────────────
// -- AUP Keywords --
export interface KeywordOut {
id: number; theme_id: string; value: string; is_regex: boolean; created_at: string;
@@ -368,3 +370,216 @@ export const keywords = {
quickScan: (datasetId: string) =>
api<ScanResponse>(`/api/keywords/scan/quick?dataset_id=${encodeURIComponent(datasetId)}`),
};
// -- Analysis (Phase 2+) --
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 {
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>;
}
export interface JobStats {
total: number;
queued: number;
by_status: Record<string, number>;
workers: number;
active_workers: number;
}
export interface LBNodeStatus {
healthy: boolean;
active_jobs: number;
total_completed: number;
total_errors: number;
avg_latency_ms: number;
last_check: number;
}
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' }),
// 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' }),
// 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) => {
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}`);
},
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' }),
};
// -- Network Topology --
export interface InventoryHost {
id: string;
hostname: string;
fqdn: string;
client_id: string;
ips: string[];
os: string;
users: string[];
datasets: string[];
row_count: number;
}
export interface InventoryConnection {
source: string;
target: string;
target_ip: string;
port: string;
count: number;
}
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 interface HostInventory {
hosts: InventoryHost[];
connections: InventoryConnection[];
stats: InventoryStats;
}
export const network = {
hostInventory: (huntId: string) =>
api<HostInventory>(`/api/network/host-inventory?hunt_id=${encodeURIComponent(huntId)}`),
};

View File

@@ -0,0 +1,818 @@
/**
* AnalysisDashboard -- 6-tab view covering the full AI analysis pipeline:
* 1. Triage results
* 2. Host profiles
* 3. Reports
* 4. Anomalies
* 5. Ask Data (natural language query with SSE streaming) -- Phase 9
* 6. Jobs & Load Balancer status -- Phase 10
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Box, Typography, Tabs, Tab, Paper, Button, Chip, Stack, CircularProgress,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Accordion, AccordionSummary, AccordionDetails, Alert, Select, MenuItem,
FormControl, InputLabel, LinearProgress, Tooltip, IconButton, Divider,
Card, CardContent, CardActions, Grid, TextField, ToggleButton,
ToggleButtonGroup,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RefreshIcon from '@mui/icons-material/Refresh';
import AssessmentIcon from '@mui/icons-material/Assessment';
import SecurityIcon from '@mui/icons-material/Security';
import PersonIcon from '@mui/icons-material/Person';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import ShieldIcon from '@mui/icons-material/Shield';
import BubbleChartIcon from '@mui/icons-material/BubbleChart';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import WorkIcon from '@mui/icons-material/Work';
import SendIcon from '@mui/icons-material/Send';
import StopIcon from '@mui/icons-material/Stop';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
import CancelIcon from '@mui/icons-material/Cancel';
import { useSnackbar } from 'notistack';
import {
analysis, hunts, datasets,
type Hunt, type DatasetSummary,
type TriageResultData, type HostProfileData, type HuntReportData,
type AnomalyResultData, type JobData, type JobStats, type LBNodeStatus,
} from '../api/client';
/* helpers */
function riskColor(score: number): 'error' | 'warning' | 'info' | 'success' | 'default' {
if (score >= 8) return 'error';
if (score >= 5) return 'warning';
if (score >= 2) return 'info';
return 'success';
}
function riskLabel(level: string): 'error' | 'warning' | 'info' | 'success' | 'default' {
if (level === 'critical' || level === 'high') return 'error';
if (level === 'medium') return 'warning';
if (level === 'low') return 'success';
return 'default';
}
function fmtMs(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtTime(ts: number): string {
if (!ts) return '--';
return new Date(ts * 1000).toLocaleTimeString();
}
const statusIcon = (s: string) => {
switch (s) {
case 'completed': return <CheckCircleIcon color="success" sx={{ fontSize: 18 }} />;
case 'failed': return <ErrorIcon color="error" sx={{ fontSize: 18 }} />;
case 'running': return <CircularProgress size={16} />;
case 'queued': return <HourglassEmptyIcon color="action" sx={{ fontSize: 18 }} />;
case 'cancelled': return <CancelIcon color="disabled" sx={{ fontSize: 18 }} />;
default: return null;
}
};
/* TabPanel */
function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
return value === index ? <Box sx={{ pt: 2 }}>{children}</Box> : null;
}
/* Main component */
export default function AnalysisDashboard() {
const { enqueueSnackbar } = useSnackbar();
const [tab, setTab] = useState(0);
// Selectors
const [huntList, setHuntList] = useState<Hunt[]>([]);
const [dsList, setDsList] = useState<DatasetSummary[]>([]);
const [huntId, setHuntId] = useState('');
const [dsId, setDsId] = useState('');
// Data tabs 0-3
const [triageResults, setTriageResults] = useState<TriageResultData[]>([]);
const [profiles, setProfiles] = useState<HostProfileData[]>([]);
const [reports, setReports] = useState<HuntReportData[]>([]);
const [anomalies, setAnomalies] = useState<AnomalyResultData[]>([]);
// Loading states
const [loadingTriage, setLoadingTriage] = useState(false);
const [loadingProfiles, setLoadingProfiles] = useState(false);
const [loadingReports, setLoadingReports] = useState(false);
const [loadingAnomalies, setLoadingAnomalies] = useState(false);
const [triggering, setTriggering] = useState(false);
// Phase 9: Ask Data
const [queryText, setQueryText] = useState('');
const [queryMode, setQueryMode] = useState<string>('quick');
const [queryAnswer, setQueryAnswer] = useState('');
const [queryStreaming, setQueryStreaming] = useState(false);
const [queryMeta, setQueryMeta] = useState<Record<string, any> | null>(null);
const [queryDone, setQueryDone] = useState<Record<string, any> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const answerRef = useRef<HTMLDivElement>(null);
// Phase 10: Jobs
const [jobs, setJobs] = useState<JobData[]>([]);
const [jobStats, setJobStats] = useState<JobStats | null>(null);
const [lbStatus, setLbStatus] = useState<Record<string, LBNodeStatus> | null>(null);
const [loadingJobs, setLoadingJobs] = useState(false);
// Load hunts and datasets
useEffect(() => {
hunts.list(0, 200).then(r => setHuntList(r.hunts)).catch(() => {});
datasets.list(0, 200).then(r => setDsList(r.datasets)).catch(() => {});
}, []);
useEffect(() => {
if (huntList.length > 0 && !huntId) setHuntId(huntList[0].id);
}, [huntList, huntId]);
useEffect(() => {
if (dsList.length > 0 && !dsId) setDsId(dsList[0].id);
}, [dsList, dsId]);
/* Fetch triage results */
const fetchTriage = useCallback(async () => {
if (!dsId) return;
setLoadingTriage(true);
try {
const data = await analysis.triageResults(dsId);
setTriageResults(data);
} catch (e: any) {
enqueueSnackbar(`Triage fetch failed: ${e.message}`, { variant: 'error' });
} finally { setLoadingTriage(false); }
}, [dsId, enqueueSnackbar]);
const fetchProfiles = useCallback(async () => {
if (!huntId) return;
setLoadingProfiles(true);
try {
const data = await analysis.hostProfiles(huntId);
setProfiles(data);
} catch (e: any) {
enqueueSnackbar(`Profiles fetch failed: ${e.message}`, { variant: 'error' });
} finally { setLoadingProfiles(false); }
}, [huntId, enqueueSnackbar]);
const fetchReports = useCallback(async () => {
if (!huntId) return;
setLoadingReports(true);
try {
const data = await analysis.listReports(huntId);
setReports(data);
} catch (e: any) {
enqueueSnackbar(`Reports fetch failed: ${e.message}`, { variant: 'error' });
} finally { setLoadingReports(false); }
}, [huntId, enqueueSnackbar]);
const fetchAnomalies = useCallback(async () => {
if (!dsId) return;
setLoadingAnomalies(true);
try {
const data = await analysis.anomalies(dsId);
setAnomalies(data);
} catch (e: any) {
enqueueSnackbar('Anomaly fetch failed: ' + e.message, { variant: 'error' });
} finally { setLoadingAnomalies(false); }
}, [dsId, enqueueSnackbar]);
const fetchJobs = useCallback(async () => {
setLoadingJobs(true);
try {
const data = await analysis.listJobs();
setJobs(data.jobs);
setJobStats(data.stats);
} catch (e: any) {
enqueueSnackbar('Jobs fetch failed: ' + e.message, { variant: 'error' });
} finally { setLoadingJobs(false); }
}, [enqueueSnackbar]);
const fetchLbStatus = useCallback(async () => {
try {
const data = await analysis.lbStatus();
setLbStatus(data);
} catch {}
}, []);
// Load data when selectors change
useEffect(() => { if (dsId) fetchTriage(); }, [dsId, fetchTriage]);
useEffect(() => { if (huntId) { fetchProfiles(); fetchReports(); } }, [huntId, fetchProfiles, fetchReports]);
// Auto-refresh jobs when on jobs tab
useEffect(() => {
if (tab === 5) {
fetchJobs();
fetchLbStatus();
const iv = setInterval(() => { fetchJobs(); fetchLbStatus(); }, 5000);
return () => clearInterval(iv);
}
}, [tab, fetchJobs, fetchLbStatus]);
/* Trigger actions */
const doTriggerTriage = useCallback(async () => {
if (!dsId) return;
setTriggering(true);
try {
await analysis.triggerTriage(dsId);
enqueueSnackbar('Triage started', { variant: 'info' });
setTimeout(fetchTriage, 5000);
} catch (e: any) {
enqueueSnackbar(`Triage trigger failed: ${e.message}`, { variant: 'error' });
} finally { setTriggering(false); }
}, [dsId, enqueueSnackbar, fetchTriage]);
const doTriggerProfiles = useCallback(async () => {
if (!huntId) return;
setTriggering(true);
try {
await analysis.triggerAllProfiles(huntId);
enqueueSnackbar('Host profiling started', { variant: 'info' });
setTimeout(fetchProfiles, 10000);
} catch (e: any) {
enqueueSnackbar(`Profile trigger failed: ${e.message}`, { variant: 'error' });
} finally { setTriggering(false); }
}, [huntId, enqueueSnackbar, fetchProfiles]);
const doGenerateReport = useCallback(async () => {
if (!huntId) return;
setTriggering(true);
try {
await analysis.generateReport(huntId);
enqueueSnackbar('Report generation started', { variant: 'info' });
setTimeout(fetchReports, 15000);
} catch (e: any) {
enqueueSnackbar(`Report generation failed: ${e.message}`, { variant: 'error' });
} finally { setTriggering(false); }
}, [huntId, enqueueSnackbar, fetchReports]);
const doTriggerAnomalies = useCallback(async () => {
if (!dsId) return;
setTriggering(true);
try {
await analysis.triggerAnomalyDetection(dsId);
enqueueSnackbar('Anomaly detection started', { variant: 'info' });
setTimeout(fetchAnomalies, 20000);
} catch (e: any) {
enqueueSnackbar('Anomaly trigger failed: ' + e.message, { variant: 'error' });
} finally { setTriggering(false); }
}, [dsId, enqueueSnackbar, fetchAnomalies]);
/* Phase 9: Streaming data query */
const doQuery = useCallback(async () => {
if (!dsId || !queryText.trim()) return;
setQueryStreaming(true);
setQueryAnswer('');
setQueryMeta(null);
setQueryDone(null);
const controller = new AbortController();
abortRef.current = controller;
try {
const resp = await analysis.queryStream(dsId, queryText.trim(), queryMode);
if (!resp.body) throw new Error('No response body');
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const evt = JSON.parse(line.slice(6));
switch (evt.type) {
case 'token':
setQueryAnswer(prev => prev + evt.content);
if (answerRef.current) {
answerRef.current.scrollTop = answerRef.current.scrollHeight;
}
break;
case 'metadata':
setQueryMeta(evt.dataset);
break;
case 'done':
setQueryDone(evt);
break;
case 'error':
enqueueSnackbar(`Query error: ${evt.message}`, { variant: 'error' });
break;
}
} catch {}
}
}
} catch (e: any) {
if (e.name !== 'AbortError') {
enqueueSnackbar('Query failed: ' + e.message, { variant: 'error' });
}
} finally {
setQueryStreaming(false);
abortRef.current = null;
}
}, [dsId, queryText, queryMode, enqueueSnackbar]);
const stopQuery = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
setQueryStreaming(false);
}
}, []);
/* Phase 10: Cancel job */
const doCancelJob = useCallback(async (jobId: string) => {
try {
await analysis.cancelJob(jobId);
enqueueSnackbar('Job cancelled', { variant: 'info' });
fetchJobs();
} catch (e: any) {
enqueueSnackbar('Cancel failed: ' + e.message, { variant: 'error' });
}
}, [enqueueSnackbar, fetchJobs]);
return (
<Box>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
<AssessmentIcon color="primary" sx={{ fontSize: 32 }} />
<Typography variant="h5">AI Analysis</Typography>
{triggering && <CircularProgress size={20} />}
</Stack>
{/* Selectors */}
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} flexWrap="wrap">
<FormControl size="small" sx={{ minWidth: 260 }}>
<InputLabel>Hunt</InputLabel>
<Select label="Hunt" value={huntId} onChange={e => setHuntId(e.target.value)}>
{huntList.map(h => (
<MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 260 }}>
<InputLabel>Dataset</InputLabel>
<Select label="Dataset" value={dsId} onChange={e => setDsId(e.target.value)}>
{dsList.map(d => (
<MenuItem key={d.id} value={d.id}>{d.name} ({d.row_count} rows)</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Paper>
{/* Tabs */}
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ mb: 1 }}>
<Tab icon={<SecurityIcon />} iconPosition="start" label={`Triage (${triageResults.length})`} />
<Tab icon={<PersonIcon />} iconPosition="start" label={`Host Profiles (${profiles.length})`} />
<Tab icon={<AssessmentIcon />} iconPosition="start" label={`Reports (${reports.length})`} />
<Tab icon={<BubbleChartIcon />} iconPosition="start" label={`Anomalies (${anomalies.filter(a => a.is_outlier).length})`} />
<Tab icon={<QuestionAnswerIcon />} iconPosition="start" label="Ask Data" />
<Tab icon={<WorkIcon />} iconPosition="start" label={`Jobs${jobStats ? ` (${jobStats.active_workers})` : ''}`} />
</Tabs>
{/* Tab 0: Triage */}
<TabPanel value={tab} index={0}>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button variant="contained" startIcon={<PlayArrowIcon />} onClick={doTriggerTriage}
disabled={!dsId || triggering} size="small">Run Triage</Button>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={fetchTriage}
disabled={!dsId || loadingTriage} size="small">Refresh</Button>
</Stack>
{loadingTriage && <LinearProgress sx={{ mb: 1 }} />}
{triageResults.length === 0 && !loadingTriage ? (
<Alert severity="info">No triage results yet. Select a dataset and click "Run Triage".</Alert>
) : (
<TableContainer component={Paper} sx={{ maxHeight: 500 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Rows</TableCell><TableCell>Risk</TableCell><TableCell>Verdict</TableCell>
<TableCell>Findings</TableCell><TableCell>MITRE</TableCell><TableCell>Model</TableCell>
</TableRow>
</TableHead>
<TableBody>
{triageResults.map(tr => (
<TableRow key={tr.id} hover>
<TableCell>{tr.row_start}-{tr.row_end}</TableCell>
<TableCell><Chip label={tr.risk_score.toFixed(1)} size="small" color={riskColor(tr.risk_score)} /></TableCell>
<TableCell><Chip label={tr.verdict} size="small" variant="outlined" /></TableCell>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{tr.findings?.join('; ') || ''}
</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} flexWrap="wrap">
{tr.mitre_techniques?.map((t: string, i: number) => (
<Chip key={i} label={t} size="small" variant="outlined" color="warning" />
))}
</Stack>
</TableCell>
<TableCell><Typography variant="caption">{tr.model_used || ''}</Typography></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</TabPanel>
{/* Tab 1: Host Profiles */}
<TabPanel value={tab} index={1}>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button variant="contained" startIcon={<PlayArrowIcon />} onClick={doTriggerProfiles}
disabled={!huntId || triggering} size="small">Profile All Hosts</Button>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={fetchProfiles}
disabled={!huntId || loadingProfiles} size="small">Refresh</Button>
</Stack>
{loadingProfiles && <LinearProgress sx={{ mb: 1 }} />}
{profiles.length === 0 && !loadingProfiles ? (
<Alert severity="info">No host profiles yet. Select a hunt and click "Profile All Hosts".</Alert>
) : (
<Grid container spacing={2}>
{profiles.map(hp => (
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={hp.id}>
<Card variant="outlined" sx={{
borderLeft: 4,
borderLeftColor: hp.risk_level === 'critical' ? 'error.main'
: hp.risk_level === 'high' ? 'error.light'
: hp.risk_level === 'medium' ? 'warning.main' : 'success.main',
}}>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6">{hp.hostname}</Typography>
<Chip label={`${hp.risk_score.toFixed(1)} ${hp.risk_level}`}
size="small" color={riskLabel(hp.risk_level)} />
</Stack>
{hp.fqdn && <Typography variant="caption" color="text.secondary">{hp.fqdn}</Typography>}
<Divider sx={{ my: 1 }} />
{hp.timeline_summary && (
<Typography variant="body2" sx={{ mb: 1, whiteSpace: 'pre-wrap' }}>
{hp.timeline_summary.slice(0, 300)}{hp.timeline_summary.length > 300 ? '...' : ''}
</Typography>
)}
{hp.suspicious_findings && hp.suspicious_findings.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="warning.main">
<WarningAmberIcon sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
{hp.suspicious_findings.length} suspicious finding(s)
</Typography>
</Box>
)}
{hp.mitre_techniques && hp.mitre_techniques.length > 0 && (
<Stack direction="row" spacing={0.5} flexWrap="wrap" sx={{ mb: 1 }}>
{hp.mitre_techniques.map((t: string, i: number) => (
<Chip key={i} label={t} size="small" variant="outlined" color="warning" />
))}
</Stack>
)}
</CardContent>
<CardActions>
<Typography variant="caption" color="text.secondary">Model: {hp.model_used || 'N/A'}</Typography>
</CardActions>
</Card>
</Grid>
))}
</Grid>
)}
</TabPanel>
{/* Tab 2: Reports */}
<TabPanel value={tab} index={2}>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button variant="contained" startIcon={<PlayArrowIcon />} onClick={doGenerateReport}
disabled={!huntId || triggering} size="small">Generate Report</Button>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={fetchReports}
disabled={!huntId || loadingReports} size="small">Refresh</Button>
</Stack>
{loadingReports && <LinearProgress sx={{ mb: 1 }} />}
{reports.length === 0 && !loadingReports ? (
<Alert severity="info">No reports yet. Select a hunt and click "Generate Report".</Alert>
) : (
reports.map(rpt => (
<Accordion key={rpt.id} defaultExpanded={reports.length === 1}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Stack direction="row" spacing={1} alignItems="center">
<ShieldIcon color="primary" />
<Typography>Report - {rpt.status}</Typography>
{rpt.generation_time_ms && (
<Chip label={fmtMs(rpt.generation_time_ms)} size="small" variant="outlined" />
)}
</Stack>
</AccordionSummary>
<AccordionDetails>
{rpt.exec_summary && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="primary">Executive Summary</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{rpt.exec_summary}</Typography>
</Box>
)}
{rpt.findings && rpt.findings.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="warning.main">Findings</Typography>
<ul style={{ margin: 0, paddingLeft: 20 }}>
{rpt.findings.map((f: any, i: number) => (
<li key={i}><Typography variant="body2">
{typeof f === 'string' ? f : JSON.stringify(f)}
</Typography></li>
))}
</ul>
</Box>
)}
{rpt.recommendations && rpt.recommendations.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="success.main">Recommendations</Typography>
<ul style={{ margin: 0, paddingLeft: 20 }}>
{rpt.recommendations.map((r: any, i: number) => (
<li key={i}><Typography variant="body2">
{typeof r === 'string' ? r : JSON.stringify(r)}
</Typography></li>
))}
</ul>
</Box>
)}
{rpt.ioc_table && rpt.ioc_table.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2">IOC Table</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small">
<TableHead>
<TableRow>
{Object.keys(rpt.ioc_table[0]).map(k => (
<TableCell key={k}>{k}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rpt.ioc_table.map((row: any, i: number) => (
<TableRow key={i}>
{Object.values(row).map((v: any, j: number) => (
<TableCell key={j}><Typography variant="caption">{String(v)}</Typography></TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
{rpt.full_report && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">Full Report</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>
{rpt.full_report}
</Typography>
</AccordionDetails>
</Accordion>
)}
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
{rpt.models_used?.map((m: string, i: number) => (
<Chip key={i} label={m} size="small" variant="outlined" />
))}
</Stack>
</AccordionDetails>
</Accordion>
))
)}
</TabPanel>
{/* Tab 3: Anomalies */}
<TabPanel value={tab} index={3}>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button variant="contained" startIcon={<PlayArrowIcon />} onClick={doTriggerAnomalies}
disabled={!dsId || triggering} size="small">Detect Anomalies</Button>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={fetchAnomalies}
disabled={!dsId || loadingAnomalies} size="small">Refresh</Button>
</Stack>
{loadingAnomalies && <LinearProgress sx={{ mb: 1 }} />}
{anomalies.length === 0 && !loadingAnomalies ? (
<Alert severity="info">No anomaly results yet. Select a dataset and click "Detect Anomalies".</Alert>
) : (
<>
<Alert severity="warning" sx={{ mb: 1 }}>
{anomalies.filter(a => a.is_outlier).length} outlier(s) detected out of {anomalies.length} rows
</Alert>
<TableContainer component={Paper} sx={{ maxHeight: 500 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Row</TableCell><TableCell>Score</TableCell>
<TableCell>Distance</TableCell><TableCell>Cluster</TableCell><TableCell>Outlier</TableCell>
</TableRow>
</TableHead>
<TableBody>
{anomalies.filter(a => a.is_outlier).concat(anomalies.filter(a => !a.is_outlier).slice(0, 20)).map((a, i) => (
<TableRow key={a.id || i} hover sx={a.is_outlier ? { bgcolor: 'rgba(244,63,94,0.08)' } : {}}>
<TableCell>{a.row_id ?? ''}</TableCell>
<TableCell>
<Chip label={a.anomaly_score.toFixed(4)} size="small"
color={a.anomaly_score > 0.5 ? 'error' : a.anomaly_score > 0.35 ? 'warning' : 'success'} />
</TableCell>
<TableCell>{a.distance_from_centroid?.toFixed(4) ?? ''}</TableCell>
<TableCell><Chip label={`C${a.cluster_id}`} size="small" variant="outlined" /></TableCell>
<TableCell>
{a.is_outlier
? <Chip label="OUTLIER" size="small" color="error" />
: <Chip label="Normal" size="small" color="success" variant="outlined" />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
)}
</TabPanel>
{/* Tab 4: Ask Data (Phase 9) */}
<TabPanel value={tab} index={4}>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Ask a question about the selected dataset in plain English
</Typography>
<Stack direction="row" spacing={1} alignItems="flex-end">
<TextField
fullWidth size="small" multiline maxRows={3}
placeholder="e.g., Are there any suspicious processes running at unusual hours?"
value={queryText}
onChange={e => setQueryText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doQuery(); } }}
disabled={queryStreaming}
/>
<ToggleButtonGroup
value={queryMode} exclusive size="small"
onChange={(_, v) => { if (v) setQueryMode(v); }}
>
<ToggleButton value="quick">
<Tooltip title="Fast (Roadrunner)"><Typography variant="caption">Quick</Typography></Tooltip>
</ToggleButton>
<ToggleButton value="deep">
<Tooltip title="Deep (Wile 70B)"><Typography variant="caption">Deep</Typography></Tooltip>
</ToggleButton>
</ToggleButtonGroup>
{queryStreaming ? (
<IconButton color="error" onClick={stopQuery}><StopIcon /></IconButton>
) : (
<IconButton color="primary" onClick={doQuery} disabled={!dsId || !queryText.trim()}>
<SendIcon />
</IconButton>
)}
</Stack>
</Paper>
{queryMeta && (
<Alert severity="info" sx={{ mb: 1 }}>
Querying <strong>{queryMeta.name}</strong> ({queryMeta.row_count} rows,{' '}
{queryMeta.sample_rows_shown} sampled) | Mode: {queryMode}
</Alert>
)}
{queryStreaming && <LinearProgress sx={{ mb: 1 }} />}
{queryAnswer && (
<Paper
ref={answerRef}
sx={{
p: 2, maxHeight: 500, overflow: 'auto',
bgcolor: 'grey.900', color: 'grey.100',
fontFamily: 'monospace', fontSize: 13, whiteSpace: 'pre-wrap',
borderRadius: 2,
}}
>
{queryAnswer}
</Paper>
)}
{queryDone && (
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Chip label={`${queryDone.tokens} tokens`} size="small" variant="outlined" />
<Chip label={fmtMs(queryDone.elapsed_ms)} size="small" variant="outlined" />
<Chip label={queryDone.model} size="small" />
<Chip label={queryDone.node} size="small" color={queryDone.node === 'wile' ? 'secondary' : 'primary'} />
</Stack>
)}
</TabPanel>
{/* Tab 5: Jobs & Load Balancer (Phase 10) */}
<TabPanel value={tab} index={5}>
{/* LB Status Cards */}
{lbStatus && (
<Grid container spacing={2} sx={{ mb: 2 }}>
{Object.entries(lbStatus).map(([name, st]) => (
<Grid size={{ xs: 12, sm: 6 }} key={name}>
<Card variant="outlined" sx={{
borderLeft: 4,
borderLeftColor: st.healthy ? 'success.main' : 'error.main',
}}>
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ textTransform: 'capitalize' }}>{name}</Typography>
<Chip label={st.healthy ? 'HEALTHY' : 'DOWN'} size="small"
color={st.healthy ? 'success' : 'error'} />
</Stack>
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
<Typography variant="body2">Active: <strong>{st.active_jobs}</strong></Typography>
<Typography variant="body2">Done: <strong>{st.total_completed}</strong></Typography>
<Typography variant="body2">Errors: <strong>{st.total_errors}</strong></Typography>
<Typography variant="body2">Avg: <strong>{st.avg_latency_ms.toFixed(0)}ms</strong></Typography>
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Job queue stats */}
{jobStats && (
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Chip label={`Workers: ${jobStats.active_workers}/${jobStats.workers}`} size="small" />
<Chip label={`Queued: ${jobStats.queued}`} size="small" color="info" />
{Object.entries(jobStats.by_status).map(([s, c]) => (
<Chip key={s} label={`${s}: ${c}`} size="small" variant="outlined" />
))}
</Stack>
)}
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={fetchJobs}
disabled={loadingJobs} size="small">Refresh</Button>
</Stack>
{loadingJobs && <LinearProgress sx={{ mb: 1 }} />}
{jobs.length === 0 && !loadingJobs ? (
<Alert severity="info">No jobs yet. Jobs appear here when you trigger triage, profiling, reports, anomaly detection, or data queries.</Alert>
) : (
<TableContainer component={Paper} sx={{ maxHeight: 500 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Type</TableCell>
<TableCell>Progress</TableCell>
<TableCell>Message</TableCell>
<TableCell>Time</TableCell>
<TableCell>Created</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{jobs.map(j => (
<TableRow key={j.id} hover
sx={j.status === 'failed' ? { bgcolor: 'rgba(244,63,94,0.06)' }
: j.status === 'running' ? { bgcolor: 'rgba(59,130,246,0.06)' } : {}}>
<TableCell>
<Stack direction="row" spacing={0.5} alignItems="center">
{statusIcon(j.status)}
<Typography variant="caption">{j.status}</Typography>
</Stack>
</TableCell>
<TableCell><Chip label={j.job_type} size="small" variant="outlined" /></TableCell>
<TableCell>
{j.status === 'running' ? (
<LinearProgress variant="determinate" value={j.progress}
sx={{ width: 80, height: 6, borderRadius: 3 }} />
) : j.status === 'completed' ? (
<Typography variant="caption" color="success.main">100%</Typography>
) : null}
</TableCell>
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
<Typography variant="caption">{j.error || j.message}</Typography>
</TableCell>
<TableCell><Typography variant="caption">{fmtMs(j.elapsed_ms)}</Typography></TableCell>
<TableCell><Typography variant="caption">{fmtTime(j.created_at)}</Typography></TableCell>
<TableCell>
{(j.status === 'queued' || j.status === 'running') && (
<IconButton size="small" color="error" onClick={() => doCancelJob(j.id)}>
<CancelIcon fontSize="small" />
</IconButton>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</TabPanel>
</Box>
);
}

View File

@@ -144,6 +144,12 @@ export default function DatasetViewer() {
<Chip label={`${selected.row_count} rows`} size="small" />
<Chip label={selected.encoding || 'utf-8'} size="small" variant="outlined" />
{selected.source_tool && <Chip label={selected.source_tool} size="small" color="info" variant="outlined" />}
{selected.artifact_type && <Chip label={selected.artifact_type} size="small" color="secondary" />}
{selected.processing_status && selected.processing_status !== 'ready' && (
<Chip label={selected.processing_status} size="small"
color={selected.processing_status === 'done' ? 'success' : selected.processing_status === 'error' ? 'error' : 'warning'}
variant="outlined" />
)}
{selected.ioc_columns && Object.keys(selected.ioc_columns).length > 0 && (
<Chip label={`${Object.keys(selected.ioc_columns).length} IOC columns`} size="small" color="warning" variant="outlined" />
)}

File diff suppressed because it is too large Load Diff