Merge origin/main (v0.3.1) into local main (v0.4.0) — keep local versions for all conflicts

This commit is contained in:
2026-02-20 14:35:08 -05:00
22 changed files with 3131 additions and 37 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

@@ -75,7 +75,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -731,7 +730,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz",
"integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
@@ -1615,7 +1613,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
"integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-module-imports": "^7.28.6",
@@ -2457,7 +2454,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2501,7 +2497,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -3083,7 +3078,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.8.tgz",
"integrity": "sha512-QKd1RhDXE1hf2sQDNayA9ic9jGkEgvZOf0tTkJxlBPG8ns8aS4rS8WwYURw2x5y3739p0HauUXX9WbH7UufFLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/core-downloads-tracker": "^7.3.8",
@@ -3194,7 +3188,6 @@
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.8.tgz",
"integrity": "sha512-hoFRj4Zw2Km8DPWZp/nKG+ao5Jw5LSk2m/e4EGc6M3RRwXKEkMSG4TgtfVJg7dS2homRwtdXSMW+iRO0ZJ4+IA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/private-theming": "^7.3.8",
@@ -4296,7 +4289,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -4464,7 +4456,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
@@ -4518,7 +4509,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -4888,7 +4878,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4987,7 +4976,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5910,7 +5898,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7037,8 +7024,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/cytoscape": {
"version": "3.33.1",
@@ -8069,7 +8055,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -10977,7 +10962,6 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^27.5.1",
"import-local": "^3.0.2",
@@ -11875,7 +11859,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -14143,7 +14126,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -15278,7 +15260,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -15654,7 +15635,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -15789,7 +15769,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -15867,7 +15846,6 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16492,7 +16470,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -16738,7 +16715,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -18152,7 +18128,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18450,7 +18425,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -18959,7 +18933,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz",
"integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -19445,7 +19418,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",

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

@@ -146,6 +146,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" />
)}