tum %!s(int64=6) %!d(string=před) dny
rodič
revize
922e61ec37

+ 2 - 0
compose-overrides/iris.shared-network.yml

10
       - soc_shared
10
       - soc_shared
11
 
11
 
12
   app:
12
   app:
13
+    ports:
14
+      - "8001:8000"
13
     networks:
15
     networks:
14
       - iris_backend
16
       - iris_backend
15
       - iris_frontend
17
       - iris_frontend

+ 1 - 0
compose-overrides/soc-integrator.yml

38
       - "${SOC_INTEGRATOR_PORT:-8088}:8080"
38
       - "${SOC_INTEGRATOR_PORT:-8088}:8080"
39
     volumes:
39
     volumes:
40
       - ../scripts:/app/scripts:ro
40
       - ../scripts:/app/scripts:ro
41
+      - ../soc-integrator/app:/app/app
41
     depends_on:
42
     depends_on:
42
       soc-integrator-db:
43
       soc-integrator-db:
43
         condition: service_healthy
44
         condition: service_healthy

+ 2 - 1
iris-web/source/app/__init__.py

103
 app.config.update(
103
 app.config.update(
104
     SESSION_COOKIE_SECURE=True,
104
     SESSION_COOKIE_SECURE=True,
105
     SESSION_COOKIE_HTTPONLY=True,
105
     SESSION_COOKIE_HTTPONLY=True,
106
-    SESSION_COOKIE_SAMESITE='Lax'
106
+    SESSION_COOKIE_SAMESITE='Lax',
107
+    SOC_INTEGRATOR_URL=os.getenv('SOC_INTEGRATOR_URL', 'http://soc-integrator:8080'),
107
 )
108
 )
108
 
109
 
109
 cache = Cache(app)
110
 cache = Cache(app)

+ 18 - 0
iris-web/source/app/blueprints/pages/case/templates/case.html

42
                                      <span title="Case outcome" class="float-right btn btn-rounded badge-pill hidden-caret btn-sm ml-2 mb-2 {% if case.status_id == 1%}badge-success{% elif case.status_id == 2 %}badge-danger{% else %}btn-light{% endif %}"
42
                                      <span title="Case outcome" class="float-right btn btn-rounded badge-pill hidden-caret btn-sm ml-2 mb-2 {% if case.status_id == 1%}badge-success{% elif case.status_id == 2 %}badge-danger{% else %}btn-light{% endif %}"
43
                                         onclick="case_detail('{{ case.case_id }}', true);"
43
                                         onclick="case_detail('{{ case.case_id }}', true);"
44
                                      ><i class="fa-solid fa-group-arrows-rotate mr-2"></i>{{ case.status_name }}</span>
44
                                      ><i class="fa-solid fa-group-arrows-rotate mr-2"></i>{{ case.status_name }}</span>
45
+                                     <span id="case-kpi-badge" class="ml-2 mb-2 d-inline-flex align-items-center" style="gap:4px;color:#fff;opacity:.9"></span>
45
                                 </div>
46
                                 </div>
46
                                 <div class="row">
47
                                 <div class="row">
47
                                       <div class="ml-auto">
48
                                       <div class="ml-auto">
338
 <script>
339
 <script>
339
     $('#modal_select_report').selectpicker();
340
     $('#modal_select_report').selectpicker();
340
     load_menu_mod_options_modal([{{case.case_id}}], 'case', $("#case_modal_quick_actions"));
341
     load_menu_mod_options_modal([{{case.case_id}}], 'case', $("#case_modal_quick_actions"));
342
+
343
+    (async function loadCaseKpi() {
344
+        try {
345
+            const res = await fetch('/kpi-dashboard/api/cases/{{ case.case_id }}');
346
+            if (!res.ok) return;
347
+            const json = await res.json();
348
+            const kpi = json.data?.case?.kpi;
349
+            if (!kpi) return;
350
+            const segs = (kpi.segments || []).map(s =>
351
+                `<div style="width:18px;height:8px;border-radius:3px;background:${s.active ? s.color : 'rgba(255,255,255,.25)'}" title="${s.label}"></div>`
352
+            ).join('');
353
+            document.getElementById('case-kpi-badge').innerHTML =
354
+                `<b><i class="fa-solid fa-gauge-high"></i></b>
355
+                 <span style="display:flex;gap:3px">${segs}</span>
356
+                 <small>${kpi.status}</small>`;
357
+        } catch(e) {}
358
+    })();
341
 </script>
359
 </script>
342
 
360
 
343
 {% endblock javascripts %}
361
 {% endblock javascripts %}

+ 0 - 0
iris-web/source/app/blueprints/pages/kpi_dashboard/__init__.py


+ 85 - 0
iris-web/source/app/blueprints/pages/kpi_dashboard/kpi_dashboard_routes.py

1
+import json
2
+import urllib.parse
3
+import urllib.request
4
+from flask import Blueprint, render_template, redirect, url_for, current_app, request, Response
5
+
6
+from app.blueprints.access_controls import ac_requires, ac_api_requires
7
+from app.models.authorization import Permissions
8
+
9
+kpi_dashboard_blueprint = Blueprint(
10
+    'kpi_dashboard', __name__, template_folder='templates'
11
+)
12
+
13
+
14
+def _soc_url() -> str:
15
+    return current_app.config.get('SOC_INTEGRATOR_URL', 'http://soc-integrator:8000')
16
+
17
+
18
+def _soc_get(path: str, params: dict) -> tuple[bytes, int, str]:
19
+    qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
20
+    url = f"{_soc_url()}{path}{'?' + qs if qs else ''}"
21
+    with urllib.request.urlopen(url, timeout=30) as r:
22
+        return r.read(), r.status, r.headers.get('Content-Type', 'application/json')
23
+
24
+
25
+def _soc_post(path: str, body: dict) -> tuple[bytes, int]:
26
+    url = f"{_soc_url()}{path}"
27
+    data = json.dumps(body).encode()
28
+    req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
29
+    with urllib.request.urlopen(req, timeout=20) as r:
30
+        return r.read(), r.status
31
+
32
+
33
+@kpi_dashboard_blueprint.route('/kpi-dashboard')
34
+@ac_requires(no_cid_required=True)
35
+def kpi_dashboard(caseid, url_redir):
36
+    if url_redir:
37
+        return redirect(url_for('index.index', cid=caseid if caseid is not None else 1, redirect=True))
38
+    return render_template('kpi_dashboard.html')
39
+
40
+
41
+@kpi_dashboard_blueprint.route('/kpi-dashboard/api/alerts')
42
+@ac_api_requires(Permissions.alerts_read)
43
+def proxy_list_alerts():
44
+    content, status, _ = _soc_get('/iris/alerts', request.args)
45
+    return Response(content, status=status, content_type='application/json')
46
+
47
+
48
+@kpi_dashboard_blueprint.route('/kpi-dashboard/api/alerts/<int:alert_id>')
49
+@ac_api_requires(Permissions.alerts_read)
50
+def proxy_get_alert(alert_id):
51
+    content, status, _ = _soc_get(f'/iris/alerts/{alert_id}', {})
52
+    return Response(content, status=status, content_type='application/json')
53
+
54
+
55
+@kpi_dashboard_blueprint.route('/kpi-dashboard/api/alerts/<int:alert_id>/assign', methods=['POST'])
56
+@ac_api_requires(Permissions.alerts_write)
57
+def proxy_assign_alert(alert_id):
58
+    content, status = _soc_post(f'/iris/alerts/{alert_id}/assign', request.get_json() or {})
59
+    return Response(content, status=status, content_type='application/json')
60
+
61
+
62
+@kpi_dashboard_blueprint.route('/kpi-dashboard/api/alerts/export-csv')
63
+@ac_api_requires(Permissions.alerts_read)
64
+def proxy_export_csv():
65
+    content, status, _ = _soc_get('/iris/alerts/export-csv', request.args)
66
+    return Response(
67
+        content,
68
+        status=status,
69
+        content_type='text/csv',
70
+        headers={'Content-Disposition': 'attachment; filename=iris_alerts.csv'},
71
+    )
72
+
73
+
74
+@kpi_dashboard_blueprint.route('/kpi-dashboard/api/cases')
75
+@ac_api_requires(Permissions.alerts_read)
76
+def proxy_list_cases():
77
+    content, status, _ = _soc_get('/iris/cases', request.args)
78
+    return Response(content, status=status, content_type='application/json')
79
+
80
+
81
+@kpi_dashboard_blueprint.route('/kpi-dashboard/api/cases/<int:case_id>')
82
+@ac_api_requires(Permissions.alerts_read)
83
+def proxy_get_case(case_id):
84
+    content, status, _ = _soc_get(f'/iris/cases/{case_id}', {})
85
+    return Response(content, status=status, content_type='application/json')

+ 74 - 0
iris-web/source/app/blueprints/pages/kpi_dashboard/templates/kpi_dashboard.html

1
+{% extends "layouts/default.html" %}
2
+
3
+{% block title %}KPI Dashboard{% endblock %}
4
+
5
+{% block stylesheets %}
6
+  <link rel="stylesheet" href="/static/assets/css/kpi_dashboard.css">
7
+{% endblock %}
8
+
9
+{% block content %}
10
+<div class="panel-header bg-primary-gradient mt--4">
11
+  <div class="page-inner py-4">
12
+    <h2 class="text-white fw-bold">KPI Dashboard</h2>
13
+  </div>
14
+</div>
15
+
16
+<div class="page-inner mt--5">
17
+  <!-- Table card -->
18
+  <div class="card">
19
+    <!-- Tabs + filters inside card header -->
20
+    <div class="card-header pb-0 pt-3">
21
+      <ul class="nav nav-tabs card-header-tabs" id="kpi-tabs">
22
+        <li class="nav-item">
23
+          <a class="nav-link active" id="tab-alerts" href="#" onclick="switchTab('alerts'); return false;">Alerts</a>
24
+        </li>
25
+        <li class="nav-item">
26
+          <a class="nav-link" id="tab-cases" href="#" onclick="switchTab('cases'); return false;">Cases</a>
27
+        </li>
28
+      </ul>
29
+      <div class="d-flex gap-2 mt-2 mb-2">
30
+        <input id="filter-owner" class="form-control form-control-sm" style="width:180px"
31
+               placeholder="Enter Assignee..." oninput="debouncedLoad()">
32
+        <input id="filter-title" class="form-control form-control-sm" style="width:240px"
33
+               placeholder="Enter name..." oninput="debouncedLoad()">
34
+      </div>
35
+    </div>
36
+    <div class="card-body p-0">
37
+      <div class="table-responsive">
38
+        <table class="table table-hover table-sm mb-0" id="alerts-table">
39
+          <thead class="table-light">
40
+            <tr>
41
+              <th><input type="checkbox" id="chk-all" onchange="toggleAll(this)"></th>
42
+              <th></th>
43
+              <th>Assignee</th>
44
+              <th class="sortable" data-col="alert_id" onclick="sortBy('alert_id')">No. <span class="sort-icon">↕</span></th>
45
+              <th class="sortable" data-col="alert_title" onclick="sortBy('alert_title')">Name <span class="sort-icon">↕</span></th>
46
+              <th class="sortable" data-col="alert_classification_id" onclick="sortBy('alert_classification_id')">Category <span class="sort-icon">↕</span></th>
47
+              <th>Product</th>
48
+              <th class="sortable" data-col="alert_creation_time" onclick="sortBy('alert_creation_time')">Created at <span class="sort-icon">↕</span></th>
49
+              <th class="sortable" data-col="kpi" onclick="sortBy('kpi')">KPI Timeout <span class="sort-icon">↕</span></th>
50
+              <th class="sortable" data-col="alert_source_event_time" onclick="sortBy('alert_source_event_time')">Notified at <span class="sort-icon">↕</span></th>
51
+              <th>Closed at</th>
52
+              <th>Region</th>
53
+            </tr>
54
+          </thead>
55
+          <tbody id="alerts-body">
56
+            <tr><td colspan="12" class="text-center text-muted py-4">Loading...</td></tr>
57
+          </tbody>
58
+        </table>
59
+      </div>
60
+    </div>
61
+  </div>
62
+
63
+  <!-- Pagination (outside card) -->
64
+  <div class="d-flex justify-content-between align-items-center mt-2">
65
+    <button class="btn btn-sm btn-outline-secondary" onclick="prevPage()">&#8249; Prev</button>
66
+    <span id="page-info" class="text-muted small"></span>
67
+    <button class="btn btn-sm btn-outline-secondary" onclick="nextPage()">Next &#8250;</button>
68
+  </div>
69
+</div>
70
+{% endblock %}
71
+
72
+{% block javascripts %}
73
+  <script src="/static/assets/js/iris/kpi_dashboard.js"></script>
74
+{% endblock %}

+ 2 - 0
iris-web/source/app/blueprints/pages/manage/templates/manage_cases.html

44
                                         <th>Close date</th>
44
                                         <th>Close date</th>
45
                                         <th>SOC Ticket</th>
45
                                         <th>SOC Ticket</th>
46
                                         <th>Opening user</th>
46
                                         <th>Opening user</th>
47
+                                        <th>KPI</th>
47
                                     </tr>
48
                                     </tr>
48
                                 </thead>
49
                                 </thead>
49
                                 <tfoot>
50
                                 <tfoot>
56
                                         <th>Close date</th>
57
                                         <th>Close date</th>
57
                                         <th>SOC Ticket</th>
58
                                         <th>SOC Ticket</th>
58
                                         <th>Opening user</th>
59
                                         <th>Opening user</th>
60
+                                        <th>KPI</th>
59
                                     </tr>
61
                                     </tr>
60
                                 </tfoot>
62
                                 </tfoot>
61
                             </table>
63
                             </table>

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

101
 								<span>DIM Tasks</span>
101
 								<span>DIM Tasks</span>
102
 							</a>
102
 							</a>
103
 						</li>
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
 						<li class="nav-section nav-advanced">
112
 						<li class="nav-section nav-advanced">

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

97
 from app.blueprints.graphql.graphql_route import graphql_blueprint
97
 from app.blueprints.graphql.graphql_route import graphql_blueprint
98
 
98
 
99
 from app.blueprints.rest.v2 import rest_v2_blueprint
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
 from app.models.authorization import User
101
 from app.models.authorization import User
101
 
102
 
102
 def register_blusprints(app):
103
 def register_blusprints(app):
183
 
184
 
184
     app.register_blueprint(rest_v2_blueprint)
185
     app.register_blueprint(rest_v2_blueprint)
185
 
186
 
187
+    app.register_blueprint(kpi_dashboard_blueprint)
188
+
186
 
189
 
187
 
190
 
188
 # provide login manager with load_user callback
191
 # provide login manager with load_user callback

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

21
         "jquery.scrollbar": "^0.2.10",
21
         "jquery.scrollbar": "^0.2.10",
22
         "jqvmap": "^1.5.1",
22
         "jqvmap": "^1.5.1",
23
         "moment": "^2.22.2",
23
         "moment": "^2.22.2",
24
+        "rollup": "^4.59.0",
24
         "showdown": "^1.9.0",
25
         "showdown": "^1.9.0",
25
         "socket.io": "^4.3.2",
26
         "socket.io": "^4.3.2",
26
         "sortablejs": "^1.7.0",
27
         "sortablejs": "^1.7.0",
842
       }
843
       }
843
     },
844
     },
844
     "node_modules/@rollup/rollup-android-arm-eabi": {
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
       "cpu": [
849
       "cpu": [
849
         "arm"
850
         "arm"
850
       ],
851
       ],
851
-      "dev": true,
852
       "license": "MIT",
852
       "license": "MIT",
853
       "optional": true,
853
       "optional": true,
854
       "os": [
854
       "os": [
856
       ]
856
       ]
857
     },
857
     },
858
     "node_modules/@rollup/rollup-android-arm64": {
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
       "cpu": [
862
       "cpu": [
863
         "arm64"
863
         "arm64"
864
       ],
864
       ],
865
-      "dev": true,
866
       "license": "MIT",
865
       "license": "MIT",
867
       "optional": true,
866
       "optional": true,
868
       "os": [
867
       "os": [
870
       ]
869
       ]
871
     },
870
     },
872
     "node_modules/@rollup/rollup-darwin-arm64": {
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
       "cpu": [
875
       "cpu": [
877
         "arm64"
876
         "arm64"
878
       ],
877
       ],
879
-      "dev": true,
880
       "license": "MIT",
878
       "license": "MIT",
881
       "optional": true,
879
       "optional": true,
882
       "os": [
880
       "os": [
884
       ]
882
       ]
885
     },
883
     },
886
     "node_modules/@rollup/rollup-darwin-x64": {
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
       "cpu": [
888
       "cpu": [
891
         "x64"
889
         "x64"
892
       ],
890
       ],
893
-      "dev": true,
894
       "license": "MIT",
891
       "license": "MIT",
895
       "optional": true,
892
       "optional": true,
896
       "os": [
893
       "os": [
897
         "darwin"
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
     "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
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
       "cpu": [
927
       "cpu": [
905
         "arm"
928
         "arm"
906
       ],
929
       ],
907
-      "dev": true,
908
       "license": "MIT",
930
       "license": "MIT",
909
       "optional": true,
931
       "optional": true,
910
       "os": [
932
       "os": [
912
       ]
934
       ]
913
     },
935
     },
914
     "node_modules/@rollup/rollup-linux-arm-musleabihf": {
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
       "cpu": [
940
       "cpu": [
919
         "arm"
941
         "arm"
920
       ],
942
       ],
921
-      "dev": true,
922
       "license": "MIT",
943
       "license": "MIT",
923
       "optional": true,
944
       "optional": true,
924
       "os": [
945
       "os": [
926
       ]
947
       ]
927
     },
948
     },
928
     "node_modules/@rollup/rollup-linux-arm64-gnu": {
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
       "cpu": [
953
       "cpu": [
933
         "arm64"
954
         "arm64"
934
       ],
955
       ],
935
-      "dev": true,
936
       "license": "MIT",
956
       "license": "MIT",
937
       "optional": true,
957
       "optional": true,
938
       "os": [
958
       "os": [
940
       ]
960
       ]
941
     },
961
     },
942
     "node_modules/@rollup/rollup-linux-arm64-musl": {
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
       "cpu": [
966
       "cpu": [
947
         "arm64"
967
         "arm64"
948
       ],
968
       ],
949
-      "dev": true,
950
       "license": "MIT",
969
       "license": "MIT",
951
       "optional": true,
970
       "optional": true,
952
       "os": [
971
       "os": [
953
         "linux"
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
       "cpu": [
1018
       "cpu": [
961
         "ppc64"
1019
         "ppc64"
962
       ],
1020
       ],
963
-      "dev": true,
964
       "license": "MIT",
1021
       "license": "MIT",
965
       "optional": true,
1022
       "optional": true,
966
       "os": [
1023
       "os": [
968
       ]
1025
       ]
969
     },
1026
     },
970
     "node_modules/@rollup/rollup-linux-riscv64-gnu": {
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
       "cpu": [
1044
       "cpu": [
975
         "riscv64"
1045
         "riscv64"
976
       ],
1046
       ],
977
-      "dev": true,
978
       "license": "MIT",
1047
       "license": "MIT",
979
       "optional": true,
1048
       "optional": true,
980
       "os": [
1049
       "os": [
982
       ]
1051
       ]
983
     },
1052
     },
984
     "node_modules/@rollup/rollup-linux-s390x-gnu": {
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
       "cpu": [
1057
       "cpu": [
989
         "s390x"
1058
         "s390x"
990
       ],
1059
       ],
991
-      "dev": true,
992
       "license": "MIT",
1060
       "license": "MIT",
993
       "optional": true,
1061
       "optional": true,
994
       "os": [
1062
       "os": [
996
       ]
1064
       ]
997
     },
1065
     },
998
     "node_modules/@rollup/rollup-linux-x64-gnu": {
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
       "cpu": [
1070
       "cpu": [
1003
         "x64"
1071
         "x64"
1004
       ],
1072
       ],
1005
-      "dev": true,
1006
       "license": "MIT",
1073
       "license": "MIT",
1007
       "optional": true,
1074
       "optional": true,
1008
       "os": [
1075
       "os": [
1010
       ]
1077
       ]
1011
     },
1078
     },
1012
     "node_modules/@rollup/rollup-linux-x64-musl": {
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
       "cpu": [
1083
       "cpu": [
1017
         "x64"
1084
         "x64"
1018
       ],
1085
       ],
1019
-      "dev": true,
1020
       "license": "MIT",
1086
       "license": "MIT",
1021
       "optional": true,
1087
       "optional": true,
1022
       "os": [
1088
       "os": [
1023
         "linux"
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
     "node_modules/@rollup/rollup-win32-arm64-msvc": {
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
       "cpu": [
1122
       "cpu": [
1031
         "arm64"
1123
         "arm64"
1032
       ],
1124
       ],
1033
-      "dev": true,
1034
       "license": "MIT",
1125
       "license": "MIT",
1035
       "optional": true,
1126
       "optional": true,
1036
       "os": [
1127
       "os": [
1038
       ]
1129
       ]
1039
     },
1130
     },
1040
     "node_modules/@rollup/rollup-win32-ia32-msvc": {
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
       "cpu": [
1135
       "cpu": [
1045
         "ia32"
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
       "license": "MIT",
1151
       "license": "MIT",
1049
       "optional": true,
1152
       "optional": true,
1050
       "os": [
1153
       "os": [
1052
       ]
1155
       ]
1053
     },
1156
     },
1054
     "node_modules/@rollup/rollup-win32-x64-msvc": {
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
       "cpu": [
1161
       "cpu": [
1059
         "x64"
1162
         "x64"
1060
       ],
1163
       ],
1061
-      "dev": true,
1062
       "license": "MIT",
1164
       "license": "MIT",
1063
       "optional": true,
1165
       "optional": true,
1064
       "os": [
1166
       "os": [
1122
       }
1224
       }
1123
     },
1225
     },
1124
     "node_modules/@types/estree": {
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
       "license": "MIT"
1230
       "license": "MIT"
1130
     },
1231
     },
1131
     "node_modules/@types/jquery": {
1232
     "node_modules/@types/jquery": {
2341
       "version": "2.3.3",
2442
       "version": "2.3.3",
2342
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
2443
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
2343
       "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
2444
       "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
2344
-      "dev": true,
2345
       "hasInstallScript": true,
2445
       "hasInstallScript": true,
2346
       "license": "MIT",
2446
       "license": "MIT",
2347
       "optional": true,
2447
       "optional": true,
3553
       }
3653
       }
3554
     },
3654
     },
3555
     "node_modules/rollup": {
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
       "license": "MIT",
3659
       "license": "MIT",
3561
       "dependencies": {
3660
       "dependencies": {
3562
-        "@types/estree": "1.0.6"
3661
+        "@types/estree": "1.0.8"
3563
       },
3662
       },
3564
       "bin": {
3663
       "bin": {
3565
         "rollup": "dist/bin/rollup"
3664
         "rollup": "dist/bin/rollup"
3569
         "npm": ">=8.0.0"
3668
         "npm": ">=8.0.0"
3570
       },
3669
       },
3571
       "optionalDependencies": {
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
         "fsevents": "~2.3.2"
3696
         "fsevents": "~2.3.2"
3589
       }
3697
       }
3590
     },
3698
     },

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

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

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

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
 let sortOrder ;
1
 let sortOrder ;
2
 let editor = null;
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
 function objectToQueryString(obj) {
30
 function objectToQueryString(obj) {
5
   return Object.keys(obj)
31
   return Object.keys(obj)
6
     .filter(key => obj[key] !== undefined && obj[key] !== null && obj[key] !== '')
32
     .filter(key => obj[key] !== undefined && obj[key] !== null && obj[key] !== '')
1239
                 <small class="text-muted ml-1">${formatTime(alert.alert_source_event_time)}</small></span>
1265
                 <small class="text-muted ml-1">${formatTime(alert.alert_source_event_time)}</small></span>
1240
                 <span title="Alert severity"><b class="ml-3"><i class="fa-solid fa-bolt"></i></b>
1266
                 <span title="Alert severity"><b class="ml-3"><i class="fa-solid fa-bolt"></i></b>
1241
                   <small class="text-muted ml-1" id="alertSeverity-${alert.alert_id}" data-severity-id="${alert.severity.severity_id}">${alert.severity.severity_name}</small></span>
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
                 <span title="Alert source"><b class="ml-3"><i class="fa-solid fa-cloud-arrow-down"></i></b>
1269
                 <span title="Alert source"><b class="ml-3"><i class="fa-solid fa-cloud-arrow-down"></i></b>
1243
                   <small class="text-muted ml-1">${filterXSS(alert.alert_source) || 'Unspecified'}</small></span>
1270
                   <small class="text-muted ml-1">${filterXSS(alert.alert_source) || 'Unspecified'}</small></span>
1244
                 <span title="Alert client"><b class="ml-3"><i class="fa-regular fa-circle-user"></i></b>
1271
                 <span title="Alert client"><b class="ml-3"><i class="fa-regular fa-circle-user"></i></b>
1300
 }
1327
 }
1301
 
1328
 
1302
 async function refreshAlert(alertId, alertData, expanded=false) {
1329
 async function refreshAlert(alertId, alertData, expanded=false) {
1330
+    const kpiFetch = _fetchAlertKpi(alertId);
1303
     if (alertData === undefined) {
1331
     if (alertData === undefined) {
1304
         const alertDataReq = await fetchAlert(alertId);
1332
         const alertDataReq = await fetchAlert(alertId);
1305
         if (api_request_failed(alertDataReq)) {
1333
         if (api_request_failed(alertDataReq)) {
1307
         }
1335
         }
1308
         alertData = alertDataReq.data;
1336
         alertData = alertDataReq.data;
1309
     }
1337
     }
1338
+    const kpi = await kpiFetch;
1339
+    if (kpi) alertData.kpi = kpi;
1310
 
1340
 
1311
       if (modulesOptionsAlertReq === null) {
1341
       if (modulesOptionsAlertReq === null) {
1312
     modulesOptionsAlertReq = await fetchModulesOptionsAlert();
1342
     modulesOptionsAlertReq = await fetchModulesOptionsAlert();
1359
 
1389
 
1360
   const filterString = objectToQueryString(filters);
1390
   const filterString = objectToQueryString(filters);
1361
   const data = await fetchAlerts(page, per_page, filterString, sortOrder).catch((error) => {
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
   if (modulesOptionsAlertReq === null) {
1401
   if (modulesOptionsAlertReq === null) {
1370
     modulesOptionsAlertReq = await fetchModulesOptionsAlert();
1402
     modulesOptionsAlertReq = await fetchModulesOptionsAlert();

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

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
 
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
 /* case table creation */
55
 /* case table creation */
32
 $.each($.find("table"), function(index, element){
56
 $.each($.find("table"), function(index, element){
112
             if (type === 'display') { data = sanitizeHTML(data);}
136
             if (type === 'display') { data = sanitizeHTML(data);}
113
             return data;
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
     dom: '<"container-fluid"<"row"<"col"l><"col"f>>>rt<"container-fluid"<"row"<"col"i><"col"p>>>',
152
     dom: '<"container-fluid"<"row"<"col"l><"col"f>>>rt<"container-fluid"<"row"<"col"i><"col"p>>>',
133
     },
168
     },
134
     initComplete: function () {
169
     initComplete: function () {
135
             tableFiltering(this.api(), 'cases_table');
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
   docker compose \
101
   docker compose \
102
     --project-name iris-web \
102
     --project-name iris-web \
103
     --project-directory "${ROOT_DIR}/iris-web" \
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
     -f "${ROOT_DIR}/compose-overrides/iris.shared-network.yml" \
105
     -f "${ROOT_DIR}/compose-overrides/iris.shared-network.yml" \
106
     "${COMMAND}" ${ARGS[@]+"${ARGS[@]}"}
106
     "${COMMAND}" ${ARGS[@]+"${ARGS[@]}"}
107
 }
107
 }

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

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
         headers = self._headers()
67
         headers = self._headers()
68
         safe_limit = max(1, limit)
68
         safe_limit = max(1, limit)
69
         safe_offset = max(0, offset)
69
         safe_offset = max(0, offset)
70
+        page = (safe_offset // safe_limit) + 1
70
         v2_url = f"{self.base_url}/api/v2/cases"
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
         async with httpx.AsyncClient(verify=False, timeout=20.0) as client:
73
         async with httpx.AsyncClient(verify=False, timeout=20.0) as client:
73
             response = await client.get(v2_url, params=params, headers=headers)
74
             response = await client.get(v2_url, params=params, headers=headers)
74
             active_url = v2_url
75
             active_url = v2_url
112
                 return {"status_code": response.status_code}
113
                 return {"status_code": response.status_code}
113
 
114
 
114
             result = response.json()
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
             if isinstance(result, dict) and isinstance(result.get("data"), list):
119
             if isinstance(result, dict) and isinstance(result.get("data"), list):
116
                 total = result.get("recordsTotal")
120
                 total = result.get("recordsTotal")
117
                 if not isinstance(total, int):
121
                 if not isinstance(total, int):
125
                     "raw": result,
129
                     "raw": result,
126
                 }
130
                 }
127
             return result
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
 
11
 
12
 from psycopg import sql
12
 from psycopg import sql
13
 from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
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
 from fastapi.staticfiles import StaticFiles
17
 from fastapi.staticfiles import StaticFiles
16
 
18
 
17
 from app.adapters.abuseipdb import AbuseIpdbAdapter
19
 from app.adapters.abuseipdb import AbuseIpdbAdapter
29
     CDetectionEvaluateRequest,
31
     CDetectionEvaluateRequest,
30
     IocEnrichRequest,
32
     IocEnrichRequest,
31
     IocEvaluateRequest,
33
     IocEvaluateRequest,
34
+    IrisAlertCreateRequest,
32
     IrisTicketCreateRequest,
35
     IrisTicketCreateRequest,
33
     LogLossCheckRequest,
36
     LogLossCheckRequest,
34
     LogLossStreamCheck,
37
     LogLossStreamCheck,
2299
             "state": getattr(app.state, "c_detection_state", {}),
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
     forever: bool = Field(default=False, description="Run continuously until stopped.", examples=[False])
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
 class ApiResponse(BaseModel):
384
 class ApiResponse(BaseModel):
359
     ok: bool = True
385
     ok: bool = True
360
     message: str = "ok"
386
     message: str = "ok"