<th>KPI</th> 47 48
                                     </tr>
48 49
                                 </thead>
49 50
                                 <tfoot>
@@ -56,6 +57,7 @@
56 57
                                         <th>Close date</th>
57 58
                                         <th>SOC Ticket</th>
58 59
                                         <th>Opening user</th>
60
+                                        <th>KPI</th>
59 61
                                     </tr>
60 62
                                 </tfoot>
61 63
                             </table>

+ 6 - 0
iris-web/source/app/templates/includes/sidenav.html

@@ -101,6 +101,12 @@
101 101
 								<span>DIM Tasks</span>
102 102
 							</a>
103 103
 						</li>
104
+						<li class="nav-item">
105
+							<a href="/kpi-dashboard">
106
+								<i class="fas fa-tachometer-alt"></i>
107
+								<span>KPI Dashboard</span>
108
+							</a>
109
+						</li>
104 110
 
105 111
 
106 112
 						<li class="nav-section nav-advanced">

+ 3 - 0
iris-web/source/app/views.py

@@ -97,6 +97,7 @@ from app.blueprints.rest.search_routes import search_rest_blueprint
97 97
 from app.blueprints.graphql.graphql_route import graphql_blueprint
98 98
 
99 99
 from app.blueprints.rest.v2 import rest_v2_blueprint
100
+from app.blueprints.pages.kpi_dashboard.kpi_dashboard_routes import kpi_dashboard_blueprint
100 101
 from app.models.authorization import User
101 102
 
102 103
 def register_blusprints(app):
@@ -183,6 +184,8 @@ def register_blusprints(app):
183 184
 
184 185
     app.register_blueprint(rest_v2_blueprint)
185 186
 
187
+    app.register_blueprint(kpi_dashboard_blueprint)
188
+
186 189
 
187 190
 
188 191
 # provide login manager with load_user callback

+ 199 - 91
iris-web/ui/package-lock.json

@@ -21,6 +21,7 @@
21 21
         "jquery.scrollbar": "^0.2.10",
22 22
         "jqvmap": "^1.5.1",
23 23
         "moment": "^2.22.2",
24
+        "rollup": "^4.59.0",
24 25
         "showdown": "^1.9.0",
25 26
         "socket.io": "^4.3.2",
26 27
         "sortablejs": "^1.7.0",
@@ -842,13 +843,12 @@
842 843
       }
843 844
     },
844 845
     "node_modules/@rollup/rollup-android-arm-eabi": {
845
-      "version": "4.22.5",
846
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.5.tgz",
847
-      "integrity": "sha512-SU5cvamg0Eyu/F+kLeMXS7GoahL+OoizlclVFX3l5Ql6yNlywJJ0OuqTzUx0v+aHhPHEB/56CT06GQrRrGNYww==",
846
+      "version": "4.59.0",
847
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
848
+      "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
848 849
       "cpu": [
849 850
         "arm"
850 851
       ],
851
-      "dev": true,
852 852
       "license": "MIT",
853 853
       "optional": true,
854 854
       "os": [
@@ -856,13 +856,12 @@
856 856
       ]
857 857
     },
858 858
     "node_modules/@rollup/rollup-android-arm64": {
859
-      "version": "4.22.5",
860
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.5.tgz",
861
-      "integrity": "sha512-S4pit5BP6E5R5C8S6tgU/drvgjtYW76FBuG6+ibG3tMvlD1h9LHVF9KmlmaUBQ8Obou7hEyS+0w+IR/VtxwNMQ==",
859
+      "version": "4.59.0",
860
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
861
+      "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
862 862
       "cpu": [
863 863
         "arm64"
864 864
       ],
865
-      "dev": true,
866 865
       "license": "MIT",
867 866
       "optional": true,
868 867
       "os": [
@@ -870,13 +869,12 @@
870 869
       ]
871 870
     },
872 871
     "node_modules/@rollup/rollup-darwin-arm64": {
873
-      "version": "4.22.5",
874
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.5.tgz",
875
-      "integrity": "sha512-250ZGg4ipTL0TGvLlfACkIxS9+KLtIbn7BCZjsZj88zSg2Lvu3Xdw6dhAhfe/FjjXPVNCtcSp+WZjVsD3a/Zlw==",
872
+      "version": "4.59.0",
873
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
874
+      "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
876 875
       "cpu": [
877 876
         "arm64"
878 877
       ],
879
-      "dev": true,
880 878
       "license": "MIT",
881 879
       "optional": true,
882 880
       "os": [
@@ -884,27 +882,51 @@
884 882
       ]
885 883
     },
886 884
     "node_modules/@rollup/rollup-darwin-x64": {
887
-      "version": "4.22.5",
888
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.5.tgz",
889
-      "integrity": "sha512-D8brJEFg5D+QxFcW6jYANu+Rr9SlKtTenmsX5hOSzNYVrK5oLAEMTUgKWYJP+wdKyCdeSwnapLsn+OVRFycuQg==",
885
+      "version": "4.59.0",
886
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
887
+      "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
890 888
       "cpu": [
891 889
         "x64"
892 890
       ],
893
-      "dev": true,
894 891
       "license": "MIT",
895 892
       "optional": true,
896 893
       "os": [
897 894
         "darwin"
898 895
       ]
899 896
     },
897
+    "node_modules/@rollup/rollup-freebsd-arm64": {
898
+      "version": "4.59.0",
899
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
900
+      "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
901
+      "cpu": [
902
+        "arm64"
903
+      ],
904
+      "license": "MIT",
905
+      "optional": true,
906
+      "os": [
907
+        "freebsd"
908
+      ]
909
+    },
910
+    "node_modules/@rollup/rollup-freebsd-x64": {
911
+      "version": "4.59.0",
912
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
913
+      "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
914
+      "cpu": [
915
+        "x64"
916
+      ],
917
+      "license": "MIT",
918
+      "optional": true,
919
+      "os": [
920
+        "freebsd"
921
+      ]
922
+    },
900 923
     "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
901
-      "version": "4.22.5",
902
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.5.tgz",
903
-      "integrity": "sha512-PNqXYmdNFyWNg0ma5LdY8wP+eQfdvyaBAojAXgO7/gs0Q/6TQJVXAXe8gwW9URjbS0YAammur0fynYGiWsKlXw==",
924
+      "version": "4.59.0",
925
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
926
+      "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
904 927
       "cpu": [
905 928
         "arm"
906 929
       ],
907
-      "dev": true,
908 930
       "license": "MIT",
909 931
       "optional": true,
910 932
       "os": [
@@ -912,13 +934,12 @@
912 934
       ]
913 935
     },
914 936
     "node_modules/@rollup/rollup-linux-arm-musleabihf": {
915
-      "version": "4.22.5",
916
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.5.tgz",
917
-      "integrity": "sha512-kSSCZOKz3HqlrEuwKd9TYv7vxPYD77vHSUvM2y0YaTGnFc8AdI5TTQRrM1yIp3tXCKrSL9A7JLoILjtad5t8pQ==",
937
+      "version": "4.59.0",
938
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
939
+      "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
918 940
       "cpu": [
919 941
         "arm"
920 942
       ],
921
-      "dev": true,
922 943
       "license": "MIT",
923 944
       "optional": true,
924 945
       "os": [
@@ -926,13 +947,12 @@
926 947
       ]
927 948
     },
928 949
     "node_modules/@rollup/rollup-linux-arm64-gnu": {
929
-      "version": "4.22.5",
930
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.5.tgz",
931
-      "integrity": "sha512-oTXQeJHRbOnwRnRffb6bmqmUugz0glXaPyspp4gbQOPVApdpRrY/j7KP3lr7M8kTfQTyrBUzFjj5EuHAhqH4/w==",
950
+      "version": "4.59.0",
951
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
952
+      "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
932 953
       "cpu": [
933 954
         "arm64"
934 955
       ],
935
-      "dev": true,
936 956
       "license": "MIT",
937 957
       "optional": true,
938 958
       "os": [
@@ -940,27 +960,64 @@
940 960
       ]
941 961
     },
942 962
     "node_modules/@rollup/rollup-linux-arm64-musl": {
943
-      "version": "4.22.5",
944
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.5.tgz",
945
-      "integrity": "sha512-qnOTIIs6tIGFKCHdhYitgC2XQ2X25InIbZFor5wh+mALH84qnFHvc+vmWUpyX97B0hNvwNUL4B+MB8vJvH65Fw==",
963
+      "version": "4.59.0",
964
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
965
+      "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
946 966
       "cpu": [
947 967
         "arm64"
948 968
       ],
949
-      "dev": true,
950 969
       "license": "MIT",
951 970
       "optional": true,
952 971
       "os": [
953 972
         "linux"
954 973
       ]
955 974
     },
956
-    "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
957
-      "version": "4.22.5",
958
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.5.tgz",
959
-      "integrity": "sha512-TMYu+DUdNlgBXING13rHSfUc3Ky5nLPbWs4bFnT+R6Vu3OvXkTkixvvBKk8uO4MT5Ab6lC3U7x8S8El2q5o56w==",
975
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
976
+      "version": "4.59.0",
977
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
978
+      "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
979
+      "cpu": [
980
+        "loong64"
981
+      ],
982
+      "license": "MIT",
983
+      "optional": true,
984
+      "os": [
985
+        "linux"
986
+      ]
987
+    },
988
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
989
+      "version": "4.59.0",
990
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
991
+      "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
992
+      "cpu": [
993
+        "loong64"
994
+      ],
995
+      "license": "MIT",
996
+      "optional": true,
997
+      "os": [
998
+        "linux"
999
+      ]
1000
+    },
1001
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
1002
+      "version": "4.59.0",
1003
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
1004
+      "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
1005
+      "cpu": [
1006
+        "ppc64"
1007
+      ],
1008
+      "license": "MIT",
1009
+      "optional": true,
1010
+      "os": [
1011
+        "linux"
1012
+      ]
1013
+    },
1014
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
1015
+      "version": "4.59.0",
1016
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
1017
+      "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
960 1018
       "cpu": [
961 1019
         "ppc64"
962 1020
       ],
963
-      "dev": true,
964 1021
       "license": "MIT",
965 1022
       "optional": true,
966 1023
       "os": [
@@ -968,13 +1025,25 @@
968 1025
       ]
969 1026
     },
970 1027
     "node_modules/@rollup/rollup-linux-riscv64-gnu": {
971
-      "version": "4.22.5",
972
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.5.tgz",
973
-      "integrity": "sha512-PTQq1Kz22ZRvuhr3uURH+U/Q/a0pbxJoICGSprNLAoBEkyD3Sh9qP5I0Asn0y0wejXQBbsVMRZRxlbGFD9OK4A==",
1028
+      "version": "4.59.0",
1029
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
1030
+      "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
1031
+      "cpu": [
1032
+        "riscv64"
1033
+      ],
1034
+      "license": "MIT",
1035
+      "optional": true,
1036
+      "os": [
1037
+        "linux"
1038
+      ]
1039
+    },
1040
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
1041
+      "version": "4.59.0",
1042
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
1043
+      "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
974 1044
       "cpu": [
975 1045
         "riscv64"
976 1046
       ],
977
-      "dev": true,
978 1047
       "license": "MIT",
979 1048
       "optional": true,
980 1049
       "os": [
@@ -982,13 +1051,12 @@
982 1051
       ]
983 1052
     },
984 1053
     "node_modules/@rollup/rollup-linux-s390x-gnu": {
985
-      "version": "4.22.5",
986
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.5.tgz",
987
-      "integrity": "sha512-bR5nCojtpuMss6TDEmf/jnBnzlo+6n1UhgwqUvRoe4VIotC7FG1IKkyJbwsT7JDsF2jxR+NTnuOwiGv0hLyDoQ==",
1054
+      "version": "4.59.0",
1055
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
1056
+      "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
988 1057
       "cpu": [
989 1058
         "s390x"
990 1059
       ],
991
-      "dev": true,
992 1060
       "license": "MIT",
993 1061
       "optional": true,
994 1062
       "os": [
@@ -996,13 +1064,12 @@
996 1064
       ]
997 1065
     },
998 1066
     "node_modules/@rollup/rollup-linux-x64-gnu": {
999
-      "version": "4.22.5",
1000
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.5.tgz",
1001
-      "integrity": "sha512-N0jPPhHjGShcB9/XXZQWuWBKZQnC1F36Ce3sDqWpujsGjDz/CQtOL9LgTrJ+rJC8MJeesMWrMWVLKKNR/tMOCA==",
1067
+      "version": "4.59.0",
1068
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
1069
+      "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
1002 1070
       "cpu": [
1003 1071
         "x64"
1004 1072
       ],
1005
-      "dev": true,
1006 1073
       "license": "MIT",
1007 1074
       "optional": true,
1008 1075
       "os": [
@@ -1010,27 +1077,51 @@
1010 1077
       ]
1011 1078
     },
1012 1079
     "node_modules/@rollup/rollup-linux-x64-musl": {
1013
-      "version": "4.22.5",
1014
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.5.tgz",
1015
-      "integrity": "sha512-uBa2e28ohzNNwjr6Uxm4XyaA1M/8aTgfF2T7UIlElLaeXkgpmIJ2EitVNQxjO9xLLLy60YqAgKn/AqSpCUkE9g==",
1080
+      "version": "4.59.0",
1081
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
1082
+      "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
1016 1083
       "cpu": [
1017 1084
         "x64"
1018 1085
       ],
1019
-      "dev": true,
1020 1086
       "license": "MIT",
1021 1087
       "optional": true,
1022 1088
       "os": [
1023 1089
         "linux"
1024 1090
       ]
1025 1091
     },
1092
+    "node_modules/@rollup/rollup-openbsd-x64": {
1093
+      "version": "4.59.0",
1094
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
1095
+      "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
1096
+      "cpu": [
1097
+        "x64"
1098
+      ],
1099
+      "license": "MIT",
1100
+      "optional": true,
1101
+      "os": [
1102
+        "openbsd"
1103
+      ]
1104
+    },
1105
+    "node_modules/@rollup/rollup-openharmony-arm64": {
1106
+      "version": "4.59.0",
1107
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
1108
+      "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
1109
+      "cpu": [
1110
+        "arm64"
1111
+      ],
1112
+      "license": "MIT",
1113
+      "optional": true,
1114
+      "os": [
1115
+        "openharmony"
1116
+      ]
1117
+    },
1026 1118
     "node_modules/@rollup/rollup-win32-arm64-msvc": {
1027
-      "version": "4.22.5",
1028
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.5.tgz",
1029
-      "integrity": "sha512-RXT8S1HP8AFN/Kr3tg4fuYrNxZ/pZf1HemC5Tsddc6HzgGnJm0+Lh5rAHJkDuW3StI0ynNXukidROMXYl6ew8w==",
1119
+      "version": "4.59.0",
1120
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
1121
+      "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
1030 1122
       "cpu": [
1031 1123
         "arm64"
1032 1124
       ],
1033
-      "dev": true,
1034 1125
       "license": "MIT",
1035 1126
       "optional": true,
1036 1127
       "os": [
@@ -1038,13 +1129,25 @@
1038 1129
       ]
1039 1130
     },
1040 1131
     "node_modules/@rollup/rollup-win32-ia32-msvc": {
1041
-      "version": "4.22.5",
1042
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.5.tgz",
1043
-      "integrity": "sha512-ElTYOh50InL8kzyUD6XsnPit7jYCKrphmddKAe1/Ytt74apOxDq5YEcbsiKs0fR3vff3jEneMM+3I7jbqaMyBg==",
1132
+      "version": "4.59.0",
1133
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
1134
+      "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
1044 1135
       "cpu": [
1045 1136
         "ia32"
1046 1137
       ],
1047
-      "dev": true,
1138
+      "license": "MIT",
1139
+      "optional": true,
1140
+      "os": [
1141
+        "win32"
1142
+      ]
1143
+    },
1144
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
1145
+      "version": "4.59.0",
1146
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
1147
+      "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
1148
+      "cpu": [
1149
+        "x64"
1150
+      ],
1048 1151
       "license": "MIT",
1049 1152
       "optional": true,
1050 1153
       "os": [
@@ -1052,13 +1155,12 @@
1052 1155
       ]
1053 1156
     },
1054 1157
     "node_modules/@rollup/rollup-win32-x64-msvc": {
1055
-      "version": "4.22.5",
1056
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.5.tgz",
1057
-      "integrity": "sha512-+lvL/4mQxSV8MukpkKyyvfwhH266COcWlXE/1qxwN08ajovta3459zrjLghYMgDerlzNwLAcFpvU+WWE5y6nAQ==",
1158
+      "version": "4.59.0",
1159
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
1160
+      "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
1058 1161
       "cpu": [
1059 1162
         "x64"
1060 1163
       ],
1061
-      "dev": true,
1062 1164
       "license": "MIT",
1063 1165
       "optional": true,
1064 1166
       "os": [
@@ -1122,10 +1224,9 @@
1122 1224
       }
1123 1225
     },
1124 1226
     "node_modules/@types/estree": {
1125
-      "version": "1.0.6",
1126
-      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
1127
-      "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
1128
-      "dev": true,
1227
+      "version": "1.0.8",
1228
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1229
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1129 1230
       "license": "MIT"
1130 1231
     },
1131 1232
     "node_modules/@types/jquery": {
@@ -2341,7 +2442,6 @@
2341 2442
       "version": "2.3.3",
2342 2443
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
2343 2444
       "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
2344
-      "dev": true,
2345 2445
       "hasInstallScript": true,
2346 2446
       "license": "MIT",
2347 2447
       "optional": true,
@@ -3553,13 +3653,12 @@
3553 3653
       }
3554 3654
     },
3555 3655
     "node_modules/rollup": {
3556
-      "version": "4.22.5",
3557
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.5.tgz",
3558
-      "integrity": "sha512-WoinX7GeQOFMGznEcWA1WrTQCd/tpEbMkc3nuMs9BT0CPjMdSjPMTVClwWd4pgSQwJdP65SK9mTCNvItlr5o7w==",
3559
-      "dev": true,
3656
+      "version": "4.59.0",
3657
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
3658
+      "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
3560 3659
       "license": "MIT",
3561 3660
       "dependencies": {
3562
-        "@types/estree": "1.0.6"
3661
+        "@types/estree": "1.0.8"
3563 3662
       },
3564 3663
       "bin": {
3565 3664
         "rollup": "dist/bin/rollup"
@@ -3569,22 +3668,31 @@
3569 3668
         "npm": ">=8.0.0"
3570 3669
       },
3571 3670
       "optionalDependencies": {
3572
-        "@rollup/rollup-android-arm-eabi": "4.22.5",
3573
-        "@rollup/rollup-android-arm64": "4.22.5",
3574
-        "@rollup/rollup-darwin-arm64": "4.22.5",
3575
-        "@rollup/rollup-darwin-x64": "4.22.5",
3576
-        "@rollup/rollup-linux-arm-gnueabihf": "4.22.5",
3577
-        "@rollup/rollup-linux-arm-musleabihf": "4.22.5",
3578
-        "@rollup/rollup-linux-arm64-gnu": "4.22.5",
3579
-        "@rollup/rollup-linux-arm64-musl": "4.22.5",
3580
-        "@rollup/rollup-linux-powerpc64le-gnu": "4.22.5",
3581
-        "@rollup/rollup-linux-riscv64-gnu": "4.22.5",
3582
-        "@rollup/rollup-linux-s390x-gnu": "4.22.5",
3583
-        "@rollup/rollup-linux-x64-gnu": "4.22.5",
3584
-        "@rollup/rollup-linux-x64-musl": "4.22.5",
3585
-        "@rollup/rollup-win32-arm64-msvc": "4.22.5",
3586
-        "@rollup/rollup-win32-ia32-msvc": "4.22.5",
3587
-        "@rollup/rollup-win32-x64-msvc": "4.22.5",
3671
+        "@rollup/rollup-android-arm-eabi": "4.59.0",
3672
+        "@rollup/rollup-android-arm64": "4.59.0",
3673
+        "@rollup/rollup-darwin-arm64": "4.59.0",
3674
+        "@rollup/rollup-darwin-x64": "4.59.0",
3675
+        "@rollup/rollup-freebsd-arm64": "4.59.0",
3676
+        "@rollup/rollup-freebsd-x64": "4.59.0",
3677
+        "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
3678
+        "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
3679
+        "@rollup/rollup-linux-arm64-gnu": "4.59.0",
3680
+        "@rollup/rollup-linux-arm64-musl": "4.59.0",
3681
+        "@rollup/rollup-linux-loong64-gnu": "4.59.0",
3682
+        "@rollup/rollup-linux-loong64-musl": "4.59.0",
3683
+        "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
3684
+        "@rollup/rollup-linux-ppc64-musl": "4.59.0",
3685
+        "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
3686
+        "@rollup/rollup-linux-riscv64-musl": "4.59.0",
3687
+        "@rollup/rollup-linux-s390x-gnu": "4.59.0",
3688
+        "@rollup/rollup-linux-x64-gnu": "4.59.0",
3689
+        "@rollup/rollup-linux-x64-musl": "4.59.0",
3690
+        "@rollup/rollup-openbsd-x64": "4.59.0",
3691
+        "@rollup/rollup-openharmony-arm64": "4.59.0",
3692
+        "@rollup/rollup-win32-arm64-msvc": "4.59.0",
3693
+        "@rollup/rollup-win32-ia32-msvc": "4.59.0",
3694
+        "@rollup/rollup-win32-x64-gnu": "4.59.0",
3695
+        "@rollup/rollup-win32-x64-msvc": "4.59.0",
3588 3696
         "fsevents": "~2.3.2"
3589 3697
       }
3590 3698
     },

+ 1 - 0
iris-web/ui/package.json

@@ -37,6 +37,7 @@
37 37
     "jquery.scrollbar": "^0.2.10",
38 38
     "jqvmap": "^1.5.1",
39 39
     "moment": "^2.22.2",
40
+    "rollup": "^4.59.0",
40 41
     "showdown": "^1.9.0",
41 42
     "socket.io": "^4.3.2",
42 43
     "sortablejs": "^1.7.0",

+ 25 - 0
iris-web/ui/src/css/kpi_dashboard.css

@@ -0,0 +1,25 @@
1
+/* KPI Alert Dashboard — styles */
2
+
3
+/* KPI bar — 4 segments */
4
+.kpi-bar-wrap  { display:flex; flex-direction:column; gap:3px; min-width:120px; }
5
+.kpi-segs      { display:flex; gap:4px; }
6
+.kpi-seg       { width:26px; height:10px; border-radius:3px; }
7
+
8
+/* Scaled fill bar (width = kpi_pct%) */
9
+.kpi-track     { width:100px; height:6px; background:#e5e7eb; border-radius:3px; overflow:hidden; }
10
+.kpi-fill      { height:6px; border-radius:3px; transition:width 0.4s ease; }
11
+.kpi-pct-label { font-size:10px; color:#6b7280; }
12
+
13
+/* Severity badges */
14
+.badge-high    { background:#fed7aa; color:#c2410c; font-size:10px; padding:1px 6px; border-radius:999px; display:inline-block; }
15
+.badge-medium  { background:#fef9c3; color:#854d0e; font-size:10px; padding:1px 6px; border-radius:999px; display:inline-block; }
16
+.badge-low     { background:#d1fae5; color:#065f46; font-size:10px; padding:1px 6px; border-radius:999px; display:inline-block; }
17
+
18
+/* Status dot */
19
+.status-dot    { width:10px; height:10px; border-radius:50%; display:inline-block; }
20
+.dot-open      { background:#ef4444; }
21
+.dot-closed    { background:#22c55e; }
22
+
23
+/* Sortable column headers */
24
+.sortable      { cursor:pointer; user-select:none; }
25
+.sortable:hover { background:#f1f5f9; }

+ 37 - 5
iris-web/ui/src/pages/alerts.js

@@ -1,6 +1,32 @@
1 1
 let sortOrder ;
2 2
 let editor = null;
3 3
 
4
+// ---------------------------------------------------------------------------
5
+// KPI — fetch from soc-integrator, injected into alert objects before render
6
+// ---------------------------------------------------------------------------
7
+async function _fetchAlertKpi(alertId) {
8
+  try {
9
+    const res = await fetch(`/kpi-dashboard/api/alerts/${alertId}`);
10
+    if (!res.ok) return null;
11
+    const json = await res.json();
12
+    return json.data?.alert?.kpi ?? null;
13
+  } catch { return null; }
14
+}
15
+
16
+function _renderAlertKpiBar(alert) {
17
+  const kpi = alert.kpi;
18
+  if (!kpi) return '';
19
+  const segs = kpi.segments.map(s =>
20
+    `<div style="width:20px;height:8px;border-radius:3px;background:${s.active ? s.color : '#e5e7eb'}" title="${s.label}"></div>`
21
+  ).join('');
22
+  return `
23
+    <span title="KPI: ${kpi.status} — ${kpi.elapsed_pct}% elapsed" class="ml-3 d-inline-flex align-items-center" style="gap:4px">
24
+      <b><i class="fa-solid fa-gauge-high"></i></b>
25
+      <span style="display:flex;gap:3px">${segs}</span>
26
+      <small class="text-muted ml-1">${kpi.status}</small>
27
+    </span>`;
28
+}
29
+
4 30
 function objectToQueryString(obj) {
5 31
   return Object.keys(obj)
6 32
     .filter(key => obj[key] !== undefined && obj[key] !== null && obj[key] !== '')
@@ -1239,6 +1265,7 @@ function renderAlert(alert, expanded=false, modulesOptionsAlertReq,
1239 1265
                 <small class="text-muted ml-1">${formatTime(alert.alert_source_event_time)}</small></span>
1240 1266
                 <span title="Alert severity"><b class="ml-3"><i class="fa-solid fa-bolt"></i></b>
1241 1267
                   <small class="text-muted ml-1" id="alertSeverity-${alert.alert_id}" data-severity-id="${alert.severity.severity_id}">${alert.severity.severity_name}</small></span>
1268
+                ${_renderAlertKpiBar(alert)}
1242 1269
                 <span title="Alert source"><b class="ml-3"><i class="fa-solid fa-cloud-arrow-down"></i></b>
1243 1270
                   <small class="text-muted ml-1">${filterXSS(alert.alert_source) || 'Unspecified'}</small></span>
1244 1271
                 <span title="Alert client"><b class="ml-3"><i class="fa-regular fa-circle-user"></i></b>
@@ -1300,6 +1327,7 @@ async function showAlertHistory(alertId) {
1300 1327
 }
1301 1328
 
1302 1329
 async function refreshAlert(alertId, alertData, expanded=false) {
1330
+    const kpiFetch = _fetchAlertKpi(alertId);
1303 1331
     if (alertData === undefined) {
1304 1332
         const alertDataReq = await fetchAlert(alertId);
1305 1333
         if (api_request_failed(alertDataReq)) {
@@ -1307,6 +1335,8 @@ async function refreshAlert(alertId, alertData, expanded=false) {
1307 1335
         }
1308 1336
         alertData = alertDataReq.data;
1309 1337
     }
1338
+    const kpi = await kpiFetch;
1339
+    if (kpi) alertData.kpi = kpi;
1310 1340
 
1311 1341
       if (modulesOptionsAlertReq === null) {
1312 1342
     modulesOptionsAlertReq = await fetchModulesOptionsAlert();
@@ -1359,12 +1389,14 @@ async function updateAlerts(page, per_page, filters = {}, paging=false){
1359 1389
 
1360 1390
   const filterString = objectToQueryString(filters);
1361 1391
   const data = await fetchAlerts(page, per_page, filterString, sortOrder).catch((error) => {
1362
-        notify_error('Failed to fetch alerts');
1363
-        alertsContainer.html('<h4 class="ml-auto mr-auto">Oops error loading the alerts - Check logs</h4>');
1364
-        console.error(error);
1365
-    });
1392
+      notify_error('Failed to fetch alerts');
1393
+      alertsContainer.html('<h4 class="ml-auto mr-auto">Oops error loading the alerts - Check logs</h4>');
1394
+      console.error(error);
1395
+  });
1396
+  if (!data) return;
1366 1397
 
1367
-  const alerts = data.data;
1398
+  const kpiResults = await Promise.all(data.data.map(a => _fetchAlertKpi(a.alert_id)));
1399
+  const alerts = data.data.map((a, i) => ({ ...a, kpi: kpiResults[i] ?? null }));
1368 1400
 
1369 1401
   if (modulesOptionsAlertReq === null) {
1370 1402
     modulesOptionsAlertReq = await fetchModulesOptionsAlert();

+ 314 - 0
iris-web/ui/src/pages/kpi_dashboard.js

@@ -0,0 +1,314 @@
1
+// KPI Alert Dashboard — vanilla JS, compiled by Vite into /static/assets/js/iris/kpi_dashboard.js
2
+import '../css/kpi_dashboard.css';
3
+
4
+const API_BASE = '/kpi-dashboard/api';
5
+
6
+const state = {
7
+  page: 1,
8
+  perPage: 20,
9
+  sortBy: 'alert_id',
10
+  sortDir: 'desc',
11
+  filterTitle: '',
12
+  filterOwner: '',
13
+  total: 0,
14
+  selected: new Set(),
15
+};
16
+
17
+// Debounce helper for filter inputs
18
+let _debounceTimer = null;
19
+function debouncedLoad() {
20
+  clearTimeout(_debounceTimer);
21
+  _debounceTimer = setTimeout(() => {
22
+    state.page = 1;
23
+    loadAlerts();
24
+  }, 350);
25
+}
26
+
27
+async function loadAlerts() {
28
+  state.filterTitle = document.getElementById('filter-title').value;
29
+  state.filterOwner = document.getElementById('filter-owner').value;
30
+
31
+  const params = new URLSearchParams({
32
+    page: state.page,
33
+    per_page: state.perPage,
34
+    sort_by: state.sortBy,
35
+    sort_dir: state.sortDir,
36
+  });
37
+  if (state.filterTitle) params.set('filter_title', state.filterTitle);
38
+  if (state.filterOwner) params.set('filter_owner', state.filterOwner);
39
+
40
+  const body = document.getElementById('alerts-body');
41
+  body.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-4">Loading...</td></tr>';
42
+
43
+  try {
44
+    const res = await fetch(`${API_BASE}/alerts?${params}`);
45
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
46
+    const json = await res.json();
47
+    const alertsData = json.data?.alerts ?? {};
48
+    const alerts = alertsData.data ?? [];
49
+    state.total = alertsData.total ?? alerts.length;
50
+    renderTable(alerts);
51
+    updatePageInfo();
52
+    updateSelectionLabel();
53
+  } catch (err) {
54
+    body.innerHTML = `<tr><td colspan="12" class="text-center text-danger py-4">Error: ${err.message}</td></tr>`;
55
+  }
56
+}
57
+
58
+function renderKpiBar(kpi) {
59
+  if (!kpi) return '<span class="text-muted">—</span>';
60
+  const containerW = 100;
61
+  const fillW = Math.round((kpi.elapsed_pct / 100) * containerW);
62
+  const fillColor = kpi.resolved ? '#6b7280'
63
+    : kpi.elapsed_pct >= 75 ? '#ef4444'
64
+    : kpi.elapsed_pct >= 50 ? '#f97316'
65
+    : kpi.elapsed_pct >= 25 ? '#eab308'
66
+    : '#22c55e';
67
+
68
+  const segs = (kpi.segments || []).map(s =>
69
+    `<div class="kpi-seg" style="background:${s.active ? s.color : '#e5e7eb'}" title="${s.label}: ${s.active ? 'active' : 'inactive'}"></div>`
70
+  ).join('');
71
+
72
+  return `
73
+    <div class="kpi-bar-wrap" title="${kpi.status} — ${kpi.elapsed_pct}% elapsed">
74
+      <div class="kpi-segs">${segs}</div>
75
+      <div class="kpi-track">
76
+        <div class="kpi-fill" style="width:${fillW}px;background:${fillColor}"></div>
77
+      </div>
78
+      <span class="kpi-pct-label">${kpi.status}</span>
79
+    </div>`;
80
+}
81
+
82
+function severityBadge(severity) {
83
+  if (!severity) return '';
84
+  const name = (severity.severity_name || '').toLowerCase();
85
+  const cls = name === 'high' ? 'badge-high' : name === 'medium' ? 'badge-medium' : 'badge-low';
86
+  return `<span class="${cls}">${severity.severity_name || ''}</span>`;
87
+}
88
+
89
+function statusDot(status) {
90
+  if (!status) return '';
91
+  const name = (status.status_name || '').toLowerCase();
92
+  const cls = name === 'closed' ? 'dot-closed' : 'dot-open';
93
+  return `<span class="status-dot ${cls}" title="${status.status_name || ''}"></span>`;
94
+}
95
+
96
+function fmtDate(str) {
97
+  if (!str) return '—';
98
+  try {
99
+    return new Date(str).toLocaleString();
100
+  } catch {
101
+    return str;
102
+  }
103
+}
104
+
105
+function renderTable(alerts) {
106
+  const body = document.getElementById('alerts-body');
107
+  if (!alerts.length) {
108
+    body.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-4">No alerts found.</td></tr>';
109
+    return;
110
+  }
111
+
112
+  body.innerHTML = alerts.map(alert => {
113
+    const id = alert.alert_id ?? '';
114
+    const checked = state.selected.has(id) ? 'checked' : '';
115
+    const owner = (alert.owner || {}).user_name || '—';
116
+    const classification = (alert.classification || {}).name || '—';
117
+    const closedAt = alert.alert_close_timestamp ? fmtDate(alert.alert_close_timestamp) : '—';
118
+    return `
119
+      <tr class="alert-row" style="cursor:pointer" onclick="openAlertDetail(${id}, event)">
120
+        <td><input type="checkbox" class="row-chk" data-id="${id}" ${checked} onchange="toggleRow(this)"></td>
121
+        <td>${statusDot(alert.status)}</td>
122
+        <td>${owner}</td>
123
+        <td>${id}</td>
124
+        <td>${alert.alert_title || '—'}</td>
125
+        <td>${classification}</td>
126
+        <td>${severityBadge(alert.severity)}</td>
127
+        <td>${fmtDate(alert.alert_creation_time)}</td>
128
+        <td>${renderKpiBar(alert.kpi)}</td>
129
+        <td>${fmtDate(alert.alert_source_event_time)}</td>
130
+        <td>${closedAt}</td>
131
+        <td>—</td>
132
+      </tr>`;
133
+  }).join('');
134
+}
135
+
136
+function toggleAll(chk) {
137
+  document.querySelectorAll('.row-chk').forEach(el => {
138
+    el.checked = chk.checked;
139
+    const id = Number(el.dataset.id);
140
+    if (chk.checked) state.selected.add(id);
141
+    else state.selected.delete(id);
142
+  });
143
+  updateSelectionLabel();
144
+}
145
+
146
+function toggleRow(el) {
147
+  const id = Number(el.dataset.id);
148
+  if (el.checked) state.selected.add(id);
149
+  else state.selected.delete(id);
150
+  updateSelectionLabel();
151
+}
152
+
153
+function updateSelectionLabel() {
154
+  // selection label removed from UI
155
+}
156
+
157
+function updatePageInfo() {
158
+  const el = document.getElementById('page-info');
159
+  if (el) {
160
+    const totalPages = Math.max(1, Math.ceil(state.total / state.perPage));
161
+    el.textContent = `Page ${state.page} of ${totalPages} (${state.total} total)`;
162
+  }
163
+}
164
+
165
+function prevPage() {
166
+  if (state.page > 1) {
167
+    state.page--;
168
+    refreshActive();
169
+  }
170
+}
171
+
172
+function nextPage() {
173
+  const totalPages = Math.ceil(state.total / state.perPage);
174
+  if (state.page < totalPages) {
175
+    state.page++;
176
+    refreshActive();
177
+  }
178
+}
179
+
180
+function sortBy(col) {
181
+  if (state.sortBy === col) {
182
+    state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
183
+  } else {
184
+    state.sortBy = col;
185
+    state.sortDir = 'desc';
186
+  }
187
+  state.page = 1;
188
+  loadAlerts();
189
+}
190
+
191
+async function assignSelected() {
192
+  if (!state.selected.size) {
193
+    alert('No alerts selected.');
194
+    return;
195
+  }
196
+  const ownerIdStr = prompt('Enter owner user ID to assign:');
197
+  if (!ownerIdStr) return;
198
+  const ownerId = parseInt(ownerIdStr, 10);
199
+  if (isNaN(ownerId)) {
200
+    alert('Invalid owner ID.');
201
+    return;
202
+  }
203
+  const ids = Array.from(state.selected);
204
+  try {
205
+    await Promise.all(ids.map(id =>
206
+      fetch(`${API_BASE}/alerts/${id}/assign`, {
207
+        method: 'POST',
208
+        headers: { 'Content-Type': 'application/json' },
209
+        body: JSON.stringify({ owner_id: ownerId }),
210
+      })
211
+    ));
212
+    state.selected.clear();
213
+    loadAlerts();
214
+  } catch (err) {
215
+    alert(`Assignment failed: ${err.message}`);
216
+  }
217
+}
218
+
219
+function exportCsv() {
220
+  const params = new URLSearchParams({
221
+    sort_by: state.sortBy,
222
+    sort_dir: state.sortDir,
223
+  });
224
+  if (state.filterTitle) params.set('filter_title', state.filterTitle);
225
+  if (state.filterOwner) params.set('filter_owner', state.filterOwner);
226
+  window.location.href = `${API_BASE}/alerts/export-csv?${params}`;
227
+}
228
+
229
+let activeTab = 'alerts';
230
+
231
+function switchTab(tab) {
232
+  activeTab = tab;
233
+  document.getElementById('tab-alerts').classList.toggle('active', tab === 'alerts');
234
+  document.getElementById('tab-cases').classList.toggle('active', tab === 'cases');
235
+  state.page = 1;
236
+  activeTab === 'alerts' ? loadAlerts() : loadCases();
237
+}
238
+
239
+async function loadCases() {
240
+  const params = new URLSearchParams({
241
+    page: state.page,
242
+    per_page: state.perPage,
243
+    sort_by: state.sortBy,
244
+    sort_dir: state.sortDir,
245
+  });
246
+  const body = document.getElementById('alerts-body');
247
+  body.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-4">Loading...</td></tr>';
248
+  try {
249
+    const res = await fetch(`${API_BASE}/cases?${params}`);
250
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
251
+    const json = await res.json();
252
+    const casesData = json.data?.cases ?? {};
253
+    const cases = casesData.data ?? [];
254
+    state.total = casesData.total ?? cases.length;
255
+    renderCasesTable(cases);
256
+    updatePageInfo();
257
+  } catch (err) {
258
+    body.innerHTML = `<tr><td colspan="12" class="text-center text-danger py-4">Error: ${err.message}</td></tr>`;
259
+  }
260
+}
261
+
262
+function renderCasesTable(cases) {
263
+  const body = document.getElementById('alerts-body');
264
+  if (!cases.length) {
265
+    body.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-4">No cases found.</td></tr>';
266
+    return;
267
+  }
268
+  body.innerHTML = cases.map(c => {
269
+    const owner = (c.owner || {}).user_name || '—';
270
+    const stateName = (c.state || {}).state_name || '—';
271
+    const closed = c.close_date ? fmtDate(c.close_date) : '—';
272
+    return `<tr>
273
+      <td></td>
274
+      <td>${statusDot({status_name: stateName})}</td>
275
+      <td>${owner}</td>
276
+      <td>${c.case_id ?? ''}</td>
277
+      <td>${c.case_name || '—'}</td>
278
+      <td>${c.case_soc_id || '—'}</td>
279
+      <td>—</td>
280
+      <td>${fmtDate(c.open_date)}</td>
281
+      <td>${renderKpiBar(c.kpi)}</td>
282
+      <td>—</td>
283
+      <td>${closed}</td>
284
+      <td>—</td>
285
+    </tr>`;
286
+  }).join('');
287
+}
288
+
289
+function openAlertDetail(alertId, event) {
290
+  // Don't navigate when clicking the checkbox
291
+  if (event && event.target.type === 'checkbox') return;
292
+  window.location.href = `/alerts?alert_ids=${alertId}&cid=1`;
293
+}
294
+
295
+function refreshActive() {
296
+  activeTab === 'alerts' ? loadAlerts() : loadCases();
297
+}
298
+
299
+// Auto-load on page ready
300
+document.addEventListener('DOMContentLoaded', loadAlerts);
301
+setInterval(refreshActive, 60_000);
302
+
303
+// Expose for inline onclick handlers
304
+window.debouncedLoad = debouncedLoad;
305
+window.loadAlerts = loadAlerts;
306
+window.toggleAll = toggleAll;
307
+window.toggleRow = toggleRow;
308
+window.assignSelected = assignSelected;
309
+window.exportCsv = exportCsv;
310
+window.prevPage = prevPage;
311
+window.nextPage = nextPage;
312
+window.sortBy = sortBy;
313
+window.switchTab = switchTab;
314
+window.openAlertDetail = openAlertDetail;

+ 46 - 2
iris-web/ui/src/pages/manage.cases.js

@@ -26,7 +26,31 @@ $('#classification_id').prepend(new Option('', ''));
26 26
 
27 27
 
28 28
  /*************************
29
- *  Case list section 
29
+ *  KPI helpers
30
+ *************************/
31
+async function _fetchCaseKpi(caseId) {
32
+  try {
33
+    const res = await fetch(`/kpi-dashboard/api/cases/${caseId}`);
34
+    if (!res.ok) return null;
35
+    const json = await res.json();
36
+    return json.data?.case?.kpi ?? null;
37
+  } catch { return null; }
38
+}
39
+
40
+function _renderCaseKpiBar(kpi) {
41
+  if (!kpi) return '<span class="text-muted">—</span>';
42
+  const segs = (kpi.segments || []).map(s =>
43
+    `<div style="width:18px;height:8px;border-radius:3px;background:${s.active ? s.color : '#e5e7eb'}" title="${s.label}"></div>`
44
+  ).join('');
45
+  return `<span title="KPI: ${kpi.status} — ${kpi.elapsed_pct}% elapsed" style="display:inline-flex;align-items:center;gap:4px">
46
+    <b><i class="fa-solid fa-gauge-high"></i></b>
47
+    <span style="display:flex;gap:3px">${segs}</span>
48
+    <small class="text-muted">${kpi.status}</small>
49
+  </span>`;
50
+}
51
+
52
+ /*************************
53
+ *  Case list section
30 54
  *************************/
31 55
 /* case table creation */
32 56
 $.each($.find("table"), function(index, element){
@@ -112,6 +136,17 @@ $('#cases_table').dataTable({
112 136
             if (type === 'display') { data = sanitizeHTML(data);}
113 137
             return data;
114 138
           }
139
+        },
140
+        {
141
+            "data": "case_id",
142
+            "orderable": false,
143
+            "searchable": false,
144
+            "render": function (data, type, row) {
145
+                if (type === 'display') {
146
+                    return `<span id="case-kpi-${data}"><small class="text-muted">…</small></span>`;
147
+                }
148
+                return data;
149
+            }
115 150
         }
116 151
     ],
117 152
     dom: '<"container-fluid"<"row"<"col"l><"col"f>>>rt<"container-fluid"<"row"<"col"i><"col"p>>>',
@@ -133,7 +168,16 @@ $('#cases_table').dataTable({
133 168
     },
134 169
     initComplete: function () {
135 170
             tableFiltering(this.api(), 'cases_table');
136
-        }
171
+        },
172
+    drawCallback: function () {
173
+        const api = this.api();
174
+        api.rows({ page: 'current' }).data().each(function (row) {
175
+            const caseId = row.case_id;
176
+            _fetchCaseKpi(caseId).then(kpi => {
177
+                $(`#case-kpi-${caseId}`).html(_renderCaseKpiBar(kpi));
178
+            });
179
+        });
180
+    }
137 181
     }
138 182
 );
139 183
 

+ 1 - 1
run-combined-stack.sh

@@ -101,7 +101,7 @@ run_iris() {
101 101
   docker compose \
102 102
     --project-name iris-web \
103 103
     --project-directory "${ROOT_DIR}/iris-web" \
104
-    -f "${ROOT_DIR}/iris-web/docker-compose.yml" \
104
+    -f "${ROOT_DIR}/iris-web/docker-compose.dev.yml" \
105 105
     -f "${ROOT_DIR}/compose-overrides/iris.shared-network.yml" \
106 106
     "${COMMAND}" ${ARGS[@]+"${ARGS[@]}"}
107 107
 }

+ 358 - 0
scripts/seed-kpi-test-data.py

@@ -0,0 +1,358 @@
1
+#!/usr/bin/env python3
2
+"""
3
+seed-kpi-test-data.py — Create test IRIS alerts and cases for KPI dashboard UI testing.
4
+
5
+Creates a spread of records covering every KPI state:
6
+  On Track | Watch | Warning | Urgent | Critical | Breached | Resolved
7
+
8
+Usage:
9
+  python3 scripts/seed-kpi-test-data.py [--alerts-only] [--cases-only] [--dry-run]
10
+
11
+Env vars (override defaults):
12
+  IRIS_BASE_URL   default: https://localhost:8443
13
+  IRIS_API_KEY    required
14
+"""
15
+import argparse
16
+import json
17
+import os
18
+import ssl
19
+import sys
20
+import urllib.request
21
+from datetime import datetime, timedelta, timezone
22
+from pathlib import Path
23
+
24
+# ---------------------------------------------------------------------------
25
+# Config
26
+# ---------------------------------------------------------------------------
27
+
28
+def _read_env_file(path: str, key: str) -> str:
29
+    p = Path(path)
30
+    if not p.exists():
31
+        return ""
32
+    for line in p.read_text().splitlines():
33
+        if line.startswith(f"{key}="):
34
+            return line[len(key) + 1:].strip()
35
+    return ""
36
+
37
+BASE_DIR = Path(__file__).parent.parent
38
+ENV_FILE = BASE_DIR / "soc-integrator" / ".env"
39
+
40
+IRIS_BASE_URL = (
41
+    os.environ.get("IRIS_BASE_URL")
42
+    or _read_env_file(str(ENV_FILE), "IRIS_BASE_URL")
43
+    or "https://localhost:8443"
44
+).rstrip("/").replace("iriswebapp_nginx", "localhost")
45
+
46
+IRIS_API_KEY = (
47
+    os.environ.get("IRIS_API_KEY")
48
+    or _read_env_file(str(ENV_FILE), "IRIS_API_KEY")
49
+    or ""
50
+)
51
+
52
+if not IRIS_API_KEY:
53
+    sys.exit("error: IRIS_API_KEY not set. Export it or add it to soc-integrator/.env")
54
+
55
+# ---------------------------------------------------------------------------
56
+# HTTP helpers (no extra deps)
57
+# ---------------------------------------------------------------------------
58
+
59
+_ssl_ctx = ssl.create_default_context()
60
+_ssl_ctx.check_hostname = False
61
+_ssl_ctx.verify_mode = ssl.CERT_NONE
62
+
63
+
64
+def _req(method: str, path: str, body: dict | None = None) -> dict:
65
+    url = f"{IRIS_BASE_URL}{path}"
66
+    data = json.dumps(body).encode() if body else None
67
+    headers = {
68
+        "Authorization": f"Bearer {IRIS_API_KEY}",
69
+        "Content-Type": "application/json",
70
+    }
71
+    req = urllib.request.Request(url, data=data, headers=headers, method=method)
72
+    with urllib.request.urlopen(req, context=_ssl_ctx, timeout=15) as r:
73
+        return json.loads(r.read())
74
+
75
+
76
+def get(path: str) -> dict:
77
+    return _req("GET", path)
78
+
79
+
80
+def post(path: str, body: dict) -> dict:
81
+    return _req("POST", path, body)
82
+
83
+
84
+def put(path: str, body: dict) -> dict:
85
+    return _req("PUT", path, body)
86
+
87
+
88
+# ---------------------------------------------------------------------------
89
+# Lookup tables
90
+# ---------------------------------------------------------------------------
91
+
92
+def _get_severity_ids() -> dict[str, int]:
93
+    """Return name→id map for alert severities."""
94
+    data = get("/manage/severities/list")
95
+    items = (data.get("data") or [])
96
+    return {s["severity_name"].lower(): s["severity_id"] for s in items if "severity_name" in s}
97
+
98
+
99
+def _get_alert_status_ids() -> dict[str, int]:
100
+    data = get("/manage/alert-status/list")
101
+    items = data.get("data") or []
102
+    return {s["status_name"].lower(): s["status_id"] for s in items if "status_name" in s}
103
+
104
+
105
+def _get_resolution_status_ids() -> dict[str, int]:
106
+    try:
107
+        data = get("/manage/alert-resolutions/list")
108
+        items = data.get("data") or []
109
+        return {s["resolution_status_name"].lower(): s["resolution_status_id"]
110
+                for s in items if "resolution_status_name" in s}
111
+    except Exception:
112
+        return {}
113
+
114
+
115
+def _get_customer_id() -> int:
116
+    try:
117
+        data = get("/api/v2/customers")
118
+        items = (data.get("data") or {}).get("customers") or []
119
+        if items:
120
+            return items[0].get("customer_id", 1)
121
+    except Exception:
122
+        pass
123
+    return 1
124
+
125
+
126
+# ---------------------------------------------------------------------------
127
+# Alert scenarios
128
+# ---------------------------------------------------------------------------
129
+
130
+def _ts(offset_hours: float) -> str:
131
+    """ISO timestamp offset_hours ago (UTC, naive — what IRIS expects)."""
132
+    dt = datetime.now(timezone.utc) - timedelta(hours=offset_hours)
133
+    return dt.strftime("%Y-%m-%dT%H:%M:%S")
134
+
135
+
136
+def _date(offset_hours: float) -> str:
137
+    """Date string (YYYY-MM-DD) offset_hours ago — for case close_date."""
138
+    dt = datetime.now(timezone.utc) - timedelta(hours=offset_hours)
139
+    return dt.strftime("%Y-%m-%d")
140
+
141
+
142
+# Each tuple: (label, severity, created_hours_ago, resolved_hours_after_creation_or_None)
143
+# SLA: High=4h  Medium=8h  Low=24h
144
+ALERT_SCENARIOS = [
145
+    # --- High severity (4h SLA) ---
146
+    ("High / On Track (1h old)",       "High",   1.0,   None),   # 75% remaining
147
+    ("High / Watch (2.5h old)",        "High",   2.5,   None),   # ~37% remaining → Watch
148
+    ("High / Warning (3h old)",        "High",   3.0,   None),   # 25% remaining
149
+    ("High / Breached (6h old)",       "High",   6.0,   None),   # 0%
150
+    ("High / Resolved in SLA (2h)",    "High",   4.0,   2.0),    # resolved 2h after open → 50% KPI frozen
151
+    ("High / Resolved breached (5h)",  "High",   7.0,   5.0),    # resolved after SLA breach → Resolved/0%
152
+    # --- Medium severity (8h SLA) ---
153
+    ("Medium / On Track (1h old)",     "Medium", 1.0,   None),
154
+    ("Medium / Watch (3h old)",        "Medium", 3.0,   None),
155
+    ("Medium / Warning (5h old)",      "Medium", 5.0,   None),
156
+    ("Medium / Urgent (7h old)",       "Medium", 7.0,   None),
157
+    ("Medium / Critical (7.8h old)",   "Medium", 7.8,   None),
158
+    ("Medium / Breached (10h old)",    "Medium", 10.0,  None),
159
+    ("Medium / Resolved in SLA (4h)",  "Medium", 9.0,   4.0),
160
+    # --- Low severity (24h SLA) ---
161
+    ("Low / On Track (2h old)",        "Low",    2.0,   None),
162
+    ("Low / Warning (14h old)",        "Low",    14.0,  None),
163
+    ("Low / Breached (30h old)",       "Low",    30.0,  None),
164
+    ("Low / Resolved in SLA (12h)",    "Low",    25.0,  12.0),
165
+]
166
+
167
+# Case scenarios: (label, tags, created_hours_ago, close_hours_after_creation_or_None)
168
+CASE_SCENARIOS = [
169
+    ("High / Open On Track",           "High,wazuh",         1.0,   None),
170
+    ("High / Open Watch",              "High,brute-force",   2.5,   None),
171
+    ("High / Breached",                "High,lateral-movement", 6.0, None),
172
+    ("High / Resolved in SLA",         "High,exfiltration",  5.0,   2.0),
173
+    ("Medium / Open Watch",            "Medium,wazuh",       3.0,   None),
174
+    ("Medium / Open Urgent",           "Medium,phishing",    7.0,   None),
175
+    ("Medium / Breached",              "Medium,ransomware",  12.0,  None),
176
+    ("Medium / Resolved",              "Medium,malware",     10.0,  5.0),
177
+    ("Low / On Track",                 "Low,wazuh",          2.0,   None),
178
+    ("Low / Warning",                  "Low,recon",          14.0,  None),
179
+    ("Low / Resolved in SLA",          "Low,policy",         26.0,  10.0),
180
+]
181
+
182
+
183
+# ---------------------------------------------------------------------------
184
+# Create alerts
185
+# ---------------------------------------------------------------------------
186
+
187
+def create_alerts(sev_ids: dict, status_ids: dict, res_ids: dict, customer_id: int, dry_run: bool):
188
+    new_id = status_ids.get("new") or 2
189
+    closed_id = status_ids.get("closed") or 6
190
+    # Pick any "true positive" resolution, falling back to first available
191
+    res_tp_id = (
192
+        res_ids.get("true positive with impact")
193
+        or res_ids.get("true positive without impact")
194
+        or (list(res_ids.values())[0] if res_ids else 2)
195
+    )
196
+
197
+    print(f"\n=== Creating {len(ALERT_SCENARIOS)} alerts ===")
198
+    for label, sev_name, created_h, resolved_h in ALERT_SCENARIOS:
199
+        sev_id = sev_ids.get(sev_name.lower()) or sev_ids.get("medium") or 3
200
+        created_ts = _ts(created_h)
201
+
202
+        payload: dict = {
203
+            "alert_title": f"[KPI Test] {label}",
204
+            "alert_description": f"Seed data: {label}. Created {created_h}h ago.",
205
+            "alert_severity_id": sev_id,
206
+            "alert_status_id": new_id,
207
+            "alert_customer_id": customer_id,
208
+            "alert_source": "kpi-seed",
209
+            "alert_source_ref": "seed-kpi-test-data",
210
+            "alert_source_event_time": created_ts,
211
+            "alert_creation_time": created_ts,
212
+        }
213
+
214
+        if resolved_h is not None:
215
+            payload["alert_status_id"] = closed_id
216
+            if res_tp_id:
217
+                payload["alert_resolution_status_id"] = res_tp_id
218
+
219
+        if dry_run:
220
+            print(f"  DRY-RUN  {label}")
221
+            continue
222
+
223
+        try:
224
+            resp = post("/alerts/add", payload)
225
+            alert_data = resp.get("data") or {}
226
+            aid = alert_data.get("alert_id", "?")
227
+            print(f"  created  alert_id={aid}  {label}")
228
+        except Exception as exc:
229
+            print(f"  FAILED   {label}: {exc}")
230
+
231
+
232
+# ---------------------------------------------------------------------------
233
+# Create cases
234
+# ---------------------------------------------------------------------------
235
+
236
+def create_cases(customer_id: int, dry_run: bool):
237
+    print(f"\n=== Creating {len(CASE_SCENARIOS)} cases ===")
238
+    for label, tags, created_h, close_h in CASE_SCENARIOS:
239
+        open_date = _ts(created_h)
240
+        # close_date: a date-only string (IRIS v2 close_date is a date, not datetime)
241
+        close_date = _date(created_h - close_h) if close_h is not None else None
242
+
243
+        payload: dict = {
244
+            "case_name": f"[KPI Test] {label}",
245
+            "case_description": f"Seed data: {label}. Opened {created_h}h ago.",
246
+            "case_customer": customer_id,
247
+            "case_tags": tags,
248
+            "case_soc_id": "seed-kpi",
249
+        }
250
+
251
+        if dry_run:
252
+            print(f"  DRY-RUN  {label}")
253
+            continue
254
+
255
+        try:
256
+            resp = post("/api/v2/cases", payload)
257
+            # v2 create returns the case object directly (no data wrapper)
258
+            cid = resp.get("case_id") or (resp.get("data") or {}).get("case_id", "?")
259
+            print(f"  created  case_id={cid}  {label}")
260
+
261
+            # Close the case if needed — IRIS v2: PUT /api/v2/cases/{id} with close_date
262
+            if close_date and cid and cid != "?":
263
+                try:
264
+                    put(f"/api/v2/cases/{cid}", {"close_date": close_date})
265
+                    print(f"           └─ closed at {close_date}")
266
+                except Exception as exc:
267
+                    print(f"           └─ close failed: {exc}")
268
+        except Exception as exc:
269
+            print(f"  FAILED   {label}: {exc}")
270
+
271
+
272
+# ---------------------------------------------------------------------------
273
+# Main
274
+# ---------------------------------------------------------------------------
275
+
276
+def _backdate_alerts_via_db(scenarios: list, dry_run: bool):
277
+    """Update alert_creation_time and modification_history in Postgres via docker exec."""
278
+    import subprocess
279
+
280
+    lines = []
281
+    for label, sev, created_h, resolved_h in scenarios:
282
+        title_sql = label.replace("'", "''")
283
+        lines.append(
284
+            f"UPDATE alerts SET alert_creation_time = NOW() - INTERVAL '{int(created_h * 60)} minutes' "
285
+            f"WHERE alert_title = '[KPI Test] {title_sql}';"
286
+        )
287
+        if resolved_h is not None:
288
+            elapsed_h = created_h - resolved_h          # hours from now to resolution
289
+            lines.append(
290
+                f"WITH ts AS (SELECT EXTRACT(EPOCH FROM NOW() - INTERVAL '{int(elapsed_h * 60)} minutes') AS t) "
291
+                f"UPDATE alerts SET modification_history = jsonb_build_object((SELECT t::text FROM ts), "
292
+                f"'{{\"user\":\"seed\",\"action\":\"Alert resolved\"}}') "
293
+                f"WHERE alert_title = '[KPI Test] {title_sql}';"
294
+            )
295
+
296
+    sql = "\n".join(lines)
297
+    print("\n--- Backdating alert timestamps via docker exec ---")
298
+    if dry_run:
299
+        print("  DRY-RUN (SQL would be):")
300
+        print(sql[:500] + "...")
301
+        return
302
+    result = subprocess.run(
303
+        ["docker", "exec", "iriswebapp_db", "psql", "-U", "postgres", "-d", "iris_db", "-c", sql],
304
+        capture_output=True, text=True,
305
+    )
306
+    if result.returncode != 0:
307
+        print(f"  WARN: backdate failed: {result.stderr[:300]}")
308
+    else:
309
+        print("  done.")
310
+
311
+
312
+def main():
313
+    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
314
+    parser.add_argument("--alerts-only", action="store_true")
315
+    parser.add_argument("--cases-only", action="store_true")
316
+    parser.add_argument("--backdate", action="store_true",
317
+                        help="Update alert_creation_time in PostgreSQL via docker exec after creation")
318
+    parser.add_argument("--dry-run", action="store_true", help="Print what would be created without calling IRIS")
319
+    args = parser.parse_args()
320
+
321
+    print(f"IRIS: {IRIS_BASE_URL}")
322
+
323
+    if args.dry_run:
324
+        print("DRY-RUN mode — no requests will be made\n")
325
+        if not args.cases_only:
326
+            create_alerts({}, {}, {}, 1, dry_run=True)
327
+            if args.backdate:
328
+                _backdate_alerts_via_db(ALERT_SCENARIOS, dry_run=True)
329
+        if not args.alerts_only:
330
+            create_cases(1, dry_run=True)
331
+        return
332
+
333
+    print("Fetching IRIS lookup tables...")
334
+    try:
335
+        sev_ids = _get_severity_ids()
336
+        status_ids = _get_alert_status_ids()
337
+        res_ids = _get_resolution_status_ids()
338
+        customer_id = _get_customer_id()
339
+    except Exception as exc:
340
+        sys.exit(f"error: could not reach IRIS at {IRIS_BASE_URL}: {exc}")
341
+
342
+    print(f"  severities:    {sev_ids}")
343
+    print(f"  alert statuses:{status_ids}")
344
+    print(f"  resolution:    {res_ids}")
345
+    print(f"  customer_id:   {customer_id}")
346
+
347
+    if not args.cases_only:
348
+        create_alerts(sev_ids, status_ids, res_ids, customer_id, dry_run=False)
349
+        if args.backdate:
350
+            _backdate_alerts_via_db(ALERT_SCENARIOS, dry_run=False)
351
+    if not args.alerts_only:
352
+        create_cases(customer_id, dry_run=False)
353
+
354
+    print("\ndone.")
355
+
356
+
357
+if __name__ == "__main__":
358
+    main()

+ 95 - 1
soc-integrator/app/adapters/iris.py

@@ -67,8 +67,9 @@ class IrisAdapter:
67 67
         headers = self._headers()
68 68
         safe_limit = max(1, limit)
69 69
         safe_offset = max(0, offset)
70
+        page = (safe_offset // safe_limit) + 1
70 71
         v2_url = f"{self.base_url}/api/v2/cases"
71
-        params = {"limit": safe_limit, "offset": safe_offset}
72
+        params = {"page": page, "per_page": safe_limit}
72 73
         async with httpx.AsyncClient(verify=False, timeout=20.0) as client:
73 74
             response = await client.get(v2_url, params=params, headers=headers)
74 75
             active_url = v2_url
@@ -112,6 +113,9 @@ class IrisAdapter:
112 113
                 return {"status_code": response.status_code}
113 114
 
114 115
             result = response.json()
116
+            # IRIS v2 response: { "total": int, "data": [...], "last_page": int, ... }
117
+            if isinstance(result, dict) and isinstance(result.get("total"), int) and isinstance(result.get("data"), list):
118
+                return result  # pass v2 response through directly
115 119
             if isinstance(result, dict) and isinstance(result.get("data"), list):
116 120
                 total = result.get("recordsTotal")
117 121
                 if not isinstance(total, int):
@@ -125,3 +129,93 @@ class IrisAdapter:
125 129
                     "raw": result,
126 130
                 }
127 131
             return result
132
+
133
+    async def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]:
134
+        headers = self._headers()
135
+        url = f"{self.base_url}/alerts/add"
136
+        async with httpx.AsyncClient(verify=False, timeout=20.0) as client:
137
+            response = await client.post(url, json=payload, headers=headers)
138
+            try:
139
+                response.raise_for_status()
140
+            except httpx.HTTPStatusError as exc:
141
+                detail = response.text.strip()
142
+                raise RuntimeError(
143
+                    f"IRIS returned {response.status_code} for {url}. Response: {detail}"
144
+                ) from exc
145
+            return response.json() if response.content else {"status_code": response.status_code}
146
+
147
+    async def get_case(self, case_id: int) -> dict[str, Any]:
148
+        headers = self._headers()
149
+        url = f"{self.base_url}/api/v2/cases/{case_id}"
150
+        async with httpx.AsyncClient(verify=False, timeout=20.0) as client:
151
+            response = await client.get(url, headers=headers)
152
+            try:
153
+                response.raise_for_status()
154
+            except httpx.HTTPStatusError as exc:
155
+                detail = response.text.strip()
156
+                raise RuntimeError(
157
+                    f"IRIS returned {response.status_code} for {url}. Response: {detail}"
158
+                ) from exc
159
+            return response.json() if response.content else {}
160
+
161
+    async def get_alert(self, alert_id: int) -> dict[str, Any]:
162
+        headers = self._headers()
163
+        url = f"{self.base_url}/alerts/{alert_id}"
164
+        async with httpx.AsyncClient(verify=False, timeout=20.0) as client:
165
+            response = await client.get(url, headers=headers)
166
+            try:
167
+                response.raise_for_status()
168
+            except httpx.HTTPStatusError as exc:
169
+                detail = response.text.strip()
170
+                raise RuntimeError(
171
+                    f"IRIS returned {response.status_code} for {url}. Response: {detail}"
172
+                ) from exc
173
+            result = response.json() if response.content else {}
174
+            # Legacy endpoint returns {"status": "success", "data": {...}}
175
+            return result.get("data", result)
176
+
177
+    async def list_alerts(
178
+        self,
179
+        page: int = 1,
180
+        per_page: int = 50,
181
+        sort_by: str = "alert_id",
182
+        sort_dir: str = "desc",
183
+        filter_title: str | None = None,
184
+        filter_owner_id: int | None = None,
185
+    ) -> dict[str, Any]:
186
+        headers = self._headers()
187
+        url = f"{self.base_url}/api/v2/alerts"
188
+        params: dict[str, Any] = {
189
+            "page": max(1, page),
190
+            "per_page": max(1, per_page),
191
+            "sort": f"{sort_by}:{sort_dir}",
192
+        }
193
+        if filter_title:
194
+            params["alert_title"] = filter_title
195
+        if filter_owner_id:
196
+            params["alert_owner_id"] = filter_owner_id
197
+        async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
198
+            response = await client.get(url, params=params, headers=headers)
199
+            try:
200
+                response.raise_for_status()
201
+            except httpx.HTTPStatusError as exc:
202
+                detail = response.text.strip()
203
+                raise RuntimeError(
204
+                    f"IRIS returned {response.status_code} for {url}. Response: {detail}"
205
+                ) from exc
206
+            return response.json() if response.content else {"status_code": response.status_code}
207
+
208
+    async def assign_alert(self, alert_id: int, owner_id: int) -> dict[str, Any]:
209
+        headers = self._headers()
210
+        url = f"{self.base_url}/api/v2/alerts/{alert_id}"
211
+        payload = {"alert_owner_id": owner_id}
212
+        async with httpx.AsyncClient(verify=False, timeout=20.0) as client:
213
+            response = await client.put(url, json=payload, headers=headers)
214
+            try:
215
+                response.raise_for_status()
216
+            except httpx.HTTPStatusError as exc:
217
+                detail = response.text.strip()
218
+                raise RuntimeError(
219
+                    f"IRIS returned {response.status_code} for {url}. Response: {detail}"
220
+                ) from exc
221
+            return response.json() if response.content else {"status_code": response.status_code}

+ 362 - 1
soc-integrator/app/main.py

@@ -11,7 +11,9 @@ from pathlib import Path
11 11
 
12 12
 from psycopg import sql
13 13
 from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
14
-from fastapi.responses import FileResponse, Response
14
+import csv
15
+import io
16
+from fastapi.responses import FileResponse, Response, StreamingResponse
15 17
 from fastapi.staticfiles import StaticFiles
16 18
 
17 19
 from app.adapters.abuseipdb import AbuseIpdbAdapter
@@ -29,6 +31,7 @@ from app.models import (
29 31
     CDetectionEvaluateRequest,
30 32
     IocEnrichRequest,
31 33
     IocEvaluateRequest,
34
+    IrisAlertCreateRequest,
32 35
     IrisTicketCreateRequest,
33 36
     LogLossCheckRequest,
34 37
     LogLossStreamCheck,
@@ -2299,3 +2302,361 @@ async def monitor_c_detections_state() -> ApiResponse:
2299 2302
             "state": getattr(app.state, "c_detection_state", {}),
2300 2303
         }
2301 2304
     )
2305
+
2306
+
2307
+# ---------------------------------------------------------------------------
2308
+# KPI Timeout helpers and IRIS alert routes
2309
+# ---------------------------------------------------------------------------
2310
+
2311
+SLA_SECONDS: dict[str, int] = {"High": 14400, "Medium": 28800, "Low": 86400}
2312
+
2313
+
2314
+def compute_kpi(
2315
+    created_at: str,
2316
+    severity_name: str,
2317
+    resolved_at: str | None = None,
2318
+) -> dict[str, object]:
2319
+    sla = SLA_SECONDS.get(severity_name, 28800)
2320
+
2321
+    def _parse(ts: str) -> datetime:
2322
+        dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
2323
+        return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
2324
+
2325
+    start = _parse(created_at)
2326
+    thresholds = [("S1", "#22c55e", 25), ("S2", "#eab308", 50), ("S3", "#f97316", 75), ("S4", "#ef4444", 100)]
2327
+
2328
+    if resolved_at:
2329
+        end = _parse(resolved_at)
2330
+        elapsed = max(0, (end - start).total_seconds())  # clamp: close_date can't precede open_date
2331
+        elapsed_pct = min(elapsed / sla * 100, 100)
2332
+        kpi_pct = max(100 - elapsed_pct, 0)
2333
+        segments = [{"label": l, "color": c, "active": elapsed_pct >= t} for l, c, t in thresholds]
2334
+        return {
2335
+            "kpi_pct": round(kpi_pct, 1),
2336
+            "elapsed_pct": round(elapsed_pct, 1),
2337
+            "status": "Resolved",
2338
+            "segments": segments,
2339
+            "resolved": True,
2340
+        }
2341
+
2342
+    elapsed = (datetime.now(timezone.utc) - start).total_seconds()
2343
+    elapsed_pct = min(elapsed / sla * 100, 100)
2344
+    kpi_pct = max(100 - elapsed_pct, 0)
2345
+    segments = [{"label": l, "color": c, "active": elapsed_pct >= t} for l, c, t in thresholds]
2346
+    if kpi_pct >= 80:
2347
+        status = "On Track"
2348
+    elif kpi_pct >= 60:
2349
+        status = "Watch"
2350
+    elif kpi_pct >= 40:
2351
+        status = "Warning"
2352
+    elif kpi_pct >= 20:
2353
+        status = "Urgent"
2354
+    elif kpi_pct > 0:
2355
+        status = "Critical"
2356
+    else:
2357
+        status = "Breached"
2358
+    return {
2359
+        "kpi_pct": round(kpi_pct, 1),
2360
+        "elapsed_pct": round(elapsed_pct, 1),
2361
+        "status": status,
2362
+        "segments": segments,
2363
+        "resolved": False,
2364
+    }
2365
+
2366
+
2367
+def _enrich_alerts_with_kpi(iris_response: dict) -> dict:
2368
+    """Inject kpi field into each alert row returned by IRIS.
2369
+
2370
+    IRIS GET /api/v2/alerts returns: { "total": N, "data": [...], ... }
2371
+    """
2372
+    alerts = iris_response.get("data", [])
2373
+    if not isinstance(alerts, list):
2374
+        return iris_response
2375
+    for alert in alerts:
2376
+        created_at = alert.get("alert_creation_time") or ""
2377
+        severity = (alert.get("severity") or {}).get("severity_name", "Medium")
2378
+        if not created_at:
2379
+            continue
2380
+        resolved_at: str | None = None
2381
+        if alert.get("alert_resolution_status_id") is not None:
2382
+            history: dict = alert.get("modification_history") or {}
2383
+            if history:
2384
+                last_ts = max(history.keys(), key=lambda k: float(k))
2385
+                resolved_at = datetime.fromtimestamp(float(last_ts), tz=timezone.utc).isoformat()
2386
+        try:
2387
+            alert["kpi"] = compute_kpi(created_at, severity, resolved_at)
2388
+        except Exception:
2389
+            alert["kpi"] = {"kpi_pct": 0, "elapsed_pct": 100, "status": "Breached", "segments": [], "resolved": False}
2390
+    return iris_response
2391
+
2392
+
2393
+def _enrich_cases_with_kpi(iris_response: dict) -> dict:
2394
+    # v2 cases list: { "data": [...], "total": N, ... }
2395
+    # Each case uses open_date / close_date / state.state_name / severity_id
2396
+    _CASE_SEV: dict[int, str] = {1: "Medium", 4: "Low", 5: "High", 6: "High"}  # severity_id → name
2397
+    cases = iris_response.get("data") or iris_response.get("items", [])
2398
+    if not isinstance(cases, list):
2399
+        return iris_response
2400
+    for case in cases:
2401
+        created_at = case.get("open_date") or ""
2402
+        if not created_at:
2403
+            continue
2404
+        sev_id = case.get("severity_id") or 1
2405
+        severity = _CASE_SEV.get(int(sev_id), "Medium")
2406
+        resolved_at = None
2407
+        close_date = case.get("close_date")
2408
+        state_name = ((case.get("state") or {}).get("state_name") or "").lower()
2409
+        if close_date:
2410
+            resolved_at = close_date
2411
+        elif state_name == "closed":
2412
+            resolved_at = created_at
2413
+        try:
2414
+            case["kpi"] = compute_kpi(created_at, severity, resolved_at)
2415
+        except Exception:
2416
+            case["kpi"] = {"kpi_pct": 0, "elapsed_pct": 100, "status": "Breached", "segments": [], "resolved": False}
2417
+    return iris_response
2418
+
2419
+
2420
+@app.get(
2421
+    "/iris/cases/export-csv",
2422
+    summary="Export IRIS cases as CSV",
2423
+    description="Download all cases (up to 1000) with KPI as a CSV attachment.",
2424
+)
2425
+async def iris_export_cases_csv() -> StreamingResponse:
2426
+    try:
2427
+        raw = await iris_adapter.list_cases(limit=1000, offset=0)
2428
+    except Exception as exc:
2429
+        raise HTTPException(status_code=502, detail=f"IRIS case export failed: {exc}") from exc
2430
+    enriched = _enrich_cases_with_kpi(raw)
2431
+    cases = enriched.get("data") or enriched.get("items", [])
2432
+    _CASE_SEV: dict[int, str] = {1: "Medium", 4: "Low", 5: "High", 6: "High"}
2433
+    output = io.StringIO()
2434
+    fieldnames = ["case_id", "case_name", "severity", "state", "open_date", "close_date", "kpi_pct", "kpi_status"]
2435
+    writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
2436
+    writer.writeheader()
2437
+    for case in cases:
2438
+        kpi = case.get("kpi", {})
2439
+        writer.writerow({
2440
+            "case_id": case.get("case_id", ""),
2441
+            "case_name": case.get("case_name", ""),
2442
+            "severity": _CASE_SEV.get(int(case.get("severity_id") or 1), "Medium"),
2443
+            "state": (case.get("state") or {}).get("state_name", ""),
2444
+            "open_date": case.get("open_date", ""),
2445
+            "close_date": case.get("close_date", ""),
2446
+            "kpi_pct": kpi.get("kpi_pct", ""),
2447
+            "kpi_status": kpi.get("status", ""),
2448
+        })
2449
+    output.seek(0)
2450
+    return StreamingResponse(
2451
+        iter([output.getvalue()]),
2452
+        media_type="text/csv",
2453
+        headers={"Content-Disposition": "attachment; filename=iris_cases.csv"},
2454
+    )
2455
+
2456
+
2457
+@app.get(
2458
+    "/iris/cases/{case_id}",
2459
+    response_model=ApiResponse,
2460
+    summary="Get single IRIS case with KPI",
2461
+    description="Fetch one DFIR-IRIS case by ID and annotate with computed KPI data.",
2462
+)
2463
+async def iris_get_case(case_id: int) -> ApiResponse:
2464
+    try:
2465
+        raw = await iris_adapter.get_case(case_id)
2466
+    except Exception as exc:
2467
+        raise HTTPException(status_code=502, detail=f"IRIS case fetch failed: {exc}") from exc
2468
+    wrapper = {"data": [raw]}
2469
+    enriched = _enrich_cases_with_kpi(wrapper)
2470
+    case_out = enriched["data"][0] if enriched.get("data") else raw
2471
+    return ApiResponse(data={"case": case_out})
2472
+
2473
+
2474
+@app.get(
2475
+    "/iris/cases",
2476
+    response_model=ApiResponse,
2477
+    summary="List IRIS cases with KPI",
2478
+    description="Fetch cases from DFIR-IRIS and annotate each with computed KPI data.",
2479
+)
2480
+async def iris_list_cases(
2481
+    page: int = 1,
2482
+    per_page: int = 20,
2483
+    sort_by: str = "case_id",
2484
+    sort_dir: str = "desc",
2485
+    filter_name: str | None = None,
2486
+) -> ApiResponse:
2487
+    # adapter maps (limit, offset) → (per_page, page) for IRIS v2
2488
+    offset = (page - 1) * per_page
2489
+    try:
2490
+        raw = await iris_adapter.list_cases(limit=per_page, offset=offset)
2491
+    except Exception as exc:
2492
+        raise HTTPException(status_code=502, detail=f"IRIS case list failed: {exc}") from exc
2493
+    enriched = _enrich_cases_with_kpi(raw)
2494
+    items = enriched.get("data") or enriched.get("items", [])
2495
+    total = enriched.get("total", len(items))
2496
+    last_page = enriched.get("last_page", max(1, -(-total // per_page)))
2497
+    if filter_name:
2498
+        items = [c for c in items if filter_name.lower() in (c.get("case_name") or "").lower()]
2499
+    reverse = sort_dir == "desc"
2500
+    items.sort(key=lambda c: c.get(sort_by) or 0, reverse=reverse)
2501
+    return ApiResponse(data={"cases": {
2502
+        "data": items,
2503
+        "total": total,
2504
+        "current_page": page,
2505
+        "last_page": last_page,
2506
+    }})
2507
+
2508
+
2509
+@app.post(
2510
+    "/iris/alerts",
2511
+    response_model=ApiResponse,
2512
+    summary="Create IRIS alert",
2513
+    description="Create a new alert in DFIR-IRIS via /api/v2/alerts.",
2514
+)
2515
+async def iris_create_alert(payload: IrisAlertCreateRequest) -> ApiResponse:
2516
+    alert_payload: dict[str, Any] = {
2517
+        "alert_title": payload.title,
2518
+        "alert_description": payload.description,
2519
+        "alert_severity_id": payload.severity_id,
2520
+        "alert_status_id": payload.status_id,
2521
+        "alert_source": payload.source,
2522
+        "alert_customer_id": payload.customer_id or settings.iris_default_customer_id,
2523
+        "alert_source_event_time": datetime.now(timezone.utc).isoformat(),
2524
+    }
2525
+    if payload.source_ref:
2526
+        alert_payload["alert_source_ref"] = payload.source_ref
2527
+    if payload.payload:
2528
+        alert_payload.update(payload.payload)
2529
+    try:
2530
+        result = await iris_adapter.create_alert(alert_payload)
2531
+    except Exception as exc:
2532
+        raise HTTPException(status_code=502, detail=f"IRIS alert create failed: {exc}") from exc
2533
+    return ApiResponse(data={"alert": result})
2534
+
2535
+
2536
+@app.get(
2537
+    "/iris/alerts",
2538
+    response_model=ApiResponse,
2539
+    summary="List IRIS alerts with KPI Timeout",
2540
+    description="Fetch alerts from DFIR-IRIS and annotate each row with computed KPI Timeout data.",
2541
+)
2542
+async def iris_list_alerts(
2543
+    page: int = 1,
2544
+    per_page: int = 20,
2545
+    sort_by: str = "alert_id",
2546
+    sort_dir: str = "desc",
2547
+    filter_title: str | None = None,
2548
+    filter_owner_id: int | None = None,
2549
+) -> ApiResponse:
2550
+    try:
2551
+        raw = await iris_adapter.list_alerts(
2552
+            page=page,
2553
+            per_page=per_page,
2554
+            sort_by=sort_by,
2555
+            sort_dir=sort_dir,
2556
+            filter_title=filter_title,
2557
+            filter_owner_id=filter_owner_id,
2558
+        )
2559
+        enriched = _enrich_alerts_with_kpi(raw)
2560
+        return ApiResponse(data={
2561
+            "alerts": {
2562
+                "data": enriched.get("data", []),
2563
+                "total": enriched.get("total", 0),
2564
+                "current_page": enriched.get("current_page", page),
2565
+                "last_page": enriched.get("last_page", 1),
2566
+            }
2567
+        })
2568
+    except Exception as exc:
2569
+        raise HTTPException(status_code=502, detail=f"IRIS alert list failed: {exc}") from exc
2570
+
2571
+
2572
+@app.get(
2573
+    "/iris/alerts/export-csv",
2574
+    summary="Export IRIS alerts as CSV",
2575
+    description="Download all matching alerts (up to 1000) as a CSV attachment.",
2576
+)
2577
+async def iris_export_alerts_csv(
2578
+    sort_by: str = "alert_id",
2579
+    sort_dir: str = "desc",
2580
+    filter_title: str | None = None,
2581
+    filter_owner_id: int | None = None,
2582
+) -> StreamingResponse:
2583
+    try:
2584
+        raw = await iris_adapter.list_alerts(
2585
+            page=1,
2586
+            per_page=1000,
2587
+            sort_by=sort_by,
2588
+            sort_dir=sort_dir,
2589
+            filter_title=filter_title,
2590
+            filter_owner_id=filter_owner_id,
2591
+        )
2592
+    except Exception as exc:
2593
+        raise HTTPException(status_code=502, detail=f"IRIS alert export failed: {exc}") from exc
2594
+
2595
+    enriched = _enrich_alerts_with_kpi(raw)
2596
+    alerts = enriched.get("data", [])
2597
+
2598
+    output = io.StringIO()
2599
+    fieldnames = [
2600
+        "alert_id", "alert_title", "alert_severity", "alert_status",
2601
+        "alert_creation_time", "alert_source_event_time", "alert_owner",
2602
+        "kpi_pct", "kpi_status",
2603
+    ]
2604
+    writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
2605
+    writer.writeheader()
2606
+    for alert in alerts:
2607
+        kpi = alert.get("kpi", {})
2608
+        severity_name = (alert.get("severity") or {}).get("severity_name", "")
2609
+        writer.writerow({
2610
+            "alert_id": alert.get("alert_id", ""),
2611
+            "alert_title": alert.get("alert_title", ""),
2612
+            "alert_severity": severity_name,
2613
+            "alert_status": (alert.get("status") or {}).get("status_name", ""),
2614
+            "alert_creation_time": alert.get("alert_creation_time", ""),
2615
+            "alert_source_event_time": alert.get("alert_source_event_time", ""),
2616
+            "alert_owner": (alert.get("owner") or {}).get("user_name", ""),
2617
+            "kpi_pct": kpi.get("kpi_pct", ""),
2618
+            "kpi_status": kpi.get("status", ""),
2619
+        })
2620
+
2621
+    output.seek(0)
2622
+    return StreamingResponse(
2623
+        iter([output.getvalue()]),
2624
+        media_type="text/csv",
2625
+        headers={"Content-Disposition": "attachment; filename=iris_alerts.csv"},
2626
+    )
2627
+
2628
+
2629
+@app.get(
2630
+    "/iris/alerts/{alert_id}",
2631
+    response_model=ApiResponse,
2632
+    summary="Get single IRIS alert with KPI",
2633
+    description="Fetch one DFIR-IRIS alert by ID and annotate with computed KPI data.",
2634
+)
2635
+async def iris_get_alert(alert_id: int) -> ApiResponse:
2636
+    try:
2637
+        raw = await iris_adapter.get_alert(alert_id)
2638
+    except Exception as exc:
2639
+        raise HTTPException(status_code=502, detail=f"IRIS alert fetch failed: {exc}") from exc
2640
+    # Wrap in list-shaped dict so _enrich_alerts_with_kpi can process it
2641
+    alert = raw if isinstance(raw, dict) else {}
2642
+    wrapper = {"data": [alert]}
2643
+    enriched = _enrich_alerts_with_kpi(wrapper)
2644
+    alert_out = enriched["data"][0] if enriched.get("data") else alert
2645
+    return ApiResponse(data={"alert": alert_out})
2646
+
2647
+
2648
+@app.post(
2649
+    "/iris/alerts/{alert_id}/assign",
2650
+    response_model=ApiResponse,
2651
+    summary="Assign IRIS alert to owner",
2652
+    description="Update the owner of a DFIR-IRIS alert.",
2653
+)
2654
+async def iris_assign_alert(alert_id: int, body: dict) -> ApiResponse:
2655
+    owner_id = body.get("owner_id")
2656
+    if not isinstance(owner_id, int):
2657
+        raise HTTPException(status_code=422, detail="owner_id must be an integer")
2658
+    try:
2659
+        result = await iris_adapter.assign_alert(alert_id=alert_id, owner_id=owner_id)
2660
+        return ApiResponse(data=result)
2661
+    except Exception as exc:
2662
+        raise HTTPException(status_code=502, detail=f"IRIS alert assign failed: {exc}") from exc

+ 26 - 0
soc-integrator/app/models.py

@@ -355,6 +355,32 @@ class SimLogRunRequest(BaseModel):
355 355
     forever: bool = Field(default=False, description="Run continuously until stopped.", examples=[False])
356 356
 
357 357
 
358
+class IrisAlertCreateRequest(BaseModel):
359
+    model_config = ConfigDict(
360
+        json_schema_extra={
361
+            "example": {
362
+                "title": "Suspicious login detected",
363
+                "description": "Multiple failed logins followed by success from unusual IP",
364
+                "severity_id": 4,
365
+                "status_id": 2,
366
+                "source": "wazuh",
367
+                "source_ref": "wazuh-alert-12345",
368
+                "customer_id": 1,
369
+                "payload": {"alert_tags": "brute-force,authentication"},
370
+            }
371
+        }
372
+    )
373
+
374
+    title: str = Field(description="Alert title.", examples=["Suspicious login detected"])
375
+    description: str = Field(default="Created by soc-integrator", description="Alert description.")
376
+    severity_id: int = Field(default=3, description="IRIS severity ID (1=Info,2=Low,3=Medium,4=High,5=Critical).", examples=[4])
377
+    status_id: int = Field(default=2, description="IRIS alert status ID.", examples=[2])
378
+    source: str = Field(default="soc-integrator", description="Alert source name.", examples=["wazuh"])
379
+    source_ref: str | None = Field(default=None, description="Source-system reference ID.", examples=["wazuh-alert-12345"])
380
+    customer_id: int | None = Field(default=None, description="IRIS customer ID (defaults to configured value).")
381
+    payload: dict[str, Any] = Field(default_factory=dict, description="Additional IRIS alert fields merged into the request.")
382
+
383
+
358 384
 class ApiResponse(BaseModel):
359 385
     ok: bool = True
360 386
     message: str = "ok"

tum/soc - Gogs: Simplico Git Service

No Description

Security Detection & Threat Intelligence Enhancement Proposal-revise.md 24KB

Security Detection & Threat Intelligence Enhancement Proposal

Security Architecture Overview


About Simplico Co., Ltd.

Simplico Co., Ltd. is a technology consulting and system integration company specializing in custom security, data, and automation solutions for enterprise and industrial environments.

We focus on designing and implementing practical, production-ready systems rather than generic or vendor-locked platforms. Our expertise covers security monitoring, SOC/MDR architecture, automation (SOAR), system integration, and long-term operational support.

We also have experience in developing mobile applications, e‑commerce platforms, large‑scale web applications, and factory automation systems.

More information about our company and services is available at: https://simplico.net/

1. Executive Summary

This proposal is prepared for บริษัท ฟู้ดโปรเจ็ค (สยาม) จำกัด (FoodProject) and delivers advanced security detection use cases, continuously updated threat‑intelligence IOC detection, and VPN authentication anomaly monitoring using a modular, open, and extensible security architecture.

The solution avoids monolithic or vendor‑locked SOC platforms and instead uses best‑of‑breed components, each responsible for a specific role: detection, automation, investigation, and escalation.

Key Outcomes

  • Improved visibility into malicious network activity and credential misuse
  • Faster detection, investigation, and escalation of high‑risk security events
  • Reduced operational risk through continuously updated threat intelligence

2. Selected Architecture

This architecture is designed to directly support the detection, automation, investigation, and escalation use cases described in Section 3 by ensuring each security function is handled by a dedicated, purpose-built component.

Layer Technology Purpose
Detection Wazuh Log analysis, correlation, alerting
Automation / SOAR Shuffle IOC matching, enrichment, response logic
Case Management DFIRTrack Incident tracking, evidence, investigation timeline
Escalation PagerDuty On‑call alerting & SLA enforcement

Why This Architecture

  • Open and extensible (no vendor lock‑in)
  • Designed for real SOC / MDR workflows
  • Clear separation of responsibility
  • Easy to maintain and scale
flowchart LR
    A["Firewall / DNS / IDS / VPN Logs"] --> B["Wazuh
Detection & Correlation"]
    B --> C["Shuffle
Automation & SOAR"]
    C --> D["DFIRTrack
Incident Tracking"]
    C --> E["PagerDuty
On-call Escalation"]

    C -->|"IOC Match / Enrichment"| D
    C -->|"SEV-1 / SEV-2"| E

3. Scope of Work

The scope of work is organized into three primary workstreams: (1) creation and tuning of detection rules tailored to the customer environment, (2) implementation of continuously updated threat‑intelligence IOC detection across network traffic, and (3) monitoring of VPN authentication anomalies based on geographic location. Together, these workstreams ensure comprehensive visibility, timely detection, and actionable response to security threats.

3.1 Create & Tune New Detection Rules / Use Cases

Activities

  • Review firewall, DNS, IDS/IPS, VPN, and Windows log formats
  • Onboard logs into Wazuh with proper parsing and normalization
  • Implement the agreed detection use cases (see Appendix A: Use Case List)
  • Tune thresholds, severities, and allowlists
  • Reduce false positives using real traffic patterns

Output

  • Stable, environment-specific detection rules
  • Severity-aligned alerts suitable for automation and escalation

3.2 Threat Intelligence IOC Detection (DNS / Firewall / IDS-IPS)

Covered Use Cases

DNS Network Traffic

  • Communication to malicious domain or IP
  • Malicious domain / IP IOC detection

IDS / IPS Network Traffic

  • Communication to malicious domain or IP
  • IOC-based detection from IDS / IPS alerts

Technical Implementation

  1. IOC feed integration (domain & IP)
  2. Automated, scheduled IOC updates
  3. IOC matching and enrichment via automation workflows
  4. Incident creation and evidence tracking
  5. Escalation for high-severity matches

Outcome

  • Continuously updated IOC detection
  • Clear evidence trail for audit and investigation

3.3 VPN Authentication Success from Outside Thailand

Detection Logic

  • Monitor VPN authentication success events
  • Perform GeoIP lookup on source IP
  • Detect successful logins originating outside Thailand

Enhancements

  • Exception list for approved overseas users
  • Risk scoring for admin accounts, first-time country access, and off-hours login

Response

  • Incident creation and tracking
  • On-call escalation for high-risk events

3.2 Threat Intelligence IOC Detection (DNS / Firewall / IDS‑IPS)

Covered Use Cases

DN5S Network Traffic

  • Communication to malicious domain or IP
  • Malicious domain / IP IOC detection

IDS / IPS Network Traffic

  • Communication to malicious domain or IP
  • IOC‑based detection from IDS / IPS alerts

Technical Implementation

  1. IOC feed integration (domain & IP)
  2. Automated, scheduled IOC updates
  3. IOC matching and enrichment via automation workflows
  4. Incident creation and evidence tracking
  5. Escalation for high‑severity matches

Outcome

  • Continuously updated IOC detection
  • Clear evidence trail for audit and investigation

3.3 VPN Authentication Success from Outside Thailand

Detection Logic

  • Monitor VPN authentication success events
  • Perform GeoIP lookup on source IP
  • Detect successful logins originating outside Thailand

Enhancements

  • Exception list for approved overseas users
  • Risk scoring for admin accounts, first‑time country access, and off‑hours login

Response

  • Incident creation and tracking
  • On‑call escalation for high‑risk events

4. End‑to‑End Workflow

  1. Firewall / DNS / IDS / VPN logs are collected
  2. Detection rules evaluate events
  3. Automation workflows enrich and classify alerts
  4. Incidents are tracked with evidence and timeline
  5. High‑severity events trigger on‑call escalation

4.1 Integration Deliverables

The implementation includes full integration with the automation and case management layers to ensure alerts are actionable and traceable:

  • Integration with Shuffle for automated enrichment, IOC matching, and response workflows
  • Integration with DFIRTrack for incident creation, evidence collection, and investigation timeline management

5. Deliverables

  • Custom detection rules and tuning
  • IOC detection workflows (DNS and IDS / IPS)
  • Automated IOC update pipeline
  • VPN geo‑anomaly detection
  • Incident templates and investigation workflow
  • Escalation logic
  • Documentation and handover

6. Timeline

The timeline below includes all activities required for full integration across detection, automation, case management, and escalation layers, including Shuffle and DFIRTrack.

Phase Duration
Log onboarding & review 1 week
Rule creation & tuning 1–2 weeks
IOC pipeline & detection 1–2 weeks
VPN geo-anomaly use case 3–5 days
Integration & end-to-end testing (Shuffle / DFIRTrack / PagerDuty) 1 week

Total estimated duration: 5–6 weeks


7. Pricing

Payment Terms

  • 50% of the total project value is payable upon project commencement.
  • The remaining 50% is payable upon project completion and acceptance, as defined in this proposal.

Project completion and acceptance are defined by the successful implementation of the agreed detection use cases, verified end-to-end workflows, and delivery of documentation as outlined in the Scope of Work and Deliverables sections.

Project completion and acceptance are defined by the successful implementation of the agreed detection use cases, verified end-to-end workflows, and delivery of documentation as outlined in the Scope of Work and Deliverables sections.  

One‑Time Implementation

Item Cost (THB)
Security use‑case implementation 320,000

Note: The above price includes full integration with the automation, case management, and escalation layers (Shuffle, DFIRTrack, and PagerDuty), including workflow configuration, API integration, and end‑to‑end testing.

VAT Disclaimer: All prices stated in this proposal are exclusive of 7% Value Added Tax (VAT), which will be charged separately in accordance with Thai tax regulations. |


Short Free Tuning Period

As part of this engagement, a short free tuning period is included after initial deployment to ensure detection rules and thresholds are well aligned with the production environment.

  • Duration: 30 calendar days after go-live
  • Delivery mode: Online / remote support only
  • Scope: fine-tuning of existing rules, threshold adjustments, and false-positive reduction
  • Excludes: new use-case development, new log sources, on-site support, or major logic changes

This tuning period helps stabilize the system and maximize detection quality without additional cost.


Complimentary Security Consultation

In addition to the implementation, a complimentary security consultation is included to support knowledge transfer and strategic alignment.

  • Duration: 30 calendar days (remote only)
  • Scope: architecture review, use-case clarification, and operational guidance
  • Purpose: help internal teams better understand the system and plan future improvements

This consultation is advisory in nature and does not include additional implementation or configuration work.



Optional Ongoing Support

Service Cost (THB / month)
IOC feed maintenance & updates 20,000 – 40,000
Rule tuning & false‑positive reduction Included

8. Assumptions, Exclusions & Out-of-Scope

Assumptions

  • Log sources are accessible and stable
  • Required access is provided during implementation
  • Log formats do not change significantly during the project timeline

Exclusions

  • 24/7 SOC monitoring
  • Incident response execution or forensic investigation
  • Advanced UEBA or machine-learning analytics

Out-of-Scope (Unless Quoted Separately)

The following items are not included in this proposal and will require a separate quotation if requested:

  • Major changes to log formats, vendors, or network architecture after project kickoff
  • Onboarding of additional log sources beyond firewall, DNS, IDS/IPS, and VPN
  • Development of custom dashboards beyond standard operational views
  • Unlimited rule changes or ongoing rule development beyond the initial tuning period
  • Integration with additional third-party systems not listed in this proposal
  • Emergency or after-hours support outside agreed working hours
  • Compliance certification, audit execution, or regulatory reporting

9. Value to Customer

  • Practical, actionable security detection
  • Continuously updated threat intelligence
  • Reduced alert noise
  • Clear investigation and audit trail
  • Scalable foundation for future MDR services

10. Closing

This implementation provides enterprise-grade detection and response capability using open, well-architected components—without vendor lock-in or unnecessary complexity.


Appendix A: Use Case List (Initial Implementation Scope)

The following use cases will be implemented and tuned as part of the initial project scope. Final severity and thresholds will be confirmed during log review and tuning.

A1. DNS / Firewall (IOC)

Category Source Use Case Target Severity
DNS Firewall/DNS logs DNS Network Traffic – Communicate to Malicious Domain Medium
DNS Firewall/DNS logs DNS Network Traffic – Malicious Domain IOCs Detection Medium

A2. FortiGate IPS/IDS & Firewall

Category Source Use Case Target Severity
IPS FortiGate IPS&IDS Network Traffic – Allowed RDP from Public IPs High
IPS FortiGate IPS&IDS Firewall Account – Admin Password Change High
IPS FortiGate IPS&IDS Firewall Account – Create/Add Admin Account High
IPS FortiGate IPS&IDS Firewall Configure – Disabled Email Notification High
IPS FortiGate IPS&IDS Firewall Configure – Download Configure FW Low
IPS FortiGate IPS&IDS IDS Alert – Multiple Critical/High Medium
IPS FortiGate IPS&IDS Network Traffic – Port Scanning Low
IPS FortiGate IPS&IDS Network Traffic – IOC Detection Medium
IPS FortiGate IPS&IDS Network Traffic – Port Scanning from Private IP Medium
IPS FortiGate IPS&IDS Network Traffic – Communicate to Malicious IP Medium

A3. FortiGate VPN

Category Source Use Case Target Severity
VPN FortiGate VPN – Authentication Success from Guest Account High
VPN FortiGate VPN – Authentication Success from Multiple Country High
VPN FortiGate VPN – Authentication Brute Force Success High
VPN FortiGate VPN – Authentication Multiple Fail (Many Accounts from 1 Source) Low
VPN FortiGate VPN – Authentication Success from Outside Thailand High

A4. Windows / Active Directory

Category Source Use Case Target Severity
Windows Windows Security Logs Windows Authentication – Multiple Fail from Privileged Account Medium
Windows Windows Security Logs Windows Authentication – Multiple Fail from Service Account Medium
Windows Windows AD Logs Windows AD – Enumeration with Malicious Tools Medium
Windows Windows Security Logs Windows Authentication – Fail from Public IPs Medium
Windows Windows Security Logs Windows File Share – Enumeration to Single Destination Medium
Windows Windows Security Logs Windows Authentication – Success from Public IPs High
Windows Windows Security Logs Windows Authentication – Privileged Account Impersonation High
Windows Windows Security Logs Windows Authentication – Successful Pass the Hash RDP High
Windows Windows Security Logs Windows Authentication – Success from Guest Account High
Windows Windows Security Logs Windows Authentication – Interactive Logon Success by Service Account High
Windows Windows Security Logs Windows Account – Added to Privileged Custom Group High
Windows Windows Security Logs Windows Account – Added to Privileged Group High
Windows Windows Domain Configure Windows Domain Configure – DSRM Password Reset High
Windows Windows Security Logs Windows Authentication – Multiple Fail (1 Account from Many Sources) Low
Windows Windows Security Logs Windows Authentication – Multiple Fail (Many Accounts from 1 Source) Low
Windows Windows Security Logs Windows Authentication – Multiple Fail from Guest Account Low
Windows Windows Security Logs Windows Authentication – Multiple Fail (1 Account from 1 Source) Low
Windows Windows Security Logs Windows Authentication – Multiple Interactive Logon Denied Low
Windows Windows Security Logs Windows Authentication – Password Spray Low
Windows Windows Security Logs Windows Authentication – Attempt from Disabled Account Low
Windows Windows Security Logs Windows Domain Account – Created Low
Windows Windows Security Logs Windows Local Account – Re-Enabled Low
Windows Windows Security Logs Windows Local Account – Created Low
Windows Windows Security Logs Windows Domain Account – Re-Enabled Low

Appendix B: Additional Use Cases (Optional / Add-On Scope)

The following use cases require additional log sources or integrations and are not included in the initial implementation scope. They can be implemented as an optional add-on or Phase 2 enhancement.

B1. VMware vCenter / ESXi

Category Source Use Case Target Severity
VMware vCenter / ESXi vCenter GUI – Login Failed 5 Times and Success 1 Time High
VMware vCenter / ESXi ESXi – Enable SSH on Hosts Medium
VMware vCenter / ESXi ESXi – SSH Failed 5 Times and Success 1 Time High

B2. Log Monitoring

Category Source Use Case Target Severity
SIEM LogMonitor Log Monitor – Logs Loss Detection Low

B3. Windows Sysmon

Category Source Use Case Target Severity
Sysmon Windows Sysmon – LSASS Dumping High
Sysmon Windows Sysmon – SQL Injection High
Sysmon Windows Sysmon – Webshell High
Sysmon Windows Sysmon – Uninstall High
Sysmon Windows Sysmon – LSASS Dumping by Task Manager High
Sysmon Windows Sysmon – CertUtil Download Medium

Notes

  • IOC-based detections require an IOC feed and update schedule. IOC matching and enrichment will be implemented via the automation layer.
  • Geo-based VPN detections require GeoIP enrichment and an exception list for approved overseas users.

Appendix C: Future Enhancement Use Cases (Post-Implementation)

The following use cases are not included in the current project scope. They are provided to illustrate additional high-value security capabilities that can be implemented in future phases after the initial deployment is stabilized.

C1. Impossible Travel Detection (VPN / AD / Cloud)

Description

Impossible Travel detects potential credential compromise by identifying authentication events where the same user account logs in from geographically distant locations within a time window that is physically impossible for normal human travel.

How It Works

  • Correlate authentication events for the same user across VPN, Active Directory, and cloud services
  • Enrich source IP addresses with GeoIP location data
  • Calculate distance and time between consecutive login events
  • Trigger an alert when the required travel speed exceeds realistic human limits

Typical Scenarios

  • VPN login from Thailand followed shortly by a VPN or cloud login from another country
  • Active Directory login from an internal office network followed by an external or overseas login
  • Cloud or SaaS login from two distant regions within a short time window

Risk & Value

  • Strong indicator of stolen or shared credentials
  • High signal with low false-positive rate when properly tuned
  • Effective for detecting attacks that bypass malware-based controls

Response Examples

  • Create an incident record for investigation
  • Enrich with user role, account type, and asset criticality
  • Optional actions such as password reset, MFA enforcement, or account lockout

**Implementation Notes

  • Known VPN exit IPs and office locations are allowlisted to reduce false positives
  • Service and automation accounts are excluded by default
  • Time windows and thresholds are tuned based on operational patterns

C2. Advanced Credential Abuse & Privilege Misuse

Example Use Cases

  • Privileged account usage outside business hours
  • Dormant accounts becoming active unexpectedly
  • Service accounts used for interactive logon
  • Rapid privilege escalation followed by sensitive access

Value

  • Detects early-stage attacker activity
  • High audit and compliance relevance
  • Low operational noise when tuned correctly

C3. Lateral Movement & Internal Reconnaissance

Example Use Cases

  • Multiple authentication successes across different hosts in a short time
  • SMB or RDP access patterns indicating lateral movement
  • Admin account accessing many servers rapidly
  • Internal scanning or enumeration behavior

Value

  • Identifies attacker movement after initial compromise
  • Difficult to detect without correlation
  • Strong indicator of real intrusion activity

C4. Ransomware Early Warning Indicators

Example Use Cases

  • Mass file rename or encryption behavior
  • Shadow copy deletion
  • Backup or recovery service stopped unexpectedly
  • High-risk process execution prior to file access

Value

  • Detects ransomware before full impact
  • High business risk reduction
  • Strong executive-level interest

C5. Endpoint & Server Behavior Anomalies

Example Use Cases

  • Unusual process execution on critical servers
  • Command-line anomaly detection
  • Creation of scheduled tasks or persistence mechanisms
  • Unexpected software installation

Value

  • Complements EDR detections
  • Detects living-off-the-land techniques
  • Useful for threat hunting and incident investigation

C6. Cloud & SaaS Security Monitoring (If Applicable)

Example Use Cases

  • Cloud administrator role changes
  • API key misuse or abnormal API usage
  • Suspicious SaaS login behavior
  • Large or unusual data download activity

Value

  • Extends visibility beyond on-prem systems
  • Important for hybrid and cloud environments
  • Often required by security audits

C7. SOC & Operational Maturity Monitoring

Example Use Cases

  • Alert fatigue and recurring alert patterns
  • Incidents exceeding SLA targets
  • Detection coverage gaps
  • Log ingestion health and drift detection

Value

  • Improves SOC efficiency and effectiveness
  • Provides management-level insight
  • Supports continuous security improvement