mirror of
https://github.com/mblanke/ThreatHunt.git
synced 2026-03-01 05:50:21 -05:00
feat: Add Playbook Manager, Saved Searches, and Timeline View components
- Implemented PlaybookManager for creating and managing investigation playbooks with templates. - Added SavedSearches component for managing bookmarked queries and recurring scans. - Introduced TimelineView for visualizing forensic event timelines with zoomable charts. - Enhanced backend processing with auto-queued jobs for dataset uploads and improved database concurrency. - Updated frontend components for better user experience and performance optimizations. - Documented changes in update log for future reference.
This commit is contained in:
@@ -16,6 +16,12 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# SSE streaming support for agent assist
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection '';
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
|
||||
# SPA fallback serve index.html for all non-file routes
|
||||
|
||||
378
frontend/package-lock.json
generated
378
frontend/package-lock.json
generated
@@ -18,7 +18,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-scripts": "5.0.1"
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
@@ -3476,6 +3477,42 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-babel": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||
@@ -3591,6 +3628,18 @@
|
||||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
||||
@@ -3921,6 +3970,69 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "8.56.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
|
||||
@@ -4246,6 +4358,12 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -6757,6 +6875,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -6851,6 +7090,12 @@
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
|
||||
@@ -7484,6 +7729,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -9645,6 +9900,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||
@@ -14239,6 +14503,29 @@
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||
@@ -14410,6 +14697,52 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/recharts/node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/recursive-readdir": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
|
||||
@@ -14422,6 +14755,21 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -16329,6 +16677,12 @@
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -16902,6 +17256,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-hr-time": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-scripts": "5.0.1"
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* ThreatHunt MUI-powered analyst-assist platform.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ThemeProvider, CssBaseline, Box, AppBar, Toolbar, Typography, IconButton,
|
||||
Drawer, List, ListItemButton, ListItemIcon, ListItemText, Divider, Chip } from '@mui/material';
|
||||
Drawer, List, ListItemButton, ListItemIcon, ListItemText, Divider, Chip,
|
||||
CircularProgress } from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
@@ -19,9 +20,14 @@ 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 TimelineIcon from '@mui/icons-material/Timeline';
|
||||
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||
import BookmarksIcon from '@mui/icons-material/Bookmarks';
|
||||
import ShieldIcon from '@mui/icons-material/Shield';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import theme from './theme';
|
||||
|
||||
/* -- Eager imports (lightweight, always needed) -- */
|
||||
import Dashboard from './components/Dashboard';
|
||||
import HuntManager from './components/HuntManager';
|
||||
import DatasetViewer from './components/DatasetViewer';
|
||||
@@ -32,28 +38,46 @@ import AnnotationPanel from './components/AnnotationPanel';
|
||||
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';
|
||||
|
||||
/* -- Lazy imports (heavy: charts, network graph, new feature pages) -- */
|
||||
const NetworkMap = React.lazy(() => import('./components/NetworkMap'));
|
||||
const AnalysisDashboard = React.lazy(() => import('./components/AnalysisDashboard'));
|
||||
const MitreMatrix = React.lazy(() => import('./components/MitreMatrix'));
|
||||
const TimelineView = React.lazy(() => import('./components/TimelineView'));
|
||||
const PlaybookManager = React.lazy(() => import('./components/PlaybookManager'));
|
||||
const SavedSearches = React.lazy(() => import('./components/SavedSearches'));
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
interface NavItem { label: string; path: string; icon: React.ReactNode }
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ label: 'Dashboard', path: '/', icon: <DashboardIcon /> },
|
||||
{ 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: 'Dashboard', path: '/', icon: <DashboardIcon /> },
|
||||
{ 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: 'MITRE Matrix', path: '/mitre', icon: <ShieldIcon /> },
|
||||
{ label: 'Timeline', path: '/timeline', icon: <TimelineIcon /> },
|
||||
{ label: 'Playbooks', path: '/playbooks', icon: <PlaylistAddCheckIcon /> },
|
||||
{ label: 'Saved Searches', path: '/saved-searches', icon: <BookmarksIcon /> },
|
||||
];
|
||||
|
||||
function LazyFallback() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
@@ -72,7 +96,7 @@ function Shell() {
|
||||
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>
|
||||
ThreatHunt
|
||||
</Typography>
|
||||
<Chip label="v0.3.0" size="small" color="primary" variant="outlined" />
|
||||
<Chip label="v0.4.0" size="small" color="primary" variant="outlined" />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
@@ -107,20 +131,26 @@ function Shell() {
|
||||
ml: open ? 0 : `-${DRAWER_WIDTH}px`,
|
||||
transition: 'margin 225ms cubic-bezier(0,0,0.2,1)',
|
||||
}}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<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 />} />
|
||||
<Route path="/hypotheses" element={<HypothesisTracker />} />
|
||||
<Route path="/correlation" element={<CorrelationView />} />
|
||||
<Route path="/network" element={<NetworkMap />} />
|
||||
<Route path="/aup" element={<AUPScanner />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<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 />} />
|
||||
<Route path="/hypotheses" element={<HypothesisTracker />} />
|
||||
<Route path="/correlation" element={<CorrelationView />} />
|
||||
<Route path="/network" element={<NetworkMap />} />
|
||||
<Route path="/aup" element={<AUPScanner />} />
|
||||
<Route path="/mitre" element={<MitreMatrix />} />
|
||||
<Route path="/timeline" element={<TimelineView />} />
|
||||
<Route path="/playbooks" element={<PlaybookManager />} />
|
||||
<Route path="/saved-searches" element={<SavedSearches />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -139,4 +169,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -71,6 +71,20 @@ export interface Hunt {
|
||||
dataset_count: number; hypothesis_count: number;
|
||||
}
|
||||
|
||||
export interface HuntProgress {
|
||||
hunt_id: string;
|
||||
status: 'idle' | 'processing' | 'ready';
|
||||
progress_percent: number;
|
||||
dataset_total: number;
|
||||
dataset_completed: number;
|
||||
dataset_processing: number;
|
||||
dataset_errors: number;
|
||||
active_jobs: number;
|
||||
queued_jobs: number;
|
||||
network_status: 'none' | 'building' | 'ready';
|
||||
stages: Record<string, any>;
|
||||
}
|
||||
|
||||
export const hunts = {
|
||||
list: (skip = 0, limit = 50) =>
|
||||
api<{ hunts: Hunt[]; total: number }>(`/api/hunts?skip=${skip}&limit=${limit}`),
|
||||
@@ -80,6 +94,7 @@ export const hunts = {
|
||||
update: (id: string, data: Partial<{ name: string; description: string; status: string }>) =>
|
||||
api<Hunt>(`/api/hunts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => api(`/api/hunts/${id}`, { method: 'DELETE' }),
|
||||
progress: (id: string) => api<HuntProgress>(`/api/hunts/${id}/progress`),
|
||||
};
|
||||
|
||||
// -- Datasets --
|
||||
@@ -166,6 +181,8 @@ export interface AssistRequest {
|
||||
active_hypotheses?: string[]; annotations_summary?: string;
|
||||
enrichment_summary?: string; mode?: 'quick' | 'deep' | 'debate';
|
||||
model_override?: string; conversation_id?: string; hunt_id?: string;
|
||||
execution_preference?: 'auto' | 'force' | 'off';
|
||||
learning_mode?: boolean;
|
||||
}
|
||||
|
||||
export interface AssistResponse {
|
||||
@@ -174,6 +191,15 @@ export interface AssistResponse {
|
||||
sans_references: string[]; model_used: string; node_used: string;
|
||||
latency_ms: number; perspectives: Record<string, any>[] | null;
|
||||
conversation_id: string | null;
|
||||
execution?: {
|
||||
scope: string;
|
||||
datasets_scanned: string[];
|
||||
policy_hits: number;
|
||||
result_count: number;
|
||||
top_domains: string[];
|
||||
top_user_hosts: string[];
|
||||
elapsed_ms: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface NodeInfo { url: string; available: boolean }
|
||||
@@ -326,10 +352,12 @@ export interface ScanHit {
|
||||
theme_name: string; theme_color: string; keyword: string;
|
||||
source_type: string; source_id: string | number; field: string;
|
||||
matched_value: string; row_index: number | null; dataset_name: string | null;
|
||||
hostname?: string | null; username?: string | null;
|
||||
}
|
||||
export interface ScanResponse {
|
||||
total_hits: number; hits: ScanHit[]; themes_scanned: number;
|
||||
keywords_scanned: number; rows_scanned: number;
|
||||
cache_used?: boolean; cache_status?: string; cached_at?: string | null;
|
||||
}
|
||||
|
||||
export const keywords = {
|
||||
@@ -363,6 +391,7 @@ export const keywords = {
|
||||
scan: (opts: {
|
||||
dataset_ids?: string[]; theme_ids?: string[];
|
||||
scan_hunts?: boolean; scan_annotations?: boolean; scan_messages?: boolean;
|
||||
prefer_cache?: boolean; force_rescan?: boolean;
|
||||
}) =>
|
||||
api<ScanResponse>('/api/keywords/scan', {
|
||||
method: 'POST', body: JSON.stringify(opts),
|
||||
@@ -579,7 +608,213 @@ export interface HostInventory {
|
||||
stats: InventoryStats;
|
||||
}
|
||||
|
||||
export interface InventoryStatus {
|
||||
hunt_id: string;
|
||||
status: 'ready' | 'building' | 'none';
|
||||
}
|
||||
|
||||
export interface NetworkSummaryHost {
|
||||
id: string;
|
||||
hostname: string;
|
||||
row_count: number;
|
||||
ip_count: number;
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export interface NetworkSummary {
|
||||
stats: InventoryStats;
|
||||
top_hosts: NetworkSummaryHost[];
|
||||
top_edges: InventoryConnection[];
|
||||
status?: 'building' | 'deferred';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const network = {
|
||||
hostInventory: (huntId: string) =>
|
||||
api<HostInventory>(`/api/network/host-inventory?hunt_id=${encodeURIComponent(huntId)}`),
|
||||
};
|
||||
hostInventory: (huntId: string, force = false) =>
|
||||
api<HostInventory | { status: 'building' | 'deferred'; message?: string }>(`/api/network/host-inventory?hunt_id=${encodeURIComponent(huntId)}${force ? '&force=true' : ''}`),
|
||||
summary: (huntId: string, topN = 20) =>
|
||||
api<NetworkSummary | { status: 'building' | 'deferred'; message?: string }>(`/api/network/summary?hunt_id=${encodeURIComponent(huntId)}&top_n=${topN}`),
|
||||
subgraph: (huntId: string, maxHosts = 250, maxEdges = 1500, nodeId?: string) => {
|
||||
let qs = `/api/network/subgraph?hunt_id=${encodeURIComponent(huntId)}&max_hosts=${maxHosts}&max_edges=${maxEdges}`;
|
||||
if (nodeId) qs += `&node_id=${encodeURIComponent(nodeId)}`;
|
||||
return api<HostInventory | { status: 'building' | 'deferred'; message?: string }>(qs);
|
||||
},
|
||||
inventoryStatus: (huntId: string) =>
|
||||
api<InventoryStatus>(`/api/network/inventory-status?hunt_id=${encodeURIComponent(huntId)}`),
|
||||
rebuildInventory: (huntId: string) =>
|
||||
api<{ job_id: string; status: string }>(`/api/network/rebuild-inventory?hunt_id=${encodeURIComponent(huntId)}`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
// -- MITRE ATT&CK Coverage (Feature 1) --
|
||||
|
||||
export interface MitreTechnique {
|
||||
id: string;
|
||||
tactic: string;
|
||||
sources: { type: string; risk_score?: number; hostname?: string; title?: string }[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MitreCoverage {
|
||||
tactics: string[];
|
||||
technique_count: number;
|
||||
detection_count: number;
|
||||
tactic_coverage: Record<string, { techniques: MitreTechnique[]; count: number }>;
|
||||
all_techniques: MitreTechnique[];
|
||||
}
|
||||
|
||||
export const mitre = {
|
||||
coverage: (huntId?: string) => {
|
||||
const q = huntId ? `?hunt_id=${encodeURIComponent(huntId)}` : '';
|
||||
return api<MitreCoverage>(`/api/mitre/coverage${q}`);
|
||||
},
|
||||
};
|
||||
|
||||
// -- Timeline (Feature 2) --
|
||||
|
||||
export interface TimelineEvent {
|
||||
timestamp: string;
|
||||
dataset_id: string;
|
||||
dataset_name: string;
|
||||
artifact_type: string;
|
||||
row_index: number;
|
||||
hostname: string;
|
||||
process: string;
|
||||
summary: string;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TimelineData {
|
||||
hunt_id: string;
|
||||
hunt_name: string;
|
||||
event_count: number;
|
||||
datasets: { id: string; name: string; artifact_type: string; row_count: number }[];
|
||||
events: TimelineEvent[];
|
||||
}
|
||||
|
||||
export const timeline = {
|
||||
getHuntTimeline: (huntId: string, limit = 2000) =>
|
||||
api<TimelineData>(`/api/timeline/hunt/${huntId}?limit=${limit}`),
|
||||
};
|
||||
|
||||
// -- Playbooks (Feature 3) --
|
||||
|
||||
export interface PlaybookStep {
|
||||
id: number;
|
||||
order_index: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
step_type: string;
|
||||
target_route: string | null;
|
||||
is_completed: boolean;
|
||||
completed_at: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface PlaybookSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_template: boolean;
|
||||
hunt_id: string | null;
|
||||
status: string;
|
||||
total_steps: number;
|
||||
completed_steps: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface PlaybookDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_template: boolean;
|
||||
hunt_id: string | null;
|
||||
status: string;
|
||||
created_at: string | null;
|
||||
steps: PlaybookStep[];
|
||||
}
|
||||
|
||||
export interface PlaybookTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
steps: { title: string; description: string; step_type: string; target_route: string }[];
|
||||
}
|
||||
|
||||
export const playbooks = {
|
||||
list: (huntId?: string) => {
|
||||
const q = huntId ? `?hunt_id=${encodeURIComponent(huntId)}` : '';
|
||||
return api<{ playbooks: PlaybookSummary[] }>(`/api/playbooks${q}`);
|
||||
},
|
||||
templates: () => api<{ templates: PlaybookTemplate[] }>('/api/playbooks/templates'),
|
||||
get: (id: string) => api<PlaybookDetail>(`/api/playbooks/${id}`),
|
||||
create: (data: { name: string; description?: string; hunt_id?: string; is_template?: boolean; steps?: { title: string; description?: string; step_type?: string; target_route?: string }[] }) =>
|
||||
api<PlaybookDetail>('/api/playbooks', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: { name?: string; description?: string; status?: string }) =>
|
||||
api(`/api/playbooks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => api(`/api/playbooks/${id}`, { method: 'DELETE' }),
|
||||
updateStep: (stepId: number, data: { is_completed?: boolean; notes?: string }) =>
|
||||
api(`/api/playbooks/steps/${stepId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
};
|
||||
|
||||
// -- Saved Searches (Feature 5) --
|
||||
|
||||
export interface SavedSearchData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
search_type: string;
|
||||
query_params: Record<string, any>;
|
||||
hunt_id?: string | null;
|
||||
threshold: number | null;
|
||||
last_run_at: string | null;
|
||||
last_result_count: number | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface SearchRunResult {
|
||||
search_id: string;
|
||||
search_name: string;
|
||||
search_type: string;
|
||||
result_count: number;
|
||||
previous_count: number;
|
||||
delta: number;
|
||||
results: any[];
|
||||
}
|
||||
|
||||
export const savedSearches = {
|
||||
list: (searchType?: string) => {
|
||||
const q = searchType ? `?search_type=${encodeURIComponent(searchType)}` : '';
|
||||
return api<{ searches: SavedSearchData[] }>(`/api/searches${q}`);
|
||||
},
|
||||
get: (id: string) => api<SavedSearchData>(`/api/searches/${id}`),
|
||||
create: (data: { name: string; description?: string; search_type: string; query_params: Record<string, any>; threshold?: number; hunt_id?: string }) =>
|
||||
api<SavedSearchData>('/api/searches', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: { name?: string; description?: string; search_type?: string; query_params?: Record<string, any>; threshold?: number; hunt_id?: string }) =>
|
||||
api(`/api/searches/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => api(`/api/searches/${id}`, { method: 'DELETE' }),
|
||||
run: (id: string) => api<SearchRunResult>(`/api/searches/${id}/run`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
// -- STIX Export --
|
||||
|
||||
export const stixExport = {
|
||||
/** Download a STIX 2.1 bundle JSON for a given hunt */
|
||||
download: async (huntId: string): Promise<void> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
const res = await fetch(`${BASE}/api/export/stix/${huntId}`, { headers });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `hunt-${huntId}-stix-bundle.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -188,11 +188,13 @@ const RESULT_COLUMNS: GridColDef[] = [
|
||||
),
|
||||
},
|
||||
{ field: 'keyword', headerName: 'Keyword', width: 140 },
|
||||
{ field: 'source_type', headerName: 'Source', width: 120 },
|
||||
{ field: 'dataset_name', headerName: 'Dataset', width: 150 },
|
||||
{ field: 'dataset_name', headerName: 'Dataset', width: 170 },
|
||||
{ field: 'hostname', headerName: 'Hostname', width: 170, valueGetter: (v, row) => row.hostname || '' },
|
||||
{ field: 'username', headerName: 'User', width: 160, valueGetter: (v, row) => row.username || '' },
|
||||
{ field: 'matched_value', headerName: 'Matched Value', flex: 1, minWidth: 220 },
|
||||
{ field: 'field', headerName: 'Field', width: 130 },
|
||||
{ field: 'matched_value', headerName: 'Matched Value', flex: 1, minWidth: 200 },
|
||||
{ field: 'row_index', headerName: 'Row #', width: 80, type: 'number' },
|
||||
{ field: 'source_type', headerName: 'Source', width: 120 },
|
||||
{ field: 'row_index', headerName: 'Row #', width: 90, type: 'number' },
|
||||
];
|
||||
|
||||
export default function AUPScanner() {
|
||||
@@ -210,9 +212,9 @@ export default function AUPScanner() {
|
||||
// Scan options
|
||||
const [selectedDs, setSelectedDs] = useState<Set<string>>(new Set());
|
||||
const [selectedThemes, setSelectedThemes] = useState<Set<string>>(new Set());
|
||||
const [scanHunts, setScanHunts] = useState(true);
|
||||
const [scanAnnotations, setScanAnnotations] = useState(true);
|
||||
const [scanMessages, setScanMessages] = useState(true);
|
||||
const [scanHunts, setScanHunts] = useState(false);
|
||||
const [scanAnnotations, setScanAnnotations] = useState(false);
|
||||
const [scanMessages, setScanMessages] = useState(false);
|
||||
|
||||
// Load themes + hunts
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -224,9 +226,13 @@ export default function AUPScanner() {
|
||||
]);
|
||||
setThemes(tRes.themes);
|
||||
setHuntList(hRes.hunts);
|
||||
if (!selectedHuntId && hRes.hunts.length > 0) {
|
||||
const best = hRes.hunts.find(h => h.dataset_count > 0) || hRes.hunts[0];
|
||||
setSelectedHuntId(best.id);
|
||||
}
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setLoading(false);
|
||||
}, [enqueueSnackbar]);
|
||||
}, [enqueueSnackbar, selectedHuntId]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
@@ -237,7 +243,7 @@ export default function AUPScanner() {
|
||||
datasets.list(0, 500, selectedHuntId).then(res => {
|
||||
if (cancelled) return;
|
||||
setDsList(res.datasets);
|
||||
setSelectedDs(new Set(res.datasets.map(d => d.id)));
|
||||
setSelectedDs(new Set(res.datasets.slice(0, 3).map(d => d.id)));
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedHuntId]);
|
||||
@@ -251,6 +257,15 @@ export default function AUPScanner() {
|
||||
|
||||
// Run scan
|
||||
const runScan = useCallback(async () => {
|
||||
if (!selectedHuntId) {
|
||||
enqueueSnackbar('Please select a hunt before running AUP scan', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (selectedDs.size === 0) {
|
||||
enqueueSnackbar('No datasets selected for this hunt', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setScanning(true);
|
||||
setScanResult(null);
|
||||
try {
|
||||
@@ -260,6 +275,7 @@ export default function AUPScanner() {
|
||||
scan_hunts: scanHunts,
|
||||
scan_annotations: scanAnnotations,
|
||||
scan_messages: scanMessages,
|
||||
prefer_cache: true,
|
||||
});
|
||||
setScanResult(res);
|
||||
enqueueSnackbar(`Scan complete — ${res.total_hits} hits found`, {
|
||||
@@ -267,7 +283,7 @@ export default function AUPScanner() {
|
||||
});
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
setScanning(false);
|
||||
}, [selectedDs, selectedThemes, scanHunts, scanAnnotations, scanMessages, enqueueSnackbar]);
|
||||
}, [selectedHuntId, selectedDs, selectedThemes, scanHunts, scanAnnotations, scanMessages, enqueueSnackbar]);
|
||||
|
||||
if (loading) return <Box sx={{ p: 4, textAlign: 'center' }}><CircularProgress /></Box>;
|
||||
|
||||
@@ -316,9 +332,38 @@ export default function AUPScanner() {
|
||||
)}
|
||||
{!selectedHuntId && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
All datasets will be scanned if no hunt is selected
|
||||
Select a hunt to enable scoped scanning
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<FormControl size="small" fullWidth sx={{ mt: 1.2 }} disabled={!selectedHuntId || dsList.length === 0}>
|
||||
<InputLabel id="aup-dataset-label">Datasets</InputLabel>
|
||||
<Select
|
||||
labelId="aup-dataset-label"
|
||||
multiple
|
||||
value={Array.from(selectedDs)}
|
||||
label="Datasets"
|
||||
renderValue={(selected) => `${(selected as string[]).length} selected`}
|
||||
onChange={(e) => setSelectedDs(new Set(e.target.value as string[]))}
|
||||
>
|
||||
{dsList.map(d => (
|
||||
<MenuItem key={d.id} value={d.id}>
|
||||
<Checkbox size="small" checked={selectedDs.has(d.id)} />
|
||||
<Typography variant="body2" sx={{ ml: 0.5 }}>
|
||||
{d.name} ({d.row_count.toLocaleString()} rows)
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedHuntId && dsList.length > 0 && (
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Button size="small" onClick={() => setSelectedDs(new Set(dsList.slice(0, 3).map(d => d.id)))}>Top 3</Button>
|
||||
<Button size="small" onClick={() => setSelectedDs(new Set(dsList.map(d => d.id)))}>All</Button>
|
||||
<Button size="small" onClick={() => setSelectedDs(new Set())}>Clear</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Theme selector */}
|
||||
@@ -372,7 +417,7 @@ export default function AUPScanner() {
|
||||
<Button
|
||||
variant="contained" color="warning" size="large"
|
||||
startIcon={scanning ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={runScan} disabled={scanning}
|
||||
onClick={runScan} disabled={scanning || !selectedHuntId || selectedDs.size === 0}
|
||||
>
|
||||
{scanning ? 'Scanning…' : 'Run Scan'}
|
||||
</Button>
|
||||
@@ -392,6 +437,15 @@ export default function AUPScanner() {
|
||||
<strong>{scanResult.total_hits}</strong> hits across{' '}
|
||||
<strong>{scanResult.rows_scanned}</strong> rows |{' '}
|
||||
{scanResult.themes_scanned} themes, {scanResult.keywords_scanned} keywords scanned
|
||||
{scanResult.cache_status && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={scanResult.cache_status === 'hit' ? 'Cached' : 'Live'}
|
||||
sx={{ ml: 1, height: 20 }}
|
||||
color={scanResult.cache_status === 'hit' ? 'success' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* AgentPanel — analyst-assist chat with quick / deep / debate modes,
|
||||
* streaming support, SANS references, and conversation persistence.
|
||||
* AgentPanel - analyst-assist chat with quick / deep / debate modes,
|
||||
* SSE streaming, SANS references, and conversation persistence.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Box, Typography, Paper, TextField, Button, Stack, Chip,
|
||||
ToggleButtonGroup, ToggleButton, CircularProgress, Alert,
|
||||
Accordion, AccordionSummary, AccordionDetails, Divider, Select,
|
||||
MenuItem, FormControl, InputLabel, LinearProgress,
|
||||
MenuItem, FormControl, InputLabel, LinearProgress, FormControlLabel, Switch,
|
||||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
@@ -16,26 +16,31 @@ import SchoolIcon from '@mui/icons-material/School';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import ForumIcon from '@mui/icons-material/Forum';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
agent, datasets, hunts, type AssistRequest, type AssistResponse,
|
||||
type DatasetSummary, type Hunt,
|
||||
} from '../api/client';
|
||||
|
||||
interface Message { role: 'user' | 'assistant'; content: string; meta?: AssistResponse }
|
||||
interface Message { role: 'user' | 'assistant'; content: string; meta?: AssistResponse; streaming?: boolean }
|
||||
|
||||
export default function AgentPanel() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [mode, setMode] = useState<'quick' | 'deep' | 'debate'>('quick');
|
||||
const [executionPreference, setExecutionPreference] = useState<'auto' | 'force' | 'off'>('auto');
|
||||
const [learningMode, setLearningMode] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [datasetList, setDatasets] = useState<DatasetSummary[]>([]);
|
||||
const [huntList, setHunts] = useState<Hunt[]>([]);
|
||||
const [selectedDataset, setSelectedDataset] = useState('');
|
||||
const [selectedHunt, setSelectedHunt] = useState('');
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
datasets.list(0, 100).then(r => setDatasets(r.datasets)).catch(() => {});
|
||||
@@ -44,6 +49,12 @@ export default function AgentPanel() {
|
||||
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const stopStreaming = () => {
|
||||
abortRef.current?.abort();
|
||||
setStreaming(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (!query.trim() || loading) return;
|
||||
const userMsg: Message = { role: 'user', content: query };
|
||||
@@ -59,18 +70,118 @@ export default function AgentPanel() {
|
||||
hunt_id: selectedHunt || undefined,
|
||||
dataset_name: ds?.name,
|
||||
data_summary: ds ? `${ds.row_count} rows, columns: ${Object.keys(ds.column_schema || {}).join(', ')}` : undefined,
|
||||
execution_preference: executionPreference,
|
||||
learning_mode: learningMode,
|
||||
};
|
||||
|
||||
// Try SSE streaming first, fall back to regular request
|
||||
try {
|
||||
const resp = await agent.assist(req);
|
||||
setConversationId(resp.conversation_id || null);
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: resp.guidance, meta: resp }]);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${e.message}` }]);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setStreaming(true);
|
||||
|
||||
const res = await agent.assistStream(req);
|
||||
if (!res.ok || !res.body) throw new Error('Stream unavailable');
|
||||
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: '', streaming: true }]);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
let metaData: AssistResponse | undefined;
|
||||
|
||||
while (true) {
|
||||
if (controller.signal.aborted) break;
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
// Parse SSE lines
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.token) {
|
||||
fullText += parsed.token;
|
||||
const nextText = fullText;
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
updated[updated.length - 1] = { ...last, content: nextText };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
if (parsed.meta || parsed.confidence) {
|
||||
metaData = parsed.meta || parsed;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON data line, treat as text token
|
||||
fullText += data;
|
||||
const nextText = fullText;
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
updated[updated.length - 1] = { ...last, content: nextText };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the streamed message
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
updated[updated.length - 1] = { ...last, content: fullText || 'No response received.', streaming: false, meta: metaData };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
if (metaData?.conversation_id) setConversationId(metaData.conversation_id);
|
||||
|
||||
} catch (streamErr: any) {
|
||||
// Streaming failed or unavailable, fall back to regular request
|
||||
setStreaming(false);
|
||||
// Remove the empty streaming message if one was added
|
||||
setMessages(prev => {
|
||||
if (prev.length > 0 && prev[prev.length - 1].streaming && prev[prev.length - 1].content === '') {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await agent.assist(req);
|
||||
setConversationId(resp.conversation_id || null);
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: resp.guidance, meta: resp }]);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${e.message}` }]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStreaming(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
setLoading(false);
|
||||
}, [query, mode, loading, conversationId, selectedDataset, selectedHunt, datasetList, enqueueSnackbar]);
|
||||
}, [
|
||||
query,
|
||||
mode,
|
||||
executionPreference,
|
||||
learningMode,
|
||||
loading,
|
||||
conversationId,
|
||||
selectedDataset,
|
||||
selectedHunt,
|
||||
datasetList,
|
||||
enqueueSnackbar,
|
||||
]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
@@ -112,6 +223,25 @@ export default function AgentPanel() {
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Execution</InputLabel>
|
||||
<Select
|
||||
label="Execution"
|
||||
value={executionPreference}
|
||||
onChange={e => setExecutionPreference(e.target.value as 'auto' | 'force' | 'off')}
|
||||
>
|
||||
<MenuItem value="auto">Auto</MenuItem>
|
||||
<MenuItem value="force">Force execute</MenuItem>
|
||||
<MenuItem value="off">Advisory only</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={learningMode} onChange={(_, v) => setLearningMode(v)} size="small" />}
|
||||
label={<Typography variant="caption">Learning mode</Typography>}
|
||||
sx={{ ml: 0.5 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -124,7 +254,7 @@ export default function AgentPanel() {
|
||||
Ask a question about your threat hunt data.
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
The agent provides advisory guidance — all decisions remain with the analyst.
|
||||
Agent can provide advisory guidance or execute policy scans based on execution mode.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -132,20 +262,24 @@ export default function AgentPanel() {
|
||||
<Box key={i} sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={700}>
|
||||
{m.role === 'user' ? 'You' : 'Agent'}
|
||||
{m.streaming && <Chip label="streaming" size="small" color="info" sx={{ ml: 1, height: 16, fontSize: '0.65rem' }} />}
|
||||
</Typography>
|
||||
<Paper sx={{
|
||||
p: 1.5, mt: 0.5,
|
||||
bgcolor: m.role === 'user' ? 'primary.dark' : 'background.default',
|
||||
borderColor: m.role === 'user' ? 'primary.main' : 'divider',
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{m.content}</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{m.content}
|
||||
{m.streaming && <span className="cursor-blink">|</span>}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Response metadata */}
|
||||
{m.meta && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" sx={{ mb: 0.5 }}>
|
||||
<Chip label={`${m.meta.confidence * 100}% confidence`} size="small"
|
||||
<Chip label={`${Math.round(m.meta.confidence * 100)}% confidence`} size="small"
|
||||
color={m.meta.confidence >= 0.7 ? 'success' : m.meta.confidence >= 0.4 ? 'warning' : 'error'} variant="outlined" />
|
||||
<Chip label={m.meta.model_used} size="small" variant="outlined" />
|
||||
<Chip label={m.meta.node_used} size="small" variant="outlined" />
|
||||
@@ -190,7 +324,7 @@ export default function AgentPanel() {
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{m.meta.sans_references.map((r, j) => (
|
||||
<Typography key={j} variant="body2" sx={{ mb: 0.5 }}>• {r}</Typography>
|
||||
<Typography key={j} variant="body2" sx={{ mb: 0.5 }}>{r}</Typography>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
@@ -214,6 +348,32 @@ export default function AgentPanel() {
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Execution summary */}
|
||||
{m.meta.execution && (
|
||||
<Accordion disableGutters sx={{ mt: 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">
|
||||
Execution Results ({m.meta.execution.policy_hits} hits in {m.meta.execution.elapsed_ms}ms)
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
Scope: {m.meta.execution.scope}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
Datasets: {m.meta.execution.datasets_scanned.join(', ') || 'None'}
|
||||
</Typography>
|
||||
{m.meta.execution.top_domains.length > 0 && (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" sx={{ mt: 0.5 }}>
|
||||
{m.meta.execution.top_domains.map((d, j) => (
|
||||
<Chip key={j} label={d} size="small" color="success" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Caveats */}
|
||||
{m.meta.caveats && (
|
||||
<Alert severity="warning" sx={{ mt: 0.5, py: 0 }}>
|
||||
@@ -224,7 +384,7 @@ export default function AgentPanel() {
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{loading && <LinearProgress sx={{ mb: 1 }} />}
|
||||
{loading && !streaming && <LinearProgress sx={{ mb: 1 }} />}
|
||||
<div ref={bottomRef} />
|
||||
</Paper>
|
||||
|
||||
@@ -237,10 +397,17 @@ export default function AgentPanel() {
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button variant="contained" onClick={send} disabled={loading || !query.trim()}>
|
||||
{loading ? <CircularProgress size={20} /> : <SendIcon />}
|
||||
</Button>
|
||||
{streaming ? (
|
||||
<Button variant="outlined" color="error" onClick={stopStreaming}>
|
||||
<StopIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" onClick={send} disabled={loading || !query.trim()}>
|
||||
{loading ? <CircularProgress size={20} /> : <SendIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ 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';
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
/**
|
||||
* CorrelationView — cross-hunt correlation analysis with IOC, time,
|
||||
* technique, and host overlap visualisation.
|
||||
* CorrelationView - cross-hunt correlation analysis with recharts visualizations.
|
||||
* IOC overlap bar chart, technique overlap heat chips, time/host overlap display.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Chip, Button, CircularProgress,
|
||||
Alert, Table, TableBody, TableCell, TableContainer, TableHead,
|
||||
TableRow, TextField,
|
||||
TableRow, TextField, Grid, Divider,
|
||||
} from '@mui/material';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as ReTooltip,
|
||||
ResponsiveContainer, PieChart, Pie, Cell, Legend,
|
||||
} from 'recharts';
|
||||
import { correlation, hunts, type Hunt, type CorrelationResult } from '../api/client';
|
||||
|
||||
const PIE_COLORS = ['#60a5fa', '#f472b6', '#34d399', '#fbbf24', '#a78bfa', '#f87171', '#38bdf8', '#fb923c'];
|
||||
|
||||
export default function CorrelationView() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
@@ -53,6 +59,18 @@ export default function CorrelationView() {
|
||||
} catch (e: any) { enqueueSnackbar(e.message, { variant: 'error' }); }
|
||||
}, [iocSearch, enqueueSnackbar]);
|
||||
|
||||
// Build chart data from results
|
||||
const iocChartData = (result?.ioc_overlaps || []).slice(0, 20).map((o: any) => ({
|
||||
name: String(o.ioc_value).length > 20 ? String(o.ioc_value).slice(0, 20) + '...' : o.ioc_value,
|
||||
hunts: (o.hunt_ids || []).length,
|
||||
type: o.ioc_type || 'unknown',
|
||||
}));
|
||||
|
||||
const techniqueChartData = (result?.technique_overlaps || []).map((t: any) => ({
|
||||
name: t.technique || t.mitre_technique || 'unknown',
|
||||
value: (t.hunt_ids || []).length || 1,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Cross-Hunt Correlation</Typography>
|
||||
@@ -98,34 +116,80 @@ export default function CorrelationView() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Results */}
|
||||
{/* Results with charts */}
|
||||
{result && (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{result.summary} — {result.total_correlations} total correlation(s) across {result.hunt_ids.length} hunts
|
||||
{result.summary} {result.total_correlations} correlation(s) across {result.hunt_ids.length} hunts
|
||||
</Alert>
|
||||
|
||||
{/* IOC overlaps */}
|
||||
{/* Symmetrical 2-column: IOC chart | Technique chart */}
|
||||
<Grid container spacing={2} sx={{ mb: 2 }} columns={12}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>IOC Overlaps ({result.ioc_overlaps.length})</Typography>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
{iocChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={iocChartData} layout="vertical" margin={{ left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis type="number" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<YAxis dataKey="name" type="category" tick={{ fill: '#94a3b8', fontSize: 10 }} width={120} />
|
||||
<ReTooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', fontSize: 12 }} />
|
||||
<Bar dataKey="hunts" name="Shared Hunts" fill="#60a5fa" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">No IOC overlaps found.</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>Technique Overlaps ({result.technique_overlaps.length})</Typography>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
{techniqueChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={techniqueChartData} dataKey="value" nameKey="name" cx="50%" cy="50%"
|
||||
outerRadius={80} label={({ name }) => name}>
|
||||
{techniqueChartData.map((_: any, i: number) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<ReTooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">No technique overlaps found.</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* IOC detail table */}
|
||||
{result.ioc_overlaps.length > 0 && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>IOC Overlaps ({result.ioc_overlaps.length})</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<Typography variant="subtitle1" gutterBottom>IOC Detail</Typography>
|
||||
<TableContainer sx={{ maxHeight: 300 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>IOC</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Shared Hunts</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>IOC</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Type</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Shared Hunts</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{result.ioc_overlaps.map((o: any, i: number) => (
|
||||
<TableRow key={i}>
|
||||
<TableRow key={i} hover>
|
||||
<TableCell><Typography variant="body2" fontFamily="monospace">{o.ioc_value}</Typography></TableCell>
|
||||
<TableCell><Chip label={o.ioc_type || 'unknown'} size="small" /></TableCell>
|
||||
<TableCell>
|
||||
{(o.hunt_ids || []).map((hid: string, j: number) => (
|
||||
<Chip key={j} label={huntList.find(h => h.id === hid)?.name || hid} size="small" sx={{ mr: 0.5 }} />
|
||||
<Chip key={j} label={huntList.find(h => h.id === hid)?.name || hid.slice(0, 8)}
|
||||
size="small" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -136,41 +200,47 @@ export default function CorrelationView() {
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Technique overlaps */}
|
||||
{result.technique_overlaps.length > 0 && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>MITRE Technique Overlaps</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{result.technique_overlaps.map((t: any, i: number) => (
|
||||
<Chip key={i} label={t.technique || t.mitre_technique} color="secondary" size="small" />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Time overlaps */}
|
||||
{result.time_overlaps.length > 0 && (
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Time Overlaps</Typography>
|
||||
{result.time_overlaps.map((t: any, i: number) => (
|
||||
<Typography key={i} variant="body2" sx={{ mb: 0.5 }}>
|
||||
{t.hunt_a || 'Hunt A'} ↔ {t.hunt_b || 'Hunt B'}: {t.overlap_start} — {t.overlap_end}
|
||||
</Typography>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Host overlaps */}
|
||||
{result.host_overlaps.length > 0 && (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Host Overlaps</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{result.host_overlaps.map((h: any, i: number) => (
|
||||
<Chip key={i} label={typeof h === 'string' ? h : h.hostname || JSON.stringify(h)} size="small" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
{/* Symmetrical 2-column: Time overlaps | Host overlaps */}
|
||||
<Grid container spacing={2} columns={12}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>Time Overlaps ({result.time_overlaps.length})</Typography>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
{result.time_overlaps.length > 0 ? (
|
||||
<Stack spacing={1}>
|
||||
{result.time_overlaps.map((t: any, i: number) => (
|
||||
<Stack key={i} direction="row" alignItems="center" spacing={1}>
|
||||
<Chip label={t.hunt_a || 'Hunt A'} size="small" color="primary" variant="outlined" />
|
||||
<Typography variant="body2"></Typography>
|
||||
<Chip label={t.hunt_b || 'Hunt B'} size="small" color="secondary" variant="outlined" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t.overlap_start} {t.overlap_end}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">No time overlaps found.</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>Host Overlaps ({result.host_overlaps.length})</Typography>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
{result.host_overlaps.length > 0 ? (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{result.host_overlaps.map((h: any, i: number) => (
|
||||
<Chip key={i} label={typeof h === 'string' ? h : h.hostname || JSON.stringify(h)}
|
||||
size="small" variant="outlined" sx={{ mb: 0.5 }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">No host overlaps found.</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Dashboard — overview cards with hunt stats, node health, recent activity.
|
||||
* Dashboard - overview cards with hunt stats, cluster health, recent activity.
|
||||
* Symmetrical 4-column grid layout, empty-state onboarding, auto-refresh.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Grid, Paper, Typography, Chip, CircularProgress,
|
||||
Stack, Alert,
|
||||
Stack, Alert, Button, Divider,
|
||||
} from '@mui/material';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
@@ -13,139 +14,245 @@ import SecurityIcon from '@mui/icons-material/Security';
|
||||
import ScienceIcon from '@mui/icons-material/Science';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { hunts, datasets, hypotheses, agent, misc, type Hunt, type DatasetSummary, type HealthInfo } from '../api/client';
|
||||
|
||||
function StatCard({ title, value, icon, color }: { title: string; value: string | number; icon: React.ReactNode; color: string }) {
|
||||
const REFRESH_INTERVAL = 30_000; // 30s auto-refresh
|
||||
|
||||
/* Stat Card */
|
||||
|
||||
function StatCard({ title, value, icon, color }: {
|
||||
title: string; value: string | number; icon: React.ReactNode; color: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Box sx={{ color, fontSize: 40, display: 'flex' }}>{icon}</Box>
|
||||
<Box>
|
||||
<Typography variant="h4">{value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{title}</Typography>
|
||||
<Paper sx={{ p: 2.5, height: '100%', display: 'flex', alignItems: 'center' }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ width: '100%' }}>
|
||||
<Box sx={{ color, fontSize: 40, display: 'flex', flexShrink: 0 }}>{icon}</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h4" noWrap>{value}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>{title}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/* Node Status */
|
||||
|
||||
function NodeStatus({ label, available }: { label: string; available: boolean }) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
{available
|
||||
? <CheckCircleIcon sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
: <ErrorIcon sx={{ color: 'error.main', fontSize: 20 }} />
|
||||
}
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
: <ErrorIcon sx={{ color: 'error.main', fontSize: 20 }} />}
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>{label}</Typography>
|
||||
<Chip label={available ? 'Online' : 'Offline'} size="small"
|
||||
color={available ? 'success' : 'error'} variant="outlined" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
|
||||
function EmptyOnboarding() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Paper sx={{ p: 4, textAlign: 'center', gridColumn: '1 / -1' }}>
|
||||
<RocketLaunchIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h5" gutterBottom>Welcome to ThreatHunt</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, maxWidth: 480, mx: 'auto' }}>
|
||||
Get started by creating a hunt, uploading CSV artifacts, and letting the AI assist your investigation.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} justifyContent="center">
|
||||
<Button variant="contained" startIcon={<SearchIcon />} onClick={() => navigate('/hunts')}>
|
||||
Create a Hunt
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<UploadFileIcon />} onClick={() => navigate('/upload')}>
|
||||
Upload Data
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/* Main Dashboard */
|
||||
|
||||
export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [health, setHealth] = useState<HealthInfo | null>(null);
|
||||
const [huntList, setHunts] = useState<Hunt[]>([]);
|
||||
const [datasetList, setDatasets] = useState<DatasetSummary[]>([]);
|
||||
const [hypoCount, setHypoCount] = useState(0);
|
||||
const [apiInfo, setApiInfo] = useState<{ name: string; version: string; status: string } | null>(null);
|
||||
const [apiInfo, setApiInfo] = useState<{ name?: string; version?: string; status?: string; service?: string } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [h, ht, ds, hy, info] = await Promise.all([
|
||||
agent.health().catch(() => null),
|
||||
hunts.list(0, 100).catch(() => ({ hunts: [], total: 0 })),
|
||||
datasets.list(0, 100).catch(() => ({ datasets: [], total: 0 })),
|
||||
hypotheses.list({ limit: 1 }).catch(() => ({ hypotheses: [], total: 0 })),
|
||||
misc.root().catch(() => null),
|
||||
]);
|
||||
setHealth(h);
|
||||
setHunts(ht.hunts);
|
||||
setDatasets(ds.datasets);
|
||||
setHypoCount(hy.total);
|
||||
setApiInfo(info);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [h, ht, ds, hy, info] = await Promise.all([
|
||||
agent.health().catch(() => null),
|
||||
hunts.list(0, 100).catch(() => ({ hunts: [], total: 0 })),
|
||||
datasets.list(0, 100).catch(() => ({ datasets: [], total: 0 })),
|
||||
hypotheses.list({ limit: 1 }).catch(() => ({ hypotheses: [], total: 0 })),
|
||||
misc.root().catch(() => null),
|
||||
]);
|
||||
setHealth(h);
|
||||
setHunts(ht.hunts);
|
||||
setDatasets(ds.datasets);
|
||||
setHypoCount(hy.total);
|
||||
setApiInfo(info);
|
||||
setLastRefresh(new Date());
|
||||
setError('');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
const timer = setInterval(refresh, REFRESH_INTERVAL);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
if (loading) return <Box sx={{ p: 4 }}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
|
||||
const activeHunts = huntList.filter(h => h.status === 'active').length;
|
||||
const totalRows = datasetList.reduce((s, d) => s + d.row_count, 0);
|
||||
const isEmpty = huntList.length === 0 && datasetList.length === 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Dashboard</Typography>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Dashboard</Typography>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Updated {lastRefresh.toLocaleTimeString()}
|
||||
</Typography>
|
||||
<Button size="small" startIcon={<RefreshIcon />} onClick={refresh}>Refresh</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Stat cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
{/* Stat cards - symmetrical 4-column */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }} columns={12}>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
<StatCard title="Active Hunts" value={activeHunts} icon={<SearchIcon fontSize="inherit" />} color="#60a5fa" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
<StatCard title="Datasets" value={datasetList.length} icon={<StorageIcon fontSize="inherit" />} color="#f472b6" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
<StatCard title="Total Rows" value={totalRows.toLocaleString()} icon={<SecurityIcon fontSize="inherit" />} color="#10b981" />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Grid size={{ xs: 6, sm: 6, md: 3 }}>
|
||||
<StatCard title="Hypotheses" value={hypoCount} icon={<ScienceIcon fontSize="inherit" />} color="#f59e0b" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Node health + API info */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" gutterBottom>LLM Cluster Health</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<NodeStatus label="Wile (100.110.190.12)" available={health?.nodes?.wile?.available ?? false} />
|
||||
<NodeStatus label="Roadrunner (100.110.190.11)" available={health?.nodes?.roadrunner?.available ?? false} />
|
||||
<NodeStatus label="SANS RAG (Open WebUI)" available={health?.rag?.available ?? false} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" gutterBottom>API Status</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{apiInfo ? `${apiInfo.name} — ${apiInfo.version}` : 'Unreachable'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Status: {apiInfo?.status ?? 'unknown'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{/* Empty state or content */}
|
||||
{isEmpty ? (
|
||||
<EmptyOnboarding />
|
||||
) : (
|
||||
<>
|
||||
{/* Symmetrical 2-column: Cluster Health | API Status */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }} columns={12}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2.5, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>LLM Cluster Health</Typography>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
<Stack spacing={1.5}>
|
||||
<NodeStatus label="Wile (Heavy Models)" available={health?.nodes?.wile?.available ?? false} />
|
||||
<NodeStatus label="Roadrunner (Fast Models)" available={health?.nodes?.roadrunner?.available ?? false} />
|
||||
<NodeStatus label="SANS RAG (Open WebUI)" available={health?.rag?.available ?? false} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2.5, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>API Status</Typography>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">Service</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{apiInfo?.service || apiInfo?.name || 'ThreatHunt API'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">Version</Typography>
|
||||
<Chip label={apiInfo?.version || 'unknown'} size="small" variant="outlined" />
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">Status</Typography>
|
||||
<Chip label={apiInfo?.status || 'unknown'} size="small"
|
||||
color={apiInfo?.status === 'running' ? 'success' : 'warning'} variant="outlined" />
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">Hunts</Typography>
|
||||
<Typography variant="body2">{huntList.length} total ({activeHunts} active)</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Recent hunts */}
|
||||
{huntList.length > 0 && (
|
||||
<Paper sx={{ p: 2.5, mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Recent Hunts</Typography>
|
||||
<Stack spacing={1}>
|
||||
{huntList.slice(0, 5).map(h => (
|
||||
<Stack key={h.id} direction="row" alignItems="center" spacing={1}>
|
||||
<Chip label={h.status} size="small"
|
||||
color={h.status === 'active' ? 'success' : h.status === 'closed' ? 'default' : 'warning'}
|
||||
variant="outlined" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{h.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{h.dataset_count} datasets · {h.hypothesis_count} hypotheses
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
{/* Symmetrical 2-column: Recent Hunts | Recent Datasets */}
|
||||
<Grid container spacing={2} columns={12}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2.5, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>Recent Hunts</Typography>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
{huntList.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No hunts yet.</Typography>
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
{huntList.slice(0, 5).map(h => (
|
||||
<Stack key={h.id} direction="row" alignItems="center" spacing={1}>
|
||||
<Chip label={h.status} size="small"
|
||||
color={h.status === 'active' ? 'success' : h.status === 'closed' ? 'default' : 'warning'}
|
||||
variant="outlined" sx={{ minWidth: 64, justifyContent: 'center' }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1, minWidth: 0 }} noWrap>{h.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{h.dataset_count}ds {h.hypothesis_count}hyp
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 2.5, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>Recent Datasets</Typography>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
{datasetList.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">No datasets yet.</Typography>
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
{datasetList.slice(0, 5).map(d => (
|
||||
<Stack key={d.id} direction="row" alignItems="center" spacing={1}>
|
||||
<Chip label={d.source_tool || 'CSV'} size="small" variant="outlined"
|
||||
sx={{ minWidth: 64, justifyContent: 'center' }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1, minWidth: 0 }} noWrap>{d.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{d.row_count.toLocaleString()} rows
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* FileUpload — multi-file drag-and-drop CSV upload with per-file progress bars.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Stack, Chip, LinearProgress,
|
||||
Select, MenuItem, FormControl, InputLabel, IconButton, Tooltip,
|
||||
@@ -12,7 +12,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { datasets, hunts, type UploadResult, type Hunt } from '../api/client';
|
||||
import { datasets, hunts, type UploadResult, type Hunt, type HuntProgress } from '../api/client';
|
||||
|
||||
interface FileJob {
|
||||
file: File;
|
||||
@@ -28,6 +28,7 @@ export default function FileUpload() {
|
||||
const [jobs, setJobs] = useState<FileJob[]>([]);
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [huntId, setHuntId] = useState('');
|
||||
const [huntProgress, setHuntProgress] = useState<HuntProgress | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
@@ -35,6 +36,28 @@ export default function FileUpload() {
|
||||
hunts.list(0, 100).then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: any = null;
|
||||
let cancelled = false;
|
||||
|
||||
const pull = async () => {
|
||||
if (!huntId) {
|
||||
if (!cancelled) setHuntProgress(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const p = await hunts.progress(huntId);
|
||||
if (!cancelled) setHuntProgress(p);
|
||||
} catch {
|
||||
if (!cancelled) setHuntProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
pull();
|
||||
if (huntId) timer = setInterval(pull, 2000);
|
||||
return () => { cancelled = true; if (timer) clearInterval(timer); };
|
||||
}, [huntId, jobs.length]);
|
||||
|
||||
// Process the queue sequentially
|
||||
const processQueue = useCallback(async (queue: FileJob[]) => {
|
||||
if (busyRef.current) return;
|
||||
@@ -163,6 +186,37 @@ export default function FileUpload() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{huntId && huntProgress && (
|
||||
<Paper sx={{ p: 1.5, mt: 1.5 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.8 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
Master Processing Progress
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={huntProgress.status.toUpperCase()}
|
||||
color={huntProgress.status === 'ready' ? 'success' : huntProgress.status === 'processing' ? 'warning' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{huntProgress.progress_percent.toFixed(1)}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.max(0, Math.min(100, huntProgress.progress_percent))}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Chip size="small" label={`Datasets ${huntProgress.dataset_completed}/${huntProgress.dataset_total}`} variant="outlined" />
|
||||
<Chip size="small" label={`Active jobs ${huntProgress.active_jobs}`} variant="outlined" />
|
||||
<Chip size="small" label={`Queued jobs ${huntProgress.queued_jobs}`} variant="outlined" />
|
||||
<Chip size="small" label={`Network ${huntProgress.network_status}`} variant="outlined" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Per-file progress list */}
|
||||
{jobs.map((job, i) => (
|
||||
<Paper key={`${job.file.name}-${i}`} sx={{ p: 2, mt: 1 }}>
|
||||
|
||||
189
frontend/src/components/MitreMatrix.tsx
Normal file
189
frontend/src/components/MitreMatrix.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* MitreMatrix Interactive MITRE ATT&CK technique heat map.
|
||||
* Aggregates detected techniques from triage, host profiles, and hypotheses.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, CircularProgress, Alert, Chip, Tooltip,
|
||||
FormControl, InputLabel, Select, MenuItem, IconButton, Button, Dialog,
|
||||
DialogTitle, DialogContent, List, ListItem, ListItemText, Divider,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as ReTooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { mitre, MitreCoverage, MitreTechnique, hunts, Hunt, stixExport } from '../api/client';
|
||||
|
||||
const TACTIC_COLORS: Record<string, string> = {
|
||||
'Reconnaissance': '#7c3aed',
|
||||
'Resource Development': '#6d28d9',
|
||||
'Initial Access': '#ef4444',
|
||||
'Execution': '#f97316',
|
||||
'Persistence': '#f59e0b',
|
||||
'Privilege Escalation': '#eab308',
|
||||
'Defense Evasion': '#84cc16',
|
||||
'Credential Access': '#22c55e',
|
||||
'Discovery': '#14b8a6',
|
||||
'Lateral Movement': '#06b6d4',
|
||||
'Collection': '#3b82f6',
|
||||
'Command and Control': '#6366f1',
|
||||
'Exfiltration': '#a855f7',
|
||||
'Impact': '#ec4899',
|
||||
};
|
||||
|
||||
export default function MitreMatrix() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<MitreCoverage | null>(null);
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [selectedHunt, setSelectedHunt] = useState<string>('');
|
||||
const [detailTech, setDetailTech] = useState<MitreTechnique | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleStixExport = async () => {
|
||||
if (!selectedHunt) { enqueueSnackbar('Select a hunt to export STIX bundle', { variant: 'info' }); return; }
|
||||
setExporting(true);
|
||||
try {
|
||||
await stixExport.download(selectedHunt);
|
||||
enqueueSnackbar('STIX bundle downloaded', { variant: 'success' });
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [coverage, h] = await Promise.all([
|
||||
mitre.coverage(selectedHunt || undefined),
|
||||
huntList.length ? Promise.resolve({ hunts: huntList, total: huntList.length }) : hunts.list(0, 100),
|
||||
]);
|
||||
setData(coverage);
|
||||
if (!huntList.length) setHuntList(h.hunts);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedHunt, huntList, enqueueSnackbar]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const chartData = data ? Object.entries(data.tactic_coverage).map(([tactic, info]) => ({
|
||||
tactic: tactic.replace(/ /g, '\n'),
|
||||
fullTactic: tactic,
|
||||
count: info.count,
|
||||
color: TACTIC_COLORS[tactic] || '#64748b',
|
||||
})) : [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Typography variant="h5">MITRE ATT&CK Coverage</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Filter by Hunt</InputLabel>
|
||||
<Select value={selectedHunt} onChange={e => setSelectedHunt(e.target.value)} label="Filter by Hunt">
|
||||
<MenuItem value="">All Hunts</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton onClick={load} disabled={loading}><RefreshIcon /></IconButton>
|
||||
<Tooltip title="Export STIX 2.1 bundle for selected hunt"><span><Button size="small" variant="outlined" startIcon={exporting ? <CircularProgress size={16} /> : <DownloadIcon />} onClick={handleStixExport} disabled={!selectedHunt || exporting}>STIX Export</Button></span></Tooltip>
|
||||
{data && (
|
||||
<>
|
||||
<Chip label={`${data.technique_count} techniques`} color="primary" size="small" />
|
||||
<Chip label={`${data.detection_count} detections`} color="secondary" size="small" />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{loading && <CircularProgress />}
|
||||
|
||||
{!loading && data && data.technique_count === 0 && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
No MITRE techniques detected yet. Run triage, host profiling, or add hypotheses with technique IDs to populate this view.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && data && data.technique_count > 0 && (
|
||||
<>
|
||||
{/* Bar chart of technique counts per tactic */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Techniques by Tactic</Typography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData} margin={{ bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="tactic" tick={{ fontSize: 10, fill: '#94a3b8' }} interval={0} angle={-35} textAnchor="end" />
|
||||
<YAxis tick={{ fill: '#94a3b8' }} />
|
||||
<ReTooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155' }} />
|
||||
<Bar dataKey="count" name="Techniques">
|
||||
{chartData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Paper>
|
||||
|
||||
{/* Heat map grid */}
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Technique Matrix</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 1.5 }}>
|
||||
{data.tactics.map(tactic => {
|
||||
const info = data.tactic_coverage[tactic];
|
||||
const techs = info?.techniques || [];
|
||||
return (
|
||||
<Paper key={tactic} sx={{ p: 1.5, bgcolor: techs.length ? 'rgba(96,165,250,0.08)' : 'transparent', border: '1px solid', borderColor: techs.length ? TACTIC_COLORS[tactic] || '#334155' : '#1e293b' }}>
|
||||
<Typography variant="caption" sx={{ color: TACTIC_COLORS[tactic] || '#94a3b8', fontWeight: 600, textTransform: 'uppercase', fontSize: '0.65rem' }}>
|
||||
{tactic}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
||||
{techs.map(tech => (
|
||||
<Tooltip key={tech.id} title={`${tech.id} ${tech.count} detection(s)`}>
|
||||
<Chip
|
||||
label={tech.id}
|
||||
size="small"
|
||||
onClick={() => setDetailTech(tech)}
|
||||
sx={{
|
||||
fontSize: '0.65rem', height: 22,
|
||||
bgcolor: tech.count >= 3 ? 'error.dark' : tech.count >= 2 ? 'warning.dark' : 'primary.dark',
|
||||
cursor: 'pointer', '&:hover': { opacity: 0.8 },
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{!techs.length && <Typography variant="caption" color="text.secondary"></Typography>}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Detail dialog */}
|
||||
<Dialog open={!!detailTech} onClose={() => setDetailTech(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{detailTech?.id} {detailTech?.tactic}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" gutterBottom>Detected {detailTech?.count} time(s) from:</Typography>
|
||||
<List dense>
|
||||
{detailTech?.sources.map((s, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={s.type === 'triage' ? `Triage (risk: ${s.risk_score})` : s.type === 'host_profile' ? `Host Profile: ${s.hostname}` : `Hypothesis: ${s.title}`}
|
||||
secondary={`Source: ${s.type}`}
|
||||
/>
|
||||
</ListItem>
|
||||
{i < (detailTech?.sources.length || 0) - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
237
frontend/src/components/PlaybookManager.tsx
Normal file
237
frontend/src/components/PlaybookManager.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* PlaybookManager - Investigation playbook workflow wizard.
|
||||
* Create/load playbooks from templates, track step completion, navigate to target views.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, CircularProgress, Alert, Button, Chip,
|
||||
List, ListItem, ListItemButton, ListItemIcon, ListItemText,
|
||||
Checkbox, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
TextField, LinearProgress, IconButton, Divider, Tooltip,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
playbooks, PlaybookSummary, PlaybookDetail, PlaybookTemplate,
|
||||
} from '../api/client';
|
||||
|
||||
export default function PlaybookManager() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pbList, setPbList] = useState<PlaybookSummary[]>([]);
|
||||
const [active, setActive] = useState<PlaybookDetail | null>(null);
|
||||
const [templates, setTemplates] = useState<PlaybookTemplate[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await playbooks.list();
|
||||
setPbList(data.playbooks);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const data = await playbooks.templates();
|
||||
setTemplates(data.templates);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadList(); loadTemplates(); }, [loadList, loadTemplates]);
|
||||
|
||||
const selectPlaybook = async (id: string) => {
|
||||
try {
|
||||
const d = await playbooks.get(id);
|
||||
setActive(d);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStep = async (stepId: number, current: boolean) => {
|
||||
if (!active) return;
|
||||
try {
|
||||
await playbooks.updateStep(stepId, { is_completed: !current });
|
||||
const d = await playbooks.get(active.id);
|
||||
setActive(d);
|
||||
loadList();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const createFromTemplate = async (tpl: PlaybookTemplate) => {
|
||||
try {
|
||||
const pb = await playbooks.create({
|
||||
name: tpl.name,
|
||||
description: tpl.description,
|
||||
steps: tpl.steps.map((s, i) => ({
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
step_type: 'task',
|
||||
target_route: s.target_route || undefined,
|
||||
})),
|
||||
});
|
||||
enqueueSnackbar('Playbook created from template', { variant: 'success' });
|
||||
loadList();
|
||||
setActive(pb);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const createCustom = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
const pb = await playbooks.create({
|
||||
name: newName,
|
||||
description: newDesc,
|
||||
steps: [{ title: 'First step', description: 'Describe what to do' }],
|
||||
});
|
||||
enqueueSnackbar('Playbook created', { variant: 'success' });
|
||||
setShowCreate(false);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
loadList();
|
||||
setActive(pb);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlaybook = async (id: string) => {
|
||||
try {
|
||||
await playbooks.delete(id);
|
||||
enqueueSnackbar('Playbook deleted', { variant: 'success' });
|
||||
if (active?.id === id) setActive(null);
|
||||
loadList();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const completedCount = active?.steps.filter(s => s.is_completed).length || 0;
|
||||
const totalSteps = active?.steps.length || 1;
|
||||
const progress = Math.round((completedCount / totalSteps) * 100);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 3, minHeight: 500 }}>
|
||||
{/* Left sidebar - playbook list */}
|
||||
<Paper sx={{ width: 320, p: 2, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Playbooks</Typography>
|
||||
<IconButton size="small" color="primary" onClick={() => setShowCreate(true)}><AddIcon /></IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Templates section */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>TEMPLATES</Typography>
|
||||
<List dense>
|
||||
{templates.map(t => (
|
||||
<ListItemButton key={t.name} onClick={() => createFromTemplate(t)} sx={{ borderRadius: 1, mb: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}><PlaylistAddCheckIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText primary={t.name} secondary={`${t.steps.length} steps`} primaryTypographyProps={{ fontSize: '0.85rem' }} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>MY PLAYBOOKS</Typography>
|
||||
{loading && <CircularProgress size={20} sx={{ display: 'block', mx: 'auto', my: 2 }} />}
|
||||
<List dense>
|
||||
{pbList.map(p => (
|
||||
<ListItemButton key={p.id} selected={active?.id === p.id} onClick={() => selectPlaybook(p.id)} sx={{ borderRadius: 1, mb: 0.5 }}>
|
||||
<ListItemText
|
||||
primary={p.name}
|
||||
secondary={`${p.completed_steps}/${p.total_steps} done`}
|
||||
primaryTypographyProps={{ fontSize: '0.85rem', fontWeight: active?.id === p.id ? 600 : 400 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip label={`${Math.round((p.completed_steps / Math.max(p.total_steps, 1)) * 100)}%`}
|
||||
size="small" color={p.completed_steps === p.total_steps ? 'success' : 'default'}
|
||||
sx={{ fontSize: '0.7rem', height: 20 }} />
|
||||
<IconButton size="small" onClick={e => { e.stopPropagation(); deletePlaybook(p.id); }}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
))}
|
||||
{!loading && pbList.length === 0 && (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>No playbooks yet. Start from a template or create one.</Alert>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
{/* Right panel - active playbook */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
{!active ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<PlaylistAddCheckIcon sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">Select or create a playbook</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Use templates for common investigation workflows, or build your own step-by-step checklist.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>{active.name}</Typography>
|
||||
{active.description && <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{active.description}</Typography>}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<LinearProgress variant="determinate" value={progress} sx={{ flex: 1, height: 8, borderRadius: 4 }} />
|
||||
<Typography variant="body2" fontWeight={600}>{progress}%</Typography>
|
||||
<Chip label={`${completedCount}/${totalSteps} steps`} size="small" color={progress === 100 ? 'success' : 'primary'} />
|
||||
</Box>
|
||||
|
||||
<List>
|
||||
{active.steps
|
||||
.sort((a, b) => a.order_index - b.order_index)
|
||||
.map(step => (
|
||||
<ListItem key={step.id} disablePadding sx={{ mb: 1 }}>
|
||||
<ListItemButton onClick={() => toggleStep(step.id, step.is_completed)} sx={{ borderRadius: 1, border: '1px solid', borderColor: step.is_completed ? 'success.main' : 'divider', bgcolor: step.is_completed ? 'success.main' : 'transparent', opacity: step.is_completed ? 0.7 : 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Checkbox edge="start" checked={step.is_completed} disableRipple />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={step.title}
|
||||
secondary={step.description}
|
||||
slotProps={{ primary: { sx: { textDecoration: step.is_completed ? 'line-through' : 'none', fontWeight: 500 } } }}
|
||||
/>
|
||||
{step.target_route && (
|
||||
<Tooltip title={`Go to ${step.target_route}`}>
|
||||
<IconButton size="small" onClick={e => { e.stopPropagation(); window.location.hash = step.target_route!; }}>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={showCreate} onClose={() => setShowCreate(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create Custom Playbook</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="Name" fullWidth value={newName} onChange={e => setNewName(e.target.value)} sx={{ mt: 1, mb: 2 }} />
|
||||
<TextField label="Description" fullWidth multiline rows={2} value={newDesc} onChange={e => setNewDesc(e.target.value)} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={createCustom} disabled={!newName.trim()}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
271
frontend/src/components/SavedSearches.tsx
Normal file
271
frontend/src/components/SavedSearches.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* SavedSearches - Manage bookmarked queries and recurring scans.
|
||||
* Supports IOC, keyword, NLP, and correlation search types with delta tracking.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, CircularProgress, Alert, Button, Chip,
|
||||
Table, TableHead, TableRow, TableCell, TableBody, TableContainer,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
TextField, FormControl, InputLabel, Select, MenuItem,
|
||||
IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import BookmarkIcon from '@mui/icons-material/Bookmark';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { savedSearches, SavedSearchData, SearchRunResult } from '../api/client';
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
{ value: 'ioc_search', label: 'IOC Search' },
|
||||
{ value: 'keyword_scan', label: 'Keyword Scan' },
|
||||
{ value: 'nlp_query', label: 'NLP Query' },
|
||||
{ value: 'correlation', label: 'Correlation' },
|
||||
];
|
||||
|
||||
function typeColor(t: string): 'primary' | 'secondary' | 'warning' | 'info' {
|
||||
switch (t) {
|
||||
case 'ioc_search': return 'primary';
|
||||
case 'keyword_scan': return 'warning';
|
||||
case 'nlp_query': return 'info';
|
||||
case 'correlation': return 'secondary';
|
||||
default: return 'primary';
|
||||
}
|
||||
}
|
||||
|
||||
export default function SavedSearchesView() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<SavedSearchData[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<SavedSearchData | null>(null);
|
||||
const [runResult, setRunResult] = useState<SearchRunResult | null>(null);
|
||||
const [runId, setRunId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [searchType, setSearchType] = useState('ioc_search');
|
||||
const [queryParams, setQueryParams] = useState('');
|
||||
const [huntId, setHuntId] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await savedSearches.list();
|
||||
setItems(data.searches);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setName('');
|
||||
setSearchType('ioc_search');
|
||||
setQueryParams('');
|
||||
setHuntId('');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: SavedSearchData) => {
|
||||
setEditing(item);
|
||||
setName(item.name);
|
||||
setSearchType(item.search_type);
|
||||
setQueryParams(JSON.stringify(item.query_params, null, 2));
|
||||
setHuntId((item.query_params as any)?.hunt_id || '');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim()) return;
|
||||
let params: Record<string, any> = {};
|
||||
try {
|
||||
params = JSON.parse(queryParams || '{}');
|
||||
} catch {
|
||||
enqueueSnackbar('Invalid JSON in query parameters', { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editing) {
|
||||
await savedSearches.update(editing.id, {
|
||||
name, search_type: searchType, query_params: params,
|
||||
hunt_id: huntId || undefined,
|
||||
});
|
||||
enqueueSnackbar('Search updated', { variant: 'success' });
|
||||
} else {
|
||||
await savedSearches.create({
|
||||
name, search_type: searchType, query_params: params,
|
||||
hunt_id: huntId || undefined,
|
||||
});
|
||||
enqueueSnackbar('Search saved', { variant: 'success' });
|
||||
}
|
||||
setShowForm(false);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await savedSearches.delete(id);
|
||||
enqueueSnackbar('Deleted', { variant: 'success' });
|
||||
load();
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const runSearch = async (id: string) => {
|
||||
setRunning(id);
|
||||
try {
|
||||
const result = await savedSearches.run(id);
|
||||
setRunResult(result);
|
||||
setRunId(id);
|
||||
load(); // refresh last_run times
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<BookmarkIcon color="primary" />
|
||||
<Typography variant="h5">Saved Searches</Typography>
|
||||
<Button startIcon={<AddIcon />} variant="contained" size="small" onClick={openCreate}>New Search</Button>
|
||||
</Box>
|
||||
|
||||
{loading && <CircularProgress />}
|
||||
|
||||
{!loading && items.length === 0 && (
|
||||
<Alert severity="info">
|
||||
No saved searches yet. Create one to bookmark frequently-used queries for quick re-execution.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Name</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Type</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Hunt ID</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Last Run</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Last Count</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }} align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map(item => (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell sx={{ fontWeight: 500 }}>{item.name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={SEARCH_TYPES.find(t => t.value === item.search_type)?.label || item.search_type}
|
||||
color={typeColor(item.search_type)} size="small" sx={{ fontSize: '0.7rem' }} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem', fontFamily: 'monospace' }}>
|
||||
{(item.query_params as any)?.hunt_id ? String((item.query_params as any).hunt_id).slice(0, 8) + '...' : 'All'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem' }}>
|
||||
{item.last_run_at ? new Date(item.last_run_at).toLocaleString() : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.last_result_count != null ? (
|
||||
<Chip label={item.last_result_count} size="small" color={item.last_result_count > 0 ? 'warning' : 'default'} />
|
||||
) : ''}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Run now">
|
||||
<IconButton size="small" color="success" onClick={() => runSearch(item.id)}
|
||||
disabled={running === item.id}>
|
||||
{running === item.id ? <CircularProgress size={16} /> : <PlayArrowIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => openEdit(item)}><EditIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" color="error" onClick={() => remove(item.id)}><DeleteIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Run result dialog */}
|
||||
<Dialog open={runResult !== null} onClose={() => setRunResult(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Search Results</DialogTitle>
|
||||
<DialogContent>
|
||||
{runResult && (
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Search: <strong>{items.find(i => i.id === runId)?.name}</strong>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<Chip label={`${runResult.result_count} results`} color={runResult.result_count > 0 ? 'warning' : 'success'} />
|
||||
{runResult.delta !== undefined && runResult.delta !== null && (
|
||||
<Chip label={`${runResult.delta >= 0 ? '+' : ''}${runResult.delta} since last run`}
|
||||
color={runResult.delta > 0 ? 'error' : 'default'} variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
{runResult.results && runResult.results.length > 0 && (
|
||||
<Paper variant="outlined" sx={{ p: 1, maxHeight: 300, overflow: 'auto' }}>
|
||||
<Typography variant="caption" color="text.secondary">Preview (first {runResult.results.length} results):</Typography>
|
||||
{runResult.results.map((item: any, i: number) => (
|
||||
<Box key={i} sx={{ p: 0.5, borderBottom: '1px solid', borderColor: 'divider', fontSize: '0.75rem', fontFamily: 'monospace' }}>
|
||||
{typeof item === 'string' ? item : JSON.stringify(item, null, 1)}
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRunResult(null)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Create/Edit dialog */}
|
||||
<Dialog open={showForm} onClose={() => setShowForm(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editing ? 'Edit Search' : 'Create Saved Search'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="Name" fullWidth value={name} onChange={e => setName(e.target.value)} sx={{ mt: 1, mb: 2 }} />
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Search Type</InputLabel>
|
||||
<Select value={searchType} onChange={e => setSearchType(e.target.value)} label="Search Type">
|
||||
{SEARCH_TYPES.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Hunt ID (optional)" fullWidth value={huntId} onChange={e => setHuntId(e.target.value)} sx={{ mb: 2 }}
|
||||
placeholder="Leave empty to search all hunts" />
|
||||
<TextField label="Query Parameters (JSON)" fullWidth multiline rows={4}
|
||||
value={queryParams} onChange={e => setQueryParams(e.target.value)}
|
||||
placeholder='{"keywords": ["mimikatz", "lsass"]}'
|
||||
helperText="JSON object with search-specific parameters" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!name.trim()}>
|
||||
{editing ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
178
frontend/src/components/TimelineView.tsx
Normal file
178
frontend/src/components/TimelineView.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* TimelineView - Forensic event timeline with zoomable chart.
|
||||
* Plots dataset rows on a time axis, color-coded by artifact type and risk.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, CircularProgress, Alert, Chip,
|
||||
FormControl, InputLabel, Select, MenuItem, IconButton,
|
||||
Table, TableHead, TableRow, TableCell, TableBody, TableContainer,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as ReTooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { timeline, TimelineData, TimelineEvent, hunts, Hunt } from '../api/client';
|
||||
|
||||
const ARTIFACT_COLORS: Record<string, string> = {
|
||||
'Windows.System.Pslist': '#60a5fa',
|
||||
'Windows.Network.Netstat': '#f472b6',
|
||||
'Windows.System.Services': '#34d399',
|
||||
'Windows.Forensics.Prefetch': '#fbbf24',
|
||||
'Windows.EventLogs.EvtxHunter': '#a78bfa',
|
||||
'Windows.Sys.Autoruns': '#f87171',
|
||||
'Unknown': '#64748b',
|
||||
};
|
||||
|
||||
function getColor(artifact: string): string {
|
||||
return ARTIFACT_COLORS[artifact] || ARTIFACT_COLORS['Unknown'];
|
||||
}
|
||||
|
||||
function bucketEvents(events: TimelineEvent[], buckets = 50): { time: string; count: number; artifacts: Record<string, number> }[] {
|
||||
if (!events.length) return [];
|
||||
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
const start = new Date(sorted[0].timestamp).getTime();
|
||||
const end = new Date(sorted[sorted.length - 1].timestamp).getTime();
|
||||
const span = Math.max(end - start, 1);
|
||||
const bucketSize = span / buckets;
|
||||
const result: { time: string; count: number; artifacts: Record<string, number> }[] = [];
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
const bStart = start + i * bucketSize;
|
||||
const bEnd = bStart + bucketSize;
|
||||
const inBucket = sorted.filter(e => {
|
||||
const t = new Date(e.timestamp).getTime();
|
||||
return t >= bStart && t < bEnd;
|
||||
});
|
||||
const artifacts: Record<string, number> = {};
|
||||
inBucket.forEach(e => { artifacts[e.artifact_type] = (artifacts[e.artifact_type] || 0) + 1; });
|
||||
result.push({
|
||||
time: new Date(bStart).toISOString().slice(0, 16).replace('T', ' '),
|
||||
count: inBucket.length,
|
||||
artifacts,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function TimelineView() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<TimelineData | null>(null);
|
||||
const [huntList, setHuntList] = useState<Hunt[]>([]);
|
||||
const [selectedHunt, setSelectedHunt] = useState<string>('');
|
||||
const [filterArtifact, setFilterArtifact] = useState<string>('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!selectedHunt) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const d = await timeline.getHuntTimeline(selectedHunt);
|
||||
setData(d);
|
||||
} catch (e: any) {
|
||||
enqueueSnackbar(e.message, { variant: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedHunt, enqueueSnackbar]);
|
||||
|
||||
useEffect(() => {
|
||||
hunts.list(0, 100).then(r => setHuntList(r.hunts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filteredEvents = data?.events.filter(e => !filterArtifact || e.artifact_type === filterArtifact) || [];
|
||||
const buckets = bucketEvents(filteredEvents);
|
||||
const artifactTypes = [...new Set(data?.events.map(e => e.artifact_type) || [])];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5">Forensic Timeline</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Hunt</InputLabel>
|
||||
<Select value={selectedHunt} onChange={e => setSelectedHunt(e.target.value)} label="Hunt">
|
||||
<MenuItem value="">Select a hunt...</MenuItem>
|
||||
{huntList.map(h => <MenuItem key={h.id} value={h.id}>{h.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Artifact Type</InputLabel>
|
||||
<Select value={filterArtifact} onChange={e => setFilterArtifact(e.target.value)} label="Artifact Type">
|
||||
<MenuItem value="">All Types</MenuItem>
|
||||
{artifactTypes.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton onClick={load} disabled={loading || !selectedHunt}><RefreshIcon /></IconButton>
|
||||
{data && <Chip label={`${filteredEvents.length} events`} color="primary" size="small" />}
|
||||
</Box>
|
||||
|
||||
{!selectedHunt && (
|
||||
<Alert severity="info">Select a hunt to view its forensic timeline.</Alert>
|
||||
)}
|
||||
|
||||
{loading && <CircularProgress />}
|
||||
|
||||
{!loading && data && filteredEvents.length === 0 && (
|
||||
<Alert severity="warning">No timestamped events found in this hunt's datasets.</Alert>
|
||||
)}
|
||||
|
||||
{!loading && data && filteredEvents.length > 0 && (
|
||||
<>
|
||||
{/* Activity histogram */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Activity Over Time</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||
{artifactTypes.map(a => (
|
||||
<Chip key={a} label={a} size="small" sx={{ bgcolor: getColor(a), color: '#fff', fontSize: '0.7rem' }}
|
||||
onClick={() => setFilterArtifact(filterArtifact === a ? '' : a)} variant={filterArtifact === a ? 'filled' : 'outlined'} />
|
||||
))}
|
||||
</Box>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={buckets}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 9, fill: '#94a3b8' }} interval={Math.floor(buckets.length / 8)} angle={-30} textAnchor="end" />
|
||||
<YAxis tick={{ fill: '#94a3b8' }} />
|
||||
<ReTooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', fontSize: 12 }} />
|
||||
<Bar dataKey="count" name="Events" fill="#60a5fa" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Paper>
|
||||
|
||||
{/* Event table */}
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Events ({filteredEvents.length})</Typography>
|
||||
<TableContainer sx={{ maxHeight: 400 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Time</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Hostname</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Artifact</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Process</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Summary</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredEvents.slice(0, 500).map((e, i) => (
|
||||
<TableRow key={i} hover>
|
||||
<TableCell sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>{e.timestamp.replace('T', ' ').slice(0, 19)}</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem' }}>{e.hostname || ''}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={e.artifact_type} size="small" sx={{ bgcolor: getColor(e.artifact_type), color: '#fff', fontSize: '0.65rem', height: 20 }} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem' }}>{e.process || ''}</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.summary || ''}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user