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:
2026-02-23 14:23:07 -05:00
parent 37a9584d0c
commit 5a2ad8ec1c
110 changed files with 10537 additions and 1185 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

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

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

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