mirror of
https://github.com/mblanke/ThreatHunt.git
synced 2026-03-01 05:50:21 -05:00
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:
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)}`),
|
||||
};
|
||||
818
frontend/src/components/AnalysisDashboard.tsx
Normal file
818
frontend/src/components/AnalysisDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user