@cryptotaxi247 / CoPilot / commits / c5642733

Refactor ai review (#822)

* refactor(ai-analyst): refine replay modal UI * refactor(ai-analyst): refine divider visibility * refactor(ai-analyst): clean up review panel logic * refactor(ai-analyst): refine review toolbar layout * refactor(ai-analyst): componentize review toolbar * refactor(alerts): update webhook URL to include port number * style(ai-analyst): add CoPilot to large logos * refactor(ai-analyst): componentize review form * refactor(ai-analyst): componentize IOC review * refactor(ai-analyst): update review panel UI * refactor(ai-analyst): componentize palace logic * refactor(ai-analyst): improve palace form UI * refactor(ai-analyst): update queue button UI * refactor(ai-analyst): update similar lessons UI * refactor(ai-analyst): update report comparison UI * refactor(ai-analyst): resize modal for report compare * refactor(ai-analyst): update consolidation drawer * refactor(ai-analyst): reorganize feedback components * refactor(ai-analyst): extract feedback toolbar * refactor(ai-analyst): extract metric tile * refactor(ai-analyst): extract dashboard metric tiles * refactor(ai-analyst): extract choice distribution * refactor(ai-analyst): extract template table * refactor(ai-analyst): extract recent reviews * refactor(ai-analyst): extract recent review detail * style(ai-analyst): add table column min-widths * refactor(ai-analyst): use cards for ioc reviews * refactor(version): update current version to 0.1.58 * refactor(version): revert current version to 0.1.57 --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Apr 24, 2026 at 22:59 UTC c5642733409977a96c051a80ff07d1945fe6d064
27 files changed +1372 -1199
docs/user/ui/alerts-graylog-threshold.md
+1 -1
@@ -95,7 +95,7 @@ ASSET_NAME → ${source.agent_name}
95 | Setting | Value |
96 |---|---|
97 | **Title** | Give it a descriptive name (e.g., `CoPilot Threshold Webhook`) |
98 -| **URL** | `https://<your-copilot-url>/api/incidents/alerts/create/threshold` |
98 +| **URL** | `http://<your-copilot-url>:5000/api/incidents/alerts/create/threshold` |
99
100 4. **Save** the notification.
101
frontend/src/assets/images/socfortress_logo_large_dark.svg
+3 -2
@@ -1,5 +1,5 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 -<!-- Generated by Pixelmator Pro 3.7.1 -->
2 +<!-- Generated by Pixelmator Pro 3.8 -->
3 <svg width="3237" height="497" viewBox="0 0 3237 497" xmlns="http://www.w3.org/2000/svg">
4 <g id="svgg">
5 <path id="path0" fill="#fbab44" fill-rule="evenodd" stroke="none" d="M 163.186996 283.316498 C 163.117004 320.059509 163.007004 318.216492 165.423004 320.644501 C 165.964005 321.1875 166.880997 322.204498 167.460999 322.903503 C 168.041 323.603485 168.641998 324.097504 168.796997 324.002502 C 168.951996 323.906494 169.044998 323.944489 169.003998 324.086487 C 168.876999 324.528503 169.815994 325.750488 170.283997 325.750488 C 170.529007 325.750488 170.634995 325.844513 170.520004 325.959503 C 170.404999 326.073486 170.544006 326.425507 170.828995 326.740509 C 171.324005 327.286499 172.623001 327.3125 199.951996 327.3125 L 228.557007 327.3125 L 229.669006 326.111511 C 237.848007 317.277496 236.850006 323.213501 236.796005 283.718506 C 236.770996 265.500488 236.701996 251.613495 236.643005 252.859497 L 236.535004 255.125504 L 200 255.125504 L 163.464996 255.125504 L 163.356995 252.859497 C 163.298004 251.613495 163.220993 265.318512 163.186996 283.316498 M 0.313 265.28949 C 0.313 275.022491 0.341 275.519501 0.973 276.778503 C 2.064 278.949493 3.126 280.146515 4.489 280.742493 C 5.2 281.05249 7.75 282.251495 10.156 283.406494 C 12.563 284.561493 16.289 286.350494 18.438 287.3815 C 20.586 288.412506 23.327999 289.717499 24.531 290.281494 C 25.733999 290.84549 29.18 292.491486 32.188 293.940491 C 35.195 295.388489 38.254002 296.837494 38.984001 297.159485 L 40.313 297.745514 L 40.382 317.138489 C 40.419998 327.804504 40.493999 334.457489 40.546001 331.921509 L 40.641998 327.3125 L 86.405998 327.3125 L 132.171005 327.3125 L 132.278 331.921509 C 132.337006 334.457489 132.363998 323.3125 132.337997 307.156494 C 132.296005 281.639496 132.223007 277.39151 131.783005 274.8125 C 130.884003 269.550507 130.031998 267.139496 127.695 263.250488 C 126.364998 261.037506 123.870003 258.250488 123.220001 258.250488 C 122.996002 258.250488 122.813004 258.125488 122.813004 257.973511 C 122.811996 257.820496 122.355003 257.465515 121.796997 257.183502 C 120.338997 256.446503 119.564003 255.951508 119.307999 255.593506 C 119.161003 255.387497 98.838997 255.253494 59.699001 255.199493 L 0.313 255.117493 L 0.313 265.28949 M 280.703003 255.5495 C 280.31601 255.741501 280 256.005493 280 256.136505 C 280 256.267487 279.812012 256.375488 279.582001 256.375488 C 278.920013 256.375488 275.625 258.861511 275.625 259.360504 C 275.625 259.609497 275.437988 259.8125 275.209015 259.8125 C 274.444 259.8125 272.173004 262.953491 270.915985 265.750488 C 270.567993 266.523499 270.165985 267.374512 270.020996 267.64151 C 269.667999 268.295502 268.526001 272.922485 268.096985 275.4375 C 267.864014 276.804504 267.727997 287.1315 267.682007 307.000488 C 267.644012 323.242493 267.661011 334.457489 267.721008 331.921509 L 267.82901 327.3125 L 313.593994 327.3125 L 359.358002 327.3125 L 359.45401 331.921509 C 359.506012 334.457489 359.582001 327.8125 359.622009 317.156494 L 359.695007 297.781494 L 360.863007 297.235504 C 363.847992 295.842499 370.954987 292.462494 375.739014 290.160492 C 378.638 288.76651 381.044006 287.625488 381.087006 287.625488 C 381.131012 287.625488 384.102997 286.202515 387.691986 284.464508 C 391.282013 282.726501 394.921997 280.990509 395.781006 280.606506 C 399.748993 278.834503 399.868988 278.37149 399.756989 265.257507 L 399.67099 255.125504 L 340.539001 255.163498 C 297.770996 255.191498 281.212006 255.298492 280.703003 255.5495 M 172.720993 328.836487 C 173.440994 329.528503 173.925995 329.724487 173.595001 329.189514 C 173.488998 329.018494 173.093994 328.719513 172.716995 328.525513 L 172.031006 328.172485 L 172.720993 328.836487"/>
@@ -8,5 +8,6 @@
8 <path id="path3" fill="#fc862e" fill-rule="evenodd" stroke="none" d="M 2.859 0.652496 C 2.283 0.9375 1.439 1.734497 0.984 2.422485 L 0.156 3.673492 L 0.146 91.195496 C 0.14 139.333496 0.179 176.646484 0.232 174.114502 L 0.329 169.509521 L 19.461 169.426514 C 29.983999 169.3815 38.759998 169.41449 38.962002 169.500488 C 39.165001 169.58551 111.797997 169.58551 200.367996 169.500488 C 288.938995 169.41449 370.015991 169.3815 380.539001 169.426514 L 399.67099 169.509521 L 399.768005 174.114502 C 399.821014 176.646484 399.859985 139.333496 399.854004 91.195496 L 399.843994 3.673492 L 399.011993 2.416504 C 397.377014 -0.054504 399.98999 0.106506 363.069 0.198486 L 330.156006 0.281494 L 329.104004 1.031494 C 326.75 2.71051 326.890015 0.46051 326.881989 36.921509 L 326.875 69.500488 L 308.906006 69.500488 L 290.937988 69.500488 L 290.937988 44.25351 L 290.937012 19.007507 L 290.234009 17.614502 C 289.772003 16.698486 289.102997 15.981506 288.281006 15.519501 L 287.031006 14.818512 L 254.162994 14.893494 C 217.136993 14.978516 220.574997 14.7435 218.828003 17.309509 L 218.125 18.342499 L 218.125 43.921509 L 218.125 69.500488 L 200 69.500488 L 181.875 69.500488 L 181.875 43.921509 L 181.875 18.342499 L 181.171997 17.309509 C 179.425003 14.7435 182.863007 14.978516 145.837006 14.893494 L 112.969002 14.818512 L 111.719002 15.519501 C 110.897003 15.981506 110.227997 16.698486 109.765999 17.614502 L 109.061996 19.007507 L 109.063004 44.25351 L 109.063004 69.500488 L 91.094002 69.500488 L 73.125 69.500488 L 73.117996 36.921509 C 73.110001 0.46051 73.25 2.71051 70.896004 1.031494 L 69.844002 0.281494 L 36.875 0.207489 C 6.708 0.140503 3.817 0.178497 2.859 0.652496 M 361.64801 171.0625 C 361.64801 171.83551 361.709015 172.152496 361.78299 171.765503 C 361.858002 171.378479 361.858002 170.746521 361.78299 170.359497 C 361.709015 169.972504 361.64801 170.28949 361.64801 171.0625 M 357.144012 171.380493 L 356.406006 172.010498 L 357.265991 171.642517 C 357.738007 171.440491 358.125 171.156494 358.125 171.012512 C 358.125 170.622498 357.958008 170.685486 357.144012 171.380493 M 43.125 171.571503 C 43.125 171.733521 43.547001 172.114502 44.063 172.419495 C 44.580002 172.724487 45 172.824493 45 172.642517 C 45 172.461487 44.834999 172.3125 44.633999 172.3125 C 44.432999 172.3125 44.011002 172.079498 43.695999 171.795502 C 43.382 171.510498 43.125 171.410522 43.125 171.571503"/>
9 <path id="path4" fill="#fcbc4c" fill-rule="evenodd" stroke="none" d="M 40.519001 334.109497 C 40.405998 337.847504 40.317001 349.41449 40.32 359.8125 C 40.327 380.949493 40.272999 380.413513 42.542 382.030487 L 43.594002 382.781494 L 86.025002 382.862488 C 123.482002 382.934509 128.563004 382.888489 129.373993 382.469513 C 130.774994 381.744507 131.789001 380.48349 132.162003 379.001495 C 132.542007 377.492493 132.636002 345.453491 132.292999 334.109497 L 132.087997 327.3125 L 86.405998 327.3125 L 40.724998 327.3125 L 40.519001 334.109497 M 172.522003 328.269501 C 173.110992 328.795502 173.593994 329.337494 173.593994 329.473511 C 173.593994 329.777496 174.623993 330.96051 183.281006 340.59549 C 185 342.508514 187.25 345.036499 188.281006 346.213501 C 189.313004 347.389496 190.302002 348.504486 190.481003 348.691498 C 190.660004 348.87851 191.919998 350.296509 193.281006 351.843506 C 196.328995 355.307495 196.772995 355.719513 198.037003 356.258514 C 200.772003 357.425507 202.677994 356.458496 206.742004 351.843506 C 208.406998 349.953491 210.278 347.840515 210.899994 347.147491 C 211.522003 346.455505 212.664001 345.185486 213.438004 344.324493 C 214.210999 343.463501 215.940994 341.543488 217.281998 340.057495 C 218.623001 338.570496 220.029007 336.96051 220.406998 336.477509 C 221.031998 335.679504 223.080994 333.384491 224.453003 331.94751 C 224.753998 331.632507 224.940994 331.375488 224.867996 331.375488 C 224.751007 331.375488 225.925003 330.076508 227.904999 328.015503 L 228.580994 327.3125 L 200.014999 327.3125 L 171.449997 327.3125 L 172.522003 328.269501 M 267.707001 334.109497 C 267.364014 345.453491 267.458008 377.492493 267.838013 379.001495 C 268.210999 380.48349 269.225006 381.744507 270.626007 382.469513 C 271.437012 382.888489 276.518005 382.934509 313.975006 382.862488 L 356.406006 382.781494 L 357.458008 382.030487 C 359.72699 380.413513 359.673004 380.949493 359.679993 359.8125 C 359.683014 349.41449 359.593994 337.847504 359.480988 334.109497 L 359.274994 327.3125 L 313.593994 327.3125 L 267.911987 327.3125 L 267.707001 334.109497"/>
10 </g>
11 - <text id="SOCFortress" xml:space="preserve" x="639" y="374" font-family="Orbitron" font-size="364" fill="#ffffff" letter-spacing="-7.28">SOCFortress</text>
11 + <text id="SOCFortress" xml:space="preserve" x="622" y="266" font-family="Orbitron" font-size="364" font-weight="700" fill="#ffffff" letter-spacing="-7.28">SOCFortress</text>
12 + <text id="CoPilot" xml:space="preserve"><tspan x="622" y="494" font-family="Orbitron" font-size="222" fill="#fc862e" letter-spacing="11.1" xml:space="preserve">C</tspan><tspan font-family="Orbitron" font-size="222" fill="#fc862e" letter-spacing="11.1" xml:space="preserve">oPilot</tspan></text>
13 </svg>
frontend/src/assets/images/socfortress_logo_large_light.svg
+3 -2
@@ -1,5 +1,5 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 -<!-- Generated by Pixelmator Pro 3.7.1 -->
2 +<!-- Generated by Pixelmator Pro 3.8 -->
3 <svg width="3237" height="497" viewBox="0 0 3237 497" xmlns="http://www.w3.org/2000/svg">
4 <g id="svgg">
5 <path id="path0" fill="#fbab44" fill-rule="evenodd" stroke="none" d="M 163.186996 283.316498 C 163.117004 320.059509 163.007004 318.216492 165.423004 320.644501 C 165.964005 321.1875 166.880997 322.204498 167.460999 322.903503 C 168.041 323.603485 168.641998 324.097504 168.796997 324.002502 C 168.951996 323.906494 169.044998 323.944489 169.003998 324.086487 C 168.876999 324.528503 169.815994 325.750488 170.283997 325.750488 C 170.529007 325.750488 170.634995 325.844513 170.520004 325.959503 C 170.404999 326.073486 170.544006 326.425507 170.828995 326.740509 C 171.324005 327.286499 172.623001 327.3125 199.951996 327.3125 L 228.557007 327.3125 L 229.669006 326.111511 C 237.848007 317.277496 236.850006 323.213501 236.796005 283.718506 C 236.770996 265.500488 236.701996 251.613495 236.643005 252.859497 L 236.535004 255.125504 L 200 255.125504 L 163.464996 255.125504 L 163.356995 252.859497 C 163.298004 251.613495 163.220993 265.318512 163.186996 283.316498 M 0.313 265.28949 C 0.313 275.022491 0.341 275.519501 0.973 276.778503 C 2.064 278.949493 3.126 280.146515 4.489 280.742493 C 5.2 281.05249 7.75 282.251495 10.156 283.406494 C 12.563 284.561493 16.289 286.350494 18.438 287.3815 C 20.586 288.412506 23.327999 289.717499 24.531 290.281494 C 25.733999 290.84549 29.18 292.491486 32.188 293.940491 C 35.195 295.388489 38.254002 296.837494 38.984001 297.159485 L 40.313 297.745514 L 40.382 317.138489 C 40.419998 327.804504 40.493999 334.457489 40.546001 331.921509 L 40.641998 327.3125 L 86.405998 327.3125 L 132.171005 327.3125 L 132.278 331.921509 C 132.337006 334.457489 132.363998 323.3125 132.337997 307.156494 C 132.296005 281.639496 132.223007 277.39151 131.783005 274.8125 C 130.884003 269.550507 130.031998 267.139496 127.695 263.250488 C 126.364998 261.037506 123.870003 258.250488 123.220001 258.250488 C 122.996002 258.250488 122.813004 258.125488 122.813004 257.973511 C 122.811996 257.820496 122.355003 257.465515 121.796997 257.183502 C 120.338997 256.446503 119.564003 255.951508 119.307999 255.593506 C 119.161003 255.387497 98.838997 255.253494 59.699001 255.199493 L 0.313 255.117493 L 0.313 265.28949 M 280.703003 255.5495 C 280.31601 255.741501 280 256.005493 280 256.136505 C 280 256.267487 279.812012 256.375488 279.582001 256.375488 C 278.920013 256.375488 275.625 258.861511 275.625 259.360504 C 275.625 259.609497 275.437988 259.8125 275.209015 259.8125 C 274.444 259.8125 272.173004 262.953491 270.915985 265.750488 C 270.567993 266.523499 270.165985 267.374512 270.020996 267.64151 C 269.667999 268.295502 268.526001 272.922485 268.096985 275.4375 C 267.864014 276.804504 267.727997 287.1315 267.682007 307.000488 C 267.644012 323.242493 267.661011 334.457489 267.721008 331.921509 L 267.82901 327.3125 L 313.593994 327.3125 L 359.358002 327.3125 L 359.45401 331.921509 C 359.506012 334.457489 359.582001 327.8125 359.622009 317.156494 L 359.695007 297.781494 L 360.863007 297.235504 C 363.847992 295.842499 370.954987 292.462494 375.739014 290.160492 C 378.638 288.76651 381.044006 287.625488 381.087006 287.625488 C 381.131012 287.625488 384.102997 286.202515 387.691986 284.464508 C 391.282013 282.726501 394.921997 280.990509 395.781006 280.606506 C 399.748993 278.834503 399.868988 278.37149 399.756989 265.257507 L 399.67099 255.125504 L 340.539001 255.163498 C 297.770996 255.191498 281.212006 255.298492 280.703003 255.5495 M 172.720993 328.836487 C 173.440994 329.528503 173.925995 329.724487 173.595001 329.189514 C 173.488998 329.018494 173.093994 328.719513 172.716995 328.525513 L 172.031006 328.172485 L 172.720993 328.836487"/>
@@ -8,5 +8,6 @@
8 <path id="path3" fill="#fc862e" fill-rule="evenodd" stroke="none" d="M 2.859 0.652496 C 2.283 0.9375 1.439 1.734497 0.984 2.422485 L 0.156 3.673492 L 0.146 91.195496 C 0.14 139.333496 0.179 176.646484 0.232 174.114502 L 0.329 169.509521 L 19.461 169.426514 C 29.983999 169.3815 38.759998 169.41449 38.962002 169.500488 C 39.165001 169.58551 111.797997 169.58551 200.367996 169.500488 C 288.938995 169.41449 370.015991 169.3815 380.539001 169.426514 L 399.67099 169.509521 L 399.768005 174.114502 C 399.821014 176.646484 399.859985 139.333496 399.854004 91.195496 L 399.843994 3.673492 L 399.011993 2.416504 C 397.377014 -0.054504 399.98999 0.106506 363.069 0.198486 L 330.156006 0.281494 L 329.104004 1.031494 C 326.75 2.71051 326.890015 0.46051 326.881989 36.921509 L 326.875 69.500488 L 308.906006 69.500488 L 290.937988 69.500488 L 290.937988 44.25351 L 290.937012 19.007507 L 290.234009 17.614502 C 289.772003 16.698486 289.102997 15.981506 288.281006 15.519501 L 287.031006 14.818512 L 254.162994 14.893494 C 217.136993 14.978516 220.574997 14.7435 218.828003 17.309509 L 218.125 18.342499 L 218.125 43.921509 L 218.125 69.500488 L 200 69.500488 L 181.875 69.500488 L 181.875 43.921509 L 181.875 18.342499 L 181.171997 17.309509 C 179.425003 14.7435 182.863007 14.978516 145.837006 14.893494 L 112.969002 14.818512 L 111.719002 15.519501 C 110.897003 15.981506 110.227997 16.698486 109.765999 17.614502 L 109.061996 19.007507 L 109.063004 44.25351 L 109.063004 69.500488 L 91.094002 69.500488 L 73.125 69.500488 L 73.117996 36.921509 C 73.110001 0.46051 73.25 2.71051 70.896004 1.031494 L 69.844002 0.281494 L 36.875 0.207489 C 6.708 0.140503 3.817 0.178497 2.859 0.652496 M 361.64801 171.0625 C 361.64801 171.83551 361.709015 172.152496 361.78299 171.765503 C 361.858002 171.378479 361.858002 170.746521 361.78299 170.359497 C 361.709015 169.972504 361.64801 170.28949 361.64801 171.0625 M 357.144012 171.380493 L 356.406006 172.010498 L 357.265991 171.642517 C 357.738007 171.440491 358.125 171.156494 358.125 171.012512 C 358.125 170.622498 357.958008 170.685486 357.144012 171.380493 M 43.125 171.571503 C 43.125 171.733521 43.547001 172.114502 44.063 172.419495 C 44.580002 172.724487 45 172.824493 45 172.642517 C 45 172.461487 44.834999 172.3125 44.633999 172.3125 C 44.432999 172.3125 44.011002 172.079498 43.695999 171.795502 C 43.382 171.510498 43.125 171.410522 43.125 171.571503"/>
9 <path id="path4" fill="#fcbc4c" fill-rule="evenodd" stroke="none" d="M 40.519001 334.109497 C 40.405998 337.847504 40.317001 349.41449 40.32 359.8125 C 40.327 380.949493 40.272999 380.413513 42.542 382.030487 L 43.594002 382.781494 L 86.025002 382.862488 C 123.482002 382.934509 128.563004 382.888489 129.373993 382.469513 C 130.774994 381.744507 131.789001 380.48349 132.162003 379.001495 C 132.542007 377.492493 132.636002 345.453491 132.292999 334.109497 L 132.087997 327.3125 L 86.405998 327.3125 L 40.724998 327.3125 L 40.519001 334.109497 M 172.522003 328.269501 C 173.110992 328.795502 173.593994 329.337494 173.593994 329.473511 C 173.593994 329.777496 174.623993 330.96051 183.281006 340.59549 C 185 342.508514 187.25 345.036499 188.281006 346.213501 C 189.313004 347.389496 190.302002 348.504486 190.481003 348.691498 C 190.660004 348.87851 191.919998 350.296509 193.281006 351.843506 C 196.328995 355.307495 196.772995 355.719513 198.037003 356.258514 C 200.772003 357.425507 202.677994 356.458496 206.742004 351.843506 C 208.406998 349.953491 210.278 347.840515 210.899994 347.147491 C 211.522003 346.455505 212.664001 345.185486 213.438004 344.324493 C 214.210999 343.463501 215.940994 341.543488 217.281998 340.057495 C 218.623001 338.570496 220.029007 336.96051 220.406998 336.477509 C 221.031998 335.679504 223.080994 333.384491 224.453003 331.94751 C 224.753998 331.632507 224.940994 331.375488 224.867996 331.375488 C 224.751007 331.375488 225.925003 330.076508 227.904999 328.015503 L 228.580994 327.3125 L 200.014999 327.3125 L 171.449997 327.3125 L 172.522003 328.269501 M 267.707001 334.109497 C 267.364014 345.453491 267.458008 377.492493 267.838013 379.001495 C 268.210999 380.48349 269.225006 381.744507 270.626007 382.469513 C 271.437012 382.888489 276.518005 382.934509 313.975006 382.862488 L 356.406006 382.781494 L 357.458008 382.030487 C 359.72699 380.413513 359.673004 380.949493 359.679993 359.8125 C 359.683014 349.41449 359.593994 337.847504 359.480988 334.109497 L 359.274994 327.3125 L 313.593994 327.3125 L 267.911987 327.3125 L 267.707001 334.109497"/>
10 </g>
11 - <text id="SOCFortress" xml:space="preserve"><tspan x="637" y="376" font-family="Orbitron" font-size="364" fill="#000000" letter-spacing="-7.28" xml:space="preserve">SOCF</tspan><tspan font-family="Orbitron" font-size="364" fill="#000000" letter-spacing="-7.28" xml:space="preserve">or</tspan><tspan font-family="Orbitron" font-size="364" fill="#000000" letter-spacing="-7.28" xml:space="preserve">tr</tspan><tspan font-family="Orbitron" font-size="364" fill="#000000" letter-spacing="-7.28" xml:space="preserve">es</tspan><tspan font-family="Orbitron" font-size="364" fill="#000000" letter-spacing="-7.28" xml:space="preserve">s</tspan></text>
11 + <text id="SOCFortress" xml:space="preserve"><tspan x="622" y="266" font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">S</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">O</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">C</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">F</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">o</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">r</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">t</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">r</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">e</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">s</tspan><tspan font-family="Orbitron" font-size="364" font-weight="700" fill="#000000" letter-spacing="-7.28" xml:space="preserve">s</tspan></text>
12 + <text id="CoPilot" xml:space="preserve"><tspan x="622" y="494" font-family="Orbitron" font-size="222" fill="#fc862e" letter-spacing="11.1" xml:space="preserve">C</tspan><tspan font-family="Orbitron" font-size="222" fill="#fc862e" letter-spacing="11.1" xml:space="preserve">oPilot</tspan></text>
13 </svg>
frontend/src/components/aiAnalyst/AlertReportCompare/AlertReportCompare.vue renamed
+22 -28
@@ -11,35 +11,36 @@
11 <template v-else>
12 <div class="text-secondary text-sm">
13 Side-by-side view of two investigations for alert
14 - <code class="text-primary">#{{ alertId }}</code>. Pick any two runs below — defaults
15 - to the current report on the left and the next-most-recent run on the right.
14 + <code class="text-primary">#{{ alertId }}</code>
15 + . Pick any two runs below — defaults to the current report on the left and the next-most-recent run
16 + on the right.
17 </div>
18
19 <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
20 <!-- Side A -->
21 <div class="flex flex-col gap-3">
21 - <div>
22 - <div class="mb-1 font-medium">Version A</div>
22 + <n-form-item label="Version A" :show-feedback="false">
23 <n-select
24 v-model:value="idA"
25 :options="reportOptions"
26 :render-label="renderOption"
27 + size="large"
28 />
28 - </div>
29 - <ReportColumn v-if="reportA" :report="reportA" />
29 + </n-form-item>
30 + <ReportColumn v-if="reportA" v-model:show-full-report="showFullReport" :report="reportA" />
31 </div>
32
33 <!-- Side B -->
34 <div class="flex flex-col gap-3">
34 - <div>
35 - <div class="mb-1 font-medium">Version B</div>
35 + <n-form-item label="Version B" :show-feedback="false">
36 <n-select
37 v-model:value="idB"
38 :options="reportOptions"
39 :render-label="renderOption"
40 + size="large"
41 />
41 - </div>
42 - <ReportColumn v-if="reportB" :report="reportB" />
42 + </n-form-item>
43 + <ReportColumn v-if="reportB" v-model:show-full-report="showFullReport" :report="reportB" />
44 </div>
45 </div>
46 </template>
@@ -49,9 +50,8 @@
50
51 <script setup lang="ts">
52 import type { AiAnalystReport } from "@/types/aiAnalyst.d"
52 -import { h } from "vue"
53 -import { NEmpty, NSelect, NSpin, useMessage } from "naive-ui"
54 -import { computed, onBeforeMount, ref, toRefs, watch } from "vue"
53 +import { NEmpty, NFormItem, NSelect, NSpin, useMessage } from "naive-ui"
54 +import { computed, h, ref, toRefs, watch } from "vue"
55 import Api from "@/api"
56 import { formatDate } from "@/utils/format"
57 import ReportColumn from "./AlertReportCompareColumn.vue"
@@ -70,6 +70,8 @@ const reports = ref<AiAnalystReport[]>([])
70 const idA = ref<number | null>(null)
71 const idB = ref<number | null>(null)
72
73 +const showFullReport = ref(false)
74 +
75 const reportOptions = computed(() =>
76 reports.value.map(r => ({
77 label: r.id.toString(),
@@ -79,27 +81,23 @@ const reportOptions = computed(() =>
81 }))
82 )
83
84 +const reportA = computed(() => reports.value.find(r => r.id === idA.value) ?? null)
85 +const reportB = computed(() => reports.value.find(r => r.id === idB.value) ?? null)
86 +
87 // Render option with created_at + severity so the picker shows meaningful
88 // distinctions between runs rather than just bare IDs.
84 -function renderOption(option: {
85 - label: string
86 - value: number
87 - severity?: string | null
88 - created_at?: string
89 -}) {
89 +function renderOption(option: { label: string; value: number; severity?: string | null; created_at?: string }) {
90 const ts = option.created_at ? String(formatDate(option.created_at, "MMM D, YYYY HH:mm")) : ""
91 const sev = option.severity ? ` · ${option.severity}` : ""
92 - return h("div", { class: "flex flex-col" }, [
92 + return h("div", { class: "flex flex-col leading-none py-2" }, [
93 h("span", `#${option.label}${sev}`),
94 h("span", { class: "text-secondary text-xs" }, ts)
95 ])
96 }
97
98 -const reportA = computed(() => reports.value.find(r => r.id === idA.value) ?? null)
99 -const reportB = computed(() => reports.value.find(r => r.id === idB.value) ?? null)
100 -
98 async function loadReports() {
99 loading.value = true
100 +
101 try {
102 const res = await Api.aiAnalyst.getReportsByAlert(alertId.value)
103 if (res.data.success) {
@@ -129,9 +127,5 @@ async function loadReports() {
127 }
128 }
129
132 -watch(alertId, () => loadReports())
133 -
134 -onBeforeMount(() => {
135 - loadReports()
136 -})
130 +watch(alertId, () => loadReports(), { immediate: true })
131 </script>
frontend/src/components/aiAnalyst/AlertReportCompare/AlertReportCompareColumn.vue renamed
+21 -12
@@ -25,26 +25,33 @@
25 <template #value>{{ report.recommended_actions }}</template>
26 </CardKV>
27
28 - <n-collapse>
29 - <n-collapse-item name="markdown">
30 - <template #header>
31 - <span class="font-medium">Full report</span>
32 - </template>
33 - <div v-if="report.report_markdown" class="pt-2">
34 - <Markdown :source="report.report_markdown" breaks />
35 - </div>
36 - <n-empty v-else description="No report content" class="min-h-20 justify-center" />
37 - </n-collapse-item>
38 - </n-collapse>
28 + <n-button text size="large" @click="showFullReport = !showFullReport">
29 + <template #icon>
30 + <Icon
31 + name="carbon:chevron-right"
32 + class="transition-transform duration-300"
33 + :class="{ 'rotate-90': showFullReport }"
34 + />
35 + </template>
36 + Full report
37 + </n-button>
38 +
39 + <n-collapse-transition :show="showFullReport">
40 + <div v-if="report.report_markdown">
41 + <Markdown :source="report.report_markdown" breaks />
42 + </div>
43 + <n-empty v-else description="No report content" class="min-h-20 justify-center" />
44 + </n-collapse-transition>
45 </div>
46 </template>
47
48 <script setup lang="ts">
49 import type { AiAnalystReport } from "@/types/aiAnalyst.d"
44 -import { NCollapse, NCollapseItem, NEmpty } from "naive-ui"
50 +import { NButton, NCollapseTransition, NEmpty } from "naive-ui"
51 import { computed, toRefs } from "vue"
52 import Badge from "@/components/common/Badge.vue"
53 import CardKV from "@/components/common/cards/CardKV.vue"
54 +import Icon from "@/components/common/Icon.vue"
55 import Markdown from "@/components/common/Markdown.vue"
56 import { formatDate } from "@/utils/format"
57
@@ -54,6 +61,8 @@ const props = defineProps<{
61
62 const { report } = toRefs(props)
63
64 +const showFullReport = defineModel<boolean>("showFullReport", { required: true })
65 +
66 const severityColor = computed(() => {
67 const severity = report.value.severity_assessment
68 if (severity === "Critical" || severity === "High") return "danger"
frontend/src/components/aiAnalyst/AlertReportDetails.vue
+32 -11
@@ -1,7 +1,14 @@
1 <template>
2 <n-spin :show="loading" class="flex grow flex-col" content-class="flex grow flex-col">
3 - <n-tabs type="line" animated :tabs-padding="24" class="grow" pane-wrapper-class="flex grow flex-col">
4 - <n-tab-pane name="Summary" tab="Summary" display-directive="show:lazy" class="flex grow flex-col">
3 + <n-tabs
4 + v-model:value="tabActive"
5 + type="line"
6 + animated
7 + :tabs-padding="24"
8 + class="grow"
9 + pane-wrapper-class="flex grow flex-col"
10 + >
11 + <n-tab-pane name="summary" tab="Summary" display-directive="show:lazy" class="flex grow flex-col">
12 <div class="p-6 pt-3">
13 <div class="flex flex-col gap-4">
14 <div v-if="report?.severity_assessment" class="flex items-center gap-2">
@@ -24,28 +31,28 @@
31 </div>
32 </div>
33 </n-tab-pane>
27 - <n-tab-pane name="Report" tab="Full Report" display-directive="show:lazy">
34 + <n-tab-pane name="report" tab="Full Report" display-directive="show:lazy">
35 <div class="p-6 pt-3">
36 <Markdown v-if="report?.report_markdown" :source="report.report_markdown" breaks />
37 <n-empty v-else description="No report content available" class="h-40" />
38 </div>
39 </n-tab-pane>
33 - <n-tab-pane name="IOCs" tab="IOCs" display-directive="show:lazy">
40 + <n-tab-pane name="iocs" tab="IOCs" display-directive="show:lazy">
41 <div class="p-6 pt-3">
42 <AlertReportIocsList :alert-id="alert.alert_id" />
43 </div>
44 </n-tab-pane>
38 - <n-tab-pane name="Jobs" tab="Jobs" display-directive="show:lazy">
45 + <n-tab-pane name="jobs" tab="Jobs" display-directive="show:lazy">
46 <div class="p-6 pt-3">
47 <AlertReportJobsList :alert-id="alert.alert_id" />
48 </div>
49 </n-tab-pane>
43 - <n-tab-pane name="Review" tab="Review" display-directive="show:lazy">
50 + <n-tab-pane name="review" tab="Review" display-directive="show:lazy">
51 <div class="p-6 pt-3">
45 - <AlertReportReviewPanel :report="report" />
52 + <AlertReportReviewPanel :report />
53 </div>
54 </n-tab-pane>
48 - <n-tab-pane name="Compare" tab="Compare" display-directive="show:lazy">
55 + <n-tab-pane name="compare" tab="Compare" display-directive="show:lazy">
56 <div class="p-6 pt-3">
57 <AlertReportCompare :alert-id="alert.alert_id" :current-report-id="report.id" />
58 </div>
@@ -57,7 +64,7 @@
64 <script setup lang="ts">
65 import type { AlertWithReport } from "@/types/aiAnalyst.d"
66 import { NEmpty, NSpin, NTabPane, NTabs } from "naive-ui"
60 -import { computed, defineAsyncComponent, ref, toRefs } from "vue"
67 +import { computed, defineAsyncComponent, ref, toRefs, watch } from "vue"
68 import Badge from "@/components/common/Badge.vue"
69 import CardKV from "@/components/common/cards/CardKV.vue"
70 import Markdown from "@/components/common/Markdown.vue"
@@ -66,12 +73,18 @@ const props = defineProps<{
73 alert: AlertWithReport
74 }>()
75
76 +const emit = defineEmits<{
77 + (e: "tab-change", value: string): void
78 +}>()
79 +
80 const { alert } = toRefs(props)
81
82 const AlertReportIocsList = defineAsyncComponent(() => import("./AlertReportIocsList.vue"))
83 const AlertReportJobsList = defineAsyncComponent(() => import("./AlertReportJobsList.vue"))
73 -const AlertReportReviewPanel = defineAsyncComponent(() => import("./AlertReportReviewPanel.vue"))
74 -const AlertReportCompare = defineAsyncComponent(() => import("./AlertReportCompare.vue"))
84 +const AlertReportReviewPanel = defineAsyncComponent(() => import("./AlertReportReviewPanel/AlertReportReviewPanel.vue"))
85 +const AlertReportCompare = defineAsyncComponent(() => import("./AlertReportCompare/AlertReportCompare.vue"))
86 +
87 +const tabActive = ref("summary")
88
89 const loading = ref(false)
90 const report = computed(() => alert.value.report)
@@ -83,4 +96,12 @@ const severityColor = computed(() => {
96 if (severity === "Low" || severity === "Informational") return "success"
97 return undefined
98 })
99 +
100 +watch(
101 + tabActive,
102 + value => {
103 + emit("tab-change", value)
104 + },
105 + { immediate: true }
106 +)
107 </script>
frontend/src/components/aiAnalyst/AlertReportItem.vue
+12 -2
@@ -68,7 +68,11 @@
68
69 <n-modal
70 v-model:show="showDetails"
71 - :style="{ maxWidth: 'min(850px, 90vw)', minHeight: 'min(540px, 90vh)', overflow: 'hidden' }"
71 + :style="{
72 + maxWidth: tabActive === 'compare' ? 'min(1650px, 90vw)' : 'min(850px, 90vw)',
73 + minHeight: 'min(540px, 90vh)',
74 + overflow: 'hidden'
75 + }"
76 content-class="p-0!"
77 display-directive="show"
78 preset="card"
@@ -76,7 +80,7 @@
80 :bordered="false"
81 segmented
82 >
79 - <AlertReportDetails v-if="alert" :alert />
83 + <AlertReportDetails v-if="alert" :alert @tab-change="handleTabChange" />
84 </n-modal>
85 </div>
86 </template>
@@ -110,6 +114,8 @@ const showDetails = ref(false)
114 const alert = computed(() => alertData.value)
115 const alertNameTruncated = computed(() => _truncate(alert.value?.alert_name, { length: 50 }))
116
117 +const tabActive = ref<string | null>(null)
118 +
119 const severityColor = computed(() => {
120 const severity = alert.value?.report.severity_assessment
121 if (severity === "Critical" || severity === "High") return "danger"
@@ -121,4 +127,8 @@ const severityColor = computed(() => {
127 function openDetails() {
128 showDetails.value = true
129 }
130 +
131 +function handleTabChange(value: string) {
132 + tabActive.value = value || null
133 +}
134 </script>
frontend/src/components/aiAnalyst/AlertReportReviewPanel.vue deleted
-555
@@ -1,555 +0,0 @@
1 -<template>
2 - <n-spin :show="loading" class="min-h-40">
3 - <div class="flex flex-col gap-6">
4 - <!-- Toolbar: mode banner + replay trigger -->
5 - <div class="flex flex-wrap items-center justify-between gap-3">
6 - <div v-if="existingReview" class="flex items-center gap-2">
7 - <Badge type="splitted" bright color="success">
8 - <template #label>Already reviewed</template>
9 - <template #value>Editing your previous submission</template>
10 - </Badge>
11 - <span v-if="existingReview.updated_at" class="text-secondary text-sm">
12 - Last edited {{ formatTs(existingReview.updated_at) }}
13 - </span>
14 - <span v-else class="text-secondary text-sm">
15 - Submitted {{ formatTs(existingReview.created_at) }}
16 - </span>
17 - </div>
18 - <div v-else />
19 - <n-button size="small" @click="showReplayModal = true">
20 - <template #icon>
21 - <Icon :name="ReplayIcon" :size="14" />
22 - </template>
23 - Replay with different template
24 - </n-button>
25 - </div>
26 -
27 - <ReplayModal v-model:show="showReplayModal" :report="report" @replayed="onReplayed" />
28 -
29 - <!-- Rubric -->
30 - <CardEntity size="small" embedded>
31 - <template #default>
32 - <div class="flex flex-col gap-4">
33 - <!-- Overall verdict -->
34 - <div>
35 - <div class="mb-1 font-medium">Overall verdict</div>
36 - <n-radio-group v-model:value="form.overall_verdict">
37 - <n-radio-button value="up">
38 - <Icon :name="ThumbUpIcon" :size="14" class="mr-1" />
39 - Good
40 - </n-radio-button>
41 - <n-radio-button value="down">
42 - <Icon :name="ThumbDownIcon" :size="14" class="mr-1" />
43 - Bad
44 - </n-radio-button>
45 - </n-radio-group>
46 - </div>
47 -
48 - <!-- Template choice -->
49 - <div>
50 - <div class="mb-1 font-medium">Template used</div>
51 - <div v-if="report.report_markdown === null" class="text-secondary text-sm">
52 - No template recorded on this report.
53 - </div>
54 - <n-radio-group v-model:value="form.template_choice">
55 - <n-radio value="correct">Correct template</n-radio>
56 - <n-radio value="partial">Partially correct</n-radio>
57 - <n-radio value="wrong">Wrong template</n-radio>
58 - </n-radio-group>
59 - </div>
60 -
61 - <!-- Ratings -->
62 - <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
63 - <div>
64 - <div class="mb-1 font-medium">Instructions quality</div>
65 - <n-slider
66 - v-model:value="form.rating_instructions"
67 - :min="1"
68 - :max="5"
69 - :step="1"
70 - :marks="rateMarks"
71 - />
72 - </div>
73 - <div>
74 - <div class="mb-1 font-medium">Artifact collection</div>
75 - <n-slider
76 - v-model:value="form.rating_artifacts"
77 - :min="1"
78 - :max="5"
79 - :step="1"
80 - :marks="rateMarks"
81 - />
82 - </div>
83 - <div>
84 - <div class="mb-1 font-medium">Severity assessment</div>
85 - <n-slider
86 - v-model:value="form.rating_severity"
87 - :min="1"
88 - :max="5"
89 - :step="1"
90 - :marks="rateMarks"
91 - />
92 - </div>
93 - </div>
94 -
95 - <!-- Missing steps -->
96 - <div>
97 - <div class="mb-1 font-medium">Missing steps</div>
98 - <n-input
99 - v-model:value="form.missing_steps"
100 - type="textarea"
101 - placeholder="What investigation steps were missed?"
102 - :autosize="{ minRows: 2, maxRows: 6 }"
103 - />
104 - </div>
105 -
106 - <!-- Suggested edits -->
107 - <div>
108 - <div class="mb-1 font-medium">Suggested prompt / template edits</div>
109 - <n-input
110 - v-model:value="form.suggested_edits"
111 - type="textarea"
112 - placeholder="How should the template or prompt be improved?"
113 - :autosize="{ minRows: 2, maxRows: 6 }"
114 - />
115 - </div>
116 - </div>
117 - </template>
118 - </CardEntity>
119 -
120 - <!-- Per-IOC verdict corrections -->
121 - <div>
122 - <div class="mb-2 font-medium">IOC verdict corrections</div>
123 - <div class="text-secondary mb-3 text-sm">
124 - Toggle off any IOC where the VirusTotal verdict above was wrong. Optionally note why.
125 - </div>
126 - <div v-if="iocs.length" class="flex flex-col gap-2">
127 - <CardEntity v-for="ioc of iocs" :key="ioc.id" size="small" embedded>
128 - <template #default>
129 - <div class="flex flex-col gap-2">
130 - <div class="flex flex-wrap items-center justify-between gap-3">
131 - <CodeSource :code="ioc.ioc_value" />
132 - <div class="flex items-center gap-3">
133 - <Badge type="splitted" bright>
134 - <template #label>Type</template>
135 - <template #value>{{ ioc.ioc_type }}</template>
136 - </Badge>
137 - <Badge type="splitted" bright :color="verdictColor(ioc.vt_verdict)">
138 - <template #label>VT</template>
139 - <template #value>{{ ioc.vt_verdict }}</template>
140 - </Badge>
141 - <n-tooltip placement="top">
142 - <template #trigger>
143 - <n-switch
144 - :value="iocCorrect(ioc.id)"
145 - @update:value="setIocCorrect(ioc.id, $event)"
146 - />
147 - </template>
148 - {{ iocCorrect(ioc.id) ? "Verdict correct" : "Verdict wrong" }}
149 - </n-tooltip>
150 - </div>
151 - </div>
152 - <n-input
153 - :value="iocNote(ioc.id)"
154 - type="textarea"
155 - placeholder="Optional reviewer note"
156 - :autosize="{ minRows: 1, maxRows: 4 }"
157 - @update:value="setIocNote(ioc.id, $event)"
158 - />
159 - </div>
160 - </template>
161 - </CardEntity>
162 - </div>
163 - <n-empty v-else description="No IOCs recorded for this report" class="min-h-24 justify-center" />
164 - </div>
165 -
166 - <!-- Submit review -->
167 - <div class="flex items-center justify-end gap-3">
168 - <span v-if="submitting" class="text-secondary text-sm">Saving…</span>
169 - <n-button :disabled="submitting || !canSubmit" type="primary" @click="handleSubmit">
170 - {{ existingReview ? "Update review" : "Submit review" }}
171 - </n-button>
172 - </div>
173 -
174 - <!-- Inline teach-the-palace -->
175 - <n-collapse>
176 - <n-collapse-item name="teach-palace">
177 - <template #header>
178 - <div class="flex items-center gap-2 font-medium">
179 - <Icon :name="BrainIcon" :size="16" />
180 - Teach the palace
181 - </div>
182 - </template>
183 - <div class="flex flex-col gap-4 p-2">
184 - <div class="text-secondary text-sm">
185 - Queue a lesson for the MemPalace. The NanoClaw drainer ingests these asynchronously.
186 - </div>
187 - <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
188 - <div>
189 - <div class="mb-1 font-medium">Room</div>
190 - <n-select
191 - v-model:value="lesson.lesson_type"
192 - :options="lessonTypeOptions"
193 - placeholder="Select room"
194 - />
195 - </div>
196 - <div>
197 - <div class="mb-1 font-medium">Durability</div>
198 - <div class="flex items-center gap-3">
199 - <n-switch v-model:value="lessonDurable" />
200 - <span class="text-secondary text-sm">
201 - {{ lessonDurable ? "Durable (persistent)" : "One-off (single session)" }}
202 - </span>
203 - </div>
204 - </div>
205 - </div>
206 - <div>
207 - <div class="mb-1 font-medium">Lesson text</div>
208 - <n-input
209 - v-model:value="lesson.lesson_text"
210 - type="textarea"
211 - placeholder="What should the palace remember?"
212 - :autosize="{ minRows: 3, maxRows: 10 }"
213 - />
214 - </div>
215 -
216 - <!-- Similar-lesson preview -->
217 - <div v-if="similarLoading || similarLessons.length" class="flex flex-col gap-2">
218 - <div class="text-secondary text-sm">
219 - <span v-if="similarLoading">Searching similar lessons…</span>
220 - <span v-else>Similar lessons already in the palace:</span>
221 - </div>
222 - <div v-if="!similarLoading" class="flex flex-col gap-1">
223 - <div
224 - v-for="(hit, idx) of similarLessons"
225 - :key="hit.id ?? idx"
226 - class="border-color bg-secondary rounded border p-2 text-sm"
227 - >
228 - <div class="flex items-center justify-between gap-2">
229 - <span class="text-secondary">
230 - {{ hit.room || "—" }}
231 - <template v-if="hit.score !== null && hit.score !== undefined">
232 - · score {{ hit.score.toFixed(2) }}
233 - </template>
234 - </span>
235 - </div>
236 - <div>{{ hit.text || "(no text)" }}</div>
237 - </div>
238 - </div>
239 - </div>
240 -
241 - <div class="flex items-center justify-end gap-3">
242 - <span v-if="queuing" class="text-secondary text-sm">Queuing…</span>
243 - <n-button :disabled="queuing || !canQueueLesson" @click="handleQueueLesson">
244 - Queue lesson
245 - </n-button>
246 - </div>
247 - </div>
248 - </n-collapse-item>
249 - </n-collapse>
250 - </div>
251 - </n-spin>
252 -</template>
253 -
254 -<script setup lang="ts">
255 -import type {
256 - AiAnalystIoc,
257 - AiAnalystReport,
258 - AiAnalystReview,
259 - Durability,
260 - IocVerdictCorrection,
261 - LessonType,
262 - PalaceSearchHit,
263 - SubmitReviewPayload
264 -} from "@/types/aiAnalyst.d"
265 -import {
266 - NButton,
267 - NCollapse,
268 - NCollapseItem,
269 - NEmpty,
270 - NInput,
271 - NRadio,
272 - NRadioButton,
273 - NRadioGroup,
274 - NSelect,
275 - NSlider,
276 - NSpin,
277 - NSwitch,
278 - NTooltip,
279 - useMessage
280 -} from "naive-ui"
281 -import { computed, onBeforeMount, ref, toRefs, watch } from "vue"
282 -import Api from "@/api"
283 -import Badge from "@/components/common/Badge.vue"
284 -import CardEntity from "@/components/common/cards/CardEntity.vue"
285 -import CodeSource from "@/components/common/CodeSource.vue"
286 -import Icon from "@/components/common/Icon.vue"
287 -import { formatDate } from "@/utils/format"
288 -import ReplayModal from "./ReplayModal.vue"
289 -
290 -const props = defineProps<{
291 - report: AiAnalystReport
292 -}>()
293 -
294 -const { report } = toRefs(props)
295 -
296 -const ThumbUpIcon = "mdi:thumb-up-outline"
297 -const ThumbDownIcon = "mdi:thumb-down-outline"
298 -const BrainIcon = "mdi:brain"
299 -const ReplayIcon = "carbon:restart"
300 -
301 -const showReplayModal = ref(false)
302 -
303 -function onReplayed(_data: Record<string, unknown> | undefined) {
304 - // The new report is created asynchronously by Talon's callbacks. Surface a
305 - // pointer so the reviewer knows where to watch — they can switch to the
306 - // Jobs tab or come back after the run completes.
307 - message.success("Replay queued — check the Jobs tab for the new run", { duration: 6000 })
308 -}
309 -
310 -const message = useMessage()
311 -const loading = ref(false)
312 -const submitting = ref(false)
313 -const queuing = ref(false)
314 -
315 -const existingReview = ref<AiAnalystReview | null>(null)
316 -const iocs = ref<AiAnalystIoc[]>([])
317 -
318 -type FormState = {
319 - overall_verdict: "up" | "down" | null
320 - template_choice: "correct" | "wrong" | "partial" | null
321 - rating_instructions: number
322 - rating_artifacts: number
323 - rating_severity: number
324 - missing_steps: string
325 - suggested_edits: string
326 -}
327 -
328 -const form = ref<FormState>({
329 - overall_verdict: null,
330 - template_choice: null,
331 - rating_instructions: 3,
332 - rating_artifacts: 3,
333 - rating_severity: 3,
334 - missing_steps: "",
335 - suggested_edits: ""
336 -})
337 -
338 -// Per-IOC review state — keyed by ioc.id so order stays stable with the list
339 -type IocState = { verdict_correct: boolean; note: string }
340 -const iocState = ref<Map<number, IocState>>(new Map())
341 -
342 -function iocCorrect(iocId: number): boolean {
343 - return iocState.value.get(iocId)?.verdict_correct ?? true
344 -}
345 -function iocNote(iocId: number): string {
346 - return iocState.value.get(iocId)?.note ?? ""
347 -}
348 -function setIocCorrect(iocId: number, val: boolean) {
349 - const cur = iocState.value.get(iocId) ?? { verdict_correct: true, note: "" }
350 - iocState.value.set(iocId, { ...cur, verdict_correct: val })
351 -}
352 -function setIocNote(iocId: number, val: string) {
353 - const cur = iocState.value.get(iocId) ?? { verdict_correct: true, note: "" }
354 - iocState.value.set(iocId, { ...cur, note: val })
355 -}
356 -
357 -function verdictColor(verdict: string) {
358 - if (verdict === "malicious") return "danger"
359 - if (verdict === "suspicious") return "warning"
360 - if (verdict === "clean") return "success"
361 - return undefined
362 -}
363 -
364 -const rateMarks = { 1: "1", 2: "2", 3: "3", 4: "4", 5: "5" }
365 -
366 -// At least overall_verdict must be set
367 -const canSubmit = computed(() => form.value.overall_verdict !== null)
368 -
369 -function formatTs(iso: string): string {
370 - return String(formatDate(iso, "MMM D, YYYY HH:mm"))
371 -}
372 -
373 -function hydrateFromReview(r: AiAnalystReview) {
374 - form.value.overall_verdict = (r.overall_verdict as "up" | "down" | null) ?? null
375 - form.value.template_choice = (r.template_choice as "correct" | "wrong" | "partial" | null) ?? null
376 - form.value.rating_instructions = r.rating_instructions ?? 3
377 - form.value.rating_artifacts = r.rating_artifacts ?? 3
378 - form.value.rating_severity = r.rating_severity ?? 3
379 - form.value.missing_steps = r.missing_steps ?? ""
380 - form.value.suggested_edits = r.suggested_edits ?? ""
381 -
382 - const next = new Map<number, IocState>()
383 - for (const ir of r.ioc_reviews || []) {
384 - next.set(ir.ioc_id, { verdict_correct: ir.verdict_correct, note: ir.note ?? "" })
385 - }
386 - iocState.value = next
387 -}
388 -
389 -function seedIocDefaults() {
390 - // Any IOC not yet in state defaults to "verdict correct". Preserves
391 - // per-IOC state hydrated from an existing review.
392 - for (const ioc of iocs.value) {
393 - if (!iocState.value.has(ioc.id)) {
394 - iocState.value.set(ioc.id, { verdict_correct: true, note: "" })
395 - }
396 - }
397 -}
398 -
399 -async function loadAll() {
400 - loading.value = true
401 - try {
402 - const [mineRes, iocsRes] = await Promise.all([
403 - Api.aiAnalyst.getMyReview(report.value.id),
404 - Api.aiAnalyst.getIocsByReport(report.value.id)
405 - ])
406 - if (iocsRes.data.success) iocs.value = iocsRes.data.iocs || []
407 - if (mineRes.data.success) {
408 - existingReview.value = mineRes.data.review ?? null
409 - if (existingReview.value) hydrateFromReview(existingReview.value)
410 - }
411 - seedIocDefaults()
412 - } catch (err: unknown) {
413 - const e = err as { response?: { data?: { message?: string } }; message?: string }
414 - message.error(e.response?.data?.message || e.message || "Failed to load review data")
415 - } finally {
416 - loading.value = false
417 - }
418 -}
419 -
420 -function buildReviewPayload(): SubmitReviewPayload {
421 - const ioc_reviews: IocVerdictCorrection[] = []
422 - for (const ioc of iocs.value) {
423 - const st = iocState.value.get(ioc.id)
424 - if (!st) continue
425 - // Only include corrections that represent meaningful input:
426 - // verdict marked wrong, OR reviewer left a note.
427 - if (st.verdict_correct === false || st.note.trim().length > 0) {
428 - ioc_reviews.push({
429 - ioc_id: ioc.id,
430 - verdict_correct: st.verdict_correct,
431 - ...(st.note.trim() ? { note: st.note.trim() } : {})
432 - })
433 - }
434 - }
435 - return {
436 - ...(form.value.overall_verdict ? { overall_verdict: form.value.overall_verdict } : {}),
437 - ...(form.value.template_choice ? { template_choice: form.value.template_choice } : {}),
438 - ...(report.value.report_markdown && existingReview.value?.template_used
439 - ? { template_used: existingReview.value.template_used }
440 - : {}),
441 - rating_instructions: form.value.rating_instructions,
442 - rating_artifacts: form.value.rating_artifacts,
443 - rating_severity: form.value.rating_severity,
444 - ...(form.value.missing_steps.trim() ? { missing_steps: form.value.missing_steps.trim() } : {}),
445 - ...(form.value.suggested_edits.trim() ? { suggested_edits: form.value.suggested_edits.trim() } : {}),
446 - ioc_reviews
447 - }
448 -}
449 -
450 -async function handleSubmit() {
451 - if (!canSubmit.value) return
452 - submitting.value = true
453 - try {
454 - const res = await Api.aiAnalyst.submitReview(report.value.id, buildReviewPayload())
455 - if (res.data.success) {
456 - existingReview.value = res.data.review
457 - message.success(res.data.message || "Review saved")
458 - } else {
459 - message.warning(res.data.message || "Failed to save review")
460 - }
461 - } catch (err: unknown) {
462 - const e = err as { response?: { data?: { message?: string } }; message?: string }
463 - message.error(e.response?.data?.message || e.message || "Failed to save review")
464 - } finally {
465 - submitting.value = false
466 - }
467 -}
468 -
469 -// --- Teach the palace ---
470 -
471 -const lessonTypeOptions: { label: string; value: LessonType }[] = [
472 - { label: "Environment", value: "environment" },
473 - { label: "False positives", value: "false_positives" },
474 - { label: "Assets", value: "assets" },
475 - { label: "Threat intel", value: "threat_intel" },
476 - { label: "Alerts", value: "alerts" }
477 -]
478 -
479 -const lesson = ref<{ lesson_type: LessonType | null; lesson_text: string }>({
480 - lesson_type: null,
481 - lesson_text: ""
482 -})
483 -const lessonDurable = ref(true)
484 -const lessonDurability = computed<Durability>(() => (lessonDurable.value ? "durable" : "one_off"))
485 -
486 -const canQueueLesson = computed(
487 - () => !!lesson.value.lesson_type && lesson.value.lesson_text.trim().length > 0
488 -)
489 -
490 -async function handleQueueLesson() {
491 - if (!canQueueLesson.value || !lesson.value.lesson_type) return
492 - queuing.value = true
493 - try {
494 - const res = await Api.aiAnalyst.queuePalaceLesson({
495 - customer_code: report.value.customer_code,
496 - lesson_type: lesson.value.lesson_type,
497 - lesson_text: lesson.value.lesson_text.trim(),
498 - durability: lessonDurability.value,
499 - ...(existingReview.value ? { review_id: existingReview.value.id } : {})
500 - })
501 - if (res.data.success) {
502 - message.success(res.data.message || "Lesson queued")
503 - lesson.value.lesson_text = ""
504 - similarLessons.value = []
505 - } else {
506 - message.warning(res.data.message || "Failed to queue lesson")
507 - }
508 - } catch (err: unknown) {
509 - const e = err as { response?: { data?: { message?: string } }; message?: string }
510 - message.error(e.response?.data?.message || e.message || "Failed to queue lesson")
511 - } finally {
512 - queuing.value = false
513 - }
514 -}
515 -
516 -// Debounced similar-lesson preview — re-run when the user pauses typing OR
517 -// changes the room. Keeps the lesson draft honest against what's already stored.
518 -const similarLessons = ref<PalaceSearchHit[]>([])
519 -const similarLoading = ref(false)
520 -let similarTimer: ReturnType<typeof setTimeout> | null = null
521 -
522 -function scheduleSimilarSearch() {
523 - if (similarTimer) clearTimeout(similarTimer)
524 - const text = lesson.value.lesson_text.trim()
525 - if (text.length < 8 || !lesson.value.lesson_type) {
526 - similarLessons.value = []
527 - similarLoading.value = false
528 - return
529 - }
530 - similarLoading.value = true
531 - similarTimer = setTimeout(async () => {
532 - try {
533 - const res = await Api.aiAnalyst.searchPalaceLessons(
534 - report.value.customer_code,
535 - text,
536 - lesson.value.lesson_type ?? undefined,
537 - 5
538 - )
539 - if (res.data.success) similarLessons.value = res.data.lessons || []
540 - else similarLessons.value = []
541 - } catch {
542 - // Non-fatal — preview is best-effort
543 - similarLessons.value = []
544 - } finally {
545 - similarLoading.value = false
546 - }
547 - }, 500)
548 -}
549 -
550 -watch(() => [lesson.value.lesson_text, lesson.value.lesson_type], scheduleSimilarSearch)
551 -
552 -onBeforeMount(() => {
553 - loadAll()
554 -})
555 -</script>
frontend/src/components/aiAnalyst/AlertReportReviewPanel/AlertReportReviewPanel.vue new
+181
@@ -0,0 +1,181 @@
1 +<template>
2 + <n-spin :show="loading" class="min-h-40">
3 + <div class="flex flex-col gap-6">
4 + <AlertReportReviewPanelReplay :report :existing-review />
5 +
6 + <AlertReportReviewPanelForm v-model:form="form" :report />
7 +
8 + <AlertReportReviewPanelIocs v-model:state="iocState" v-model:iocs="iocs" :report />
9 +
10 + <!-- Submit review -->
11 + <div class="flex flex-col gap-0">
12 + <div class="flex items-center justify-between gap-3">
13 + <n-button text size="large" @click="showTeachPalace = !showTeachPalace">
14 + <template #icon>
15 + <Icon
16 + name="carbon:chevron-right"
17 + class="transition-transform duration-300"
18 + :class="{ 'rotate-90': showTeachPalace }"
19 + />
20 + </template>
21 + Teach the palace
22 + </n-button>
23 + <n-button :disabled="!canSubmit" :loading="submitting" type="primary" @click="handleSubmit">
24 + {{ submitting ? "Saving..." : existingReview ? "Update review" : "Submit review" }}
25 + </n-button>
26 + </div>
27 +
28 + <!-- Inline teach-the-palace -->
29 + <n-collapse-transition :show="showTeachPalace">
30 + <div class="pt-6">
31 + <AlertReportReviewPanelTeachThePalace :report :existing-review />
32 + </div>
33 + </n-collapse-transition>
34 + </div>
35 + </div>
36 + </n-spin>
37 +</template>
38 +
39 +<script setup lang="ts">
40 +import type { FormState } from "./AlertReportReviewPanelForm.vue"
41 +import type { IocState } from "./AlertReportReviewPanelIocs.vue"
42 +import type {
43 + AiAnalystIoc,
44 + AiAnalystReport,
45 + AiAnalystReview,
46 + IocVerdictCorrection,
47 + SubmitReviewPayload
48 +} from "@/types/aiAnalyst.d"
49 +import type { ApiError } from "@/types/common"
50 +import { NButton, NCollapseTransition, NSpin, useMessage } from "naive-ui"
51 +import { computed, onBeforeMount, ref, toRefs } from "vue"
52 +import Api from "@/api"
53 +import Icon from "@/components/common/Icon.vue"
54 +import { getApiErrorMessage } from "@/utils"
55 +import AlertReportReviewPanelForm from "./AlertReportReviewPanelForm.vue"
56 +import AlertReportReviewPanelIocs from "./AlertReportReviewPanelIocs.vue"
57 +import AlertReportReviewPanelReplay from "./AlertReportReviewPanelReplay.vue"
58 +import AlertReportReviewPanelTeachThePalace from "./AlertReportReviewPanelTeachThePalace.vue"
59 +
60 +const props = defineProps<{
61 + report: AiAnalystReport
62 +}>()
63 +
64 +const { report } = toRefs(props)
65 +
66 +const message = useMessage()
67 +const loading = ref(false)
68 +const submitting = ref(false)
69 +
70 +const iocState = ref<Map<number, IocState>>(new Map())
71 +const iocs = ref<AiAnalystIoc[]>([])
72 +
73 +const existingReview = ref<AiAnalystReview | null>(null)
74 +
75 +const form = ref<FormState>({
76 + overall_verdict: null,
77 + template_choice: null,
78 + rating_instructions: 3,
79 + rating_artifacts: 3,
80 + rating_severity: 3,
81 + missing_steps: "",
82 + suggested_edits: ""
83 +})
84 +
85 +// --- Teach the palace ---
86 +const showTeachPalace = ref(false)
87 +
88 +// At least overall_verdict must be set
89 +const canSubmit = computed(() => form.value.overall_verdict !== null)
90 +
91 +function hydrateFromReview(r: AiAnalystReview) {
92 + form.value.overall_verdict = (r.overall_verdict as "up" | "down" | null) ?? null
93 + form.value.template_choice = (r.template_choice as "correct" | "wrong" | "partial" | null) ?? null
94 + form.value.rating_instructions = r.rating_instructions ?? 3
95 + form.value.rating_artifacts = r.rating_artifacts ?? 3
96 + form.value.rating_severity = r.rating_severity ?? 3
97 + form.value.missing_steps = r.missing_steps ?? ""
98 + form.value.suggested_edits = r.suggested_edits ?? ""
99 +
100 + const next = new Map<number, IocState>()
101 +
102 + for (const ir of r.ioc_reviews || []) {
103 + next.set(ir.ioc_id, { verdict_correct: ir.verdict_correct, note: ir.note ?? "" })
104 + }
105 +
106 + iocState.value = next
107 +}
108 +
109 +async function loadReview() {
110 + loading.value = true
111 +
112 + try {
113 + const res = await Api.aiAnalyst.getMyReview(report.value.id)
114 +
115 + if (res.data.success) {
116 + existingReview.value = res.data.review ?? null
117 + if (existingReview.value) hydrateFromReview(existingReview.value)
118 + }
119 + } catch (err) {
120 + message.error(getApiErrorMessage(err as ApiError) || "Failed to load review data")
121 + } finally {
122 + loading.value = false
123 + }
124 +}
125 +
126 +function buildReviewPayload(): SubmitReviewPayload {
127 + const ioc_reviews: IocVerdictCorrection[] = []
128 +
129 + for (const ioc of iocs.value) {
130 + const st = iocState.value.get(ioc.id)
131 + if (!st) continue
132 + // Only include corrections that represent meaningful input:
133 + // verdict marked wrong, OR reviewer left a note.
134 + if (st.verdict_correct === false || st.note.trim().length > 0) {
135 + ioc_reviews.push({
136 + ioc_id: ioc.id,
137 + verdict_correct: st.verdict_correct,
138 + ...(st.note.trim() ? { note: st.note.trim() } : {})
139 + })
140 + }
141 + }
142 +
143 + return {
144 + ...(form.value.overall_verdict ? { overall_verdict: form.value.overall_verdict } : {}),
145 + ...(form.value.template_choice ? { template_choice: form.value.template_choice } : {}),
146 + ...(report.value.report_markdown && existingReview.value?.template_used
147 + ? { template_used: existingReview.value.template_used }
148 + : {}),
149 + rating_instructions: form.value.rating_instructions,
150 + rating_artifacts: form.value.rating_artifacts,
151 + rating_severity: form.value.rating_severity,
152 + ...(form.value.missing_steps.trim() ? { missing_steps: form.value.missing_steps.trim() } : {}),
153 + ...(form.value.suggested_edits.trim() ? { suggested_edits: form.value.suggested_edits.trim() } : {}),
154 + ioc_reviews
155 + }
156 +}
157 +
158 +async function handleSubmit() {
159 + if (!canSubmit.value) return
160 +
161 + submitting.value = true
162 +
163 + try {
164 + const res = await Api.aiAnalyst.submitReview(report.value.id, buildReviewPayload())
165 + if (res.data.success) {
166 + existingReview.value = res.data.review
167 + message.success(res.data.message || "Review saved")
168 + } else {
169 + message.warning(res.data.message || "Failed to save review")
170 + }
171 + } catch (err) {
172 + message.error(getApiErrorMessage(err as ApiError) || "Failed to save review")
173 + } finally {
174 + submitting.value = false
175 + }
176 +}
177 +
178 +onBeforeMount(() => {
179 + loadReview()
180 +})
181 +</script>
frontend/src/components/aiAnalyst/AlertReportReviewPanel/AlertReportReviewPanelForm.vue new
+105
@@ -0,0 +1,105 @@
1 +<template>
2 + <n-card size="small" title="Review" segmented>
3 + <div class="@container flex flex-col gap-6">
4 + <n-form-item label="Overall verdict" :show-feedback="false">
5 + <n-radio-group v-model:value="form.overall_verdict">
6 + <n-radio-button value="up">
7 + <Icon :name="ThumbUpIcon" :size="14" class="mr-1" />
8 + Good
9 + </n-radio-button>
10 + <n-radio-button value="down">
11 + <Icon :name="ThumbDownIcon" :size="14" class="mr-1" />
12 + Bad
13 + </n-radio-button>
14 + </n-radio-group>
15 + </n-form-item>
16 +
17 + <!-- Template choice -->
18 + <n-form-item label="Template used" :show-feedback="false">
19 + <div v-if="report.report_markdown === null" class="text-secondary mb-1 text-sm">
20 + No template recorded on this report.
21 + </div>
22 + <n-radio-group v-model:value="form.template_choice">
23 + <n-radio value="correct">Correct template</n-radio>
24 + <n-radio value="partial">Partially correct</n-radio>
25 + <n-radio value="wrong">Wrong template</n-radio>
26 + </n-radio-group>
27 + </n-form-item>
28 +
29 + <!-- Ratings -->
30 + <div class="grid grid-cols-1 gap-6 @xl:grid-cols-3">
31 + <n-form-item label="Instructions quality" :show-feedback="false">
32 + <n-slider v-model:value="form.rating_instructions" :min="1" :max="5" :step="1" :marks="rateMarks" />
33 + </n-form-item>
34 +
35 + <n-form-item label="Artifact collection" :show-feedback="false">
36 + <n-slider v-model:value="form.rating_artifacts" :min="1" :max="5" :step="1" :marks="rateMarks" />
37 + </n-form-item>
38 +
39 + <n-form-item label="Severity assessment" :show-feedback="false">
40 + <n-slider v-model:value="form.rating_severity" :min="1" :max="5" :step="1" :marks="rateMarks" />
41 + </n-form-item>
42 + </div>
43 +
44 + <!-- Missing steps -->
45 + <n-form-item label="Missing steps" :show-feedback="false">
46 + <n-input
47 + v-model:value="form.missing_steps"
48 + type="textarea"
49 + placeholder="What investigation steps were missed?"
50 + :autosize="{ minRows: 2, maxRows: 6 }"
51 + />
52 + </n-form-item>
53 +
54 + <!-- Suggested edits -->
55 + <n-form-item label="Suggested prompt / template edits" :show-feedback="false">
56 + <n-input
57 + v-model:value="form.suggested_edits"
58 + type="textarea"
59 + placeholder="How should the template or prompt be improved?"
60 + :autosize="{ minRows: 2, maxRows: 6 }"
61 + />
62 + </n-form-item>
63 + </div>
64 + </n-card>
65 +</template>
66 +
67 +<script setup lang="ts">
68 +import type { AiAnalystReport } from "@/types/aiAnalyst.d"
69 +import { NCard, NFormItem, NInput, NRadio, NRadioButton, NRadioGroup, NSlider } from "naive-ui"
70 +import { toRefs } from "vue"
71 +import Icon from "@/components/common/Icon.vue"
72 +
73 +export interface FormState {
74 + overall_verdict: "up" | "down" | null
75 + template_choice: "correct" | "wrong" | "partial" | null
76 + rating_instructions: number
77 + rating_artifacts: number
78 + rating_severity: number
79 + missing_steps: string
80 + suggested_edits: string
81 +}
82 +
83 +const props = defineProps<{
84 + report: AiAnalystReport
85 +}>()
86 +
87 +const { report } = toRefs(props)
88 +
89 +const form = defineModel<FormState>("form", {
90 + default: {
91 + overall_verdict: null,
92 + template_choice: null,
93 + rating_instructions: 3,
94 + rating_artifacts: 3,
95 + rating_severity: 3,
96 + missing_steps: "",
97 + suggested_edits: ""
98 + }
99 +})
100 +
101 +const ThumbUpIcon = "mdi:thumb-up-outline"
102 +const ThumbDownIcon = "mdi:thumb-down-outline"
103 +
104 +const rateMarks = { 1: "1", 2: "2", 3: "3", 4: "4", 5: "5" }
105 +</script>
frontend/src/components/aiAnalyst/AlertReportReviewPanel/AlertReportReviewPanelIocs.vue new
+128
@@ -0,0 +1,128 @@
1 +<template>
2 + <n-card title="IOC verdict corrections" size="small" segmented>
3 + <n-spin :show="loading" class="min-h-40">
4 + <div class="flex flex-col gap-3">
5 + <p class="text-sm">
6 + Toggle off any IOC where the VirusTotal verdict above was wrong. Optionally note why.
7 + </p>
8 + <div v-if="iocs.length" class="flex flex-col gap-2">
9 + <CardEntity v-for="ioc of iocs" :key="ioc.id" size="small" embedded>
10 + <template #headerExtra>
11 + <div class="flex items-center gap-3">
12 + <Badge type="splitted" bright>
13 + <template #label>Type</template>
14 + <template #value>{{ ioc.ioc_type }}</template>
15 + </Badge>
16 + <Badge type="splitted" bright :color="verdictColor(ioc.vt_verdict)">
17 + <template #label>VT</template>
18 + <template #value>{{ ioc.vt_verdict }}</template>
19 + </Badge>
20 + <n-tooltip placement="top">
21 + <template #trigger>
22 + <n-switch
23 + :value="iocCorrect(ioc.id)"
24 + @update:value="setIocCorrect(ioc.id, $event)"
25 + />
26 + </template>
27 + {{ iocCorrect(ioc.id) ? "Verdict correct" : "Verdict wrong" }}
28 + </n-tooltip>
29 + </div>
30 + </template>
31 + <template #default>
32 + <CodeSource :code="ioc.ioc_value" />
33 + </template>
34 + <template #mainExtra>
35 + <n-input
36 + :value="iocNote(ioc.id)"
37 + type="textarea"
38 + placeholder="Optional reviewer note"
39 + :autosize="{ minRows: 1, maxRows: 4 }"
40 + @update:value="setIocNote(ioc.id, $event)"
41 + />
42 + </template>
43 + </CardEntity>
44 + </div>
45 + <n-empty v-else description="No IOCs recorded for this report" class="min-h-24 justify-center" />
46 + </div>
47 + </n-spin>
48 + </n-card>
49 +</template>
50 +
51 +<script setup lang="ts">
52 +import type { AiAnalystIoc, AiAnalystReport } from "@/types/aiAnalyst.d"
53 +import { NCard, NEmpty, NInput, NSpin, NSwitch, NTooltip, useMessage } from "naive-ui"
54 +import { onBeforeMount, ref, toRefs } from "vue"
55 +import Api from "@/api"
56 +import Badge from "@/components/common/Badge.vue"
57 +import CardEntity from "@/components/common/cards/CardEntity.vue"
58 +import CodeSource from "@/components/common/CodeSource.vue"
59 +
60 +export interface IocState {
61 + verdict_correct: boolean
62 + note: string
63 +}
64 +
65 +const props = defineProps<{
66 + report: AiAnalystReport
67 +}>()
68 +
69 +const { report } = toRefs(props)
70 +
71 +// Per-IOC review state — keyed by ioc.id so order stays stable with the list
72 +const iocState = defineModel<Map<number, IocState>>("state", { required: true, default: () => new Map() })
73 +const iocs = defineModel<AiAnalystIoc[]>("iocs", { required: true, default: () => [] })
74 +
75 +const message = useMessage()
76 +const loading = ref(false)
77 +
78 +function iocCorrect(iocId: number): boolean {
79 + return iocState.value.get(iocId)?.verdict_correct ?? true
80 +}
81 +function iocNote(iocId: number): string {
82 + return iocState.value.get(iocId)?.note ?? ""
83 +}
84 +function setIocCorrect(iocId: number, val: boolean) {
85 + const cur = iocState.value.get(iocId) ?? { verdict_correct: true, note: "" }
86 + iocState.value.set(iocId, { ...cur, verdict_correct: val })
87 +}
88 +function setIocNote(iocId: number, val: string) {
89 + const cur = iocState.value.get(iocId) ?? { verdict_correct: true, note: "" }
90 + iocState.value.set(iocId, { ...cur, note: val })
91 +}
92 +
93 +function verdictColor(verdict: string) {
94 + if (verdict === "malicious") return "danger"
95 + if (verdict === "suspicious") return "warning"
96 + if (verdict === "clean") return "success"
97 + return undefined
98 +}
99 +
100 +function seedIocDefaults() {
101 + // Any IOC not yet in state defaults to "verdict correct". Preserves
102 + // per-IOC state hydrated from an existing review.
103 + for (const ioc of iocs.value) {
104 + if (!iocState.value.has(ioc.id)) {
105 + iocState.value.set(ioc.id, { verdict_correct: true, note: "" })
106 + }
107 + }
108 +}
109 +
110 +async function loadIocs() {
111 + loading.value = true
112 +
113 + try {
114 + const iocsRes = await Api.aiAnalyst.getIocsByReport(report.value.id)
115 + if (iocsRes.data.success) iocs.value = iocsRes.data.iocs || []
116 + seedIocDefaults()
117 + } catch (err: unknown) {
118 + const e = err as { response?: { data?: { message?: string } }; message?: string }
119 + message.error(e.response?.data?.message || e.message || "Failed to load review data")
120 + } finally {
121 + loading.value = false
122 + }
123 +}
124 +
125 +onBeforeMount(() => {
126 + loadIocs()
127 +})
128 +</script>
frontend/src/components/aiAnalyst/AlertReportReviewPanel/AlertReportReviewPanelReplay.vue new
+59
@@ -0,0 +1,59 @@
1 +<template>
2 + <div class="flex flex-col gap-6">
3 + <!-- Toolbar: mode banner + replay trigger -->
4 + <div class="flex flex-wrap items-start justify-between gap-3">
5 + <div v-if="existingReview" class="flex flex-col gap-2">
6 + <Badge type="splitted" bright color="success">
7 + <template #label>Already reviewed</template>
8 + <template #value>Editing your previous submission</template>
9 + </Badge>
10 + <span v-if="existingReview.updated_at" class="text-secondary text-sm">
11 + Last edited {{ formatDate(existingReview.updated_at, dFormats.datetime) }}
12 + </span>
13 + <span v-else class="text-secondary text-sm">
14 + Submitted {{ formatDate(existingReview.created_at, dFormats.datetime) }}
15 + </span>
16 + </div>
17 + <div v-else />
18 + <n-button size="small" @click="showReplayModal = true">
19 + <template #icon>
20 + <Icon :name="ReplayIcon" :size="14" />
21 + </template>
22 + Replay with different template
23 + </n-button>
24 + </div>
25 +
26 + <ReplayModal v-model:show="showReplayModal" :report @replayed="onReplayed" />
27 + </div>
28 +</template>
29 +
30 +<script setup lang="ts">
31 +import type { AiAnalystReport, AiAnalystReview } from "@/types/aiAnalyst.d"
32 +import { NButton, useMessage } from "naive-ui"
33 +import { ref } from "vue"
34 +import Badge from "@/components/common/Badge.vue"
35 +import Icon from "@/components/common/Icon.vue"
36 +import { useSettingsStore } from "@/stores/settings"
37 +import { formatDate } from "@/utils/format"
38 +import ReplayModal from "./ReplayModal.vue"
39 +
40 +defineProps<{
41 + report: AiAnalystReport
42 + existingReview: AiAnalystReview | null
43 +}>()
44 +
45 +const ReplayIcon = "carbon:restart"
46 +
47 +const message = useMessage()
48 +
49 +const dFormats = useSettingsStore().dateFormat
50 +
51 +const showReplayModal = ref(false)
52 +
53 +function onReplayed(_data: Record<string, unknown> | undefined) {
54 + // The new report is created asynchronously by Talon's callbacks. Surface a
55 + // pointer so the reviewer knows where to watch — they can switch to the
56 + // Jobs tab or come back after the run completes.
57 + message.success("Replay queued — check the Jobs tab for the new run", { duration: 6000 })
58 +}
59 +</script>
frontend/src/components/aiAnalyst/AlertReportReviewPanel/AlertReportReviewPanelTeachThePalace.vue new
+176
@@ -0,0 +1,176 @@
1 +<template>
2 + <n-card size="small" title="Teach the palace" segmented>
3 + <div class="flex flex-col gap-6">
4 + <p class="text-sm">Queue a lesson for the MemPalace. The NanoClaw drainer ingests these asynchronously.</p>
5 +
6 + <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
7 + <n-form-item label="Room" :show-feedback="false">
8 + <n-select
9 + v-model:value="lesson.lesson_type"
10 + :options="lessonTypeOptions"
11 + placeholder="Select room"
12 + />
13 + </n-form-item>
14 +
15 + <n-form-item label="Durability" :show-feedback="false">
16 + <div class="flex items-center gap-3">
17 + <n-switch v-model:value="lesson.durable" />
18 + <span class="text-secondary text-sm">
19 + {{ lesson.durable ? "Durable (persistent)" : "One-off (single session)" }}
20 + </span>
21 + </div>
22 + </n-form-item>
23 + </div>
24 +
25 + <n-form-item label="Lesson text" :show-feedback="false">
26 + <n-input
27 + v-model:value="lesson.lesson_text"
28 + type="textarea"
29 + placeholder="What should the palace remember?"
30 + :autosize="{ minRows: 3, maxRows: 10 }"
31 + />
32 + </n-form-item>
33 +
34 + <!-- Similar-lesson preview -->
35 + <div v-if="similarLoading || similarLessons.length" class="flex flex-col gap-2">
36 + <div class="text-secondary text-sm">
37 + <span v-if="similarLoading">Searching similar lessons…</span>
38 + <span v-else>Similar lessons already in the palace:</span>
39 + </div>
40 + <div v-if="!similarLoading" class="flex flex-col gap-1">
41 + <CardEntity v-for="(hit, idx) of similarLessons" :key="hit.id ?? idx" size="small" embedded>
42 + <template #headerMain>
43 + {{ hit.room || "—" }}
44 + </template>
45 + <template #headerExtra>
46 + <Badge v-if="hit.score != null" type="splitted" bright>
47 + <template #label>score</template>
48 + <template #value>{{ hit.score.toFixed(2) }}</template>
49 + </Badge>
50 + </template>
51 + <template #default>
52 + <div>{{ hit.text || "(no text)" }}</div>
53 + </template>
54 + </CardEntity>
55 + </div>
56 + </div>
57 +
58 + <div class="flex items-center justify-end gap-3">
59 + <n-button
60 + :disabled="!canQueueLesson"
61 + :loading="queuing"
62 + type="primary"
63 + secondary
64 + @click="handleQueueLesson"
65 + >
66 + {{ queuing ? "Queueing..." : "Queue lesson" }}
67 + </n-button>
68 + </div>
69 + </div>
70 + </n-card>
71 +</template>
72 +
73 +<script setup lang="ts">
74 +import type { AiAnalystReport, AiAnalystReview, Durability, LessonType, PalaceSearchHit } from "@/types/aiAnalyst.d"
75 +import type { ApiError } from "@/types/common"
76 +import { NButton, NCard, NFormItem, NInput, NSelect, NSwitch, useMessage } from "naive-ui"
77 +import { computed, ref, toRefs, watch } from "vue"
78 +import Api from "@/api"
79 +import Badge from "@/components/common/Badge.vue"
80 +import CardEntity from "@/components/common/cards/CardEntity.vue"
81 +import { getApiErrorMessage } from "@/utils"
82 +
83 +const props = defineProps<{
84 + report: AiAnalystReport
85 + existingReview: AiAnalystReview | null
86 +}>()
87 +
88 +const { report, existingReview } = toRefs(props)
89 +
90 +const message = useMessage()
91 +const queuing = ref(false)
92 +
93 +const lessonTypeOptions: { label: string; value: LessonType }[] = [
94 + { label: "Environment", value: "environment" },
95 + { label: "False positives", value: "false_positives" },
96 + { label: "Assets", value: "assets" },
97 + { label: "Threat intel", value: "threat_intel" },
98 + { label: "Alerts", value: "alerts" }
99 +]
100 +
101 +const lesson = ref<{ lesson_type: LessonType | null; lesson_text: string; durable: boolean }>({
102 + lesson_type: null,
103 + lesson_text: "",
104 + durable: true
105 +})
106 +const lessonDurability = computed<Durability>(() => (lesson.value.durable ? "durable" : "one_off"))
107 +
108 +const canQueueLesson = computed(() => !!lesson.value.lesson_type && lesson.value.lesson_text.trim().length > 0)
109 +
110 +// Debounced similar-lesson preview — re-run when the user pauses typing OR
111 +// changes the room. Keeps the lesson draft honest against what's already stored.
112 +const similarLessons = ref<PalaceSearchHit[]>([])
113 +const similarLoading = ref(false)
114 +let similarTimer: ReturnType<typeof setTimeout> | null = null
115 +
116 +async function handleQueueLesson() {
117 + if (!canQueueLesson.value || !lesson.value.lesson_type) return
118 +
119 + queuing.value = true
120 +
121 + try {
122 + const res = await Api.aiAnalyst.queuePalaceLesson({
123 + customer_code: report.value.customer_code,
124 + lesson_type: lesson.value.lesson_type,
125 + lesson_text: lesson.value.lesson_text.trim(),
126 + durability: lessonDurability.value,
127 + ...(existingReview.value ? { review_id: existingReview.value.id } : {})
128 + })
129 + if (res.data.success) {
130 + message.success(res.data.message || "Lesson queued")
131 + lesson.value.lesson_text = ""
132 + similarLessons.value = []
133 + } else {
134 + message.warning(res.data.message || "Failed to queue lesson")
135 + }
136 + } catch (err) {
137 + message.error(getApiErrorMessage(err as ApiError) || "Failed to queue lesson")
138 + } finally {
139 + queuing.value = false
140 + }
141 +}
142 +
143 +function scheduleSimilarSearch() {
144 + if (similarTimer) clearTimeout(similarTimer)
145 +
146 + const text = lesson.value.lesson_text.trim()
147 +
148 + if (text.length < 8 || !lesson.value.lesson_type) {
149 + similarLessons.value = []
150 + similarLoading.value = false
151 + return
152 + }
153 +
154 + similarLoading.value = true
155 +
156 + similarTimer = setTimeout(async () => {
157 + try {
158 + const res = await Api.aiAnalyst.searchPalaceLessons(
159 + report.value.customer_code,
160 + text,
161 + lesson.value.lesson_type ?? undefined,
162 + 5
163 + )
164 + if (res.data.success) similarLessons.value = res.data.lessons || []
165 + else similarLessons.value = []
166 + } catch {
167 + // Non-fatal — preview is best-effort
168 + similarLessons.value = []
169 + } finally {
170 + similarLoading.value = false
171 + }
172 + }, 500)
173 +}
174 +
175 +watch(() => [lesson.value.lesson_text, lesson.value.lesson_type], scheduleSimilarSearch)
176 +</script>
frontend/src/components/aiAnalyst/AlertReportReviewPanel/ReplayModal.vue renamed
+19 -26
@@ -11,37 +11,32 @@
11 <div class="flex flex-col gap-4">
12 <div class="text-secondary text-sm">
13 Re-runs the investigation for alert
14 - <code class="text-primary">#{{ report.alert_id }}</code> with a forced template. The
15 - replay creates its own new job and report via Talon — nothing on this report is
16 - modified.
14 + <code class="text-primary">#{{ report.alert_id }}</code>
15 + with a forced template. The replay creates its own new job and report via Talon — nothing on this
16 + report is modified.
17 </div>
18
19 - <div class="flex flex-wrap items-center gap-3">
20 - <Badge type="splitted" bright>
21 - <template #label>Customer</template>
22 - <template #value>{{ report.customer_code }}</template>
23 - </Badge>
24 - </div>
25 -
26 - <div>
27 - <div class="mb-2 font-medium">Choose a template</div>
19 + <div class="flex flex-col gap-2">
20 + <div class="flex items-center justify-between gap-2">
21 + <div class="text-sm">Choose a template</div>
22 + <Badge type="splitted" bright>
23 + <template #label>Customer</template>
24 + <template #value>{{ report.customer_code }}</template>
25 + </Badge>
26 + </div>
27 <div v-if="!loading && !templates.length">
28 <n-empty description="No templates available" class="min-h-20 justify-center" />
29 </div>
31 - <n-radio-group
32 - v-else
33 - v-model:value="selectedFilename"
34 - class="flex max-h-80 w-full flex-col gap-2 overflow-y-auto pr-1"
35 - >
30 + <n-radio-group v-else v-model:value="selectedFilename" class="flex! w-full flex-col gap-2">
31 <n-radio
32 v-for="tpl of templates"
33 :key="tpl.filename"
34 :value="tpl.filename"
40 - class="border-color hover:border-primary w-full rounded border p-3"
35 + class="border-default hover:border-primary w-full rounded-lg border p-3 transition-colors [&_.n-radio\_\_label]:grow"
36 :class="selectedFilename === tpl.filename ? 'border-primary bg-primary/5' : ''"
37 >
43 - <div class="flex w-full flex-col gap-1">
44 - <div class="flex items-center justify-between gap-3">
38 + <div class="flex w-full flex-col gap-1 pl-0.5">
39 + <div class="flex w-full items-center justify-between gap-3">
40 <span class="font-medium">{{ tpl.filename }}</span>
41 <span class="text-secondary text-xs">{{ formatBytes(tpl.size_bytes) }}</span>
42 </div>
@@ -58,12 +53,7 @@
53 <template #action>
54 <div class="flex w-full items-center justify-end gap-3">
55 <n-button :disabled="submitting" @click="showLocal = false">Cancel</n-button>
61 - <n-button
62 - type="primary"
63 - :disabled="!selectedFilename || submitting"
64 - :loading="submitting"
65 - @click="handleReplay"
66 - >
56 + <n-button type="primary" :disabled="!selectedFilename" :loading="submitting" @click="handleReplay">
57 Replay
58 </n-button>
59 </div>
@@ -104,6 +94,7 @@ const showLocal = computed({
94 async function loadTemplates() {
95 loading.value = true
96 selectedFilename.value = null
97 +
98 try {
99 const res = await Api.talon.getTemplates()
100 if (res.data.success) {
@@ -123,7 +114,9 @@ async function loadTemplates() {
114
115 async function handleReplay() {
116 if (!selectedFilename.value) return
117 +
118 submitting.value = true
119 +
120 try {
121 const res = await Api.aiAnalyst.replayReport(props.report.id, {
122 template_override: selectedFilename.value,
frontend/src/components/aiAnalyst/Feedback/FeedbackDashboard.vue new
+67
@@ -0,0 +1,67 @@
1 +<template>
2 + <div class="feedback-dashboard @container flex flex-col gap-5">
3 + <FeedbackDashboardToolbar v-model:customer="customer" v-model:loading="loading" @refresh="loadStats()" />
4 +
5 + <n-spin :show="loading" class="min-h-40">
6 + <div v-if="!customer" class="pt-6 text-center">
7 + <n-empty description="Pick a customer to see their review feedback" />
8 + </div>
9 +
10 + <div v-else-if="stats" class="flex flex-col gap-5">
11 + <FeedbackDashboardMetricTiles :stats />
12 + <FeedbackDashboardTemplateChoiceDistribution :stats />
13 + <FeedbackDashboardTemplateTable :stats />
14 + <FeedbackDashboardRecentReviews :stats />
15 + </div>
16 + </n-spin>
17 + </div>
18 +</template>
19 +
20 +<script setup lang="ts">
21 +import type { AiAnalystReviewStats } from "@/types/aiAnalyst.d"
22 +import { NEmpty, NSpin, useMessage } from "naive-ui"
23 +import { ref, watch } from "vue"
24 +import Api from "@/api"
25 +import { getApiErrorMessage } from "@/utils"
26 +import FeedbackDashboardMetricTiles from "./FeedbackDashboardMetricTiles.vue"
27 +import FeedbackDashboardRecentReviews from "./FeedbackDashboardRecentReviews.vue"
28 +import FeedbackDashboardTemplateChoiceDistribution from "./FeedbackDashboardTemplateChoiceDistribution.vue"
29 +import FeedbackDashboardTemplateTable from "./FeedbackDashboardTemplateTable.vue"
30 +import FeedbackDashboardToolbar from "./FeedbackDashboardToolbar.vue"
31 +
32 +const message = useMessage()
33 +const customer = ref<string | null>(null)
34 +const loading = ref(false)
35 +const stats = ref<AiAnalystReviewStats | null>(null)
36 +
37 +async function loadStats() {
38 + if (!customer.value) {
39 + stats.value = null
40 + return
41 + }
42 + loading.value = true
43 + try {
44 + const res = await Api.aiAnalyst.getReviewStats(customer.value, 10)
45 + if (res.data.success) {
46 + stats.value = res.data
47 + } else {
48 + message.warning(res.data.message || "Failed to load stats")
49 + stats.value = null
50 + }
51 + } catch (err: unknown) {
52 + message.error(getApiErrorMessage(err as never) || "Failed to load stats")
53 + stats.value = null
54 + } finally {
55 + loading.value = false
56 + }
57 +}
58 +
59 +// Refetch stats on actual customer changes only. Wiring via @update:value on
60 +// the select is fragile — naive-ui can fire update:value when the options
61 +// prop identity churns, which would loop the stats endpoint. watch() only
62 +// fires on real value changes, so programmatic and user-driven picks behave
63 +// the same and options churn is ignored.
64 +watch(customer, () => {
65 + loadStats()
66 +})
67 +</script>
frontend/src/components/aiAnalyst/Feedback/FeedbackDashboardMetricTiles.vue new
+67
@@ -0,0 +1,67 @@
1 +<template>
2 + <div class="@container w-full">
3 + <div class="grid grid-cols-1 gap-3 @lg:grid-cols-2 @3xl:grid-cols-4">
4 + <MetricTile label="Total reviews" :value="stats.total_reviews.toString()" />
5 + <MetricTile
6 + label="Thumbs up"
7 + :value="pctLabel(stats.thumbs_up_pct)"
8 + :sub="`${stats.thumbs_up} up / ${stats.thumbs_down} down`"
9 + :color="pctColor(stats.thumbs_up_pct)"
10 + />
11 + <MetricTile
12 + label="IOC verdict accuracy"
13 + :value="pctLabel(stats.ioc_accuracy.accuracy_pct)"
14 + :sub="`${stats.ioc_accuracy.correct}/${stats.ioc_accuracy.total} IOCs correct`"
15 + :color="pctColor(stats.ioc_accuracy.accuracy_pct)"
16 + />
17 + <MetricTile
18 + label="Avg rating (overall)"
19 + :value="avgOverall == null ? '—' : `${avgOverall.toFixed(2)} / 5`"
20 + :sub="ratingSubtitle"
21 + />
22 + </div>
23 + </div>
24 +</template>
25 +
26 +<script setup lang="ts">
27 +import type { AiAnalystReviewStats } from "@/types/aiAnalyst.d"
28 +import { computed, toRefs } from "vue"
29 +import MetricTile from "./FeedbackMetricTile.vue"
30 +
31 +const props = defineProps<{
32 + stats: AiAnalystReviewStats
33 +}>()
34 +
35 +const { stats } = toRefs(props)
36 +
37 +// Composite avg across the three rubric axes — only counts axes with data.
38 +const avgOverall = computed(() => {
39 + if (!stats.value) return null
40 + const parts = [
41 + stats.value.avg_rating_instructions,
42 + stats.value.avg_rating_artifacts,
43 + stats.value.avg_rating_severity
44 + ].filter((v): v is number => v !== null)
45 + if (!parts.length) return null
46 + return parts.reduce((a, b) => a + b, 0) / parts.length
47 +})
48 +
49 +const ratingSubtitle = computed(() => {
50 + if (!stats.value) return ""
51 + const i = stats.value.avg_rating_instructions
52 + const a = stats.value.avg_rating_artifacts
53 + const s = stats.value.avg_rating_severity
54 + return `instr ${i ?? "—"} · artif ${a ?? "—"} · sev ${s ?? "—"}`
55 +})
56 +
57 +function pctLabel(pct: number | null): string {
58 + return pct === null || pct === undefined ? "—" : `${pct.toFixed(1)}%`
59 +}
60 +
61 +function pctColor(pct: number | null): "success" | "warning" | "danger" | undefined {
62 + if (pct === null) return undefined
63 + if (pct >= 75) return "success"
64 + if (pct >= 50) return "warning"
65 + return "danger"
66 +}
67 +</script>
frontend/src/components/aiAnalyst/Feedback/FeedbackDashboardRecentReviewDetail.vue new
+73
@@ -0,0 +1,73 @@
1 +<template>
2 + <n-drawer v-model:show="show" :width="520" placement="right">
3 + <n-drawer-content v-if="review" closable>
4 + <template #header>Review for report #{{ review.report_id }}</template>
5 + <div class="flex flex-col gap-3">
6 + <div class="flex flex-wrap items-center gap-2">
7 + <Badge
8 + v-if="review.overall_verdict"
9 + type="splitted"
10 + bright
11 + :color="review.overall_verdict === 'up' ? 'success' : 'danger'"
12 + >
13 + <template #label>Verdict</template>
14 + <template #value>
15 + {{ review.overall_verdict === "up" ? "Up" : "Down" }}
16 + </template>
17 + </Badge>
18 + <Badge v-if="review.template_used" type="splitted">
19 + <template #label>Template</template>
20 + <template #value>{{ review.template_used }}</template>
21 + </Badge>
22 + <Badge v-if="review.template_choice" type="splitted" bright>
23 + <template #label>Template choice</template>
24 + <template #value>{{ review.template_choice }}</template>
25 + </Badge>
26 + </div>
27 +
28 + <CardKV v-if="review.missing_steps">
29 + <template #key>Missing steps</template>
30 + <template #value>{{ review.missing_steps }}</template>
31 + </CardKV>
32 + <CardKV v-if="review.suggested_edits">
33 + <template #key>Suggested edits</template>
34 + <template #value>{{ review.suggested_edits }}</template>
35 + </CardKV>
36 +
37 + <n-card v-if="review.ioc_reviews.length" size="small" title="IOC corrections" embedded>
38 + <div class="flex flex-col gap-2">
39 + <CardEntity v-for="ir of review.ioc_reviews" :key="ir.id" embedded size="small">
40 + <template #default>
41 + <div class="flex items-center gap-2">
42 + <Badge type="splitted" :color="ir.verdict_correct ? 'success' : 'danger'">
43 + <template #label>IOC {{ ir.ioc_id }}</template>
44 + <template #value>
45 + {{ ir.verdict_correct ? "Correct" : "Wrong" }}
46 + </template>
47 + </Badge>
48 + </div>
49 + </template>
50 + <template v-if="ir.note" #mainExtra>
51 + {{ ir.note }}
52 + </template>
53 + </CardEntity>
54 + </div>
55 + </n-card>
56 + </div>
57 + </n-drawer-content>
58 + </n-drawer>
59 +</template>
60 +
61 +<script setup lang="ts">
62 +import type { AiAnalystReview } from "@/types/aiAnalyst.d"
63 +import { NCard, NDrawer, NDrawerContent } from "naive-ui"
64 +import Badge from "@/components/common/Badge.vue"
65 +import CardEntity from "@/components/common/cards/CardEntity.vue"
66 +import CardKV from "@/components/common/cards/CardKV.vue"
67 +
68 +defineProps<{
69 + review: AiAnalystReview | null
70 +}>()
71 +
72 +const show = defineModel<boolean>("show")
73 +</script>
frontend/src/components/aiAnalyst/Feedback/FeedbackDashboardRecentReviews.vue new
+98
@@ -0,0 +1,98 @@
1 +<template>
2 + <n-card size="small" title="Recent reviews" embedded>
3 + <p v-if="!stats.recent_reviews.length" class="text-sm">No reviews yet.</p>
4 + <div v-else class="flex flex-col gap-2">
5 + <CardEntity
6 + v-for="r of stats.recent_reviews"
7 + :key="r.id"
8 + size="small"
9 + hoverable
10 + clickable
11 + @click="openDrawer(r)"
12 + >
13 + <template #headerMain>
14 + Report #{{ r.report_id }}
15 + <span v-if="r.template_used" class="text-secondary ml-2 text-sm">· {{ r.template_used }}</span>
16 + </template>
17 + <template #headerExtra>
18 + <span class="text-secondary text-sm">
19 + {{ formatDate(r.updated_at ?? r.created_at, "MMM D, YYYY HH:mm") }}
20 + </span>
21 + </template>
22 + <template #default>
23 + <div class="flex flex-wrap items-center gap-3">
24 + <Badge
25 + v-if="r.overall_verdict"
26 + type="splitted"
27 + bright
28 + :color="r.overall_verdict === 'up' ? 'success' : 'danger'"
29 + >
30 + <template #label>Verdict</template>
31 + <template #value>
32 + {{ r.overall_verdict === "up" ? "Up" : "Down" }}
33 + </template>
34 + </Badge>
35 + <Badge
36 + v-if="r.template_choice"
37 + type="splitted"
38 + bright
39 + :color="tplChoiceColor(r.template_choice)"
40 + >
41 + <template #label>Template</template>
42 + <template #value>{{ r.template_choice }}</template>
43 + </Badge>
44 + <Badge v-if="r.rating_instructions" type="splitted">
45 + <template #label>Instr</template>
46 + <template #value>{{ r.rating_instructions }}/5</template>
47 + </Badge>
48 + <Badge v-if="r.rating_artifacts" type="splitted">
49 + <template #label>Artifacts</template>
50 + <template #value>{{ r.rating_artifacts }}/5</template>
51 + </Badge>
52 + <Badge v-if="r.rating_severity" type="splitted">
53 + <template #label>Severity</template>
54 + <template #value>{{ r.rating_severity }}/5</template>
55 + </Badge>
56 + <Badge v-if="r.ioc_reviews.length" type="splitted">
57 + <template #label>IOC corrections</template>
58 + <template #value>{{ r.ioc_reviews.length }}</template>
59 + </Badge>
60 + </div>
61 + </template>
62 + </CardEntity>
63 + </div>
64 +
65 + <FeedbackDashboardRecentReviewDetail v-model:show="showDrawer" :review="drawerReview" />
66 + </n-card>
67 +</template>
68 +
69 +<script setup lang="ts">
70 +import type { AiAnalystReview, AiAnalystReviewStats } from "@/types/aiAnalyst.d"
71 +import { NCard } from "naive-ui"
72 +import { ref, toRefs } from "vue"
73 +import Badge from "@/components/common/Badge.vue"
74 +import CardEntity from "@/components/common/cards/CardEntity.vue"
75 +import { formatDate } from "@/utils/format"
76 +import FeedbackDashboardRecentReviewDetail from "./FeedbackDashboardRecentReviewDetail.vue"
77 +
78 +const props = defineProps<{
79 + stats: AiAnalystReviewStats
80 +}>()
81 +
82 +const { stats } = toRefs(props)
83 +
84 +const showDrawer = ref(false)
85 +const drawerReview = ref<AiAnalystReview | null>(null)
86 +
87 +function tplChoiceColor(choice: string): "success" | "warning" | "danger" | undefined {
88 + if (choice === "correct") return "success"
89 + if (choice === "partial") return "warning"
90 + if (choice === "wrong") return "danger"
91 + return undefined
92 +}
93 +
94 +function openDrawer(r: AiAnalystReview) {
95 + drawerReview.value = r
96 + showDrawer.value = true
97 +}
98 +</script>
frontend/src/components/aiAnalyst/Feedback/FeedbackDashboardTemplateChoiceDistribution.vue new
+44
@@ -0,0 +1,44 @@
1 +<template>
2 + <n-card size="small" embedded title="Template choice distribution">
3 + <div class="flex flex-col gap-2">
4 + <TemplateChoiceBar
5 + label="Correct"
6 + color="success"
7 + :count="stats.template_choice_correct"
8 + :total="templateChoiceTotal"
9 + />
10 + <TemplateChoiceBar
11 + label="Partial"
12 + color="warning"
13 + :count="stats.template_choice_partial"
14 + :total="templateChoiceTotal"
15 + />
16 + <TemplateChoiceBar
17 + label="Wrong"
18 + color="error"
19 + :count="stats.template_choice_wrong"
20 + :total="templateChoiceTotal"
21 + />
22 + <p v-if="templateChoiceTotal === 0" class="text-sm">No template choice feedback yet.</p>
23 + </div>
24 + </n-card>
25 +</template>
26 +
27 +<script setup lang="ts">
28 +import type { AiAnalystReviewStats } from "@/types/aiAnalyst.d"
29 +import { NCard } from "naive-ui"
30 +import { computed, toRefs } from "vue"
31 +import TemplateChoiceBar from "./FeedbackTemplateChoiceBar.vue"
32 +
33 +const props = defineProps<{
34 + stats: AiAnalystReviewStats
35 +}>()
36 +
37 +const { stats } = toRefs(props)
38 +
39 +const templateChoiceTotal = computed(() =>
40 + stats.value
41 + ? stats.value.template_choice_correct + stats.value.template_choice_partial + stats.value.template_choice_wrong
42 + : 0
43 +)
44 +</script>
frontend/src/components/aiAnalyst/Feedback/FeedbackDashboardTemplateTable.vue new
+87
@@ -0,0 +1,87 @@
1 +<template>
2 + <n-card size="small" title="Per-template performance" embedded>
3 + <n-empty v-if="!stats.per_template.length" description="No reviews yet" class="min-h-20 justify-center" />
4 + <n-data-table
5 + v-else
6 + :columns="perTemplateColumns"
7 + :data="stats.per_template"
8 + size="small"
9 + :row-key="(r: ReviewStatsTemplate) => r.template_used ?? '__null__'"
10 + />
11 + </n-card>
12 +</template>
13 +
14 +<script setup lang="tsx">
15 +import type { DataTableColumns } from "naive-ui"
16 +import type { AiAnalystReviewStats, ReviewStatsTemplate } from "@/types/aiAnalyst.d"
17 +import { NCard, NDataTable, NEmpty } from "naive-ui"
18 +import { computed, toRefs } from "vue"
19 +
20 +const props = defineProps<{
21 + stats: AiAnalystReviewStats
22 +}>()
23 +
24 +const { stats } = toRefs(props)
25 +
26 +const perTemplateColumns = computed<DataTableColumns<ReviewStatsTemplate>>(() => [
27 + {
28 + title: "Template",
29 + key: "template_used",
30 + minWidth: 200,
31 + render: row => <div class="min-w-40">{row.template_used ?? "(none)"}</div>
32 + },
33 + { title: "Total", key: "total", width: 80, render: row => <div class="min-w-14">{row.total}</div> },
34 + {
35 + title: "Up / Down",
36 + key: "verdict",
37 + width: 110,
38 + render: row => (
39 + <div class="min-w-18">
40 + {row.thumbs_up}
41 + {" / "}
42 + {row.thumbs_down}
43 + </div>
44 + )
45 + },
46 + {
47 + title: "C / P / W",
48 + key: "choice",
49 + width: 110,
50 + render: row => (
51 + <div class="min-w-22">
52 + {row.correct}
53 + {" / "}
54 + {row.partial}
55 + {" / "}
56 + {row.wrong}
57 + </div>
58 + )
59 + },
60 + {
61 + title: "Instr",
62 + key: "avg_rating_instructions",
63 + width: 80,
64 + render: row => (
65 + <div class="min-w-14">
66 + {row.avg_rating_instructions == null ? "—" : row.avg_rating_instructions.toFixed(2)}
67 + </div>
68 + )
69 + },
70 + {
71 + title: "Artif",
72 + key: "avg_rating_artifacts",
73 + width: 80,
74 + render: row => (
75 + <div class="min-w-14">{row.avg_rating_artifacts == null ? "—" : row.avg_rating_artifacts.toFixed(2)}</div>
76 + )
77 + },
78 + {
79 + title: "Sev",
80 + key: "avg_rating_severity",
81 + width: 80,
82 + render: row => (
83 + <div class="min-w-14">{row.avg_rating_severity == null ? "—" : row.avg_rating_severity.toFixed(2)}</div>
84 + )
85 + }
86 +])
87 +</script>
frontend/src/components/aiAnalyst/Feedback/FeedbackDashboardToolbar.vue new
+91
@@ -0,0 +1,91 @@
1 +<template>
2 + <div class="flex flex-wrap items-center justify-between gap-3">
3 + <div class="flex items-center gap-2 text-sm">
4 + <span>Customer</span>
5 + <n-select
6 + v-model:value="customer"
7 + size="small"
8 + placeholder="Select a customer"
9 + :options="customerOptions"
10 + :show-checkmark="false"
11 + class="min-w-52"
12 + :loading="customerBootstrapLoading"
13 + />
14 + </div>
15 + <div class="flex items-center gap-2">
16 + <n-button size="small" :disabled="!customer" @click="showConsolidation = true">
17 + <template #icon>
18 + <Icon :name="ConsolidateIcon" />
19 + </template>
20 + Consolidate lessons
21 + </n-button>
22 + <n-button size="small" :disabled="!customer" :loading @click="refresh()">
23 + <template #icon>
24 + <Icon :name="RefreshIcon" />
25 + </template>
26 + Refresh
27 + </n-button>
28 + </div>
29 +
30 + <PalaceConsolidationDrawer v-if="customer" v-model:show="showConsolidation" :customer-code="customer" />
31 + </div>
32 +</template>
33 +
34 +<script setup lang="ts">
35 +import type { ApiError } from "@/types/common"
36 +import { NButton, NSelect, useMessage } from "naive-ui"
37 +import { onBeforeMount, ref } from "vue"
38 +import Api from "@/api"
39 +import Icon from "@/components/common/Icon.vue"
40 +import { getApiErrorMessage } from "@/utils"
41 +import PalaceConsolidationDrawer from "./PalaceConsolidationDrawer.vue"
42 +
43 +const emit = defineEmits<{
44 + (e: "refresh"): void
45 +}>()
46 +const RefreshIcon = "carbon:renew"
47 +const ConsolidateIcon = "carbon:data-collection"
48 +
49 +const message = useMessage()
50 +
51 +const loading = defineModel<boolean>("loading")
52 +
53 +const customer = defineModel<string | null>("customer")
54 +const customerOptions = ref<{ label: string; value: string }[]>([])
55 +const customerBootstrapLoading = ref(false)
56 +
57 +const showConsolidation = ref(false)
58 +
59 +async function bootstrapCustomers() {
60 + // Bootstrap customer picker off alerts_with_reports so we only list
61 + // customers that actually have AI runs — no external customer endpoint call.
62 + customerBootstrapLoading.value = true
63 +
64 + try {
65 + const res = await Api.aiAnalyst.getAlertsWithReports()
66 + if (res.data.success) {
67 + const codes = new Set((res.data.alerts || []).map(a => a.customer_code))
68 + customerOptions.value = Array.from(codes)
69 + .sort()
70 + .map(c => ({ label: c, value: c }))
71 + if (customerOptions.value.length && !customer.value) {
72 + // The watch(customer, loadStats) below picks this up — no
73 + // need to call loadStats() explicitly from bootstrap.
74 + customer.value = customerOptions.value[0].value
75 + }
76 + }
77 + } catch (err) {
78 + message.error(getApiErrorMessage(err as ApiError) || "Failed to load customers")
79 + } finally {
80 + customerBootstrapLoading.value = false
81 + }
82 +}
83 +
84 +function refresh() {
85 + emit("refresh")
86 +}
87 +
88 +onBeforeMount(() => {
89 + bootstrapCustomers()
90 +})
91 +</script>
frontend/src/components/aiAnalyst/Feedback/FeedbackMetricTile.vue renamed
frontend/src/components/aiAnalyst/Feedback/FeedbackTemplateChoiceBar.vue renamed
+2 -17
@@ -1,14 +1,7 @@
1 <template>
2 <div class="flex items-center gap-3">
3 <div class="w-16 shrink-0 text-sm">{{ label }}</div>
4 - <n-progress
5 - type="line"
6 - :percentage="percentage"
7 - :show-indicator="false"
8 - :color="barColor"
9 - :height="10"
10 - class="grow"
11 - />
4 + <n-progress type="line" :percentage :show-indicator="false" :status="color" :height="10" class="grow" />
5 <div class="text-secondary w-28 shrink-0 text-right text-sm">
6 {{ count }} / {{ total }} ({{ percentage.toFixed(1) }}%)
7 </div>
@@ -23,16 +16,8 @@ const props = defineProps<{
16 label: string
17 count: number
18 total: number
26 - color: "success" | "warning" | "danger"
19 + color: "success" | "warning" | "error"
20 }>()
21
22 const percentage = computed(() => (props.total === 0 ? 0 : (props.count / props.total) * 100))
30 -
31 -// Map semantic colors to the brand palette. Naive's type="line" accepts raw
32 -// CSS colors via `color` prop — reach for the same tokens CardEntity uses.
33 -const barColor = computed(() => {
34 - if (props.color === "success") return "#16a34a"
35 - if (props.color === "warning") return "#f59e0b"
36 - return "#dc2626"
37 -})
23 </script>
frontend/src/components/aiAnalyst/Feedback/PalaceConsolidationDrawer.vue renamed
+78 -99
@@ -3,7 +3,7 @@
3 <n-drawer-content closable>
4 <template #header>
5 Palace consolidation
6 - <span v-if="customerCode" class="text-secondary ml-2 text-sm">· {{ customerCode }}</span>
6 + <code v-if="customerCode" class="ml-2">{{ customerCode }}</code>
7 </template>
8
9 <n-spin :show="loading" class="min-h-40">
@@ -18,47 +18,25 @@
18 <div v-else-if="data" class="flex flex-col gap-4">
19 <!-- Summary tiles -->
20 <div class="grid grid-cols-2 gap-3 md:grid-cols-4">
21 - <CardEntity size="small" embedded>
22 - <template #default>
23 - <div class="flex flex-col gap-1">
24 - <div class="text-secondary text-xs tracking-wide uppercase">Active</div>
25 - <div class="text-2xl font-semibold">{{ data.total_lessons }}</div>
26 - <div class="text-secondary text-xs">{{ data.total_pending }} pending</div>
27 - </div>
28 - </template>
29 - </CardEntity>
30 - <CardEntity size="small" embedded>
31 - <template #default>
32 - <div class="flex flex-col gap-1">
33 - <div class="text-secondary text-xs tracking-wide uppercase">Durable</div>
34 - <div class="text-2xl font-semibold text-success">{{ data.total_durable }}</div>
35 - <div class="text-secondary text-xs">never expire</div>
36 - </div>
37 - </template>
38 - </CardEntity>
39 - <CardEntity size="small" embedded>
40 - <template #default>
41 - <div class="flex flex-col gap-1">
42 - <div class="text-secondary text-xs tracking-wide uppercase">One-off</div>
43 - <div class="text-2xl font-semibold text-warning">{{ data.total_one_off }}</div>
44 - <div class="text-secondary text-xs">7-day TTL</div>
45 - </div>
46 - </template>
47 - </CardEntity>
48 - <CardEntity size="small" embedded>
49 - <template #default>
50 - <div class="flex flex-col gap-1">
51 - <div class="text-secondary text-xs tracking-wide uppercase">Duplicates</div>
52 - <div
53 - class="text-2xl font-semibold"
54 - :class="data.duplicate_candidates.length ? 'text-warning' : ''"
55 - >
56 - {{ data.duplicate_candidates.length }}
57 - </div>
58 - <div class="text-secondary text-xs">near-dupe pairs</div>
59 - </div>
60 - </template>
61 - </CardEntity>
21 + <MetricTile label="Active" :value="data.total_lessons.toString()" :sub="`${data.total_pending} pending`" />
22 + <MetricTile
23 + label="Durable"
24 + :value="data.total_durable.toString()"
25 + sub="never expire"
26 + color="success"
27 + />
28 + <MetricTile
29 + label="One-off"
30 + :value="data.total_one_off.toString()"
31 + sub="7-day TTL"
32 + color="warning"
33 + />
34 + <MetricTile
35 + label="Duplicates"
36 + :value="data.duplicate_candidates.length.toString()"
37 + sub="near-dupe pairs"
38 + :color="data.duplicate_candidates.length ? 'warning' : undefined"
39 + />
40 </div>
41
42 <!-- Upcoming expirations -->
@@ -81,7 +59,7 @@
59 </Badge>
60 <span class="text-secondary text-xs">id {{ ls.id }}</span>
61 </div>
84 - <div class="break-words whitespace-pre-wrap">{{ ls.lesson_text }}</div>
62 + <div class="wrap-break-word whitespace-pre-wrap">{{ ls.lesson_text }}</div>
63 </div>
64 </div>
65 </template>
@@ -100,22 +78,22 @@
78 class="border-color bg-secondary rounded border p-2 text-sm"
79 >
80 <div class="mb-1 flex flex-wrap items-center gap-2">
103 - <Badge
104 - type="splitted"
105 - bright
106 - :color="simColor(pair.similarity)"
107 - >
81 + <Badge type="splitted" bright :color="simColor(pair.similarity)">
82 <template #label>{{ pair.room }}</template>
83 <template #value>{{ Math.round(pair.similarity * 100) }}%</template>
84 </Badge>
85 </div>
86 <div class="mb-1">
87 <span class="text-secondary text-xs">#{{ pair.lesson_a_id }}</span>
114 - <span class="ml-1 break-words whitespace-pre-wrap">{{ pair.lesson_a_text }}</span>
88 + <span class="ml-1 wrap-break-word whitespace-pre-wrap">
89 + {{ pair.lesson_a_text }}
90 + </span>
91 </div>
92 <div>
93 <span class="text-secondary text-xs">#{{ pair.lesson_b_id }}</span>
118 - <span class="ml-1 break-words whitespace-pre-wrap">{{ pair.lesson_b_text }}</span>
94 + <span class="ml-1 wrap-break-word whitespace-pre-wrap">
95 + {{ pair.lesson_b_text }}
96 + </span>
97 </div>
98 </div>
99 </div>
@@ -132,11 +110,7 @@
110 class="min-h-20 justify-center"
111 />
112 <n-collapse v-else>
135 - <n-collapse-item
136 - v-for="group of data.rooms"
137 - :key="group.room"
138 - :name="group.room"
139 - >
113 + <n-collapse-item v-for="group of data.rooms" :key="group.room" :name="group.room">
114 <template #header>
115 <div class="flex items-center gap-2">
116 <span class="font-medium">{{ group.room }}</span>
@@ -155,54 +129,62 @@
129 </div>
130 </template>
131 <div class="flex flex-col gap-2">
158 - <div
159 - v-for="ls of group.lessons"
160 - :key="ls.id"
161 - class="border-color bg-secondary rounded border p-2 text-sm"
162 - >
163 - <div class="mb-1 flex flex-wrap items-center gap-2">
164 - <Badge
165 - type="splitted"
166 - :color="ls.durability === 'durable' ? 'success' : 'warning'"
167 - >
168 - <template #label>{{ ls.durability }}</template>
169 - <template #value>
170 - {{ ls.durability === "one_off"
171 - ? expiryLabel(ls.days_until_expiry)
172 - : "∞" }}
173 - </template>
174 - </Badge>
175 - <Badge type="splitted" :color="statusColor(ls.status)">
176 - <template #label>Status</template>
177 - <template #value>{{ ls.status }}</template>
178 - </Badge>
179 - <span class="text-secondary text-xs">
180 - id {{ ls.id }} · {{ formatDate(ls.created_at, "MMM D") }}
181 - </span>
182 - </div>
183 - <div class="break-words whitespace-pre-wrap">{{ ls.lesson_text }}</div>
184 - </div>
132 + <CardEntity v-for="ls of group.lessons" :key="ls.id" size="small">
133 + <template #default>
134 + <div class="flex flex-wrap items-center gap-2">
135 + <Badge
136 + type="splitted"
137 + :color="ls.durability === 'durable' ? 'success' : 'warning'"
138 + >
139 + <template #label>{{ ls.durability }}</template>
140 + <template #value>
141 + {{
142 + ls.durability === "one_off"
143 + ? expiryLabel(ls.days_until_expiry)
144 + : "∞"
145 + }}
146 + </template>
147 + </Badge>
148 + <Badge type="splitted" :color="statusColor(ls.status)">
149 + <template #label>Status</template>
150 + <template #value>{{ ls.status }}</template>
151 + </Badge>
152 + <Badge type="splitted">
153 + <template #label>ID</template>
154 + <template #value>{{ ls.id }}</template>
155 + </Badge>
156 + <Badge type="splitted">
157 + <template #label>Created at</template>
158 + <template #value>
159 + {{ formatDate(ls.created_at, "MMM D") }}
160 + </template>
161 + </Badge>
162 + </div>
163 + </template>
164 + <template #mainExtra>
165 + {{ ls.lesson_text }}
166 + </template>
167 + </CardEntity>
168 </div>
169 </n-collapse-item>
170 </n-collapse>
171 </template>
172 </CardEntity>
173
191 - <div class="text-secondary text-xs">
192 - Generated {{ formatDate(data.generated_at, "MMM D, YYYY HH:mm") }} UTC
193 - </div>
174 + <p class="text-xs">Generated {{ formatDate(data.generated_at, "MMM D, YYYY HH:mm") }} UTC</p>
175 </div>
176 </n-spin>
177
178 <template #footer>
179 <div class="flex w-full items-center justify-between gap-2">
199 - <div class="text-secondary text-xs">
180 + <p class="text-sm">
181 <template v-if="data">
182 {{ data.total_lessons }} lesson(s) · {{ data.rooms.length }} room(s)
183 </template>
203 - </div>
184 + </p>
185 <div class="flex items-center gap-2">
186 <n-button
187 + v-if="isCopySupported"
188 size="small"
189 :disabled="!data || !data.markdown"
190 @click="copyMarkdown"
@@ -210,13 +192,11 @@
192 <template #icon>
193 <Icon :name="CopyIcon" :size="14" />
194 </template>
213 - Copy markdown
195 + <div class="min-w-28 text-left">
196 + {{ showCopyTooltip ? "Copied!" : "Copy markdown" }}
197 + </div>
198 </n-button>
215 - <n-button
216 - size="small"
217 - :disabled="!customerCode || loading"
218 - @click="load()"
219 - >
199 + <n-button size="small" :disabled="!customerCode" :loading @click="load()">
200 <template #icon>
201 <Icon :name="RefreshIcon" :size="14" />
202 </template>
@@ -231,6 +211,7 @@
211
212 <script setup lang="ts">
213 import type { PalaceConsolidation } from "@/types/aiAnalyst.d"
214 +import { useClipboard } from "@vueuse/core"
215 import { NButton, NCollapse, NCollapseItem, NDrawer, NDrawerContent, NEmpty, NSpin, useMessage } from "naive-ui"
216 import { computed, ref, watch } from "vue"
217 import Api from "@/api"
@@ -239,6 +220,7 @@ import CardEntity from "@/components/common/cards/CardEntity.vue"
220 import Icon from "@/components/common/Icon.vue"
221 import { getApiErrorMessage } from "@/utils"
222 import { formatDate } from "@/utils/format"
223 +import MetricTile from "./FeedbackMetricTile.vue"
224
225 const props = defineProps<{
226 show: boolean
@@ -254,6 +236,7 @@ const CopyIcon = "carbon:copy"
236 const WarningIcon = "carbon:warning"
237
238 const message = useMessage()
239 +const { copy: copyMarkdownText, copied: showCopyTooltip, isSupported: isCopySupported } = useClipboard()
240
241 // v-model:show bridge — local ref mirrors the prop so the drawer's
242 // internal close button also propagates back to the parent.
@@ -311,12 +294,8 @@ async function load() {
294
295 async function copyMarkdown() {
296 if (!data.value?.markdown) return
314 - try {
315 - await navigator.clipboard.writeText(data.value.markdown)
316 - message.success("Markdown copied to clipboard")
317 - } catch {
318 - message.error("Clipboard unavailable — select + copy from the drawer body")
319 - }
297 +
298 + copyMarkdownText(data.value.markdown)
299 }
300
301 // Auto-load on open (and reload if customer changes while open).
frontend/src/components/aiAnalyst/FeedbackDashboard.vue deleted
-441
@@ -1,441 +0,0 @@
1 -<template>
2 - <div class="feedback-dashboard @container flex flex-col gap-5">
3 - <!-- Customer picker -->
4 - <div class="flex flex-wrap items-center justify-between gap-3">
5 - <div class="flex items-center gap-2 text-sm">
6 - <span>Customer</span>
7 - <n-select
8 - v-model:value="customer"
9 - size="small"
10 - placeholder="Select a customer"
11 - :options="customerOptions"
12 - :show-checkmark="false"
13 - class="min-w-52"
14 - :disabled="loading || customerBootstrapLoading"
15 - />
16 - </div>
17 - <div class="flex items-center gap-2">
18 - <n-button size="small" :disabled="!customer" @click="showConsolidation = true">
19 - <template #icon>
20 - <Icon :name="ConsolidateIcon" :size="14" />
21 - </template>
22 - Consolidate lessons
23 - </n-button>
24 - <n-button size="small" :disabled="!customer || loading" @click="loadStats()">
25 - <template #icon>
26 - <Icon :name="RefreshIcon" :size="14" />
27 - </template>
28 - Refresh
29 - </n-button>
30 - </div>
31 - </div>
32 -
33 - <n-spin :show="loading" class="min-h-40">
34 - <div v-if="!customer" class="pt-6 text-center">
35 - <n-empty description="Pick a customer to see their review feedback" />
36 - </div>
37 -
38 - <div v-else-if="stats" class="flex flex-col gap-5">
39 - <!-- Metric tiles -->
40 - <div class="grid grid-cols-1 gap-3 md:grid-cols-4">
41 - <MetricTile label="Total reviews" :value="stats.total_reviews.toString()" />
42 - <MetricTile
43 - label="Thumbs up"
44 - :value="pctLabel(stats.thumbs_up_pct)"
45 - :sub="`${stats.thumbs_up} up / ${stats.thumbs_down} down`"
46 - :color="pctColor(stats.thumbs_up_pct)"
47 - />
48 - <MetricTile
49 - label="IOC verdict accuracy"
50 - :value="pctLabel(stats.ioc_accuracy.accuracy_pct)"
51 - :sub="`${stats.ioc_accuracy.correct}/${stats.ioc_accuracy.total} IOCs correct`"
52 - :color="pctColor(stats.ioc_accuracy.accuracy_pct)"
53 - />
54 - <MetricTile
55 - label="Avg rating (overall)"
56 - :value="avgOverall == null ? '—' : `${avgOverall.toFixed(2)} / 5`"
57 - :sub="ratingSubtitle"
58 - />
59 - </div>
60 -
61 - <!-- Template choice breakdown -->
62 - <CardEntity size="small" embedded>
63 - <template #headerMain>Template choice distribution</template>
64 - <template #default>
65 - <div class="flex flex-col gap-2">
66 - <TemplateChoiceBar
67 - label="Correct"
68 - color="success"
69 - :count="stats.template_choice_correct"
70 - :total="templateChoiceTotal"
71 - />
72 - <TemplateChoiceBar
73 - label="Partial"
74 - color="warning"
75 - :count="stats.template_choice_partial"
76 - :total="templateChoiceTotal"
77 - />
78 - <TemplateChoiceBar
79 - label="Wrong"
80 - color="danger"
81 - :count="stats.template_choice_wrong"
82 - :total="templateChoiceTotal"
83 - />
84 - <div v-if="templateChoiceTotal === 0" class="text-secondary text-sm">
85 - No template choice feedback yet.
86 - </div>
87 - </div>
88 - </template>
89 - </CardEntity>
90 -
91 - <!-- Per-template table -->
92 - <CardEntity size="small" embedded>
93 - <template #headerMain>Per-template performance</template>
94 - <template #default>
95 - <n-empty
96 - v-if="!stats.per_template.length"
97 - description="No reviews yet"
98 - class="min-h-20 justify-center"
99 - />
100 - <n-data-table
101 - v-else
102 - :columns="perTemplateColumns"
103 - :data="stats.per_template"
104 - :bordered="false"
105 - size="small"
106 - :row-key="(r: ReviewStatsTemplate) => r.template_used ?? '__null__'"
107 - />
108 - </template>
109 - </CardEntity>
110 -
111 - <!-- Recent reviews -->
112 - <CardEntity size="small" embedded>
113 - <template #headerMain>Recent reviews</template>
114 - <template #default>
115 - <div v-if="!stats.recent_reviews.length" class="text-secondary text-sm">
116 - No reviews yet.
117 - </div>
118 - <div v-else class="flex flex-col gap-2">
119 - <CardEntity
120 - v-for="r of stats.recent_reviews"
121 - :key="r.id"
122 - size="small"
123 - embedded
124 - hoverable
125 - clickable
126 - @click="openDrawer(r)"
127 - >
128 - <template #headerMain>
129 - Report #{{ r.report_id }}
130 - <span v-if="r.template_used" class="text-secondary ml-2 text-sm">
131 - · {{ r.template_used }}
132 - </span>
133 - </template>
134 - <template #headerExtra>
135 - <span class="text-secondary text-sm">
136 - {{ formatDate(r.updated_at ?? r.created_at, "MMM D, YYYY HH:mm") }}
137 - </span>
138 - </template>
139 - <template #default>
140 - <div class="flex flex-wrap items-center gap-3">
141 - <Badge
142 - v-if="r.overall_verdict"
143 - type="splitted"
144 - bright
145 - :color="r.overall_verdict === 'up' ? 'success' : 'danger'"
146 - >
147 - <template #label>Verdict</template>
148 - <template #value>
149 - {{ r.overall_verdict === "up" ? "Up" : "Down" }}
150 - </template>
151 - </Badge>
152 - <Badge
153 - v-if="r.template_choice"
154 - type="splitted"
155 - bright
156 - :color="tplChoiceColor(r.template_choice)"
157 - >
158 - <template #label>Template</template>
159 - <template #value>{{ r.template_choice }}</template>
160 - </Badge>
161 - <Badge v-if="r.rating_instructions" type="splitted">
162 - <template #label>Instr</template>
163 - <template #value>{{ r.rating_instructions }}/5</template>
164 - </Badge>
165 - <Badge v-if="r.rating_artifacts" type="splitted">
166 - <template #label>Artifacts</template>
167 - <template #value>{{ r.rating_artifacts }}/5</template>
168 - </Badge>
169 - <Badge v-if="r.rating_severity" type="splitted">
170 - <template #label>Severity</template>
171 - <template #value>{{ r.rating_severity }}/5</template>
172 - </Badge>
173 - <Badge v-if="r.ioc_reviews.length" type="splitted">
174 - <template #label>IOC corrections</template>
175 - <template #value>{{ r.ioc_reviews.length }}</template>
176 - </Badge>
177 - </div>
178 - </template>
179 - </CardEntity>
180 - </div>
181 - </template>
182 - </CardEntity>
183 - </div>
184 - </n-spin>
185 -
186 - <!-- Drawer: full review detail -->
187 - <n-drawer v-model:show="showDrawer" :width="520" placement="right">
188 - <n-drawer-content v-if="drawerReview" closable>
189 - <template #header>Review for report #{{ drawerReview.report_id }}</template>
190 - <div class="flex flex-col gap-3">
191 - <div class="flex flex-wrap items-center gap-2">
192 - <Badge
193 - v-if="drawerReview.overall_verdict"
194 - type="splitted"
195 - bright
196 - :color="drawerReview.overall_verdict === 'up' ? 'success' : 'danger'"
197 - >
198 - <template #label>Verdict</template>
199 - <template #value>
200 - {{ drawerReview.overall_verdict === "up" ? "Up" : "Down" }}
201 - </template>
202 - </Badge>
203 - <Badge v-if="drawerReview.template_used" type="splitted">
204 - <template #label>Template</template>
205 - <template #value>{{ drawerReview.template_used }}</template>
206 - </Badge>
207 - <Badge v-if="drawerReview.template_choice" type="splitted" bright>
208 - <template #label>Template choice</template>
209 - <template #value>{{ drawerReview.template_choice }}</template>
210 - </Badge>
211 - </div>
212 -
213 - <CardKV v-if="drawerReview.missing_steps">
214 - <template #key>Missing steps</template>
215 - <template #value>{{ drawerReview.missing_steps }}</template>
216 - </CardKV>
217 - <CardKV v-if="drawerReview.suggested_edits">
218 - <template #key>Suggested edits</template>
219 - <template #value>{{ drawerReview.suggested_edits }}</template>
220 - </CardKV>
221 -
222 - <div v-if="drawerReview.ioc_reviews.length" class="flex flex-col gap-2">
223 - <div class="font-medium">IOC corrections</div>
224 - <div
225 - v-for="ir of drawerReview.ioc_reviews"
226 - :key="ir.id"
227 - class="border-color bg-secondary rounded border p-2 text-sm"
228 - >
229 - <div class="flex items-center gap-2">
230 - <Badge type="splitted" :color="ir.verdict_correct ? 'success' : 'danger'">
231 - <template #label>IOC {{ ir.ioc_id }}</template>
232 - <template #value>
233 - {{ ir.verdict_correct ? "Correct" : "Wrong" }}
234 - </template>
235 - </Badge>
236 - </div>
237 - <div v-if="ir.note" class="text-secondary mt-1">{{ ir.note }}</div>
238 - </div>
239 - </div>
240 - </div>
241 - </n-drawer-content>
242 - </n-drawer>
243 -
244 - <!-- Palace consolidation drawer (manual Step 21.B trigger) -->
245 - <PalaceConsolidationDrawer v-model:show="showConsolidation" :customer-code="customer" />
246 - </div>
247 -</template>
248 -
249 -<script setup lang="ts">
250 -import type {
251 - AiAnalystReview,
252 - AiAnalystReviewStats,
253 - ReviewStatsTemplate
254 -} from "@/types/aiAnalyst.d"
255 -import type { DataTableColumns } from "naive-ui"
256 -import {
257 - NButton,
258 - NDataTable,
259 - NDrawer,
260 - NDrawerContent,
261 - NEmpty,
262 - NSelect,
263 - NSpin,
264 - useMessage
265 -} from "naive-ui"
266 -import { computed, onBeforeMount, ref, watch } from "vue"
267 -import Api from "@/api"
268 -import Badge from "@/components/common/Badge.vue"
269 -import CardEntity from "@/components/common/cards/CardEntity.vue"
270 -import CardKV from "@/components/common/cards/CardKV.vue"
271 -import Icon from "@/components/common/Icon.vue"
272 -import { getApiErrorMessage } from "@/utils"
273 -import { formatDate } from "@/utils/format"
274 -import MetricTile from "./FeedbackMetricTile.vue"
275 -import PalaceConsolidationDrawer from "./PalaceConsolidationDrawer.vue"
276 -import TemplateChoiceBar from "./FeedbackTemplateChoiceBar.vue"
277 -
278 -const RefreshIcon = "carbon:renew"
279 -const ConsolidateIcon = "carbon:data-collection"
280 -
281 -const message = useMessage()
282 -
283 -const customer = ref<string | null>(null)
284 -const customerOptions = ref<{ label: string; value: string }[]>([])
285 -const customerBootstrapLoading = ref(false)
286 -
287 -const loading = ref(false)
288 -const stats = ref<AiAnalystReviewStats | null>(null)
289 -
290 -const showDrawer = ref(false)
291 -const drawerReview = ref<AiAnalystReview | null>(null)
292 -
293 -const showConsolidation = ref(false)
294 -
295 -const templateChoiceTotal = computed(() =>
296 - stats.value
297 - ? stats.value.template_choice_correct +
298 - stats.value.template_choice_partial +
299 - stats.value.template_choice_wrong
300 - : 0
301 -)
302 -
303 -// Composite avg across the three rubric axes — only counts axes with data.
304 -const avgOverall = computed(() => {
305 - if (!stats.value) return null
306 - const parts = [
307 - stats.value.avg_rating_instructions,
308 - stats.value.avg_rating_artifacts,
309 - stats.value.avg_rating_severity
310 - ].filter((v): v is number => v !== null)
311 - if (!parts.length) return null
312 - return parts.reduce((a, b) => a + b, 0) / parts.length
313 -})
314 -
315 -const ratingSubtitle = computed(() => {
316 - if (!stats.value) return ""
317 - const i = stats.value.avg_rating_instructions
318 - const a = stats.value.avg_rating_artifacts
319 - const s = stats.value.avg_rating_severity
320 - return `instr ${i ?? "—"} · artif ${a ?? "—"} · sev ${s ?? "—"}`
321 -})
322 -
323 -function pctLabel(pct: number | null): string {
324 - return pct === null || pct === undefined ? "—" : `${pct.toFixed(1)}%`
325 -}
326 -function pctColor(pct: number | null): "success" | "warning" | "danger" | undefined {
327 - if (pct === null) return undefined
328 - if (pct >= 75) return "success"
329 - if (pct >= 50) return "warning"
330 - return "danger"
331 -}
332 -function tplChoiceColor(choice: string): "success" | "warning" | "danger" | undefined {
333 - if (choice === "correct") return "success"
334 - if (choice === "partial") return "warning"
335 - if (choice === "wrong") return "danger"
336 - return undefined
337 -}
338 -
339 -const perTemplateColumns = computed<DataTableColumns<ReviewStatsTemplate>>(() => [
340 - {
341 - title: "Template",
342 - key: "template_used",
343 - render: row => row.template_used ?? "(none)"
344 - },
345 - { title: "Total", key: "total", width: 80 },
346 - {
347 - title: "Up / Down",
348 - key: "verdict",
349 - width: 110,
350 - render: row => `${row.thumbs_up} / ${row.thumbs_down}`
351 - },
352 - {
353 - title: "C / P / W",
354 - key: "choice",
355 - width: 110,
356 - render: row => `${row.correct} / ${row.partial} / ${row.wrong}`
357 - },
358 - {
359 - title: "Instr",
360 - key: "avg_rating_instructions",
361 - width: 80,
362 - render: row => (row.avg_rating_instructions == null ? "—" : row.avg_rating_instructions.toFixed(2))
363 - },
364 - {
365 - title: "Artif",
366 - key: "avg_rating_artifacts",
367 - width: 80,
368 - render: row => (row.avg_rating_artifacts == null ? "—" : row.avg_rating_artifacts.toFixed(2))
369 - },
370 - {
371 - title: "Sev",
372 - key: "avg_rating_severity",
373 - width: 80,
374 - render: row => (row.avg_rating_severity == null ? "—" : row.avg_rating_severity.toFixed(2))
375 - }
376 -])
377 -
378 -function openDrawer(r: AiAnalystReview) {
379 - drawerReview.value = r
380 - showDrawer.value = true
381 -}
382 -
383 -async function bootstrapCustomers() {
384 - // Bootstrap customer picker off alerts_with_reports so we only list
385 - // customers that actually have AI runs — no external customer endpoint call.
386 - customerBootstrapLoading.value = true
387 - try {
388 - const res = await Api.aiAnalyst.getAlertsWithReports()
389 - if (res.data.success) {
390 - const codes = new Set((res.data.alerts || []).map(a => a.customer_code))
391 - customerOptions.value = Array.from(codes)
392 - .sort()
393 - .map(c => ({ label: c, value: c }))
394 - if (customerOptions.value.length && !customer.value) {
395 - // The watch(customer, loadStats) below picks this up — no
396 - // need to call loadStats() explicitly from bootstrap.
397 - customer.value = customerOptions.value[0].value
398 - }
399 - }
400 - } catch (err: unknown) {
401 - message.error(getApiErrorMessage(err as never) || "Failed to load customers")
402 - } finally {
403 - customerBootstrapLoading.value = false
404 - }
405 -}
406 -
407 -async function loadStats() {
408 - if (!customer.value) {
409 - stats.value = null
410 - return
411 - }
412 - loading.value = true
413 - try {
414 - const res = await Api.aiAnalyst.getReviewStats(customer.value, 10)
415 - if (res.data.success) {
416 - stats.value = res.data
417 - } else {
418 - message.warning(res.data.message || "Failed to load stats")
419 - stats.value = null
420 - }
421 - } catch (err: unknown) {
422 - message.error(getApiErrorMessage(err as never) || "Failed to load stats")
423 - stats.value = null
424 - } finally {
425 - loading.value = false
426 - }
427 -}
428 -
429 -// Refetch stats on actual customer changes only. Wiring via @update:value on
430 -// the select is fragile — naive-ui can fire update:value when the options
431 -// prop identity churns, which would loop the stats endpoint. watch() only
432 -// fires on real value changes, so programmatic and user-driven picks behave
433 -// the same and options churn is ignored.
434 -watch(customer, () => {
435 - loadStats()
436 -})
437 -
438 -onBeforeMount(() => {
439 - bootstrapCustomers()
440 -})
441 -</script>
frontend/src/components/common/cards/CardStatsBars.vue
+1 -1
@@ -190,7 +190,7 @@ const barValues = computed<ItemPropsExt[]>(() => sanitizedValues.value.filter(o
190
191 .divider {
192 height: 1px;
193 - background-color: rgba(var(--border-color-rgb) / 0.1);
193 + background-color: var(--border-color);
194 }
195
196 &.success {
frontend/src/views/AiAnalyst.vue
+2 -2
@@ -11,7 +11,7 @@
11 <AlertsReportsList />
12 </n-tab-pane>
13 <n-tab-pane name="feedback" tab="Feedback" display-directive="show:lazy">
14 - <FeedbackDashboard />
14 + <FeedbackDashboard class="pt-8" />
15 </n-tab-pane>
16 </n-tabs>
17 </div>
@@ -21,7 +21,7 @@
21 import { NTabPane, NTabs } from "naive-ui"
22 import { ref } from "vue"
23 import AlertsReportsList from "@/components/aiAnalyst/AlertsReportsList.vue"
24 -import FeedbackDashboard from "@/components/aiAnalyst/FeedbackDashboard.vue"
24 +import FeedbackDashboard from "@/components/aiAnalyst/Feedback/FeedbackDashboard.vue"
25 import TalonOverview from "@/components/aiAnalyst/TalonOverview.vue"
26 import TalonChat from "@/components/talonChat/TalonChatContainer.vue"
27