Compare commits

..

35 commits

Author SHA1 Message Date
Sami Mokaddem
202f7b7eb6 chg: [build] Rebuild assets 2024-07-09 13:22:06 +02:00
Sami Mokaddem
0c2fdf5591 chg: [front:live-logs_user-activity-graph] Further improved UI 2024-07-09 13:19:27 +02:00
Sami Mokaddem
4d445f3408 chg: [build] Build assets 2024-07-09 13:02:24 +02:00
Sami Mokaddem
3d928b6437 chg: [front:live-logs_user_activity_graph] Improved colors 2024-07-09 13:00:26 +02:00
Sami Mokaddem
58da718c5d new: [app:user_activity] Added user activity chart 2024-07-09 12:19:20 +02:00
Sami Mokaddem
bc78e2f2cb chg: [app] Build frontend files 2024-07-08 15:06:38 +02:00
Sami Mokaddem
f1a0ed3ab1 chg: [front:theme] Better support of theme choice 2024-07-08 14:59:52 +02:00
Sami Mokaddem
e1010793dc chg: [front:live-logs-activity-graph] Split graph into its own component 2024-07-08 13:47:57 +02:00
Sami Mokaddem
58d4af812d chg: [app:live-logs] Improved notification activity chart 2024-07-08 13:17:12 +02:00
Sami Mokaddem
33bc5ca0bb chg: [app:config] Added eventReports as accepted notification scope 2024-07-08 10:25:19 +02:00
Sami Mokaddem
1277dbb132 new: [app:backup] Added backup feature that saves exercise progress every 5sec 2024-07-08 10:21:59 +02:00
Sami Mokaddem
6178592d10 chg: [app:live-logs] Increased refresh frequency of activity bars 2024-07-08 10:06:50 +02:00
Sami Mokaddem
dc1f7b6376 chg: [front:scores] Improved heading of the table 2024-07-08 10:06:33 +02:00
Sami Mokaddem
abca50d615 new: [app:notification_history] Added notification history support 2024-07-04 19:46:05 +02:00
Sami Mokaddem
06f179378f fix: [backend:server] Exception won't stop the server 2024-07-04 15:49:51 +02:00
Sami Mokaddem
a07a0de89e chg: [app] Updated build files 2024-07-04 15:02:22 +02:00
Sami Mokaddem
84ab2665d7 chg: [app:config] Avoid overriding config file and git conflicts 2024-07-04 14:59:19 +02:00
Sami Mokaddem
99f9751e22 new: [app:evaluator_query-search] Added query-search evaluator and made full evaluation chain async 2024-07-04 14:49:26 +02:00
Sami Mokaddem
8774d70759 new: [front:socketConnection] Added last-keep alive message 2024-07-04 11:30:44 +02:00
Sami Mokaddem
b79152a6e5 chg: [front:socketConnectionState] Small UI improvement for disconnected socket 2024-07-04 11:23:35 +02:00
Sami Mokaddem
44d040c70b new: [app:eval_strategy] Added new evaluation strategy query-search 2024-07-04 11:21:10 +02:00
Sami Mokaddem
a21549f587 chg: [front:live-logs] Better support of light theme 2024-07-04 10:18:35 +02:00
Sami Mokaddem
8cca38f527 chg: [front] Improved CSS 2024-07-04 10:10:38 +02:00
Sami Mokaddem
0a89423d31 chg: [front:scores] Small UI tweak 2024-07-04 09:03:55 +02:00
Sami Mokaddem
f0d079ea32 chg: [app] Renamed query-comparison with query-mirror 2024-07-04 08:46:08 +02:00
Sami Mokaddem
9b0cb51643 chg: [app] Renamed project 2024-07-04 08:40:02 +02:00
Sami Mokaddem
34a1242ed9 new: [front:scores] Added task depedency icon 2024-07-04 08:32:28 +02:00
Sami Mokaddem
7231a55356 chg: [front:scores] Added description and medal 2024-07-03 15:44:58 +02:00
Sami Mokaddem
d4f148bb69 chg: [front:live-logs] Renamed users into players 2024-07-03 13:31:25 +02:00
Sami Mokaddem
e16fc0c7cd new: [app] Added keepalive messages 2024-07-03 13:30:12 +02:00
Sami Mokaddem
34869497a1 new: [app:live_logs] Added filters for API queries only 2024-07-03 12:43:20 +02:00
Sami Mokaddem
fb32b59abe chg: [app:scores] Added support of first_completion 2024-07-03 12:37:23 +02:00
Sami Mokaddem
6814294e77 chg: [app:scores] Added completion timestamp 2024-07-03 12:28:47 +02:00
Sami Mokaddem
96d5b6d89e chg: [backend] Make everything async relying on asyncio 2024-07-03 11:51:44 +02:00
Sami Mokaddem
f191af8573 chg: [backend] Usage of aiohttp in place of eventlet 2024-07-02 16:48:55 +02:00
33 changed files with 2391 additions and 1042 deletions

2
.gitignore vendored
View file

@ -1,6 +1,8 @@
__pycache__
venv
config.py
misp_cache.sqlite
backup.json
# Logs
logs

View file

@ -8,12 +8,15 @@ source venv/bin/activate
# Install deps
pip3 install -r REQUIREMENTS
# Create config file and adapt it to your needs
cp config.py.sample config.py
```
## Running the PROD setup
```bash
python3 server.py
# Access the page http://localhost:3000 with your browser
# Access the page http://localhost:4000 with your browser
```

View file

@ -1,6 +1,6 @@
pyzmq
python-socketio
eventlet
aiohttp
requests
requests-cache
jq

View file

@ -10,6 +10,7 @@ misp_skipssl = True
live_logs_accepted_scope = {
'events': ['add', 'edit', 'delete', 'restSearch',],
'attributes': ['add', 'edit', 'delete', 'restSearch',],
'eventReports': ['add', 'edit', 'delete',],
'tags': '*',
}
@ -18,7 +19,6 @@ logger = logging.getLogger('misp-exercise-dashboard')
format = '[%(levelname)s] %(asctime)s - %(message)s'
formatter = logging.Formatter(format)
logging.basicConfig(filename='misp-exercise-dashboard.log', encoding='utf-8', level=logging.DEBUG, format=format)
# create console handler and set level to debug
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
ch.setFormatter(formatter)

36
db.py
View file

@ -5,17 +5,49 @@ import collections
USER_ID_TO_EMAIL_MAPPING = {}
USER_ID_TO_AUTHKEY_MAPPING = {}
ALL_EXERCISES = []
SELECTED_EXERCISES = []
INJECT_BY_UUID = {}
INJECT_SEQUENCE_BY_INJECT_UUID = {}
INJECT_REQUIREMENTS_BY_INJECT_UUID = {}
EXERCISES_STATUS = {}
PROGRESS = {
}
NOTIFICATION_BUFFER_SIZE = 30
NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE)
NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN = 12
NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN = 20
NOTIFICATION_HISTORY_FREQUENCY = 60 / NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN
notification_history_buffer_size = NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN * NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN
NOTIFICATION_HISTORY = collections.deque([], notification_history_buffer_size)
NOTIFICATION_HISTORY.extend([0] * notification_history_buffer_size)
USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN = 2
USER_ACTIVITY_TIMESPAN_MIN = 20
USER_ACTIVITY_FREQUENCY = 60 / USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN
USER_ACTIVITY = {}
user_activity_buffer_size = USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN * USER_ACTIVITY_TIMESPAN_MIN
def resetNotificationMessage():
global NOTIFICATION_MESSAGES
NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE)
def resetNotificationHistory():
global NOTIFICATION_HISTORY
NOTIFICATION_HISTORY = collections.deque([], notification_history_buffer_size)
NOTIFICATION_HISTORY.extend([0] * notification_history_buffer_size)
def addUserActivity(user_id: int, count: int):
global USER_ACTIVITY, USER_ACTIVITY_TIMESPAN_MIN
if user_id not in USER_ACTIVITY:
USER_ACTIVITY[user_id] = collections.deque([], user_activity_buffer_size)
USER_ACTIVITY[user_id].extend([0] * user_activity_buffer_size)
USER_ACTIVITY[user_id].append(count)
def resetUserActivity():
for user_id in USER_ACTIVITY.keys():
USER_ACTIVITY[user_id] = collections.deque([], user_activity_buffer_size)
USER_ACTIVITY[user_id].extend([0] * user_activity_buffer_size)

1480
dist/assets/index-BbC5Fp4k.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-XAPeN3Gs.css vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -5,8 +5,8 @@
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<script type="module" crossorigin src="/assets/index-Bk6s3GdT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DlglK08D.css">
<script type="module" crossorigin src="/assets/index-BbC5Fp4k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-XAPeN3Gs.css">
</head>
<body>
<div id="app"></div>

View file

@ -2,7 +2,6 @@
import functools
import time
from collections import defaultdict
from pathlib import Path
import json
import re
@ -10,7 +9,7 @@ from typing import Union
import jq
import db
from inject_evaluator import eval_data_filtering, eval_query_comparison
from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search
import misp_api
import config
from config import logger
@ -61,6 +60,29 @@ def read_exercise_dir():
return exercises
def backup_exercises_progress():
with open('backup.json', 'w') as f:
toBackup = {
'EXERCISES_STATUS': db.EXERCISES_STATUS,
'SELECTED_EXERCISES': db.SELECTED_EXERCISES,
'USER_ID_TO_EMAIL_MAPPING': db.USER_ID_TO_EMAIL_MAPPING,
'USER_ID_TO_AUTHKEY_MAPPING': db.USER_ID_TO_AUTHKEY_MAPPING,
}
json.dump(toBackup, f)
def restore_exercices_progress():
try:
with open('backup.json', 'r') as f:
data = json.load(f)
db.EXERCISES_STATUS = data['EXERCISES_STATUS']
db.SELECTED_EXERCISES = data['SELECTED_EXERCISES']
db.USER_ID_TO_EMAIL_MAPPING = data['USER_ID_TO_EMAIL_MAPPING']
db.USER_ID_TO_AUTHKEY_MAPPING = data['USER_ID_TO_AUTHKEY_MAPPING']
except:
logger.info('Could not restore exercise progress')
def is_validate_exercises(exercises: list) -> bool:
exercises_uuid = set()
tasks_uuid = set()
@ -140,11 +162,14 @@ def get_exercises():
tasks = []
for inject in exercise['injects']:
score = db.EXERCISES_STATUS[exercise['exercise']['uuid']]['tasks'][inject['uuid']]['score']
requirements = db.INJECT_REQUIREMENTS_BY_INJECT_UUID[inject['uuid']]
tasks.append(
{
"name": inject['name'],
"uuid": inject['uuid'],
"description": inject.get('description', ''),
"score": score,
"requirements": requirements,
}
)
exercises.append(
@ -179,6 +204,7 @@ def resetAllExerciseProgress():
for exercise_status in db.EXERCISES_STATUS.values():
for task in exercise_status['tasks'].values():
mark_task_incomplete(user_id, exercise_status['uuid'], task['uuid'])
backup_exercises_progress()
def get_completed_tasks_for_user(user_id: int):
@ -247,8 +273,9 @@ def get_completion_for_users():
for task in exercise_status['tasks'].values():
for user_id in completion_per_user.keys():
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
for user_id in task['completed_by_user']:
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = True
for entry in task['completed_by_user']:
user_id = entry['user_id']
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
return completion_per_user
@ -268,13 +295,27 @@ def get_score_for_task_completion(tasks_completion: dict) -> int:
def mark_task_completed(user_id: int, exercise_uuid: str , task_uuid: str):
if user_id not in db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']:
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'].append(user_id)
is_completed = any(filter(lambda x: x['user_id'] == user_id, db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']))
if not is_completed:
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'].append({
'user_id': user_id,
'timestamp': time.time(),
'first_completion': False,
})
# Update who was the first to complete the task
first_completion_index = None
first_completion_time = time.time()
for i, entry in enumerate(db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']):
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'][i]['first_completion'] = False
if entry['timestamp'] < first_completion_time:
first_completion_time = entry['timestamp']
first_completion_index = i
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'][first_completion_index]['first_completion'] = True
def mark_task_incomplete(user_id: int, exercise_uuid: str , task_uuid: str):
if user_id in db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']:
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'].remove(user_id)
completed_without_user = list(filter(lambda x: x['user_id'] != user_id, db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']))
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'] = completed_without_user
def get_progress():
@ -294,9 +335,9 @@ def get_progress():
return progress
def check_inject(user_id: int, inject: dict, data: dict, context: dict) -> bool:
async def check_inject(user_id: int, inject: dict, data: dict, context: dict) -> bool:
for inject_evaluation in inject['inject_evaluation']:
success = inject_checker_router(user_id, inject_evaluation, data, context)
success = await inject_checker_router(user_id, inject_evaluation, data, context)
if not success:
logger.info(f"Task not completed: {inject['uuid']}")
return False
@ -320,35 +361,39 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
return False
return False
def inject_checker_router(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
async def inject_checker_router(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
if not is_valid_evaluation_context(user_id, inject_evaluation, data, context):
return False
if 'evaluation_strategy' not in inject_evaluation:
return False
data_to_validate = get_data_to_validate(user_id, inject_evaluation, data)
data_to_validate = await get_data_to_validate(user_id, inject_evaluation, data)
if data_to_validate is None:
logger.debug('Could not fetch data to validate')
return False
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
return eval_data_filtering(user_id, inject_evaluation, data_to_validate)
elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
return eval_data_filtering(user_id, inject_evaluation, data_to_validate, context)
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
expected_data = data_to_validate['expected_data']
data_to_validate = data_to_validate['data_to_validate']
return eval_query_comparison(user_id, expected_data, data_to_validate)
return eval_query_mirror(user_id, expected_data, data_to_validate, context)
elif inject_evaluation['evaluation_strategy'] == 'query_search':
return eval_query_search(user_id, inject_evaluation, data_to_validate, context)
return False
def get_data_to_validate(user_id: int, inject_evaluation: dict, data: dict) -> Union[dict, list, str, None]:
async def get_data_to_validate(user_id: int, inject_evaluation: dict, data: dict) -> Union[dict, list, str, None]:
data_to_validate = None
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
event_id = parse_event_id_from_log(data)
data_to_validate = fetch_data_for_data_filtering(event_id=event_id)
elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
data_to_validate = await fetch_data_for_data_filtering(event_id=event_id)
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
perfomed_query = parse_performed_query_from_log(data)
data_to_validate = fetch_data_for_query_comparison(user_id, inject_evaluation, perfomed_query)
data_to_validate = await fetch_data_for_query_mirror(user_id, inject_evaluation, perfomed_query)
elif inject_evaluation['evaluation_strategy'] == 'query_search':
data_to_validate = await fetch_data_for_query_search(user_id, inject_evaluation)
return data_to_validate
@ -394,14 +439,14 @@ def parse_performed_query_from_log(data: dict) -> Union[dict, None]:
return None
def fetch_data_for_data_filtering(event_id=None) -> Union[None, dict]:
async def fetch_data_for_data_filtering(event_id=None) -> Union[None, dict]:
data = None
if event_id is not None:
data = misp_api.getEvent(event_id)
data = await misp_api.getEvent(event_id)
return data
def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfomed_query: dict) -> Union[None, dict]:
async def fetch_data_for_query_mirror(user_id: int, inject_evaluation: dict, perfomed_query: dict) -> Union[None, dict]:
data = None
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
if perfomed_query is not None:
@ -411,8 +456,8 @@ def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfo
expected_method = query_context['request_method']
expected_url = query_context['url']
expected_payload = inject_evaluation['parameters'][0]
expected_data = misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload)
data_to_validate = misp_api.doRestQuery(authkey, perfomed_query['request_method'], perfomed_query['url'], perfomed_query['payload'])
expected_data = await misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload)
data_to_validate = await misp_api.doRestQuery(authkey, perfomed_query['request_method'], perfomed_query['url'], perfomed_query['payload'])
data = {
'expected_data' : expected_data,
'data_to_validate' : data_to_validate,
@ -420,8 +465,20 @@ def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfo
return data
async def fetch_data_for_query_search(user_id: int, inject_evaluation: dict) -> Union[None, dict]:
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
if 'evaluation_context' not in inject_evaluation and 'query_context' not in inject_evaluation['evaluation_context']:
return None
query_context = inject_evaluation['evaluation_context']['query_context']
search_method = query_context['request_method']
search_url = query_context['url']
search_payload = query_context['payload']
search_data = await misp_api.doRestQuery(authkey, search_method, search_url, search_payload)
return search_data
@debounce_check_active_tasks(debounce_seconds=2)
def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
async def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
succeeded_once = False
available_tasks = get_available_tasks_for_user(user_id)
for task_uuid in available_tasks:
@ -429,7 +486,7 @@ def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
if inject['exercise_uuid'] not in db.SELECTED_EXERCISES:
continue
logger.debug(f"[{task_uuid}] :: checking: {inject['name']}")
completed = check_inject(user_id, inject, data, context)
completed = await check_inject(user_id, inject, data, context)
if completed:
succeeded_once = True
return succeeded_once

View file

@ -138,7 +138,15 @@
{
"parameters": [
{
".Event.info": {
".response[].Event.event_creator_email": {
"comparison": "equals",
"values": [
"{{user_email}}"
]
}
},
{
".response[].Event.info": {
"comparison": "contains",
"values": [
"event",
@ -148,9 +156,17 @@
}
],
"result": "MISP Event created",
"evaluation_strategy": "data_filtering",
"evaluation_strategy": "query_search",
"evaluation_context": {
"request_is_rest": true
"request_is_rest": true,
"query_context": {
"url": "/events/restSearch",
"request_method": "POST",
"payload": {
"timestamp": "10d",
"eventinfo": "%API%"
}
}
},
"score_range": [
0,

View file

@ -119,7 +119,7 @@
}
],
"result": "Published 48h retreived",
"evaluation_strategy": "query_comparison",
"evaluation_strategy": "query_mirror",
"evaluation_context": {
"request_is_rest": true,
"query_context": {
@ -134,7 +134,7 @@
}
],
"name": "Get Published in the past 48h",
"target_tool": "MISP-query",
"target_tool": "MISP",
"uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4"
},
{
@ -150,7 +150,7 @@
}
],
"result": "IP CSV retrieved",
"evaluation_strategy": "query_comparison",
"evaluation_strategy": "query_mirror",
"evaluation_context": {
"request_is_rest": true,
"query_context": {
@ -165,7 +165,7 @@
}
],
"name": "IP IoCs changed in the past 48h in CSV",
"target_tool": "MISP-query",
"target_tool": "MISP",
"uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba"
},
{
@ -180,7 +180,7 @@
}
],
"result": "20 Attribute tagged retrieved",
"evaluation_strategy": "query_comparison",
"evaluation_strategy": "query_mirror",
"evaluation_context": {
"request_is_rest": true,
"query_context": {
@ -195,7 +195,7 @@
}
],
"name": "First 20 Attribute with TLP lower than `amber`",
"target_tool": "MISP-query",
"target_tool": "MISP",
"uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88"
},
{
@ -209,7 +209,7 @@
}
],
"result": "Phising counted",
"evaluation_strategy": "query_comparison",
"evaluation_strategy": "query_mirror",
"evaluation_context": {
"request_is_rest": true,
"query_context": {
@ -224,7 +224,7 @@
}
],
"name": "Event count with `Phishing - T1566` involved",
"target_tool": "MISP-query",
"target_tool": "MISP",
"uuid": "1da0fdc8-9d0d-4618-a811-66491e196833"
}
]

View file

@ -219,6 +219,7 @@
}
],
"name": "Event Creation",
"description": "Create an Event containing `ransomware`",
"target_tool": "MISP",
"uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
},

View file

@ -6,7 +6,6 @@ import operator
from config import logger
# .Event.Attribute[] | select(.value == "evil.exe") | .Tag
def jq_extract(path: str, data: dict, extract_type='first'):
query = jq.compile(path).input_value(data)
try:
@ -15,28 +14,42 @@ def jq_extract(path: str, data: dict, extract_type='first'):
return None
# Replace the substring `{{variable}}` by context[variable] in the provided string
def apply_replacement_from_context(string: str, context: dict) -> str:
replacement_regex = r"{{(\w+)}}"
if r'{{' not in string and r'}}' not in string:
return string
matches = re.fullmatch(replacement_regex, string, re.MULTILINE)
if not matches:
return string
subst_str = matches.groups()[0]
subst = str(context.get(subst_str, ''))
return re.sub(replacement_regex, subst, string)
##
## Data Filtering
##
def condition_satisfied(evaluation_config: dict, data_to_validate: Union[dict, list, str]) -> bool:
def condition_satisfied(evaluation_config: dict, data_to_validate: Union[dict, list, str], context: dict) -> bool:
if type(data_to_validate) is bool:
data_to_validate = "1" if data_to_validate else "0"
if type(data_to_validate) is str:
return eval_condition_str(evaluation_config, data_to_validate)
return eval_condition_str(evaluation_config, data_to_validate, context)
elif type(data_to_validate) is list:
return eval_condition_list(evaluation_config, data_to_validate)
return eval_condition_list(evaluation_config, data_to_validate, context)
elif type(data_to_validate) is dict:
# Not sure how we could have condition on this
return eval_condition_dict(evaluation_config, data_to_validate)
return eval_condition_dict(evaluation_config, data_to_validate, context)
return False
def eval_condition_str(evaluation_config: dict, data_to_validate: str) -> bool:
def eval_condition_str(evaluation_config: dict, data_to_validate: str, context: dict) -> bool:
comparison_type = evaluation_config['comparison']
values = evaluation_config['values']
if len(values) == 0:
return False
values = [apply_replacement_from_context(v, context) for v in values]
if comparison_type == 'contains':
values = [v.lower() for v in values]
@ -56,7 +69,7 @@ def eval_condition_str(evaluation_config: dict, data_to_validate: str) -> bool:
return False
def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
def eval_condition_list(evaluation_config: dict, data_to_validate: str, context: dict) -> bool:
comparison_type = evaluation_config['comparison']
values = evaluation_config['values']
comparators = {
@ -69,7 +82,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
if len(values) == 0:
return False
values = [apply_replacement_from_context(v, context) for v in values]
if comparison_type == 'contains' or comparison_type == 'equals':
data_to_validate_set = set(data_to_validate)
@ -102,7 +115,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
return False
def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
def eval_condition_dict(evaluation_config: dict, data_to_validate: str, context: dict) -> bool:
comparison_type = evaluation_config['comparison']
values = evaluation_config['values']
comparators = {
@ -113,6 +126,10 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
'=': operator.eq,
}
if len(values) == 0:
return False
values = [apply_replacement_from_context(v, context) for v in values]
comparison_type = evaluation_config['comparison']
if comparison_type == 'contains':
pass
@ -129,21 +146,31 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
return False
def eval_data_filtering(user_id: int, inject_evaluation: dict, data: dict) -> bool:
def eval_data_filtering(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
for evaluation_params in inject_evaluation['parameters']:
for evaluation_path, evaluation_config in evaluation_params.items():
evaluation_path = apply_replacement_from_context(evaluation_path, context)
data_to_validate = jq_extract(evaluation_path, data, evaluation_config.get('extract_type', 'first'))
if data_to_validate is None:
logger.debug('Could not extract data')
return False
if not condition_satisfied(evaluation_config, data_to_validate):
if not condition_satisfied(evaluation_config, data_to_validate, context):
return False
return True
##
## Query comparison
## Query mirror
##
def eval_query_comparison(user_id: int, expected_data, data_to_validate) -> bool:
def eval_query_mirror(user_id: int, expected_data, data_to_validate, context: dict) -> bool:
return expected_data == data_to_validate
##
## Query search
##
def eval_query_search(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
return eval_data_filtering(user_id, inject_evaluation, data, context)

View file

@ -4,6 +4,7 @@ import json
from datetime import timedelta
from typing import Union
from urllib.parse import urljoin
import asyncio
import requests # type: ignore
import requests.adapters # type: ignore
from requests_cache import CachedSession
@ -18,7 +19,7 @@ requestSession.mount('https://', adapterCache)
requestSession.mount('http://', adapterCache)
def get(url, data={}, api_key=misp_apikey):
async def get(url, data={}, api_key=misp_apikey):
headers = {
'User-Agent': 'misp-exercise-dashboard',
"Authorization": api_key,
@ -27,17 +28,22 @@ def get(url, data={}, api_key=misp_apikey):
}
full_url = urljoin(misp_url, url)
try:
response = requestSession.get(full_url, data=data, headers=headers, verify=not misp_skipssl)
loop = asyncio.get_event_loop()
job = lambda: requestSession.get(full_url, data=data, headers=headers, verify=not misp_skipssl)
runningJob = loop.run_in_executor(None, job)
response = await runningJob
except requests.exceptions.ConnectionError as e:
logger.info('Could not perform request on MISP. %s', e)
return None
except Exception as e:
logger.warning('Could not perform request on MISP. %s', e)
try:
return response.json() if response.headers['content-type'].startswith('application/json') else response.text
except requests.exceptions.JSONDecodeError:
return response.text
def post(url, data={}, api_key=misp_apikey):
async def post(url, data={}, api_key=misp_apikey):
headers = {
'User-Agent': 'misp-exercise-dashboard',
"Authorization": api_key,
@ -46,32 +52,37 @@ def post(url, data={}, api_key=misp_apikey):
}
full_url = urljoin(misp_url, url)
try:
response = requestSession.post(full_url, data=json.dumps(data), headers=headers, verify=not misp_skipssl)
loop = asyncio.get_event_loop()
job = lambda: requestSession.post(full_url, data=json.dumps(data), headers=headers, verify=not misp_skipssl)
runningJob = loop.run_in_executor(None, job)
response = await runningJob
except requests.exceptions.ConnectionError as e:
logger.info('Could not perform request on MISP. %s', e)
return None
except Exception as e:
logger.warning('Could not perform request on MISP. %s', e)
try:
return response.json() if response.headers['content-type'].startswith('application/json') else response.text
except requests.exceptions.JSONDecodeError:
return response.text
def getEvent(event_id: int) -> Union[None, dict]:
return get(f'/events/view/{event_id}')
async def getEvent(event_id: int) -> Union[None, dict]:
return await get(f'/events/view/{event_id}')
def doRestQuery(authkey: str, request_method: str, url: str, payload: dict = {}) -> Union[None, dict]:
async def doRestQuery(authkey: str, request_method: str, url: str, payload: dict = {}) -> Union[None, dict]:
if request_method == 'POST':
return post(url, payload, api_key=authkey)
return await post(url, payload, api_key=authkey)
else:
return get(url, payload, api_key=authkey)
return await get(url, payload, api_key=authkey)
def getVersion() -> Union[None, dict]:
return get(f'/servers/getVersion.json')
async def getVersion() -> Union[None, dict]:
return await get(f'/servers/getVersion.json')
def getSettings() -> Union[None, dict]:
async def getSettings() -> Union[None, dict]:
SETTING_TO_QUERY = [
'Plugin.ZeroMQ_enable',
'Plugin.ZeroMQ_audit_notifications_enable',
@ -83,7 +94,7 @@ def getSettings() -> Union[None, dict]:
'MISP.log_auth',
'Security.allow_unsafe_cleartext_apikey_logging',
]
settings = get(f'/servers/serverSettings.json')
settings = await get(f'/servers/serverSettings.json')
if not settings:
return None
return {

View file

@ -9,6 +9,7 @@ from urllib.parse import parse_qs
VERBOSE_MODE = False
APIQUERY_MODE = False
NOTIFICATION_COUNT = 1
@ -17,10 +18,39 @@ def set_verbose_mode(enabled: bool):
VERBOSE_MODE = enabled
def set_apiquery_mode(enabled: bool):
global APIQUERY_MODE
APIQUERY_MODE = enabled
def get_notifications() -> list[dict]:
return list(db.NOTIFICATION_MESSAGES)
def get_notifications_history() -> dict:
return {
'history': list(db.NOTIFICATION_HISTORY),
'config': {
'buffer_resolution_per_minute': db.NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN,
'buffer_timestamp_min': db.NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN,
'frequency': db.NOTIFICATION_HISTORY_FREQUENCY,
'notification_history_size': db.notification_history_buffer_size,
},
}
def get_users_activity() -> dict:
return {
'activity': {user_id: list(activity) for user_id, activity in db.USER_ACTIVITY.items()},
'config': {
'timestamp_min': db.USER_ACTIVITY_TIMESPAN_MIN,
'buffer_resolution_per_minute': db.USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN,
'frequency': db.USER_ACTIVITY_FREQUENCY,
'activity_buffer_size': db.user_activity_buffer_size,
},
}
def reset_notifications():
db.resetNotificationMessage()
@ -29,6 +59,14 @@ def record_notification(notification: dict):
db.NOTIFICATION_MESSAGES.appendleft(notification)
def record_notification_history(message_count: int):
db.NOTIFICATION_HISTORY.append(message_count)
def record_user_activity(user_id: int, count: int):
db.addUserActivity(user_id, count)
def get_user_id(data: dict):
if 'user_id' in data:
return int(data['user_id'])
@ -148,6 +186,8 @@ def is_accepted_notification(notification) -> bool:
return False
if VERBOSE_MODE:
return True
if APIQUERY_MODE and not notification['is_api_request']:
return False
if '@' not in notification['user']: # Ignore message from system
return False

126
package-lock.json generated
View file

@ -13,8 +13,10 @@
"@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"apexcharts": "^3.49.2",
"lodash.debounce": "^4.0.8",
"vue": "^3.4.29"
"vue": "^3.4.29",
"vue3-apexcharts": "^1.5.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
@ -1077,6 +1079,12 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz",
"integrity": "sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA=="
},
"node_modules/@yr/monotone-cubic-spline": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
@ -1157,6 +1165,21 @@
"node": ">= 8"
}
},
"node_modules/apexcharts": {
"version": "3.49.2",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.49.2.tgz",
"integrity": "sha512-vBB8KgwfD9rSObA7s4kY2rU6DeaN67gTR3JN7r32ztgKVf8lKkdFQ6iUhk6oIHrV7W8PoHhr5EwKymn0z5Fz6A==",
"license": "MIT",
"dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3",
"svg.draggable.js": "^2.2.2",
"svg.easing.js": "^2.0.0",
"svg.filter.js": "^2.0.2",
"svg.pathmorphing.js": "^0.1.3",
"svg.resize.js": "^1.4.3",
"svg.select.js": "^3.0.1"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -3292,6 +3315,97 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg.draggable.js": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
"integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.easing.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz",
"integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==",
"license": "MIT",
"dependencies": {
"svg.js": ">=2.3.x"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.filter.js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz",
"integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.js": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz",
"integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==",
"license": "MIT"
},
"node_modules/svg.pathmorphing.js": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz",
"integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.resize.js": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz",
"integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.6.5",
"svg.select.js": "^2.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.resize.js/node_modules/svg.select.js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.select.js": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
"integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.6.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/synckit": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
@ -3564,6 +3678,16 @@
"eslint": ">=6.0.0"
}
},
"node_modules/vue3-apexcharts": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.5.3.tgz",
"integrity": "sha512-yaHTPoj0iVKAtEVg8wEwIwwvf0VG+lPYNufCf3txRzYQOqdKPoZaZ9P3Dj3X+2A1XY9O1kcTk9HVqvLo+rppvQ==",
"license": "MIT",
"peerDependencies": {
"apexcharts": "> 3.0.0",
"vue": "> 3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -16,8 +16,10 @@
"@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"apexcharts": "^3.49.2",
"lodash.debounce": "^4.0.8",
"vue": "^3.4.29"
"vue": "^3.4.29",
"vue3-apexcharts": "^1.5.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",

171
server.py
View file

@ -1,13 +1,14 @@
#!/usr/bin/env python3
import collections
import functools
import json
import sys
import time
import zmq
import socketio
import eventlet
from eventlet.green import zmq as gzmq
from aiohttp import web
import zmq.asyncio
import exercise as exercise_model
import notification as notification_model
@ -17,7 +18,10 @@ from config import logger
import misp_api
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
ZMQ_MESSAGE_COUNT = 0
ZMQ_LAST_TIME = None
USER_ACTIVITY = collections.defaultdict(int)
def debounce(debounce_seconds: int = 1):
@ -41,77 +45,91 @@ def debounce(debounce_seconds: int = 1):
# Initialize ZeroMQ context and subscriber socket
context = gzmq.Context()
zsocket = context.socket(gzmq.SUB)
context = zmq.asyncio.Context()
zsocket = context.socket(zmq.SUB)
zmq_url = config.zmq_url
zsocket.connect(zmq_url)
zsocket.setsockopt_string(gzmq.SUBSCRIBE, '')
zsocket.setsockopt_string(zmq.SUBSCRIBE, '')
# Initialize Socket.IO server
sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet')
app = socketio.WSGIApp(sio, static_files={
'/': {'content_type': 'text/html', 'filename': 'dist/index.html'},
'/assets': './dist/assets',
})
sio = socketio.AsyncServer(cors_allowed_origins='*', async_mode='aiohttp')
app = web.Application()
sio.attach(app)
async def index(request):
with open('dist/index.html') as f:
return web.Response(text=f.read(), content_type='text/html')
@sio.event
def connect(sid, environ):
async def connect(sid, environ):
logger.debug("Client connected: %s", sid)
@sio.event
def disconnect(sid):
async def disconnect(sid):
logger.debug("Client disconnected: %s", sid)
@sio.event
def get_exercises(sid):
async def get_exercises(sid):
return exercise_model.get_exercises()
@sio.event
def get_selected_exercises(sid):
async def get_selected_exercises(sid):
return exercise_model.get_selected_exercises()
@sio.event
def change_exercise_selection(sid, payload):
async def change_exercise_selection(sid, payload):
return exercise_model.change_exercise_selection(payload['exercise_uuid'], payload['selected'])
@sio.event
def get_progress(sid):
async def get_progress(sid):
return exercise_model.get_progress()
@sio.event
def get_notifications(sid):
async def get_notifications(sid):
return notification_model.get_notifications()
@sio.event
def mark_task_completed(sid, payload):
async def mark_task_completed(sid, payload):
return exercise_model.mark_task_completed(int(payload['user_id']), payload['exercise_uuid'], payload['task_uuid'])
@sio.event
def mark_task_incomplete(sid, payload):
async def mark_task_incomplete(sid, payload):
return exercise_model.mark_task_incomplete(int(payload['user_id']), payload['exercise_uuid'], payload['task_uuid'])
@sio.event
def reset_all_exercise_progress(sid):
async def reset_all_exercise_progress(sid):
return exercise_model.resetAllExerciseProgress()
@sio.event
def reset_notifications(sid):
async def reset_notifications(sid):
return notification_model.reset_notifications()
@sio.event
def get_diagnostic(sid):
return getDiagnostic()
async def get_diagnostic(sid):
return await getDiagnostic()
@sio.event
def toggle_verbose_mode(sid, payload):
async def get_users_activity(sid):
return notification_model.get_users_activity()
@sio.event
async def toggle_verbose_mode(sid, payload):
return notification_model.set_verbose_mode(payload['verbose'])
@sio.event
async def toggle_apiquery_mode(sid, payload):
return notification_model.set_apiquery_mode(payload['apiquery'])
@sio.on('*')
def any_event(event, sid, data={}):
async def any_event(event, sid, data={}):
logger.info('>> Unhandled event %s', event)
def handleMessage(topic, s, message):
async def handleMessage(topic, s, message):
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
data = json.loads(message)
if topic == 'misp_json_audit':
@ -119,7 +137,7 @@ def handleMessage(topic, s, message):
if user_id is not None and '@' in email:
if user_id not in db.USER_ID_TO_EMAIL_MAPPING:
db.USER_ID_TO_EMAIL_MAPPING[user_id] = email
sio.emit('new_user', email)
await sio.emit('new_user', email)
user_id, authkey = notification_model.get_user_authkey_id_pair(data)
if user_id is not None:
@ -131,24 +149,33 @@ def handleMessage(topic, s, message):
notification = notification_model.get_notification_message(data)
if notification_model.is_accepted_notification(notification):
notification_model.record_notification(notification)
sio.emit('notification', notification)
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN += 1
user_id = notification_model.get_user_id(data)
if user_id is not None:
USER_ACTIVITY[user_id] += 1
await sio.emit('notification', notification)
user_id = notification_model.get_user_id(data)
if user_id is not None:
if exercise_model.is_accepted_query(data):
context = get_context(data)
succeeded_once = exercise_model.check_active_tasks(user_id, data, context)
context = get_context(topic, user_id, data)
succeeded_once = await exercise_model.check_active_tasks(user_id, data, context)
if succeeded_once:
sendRefreshScore()
await sendRefreshScore()
@debounce(debounce_seconds=1)
def sendRefreshScore():
sio.emit('refresh_score')
async def sendRefreshScore():
await sio.emit('refresh_score')
def get_context(data: dict) -> dict:
context = {}
def get_context(topic: str, user_id: int, data: dict) -> dict:
context = {
'zmq_topic': topic,
'user_id': user_id,
'user_email': db.USER_ID_TO_EMAIL_MAPPING.get(user_id, None),
'user_authkey': db.USER_ID_TO_AUTHKEY_MAPPING.get(user_id, None),
}
if 'Log' in data:
if 'request_is_rest' in data['Log']:
context['request_is_rest'] = data['Log']['request_is_rest']
@ -158,35 +185,87 @@ def get_context(data: dict) -> dict:
return context
def getDiagnostic() -> dict:
async def getDiagnostic() -> dict:
global ZMQ_MESSAGE_COUNT
diagnostic = {}
misp_version = misp_api.getVersion()
misp_version = await misp_api.getVersion()
if misp_version is None:
diagnostic['online'] = False
return diagnostic
diagnostic['version'] = misp_version
misp_settings = misp_api.getSettings()
misp_settings = await misp_api.getSettings()
diagnostic['settings'] = misp_settings
diagnostic['zmq_message_count'] = ZMQ_MESSAGE_COUNT
return diagnostic
# Function to forward zmq messages to Socket.IO
def forward_zmq_to_socketio():
global ZMQ_MESSAGE_COUNT
async def notification_history():
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
while True:
await sio.sleep(db.NOTIFICATION_HISTORY_FREQUENCY)
notification_model.record_notification_history(ZMQ_MESSAGE_COUNT_LAST_TIMESPAN)
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
payload = notification_model.get_notifications_history()
await sio.emit('update_notification_history', payload)
async def record_users_activity():
global USER_ACTIVITY
while True:
message = zsocket.recv_string()
await sio.sleep(db.USER_ACTIVITY_FREQUENCY)
for user_id, activity in USER_ACTIVITY.items():
notification_model.record_user_activity(user_id, activity)
USER_ACTIVITY[user_id] = 0
payload = notification_model.get_users_activity()
await sio.emit('update_users_activity', payload)
async def keepalive():
global ZMQ_LAST_TIME
while True:
await sio.sleep(5)
payload = {
'zmq_last_time': ZMQ_LAST_TIME,
}
await sio.emit('keep_alive', payload)
async def backup_exercises_progress():
while True:
await sio.sleep(5)
exercise_model.backup_exercises_progress()
# Function to forward zmq messages to Socket.IO
async def forward_zmq_to_socketio():
global ZMQ_MESSAGE_COUNT, ZMQ_LAST_TIME
while True:
message = await zsocket.recv_string()
topic, s, m = message.partition(" ")
await handleMessage(topic, s, m)
try:
ZMQ_MESSAGE_COUNT += 1
handleMessage(topic, s, m)
ZMQ_LAST_TIME = time.time()
# await handleMessage(topic, s, m)
except Exception as e:
logger.error('Error handling message %s', e)
async def init_app():
sio.start_background_task(forward_zmq_to_socketio)
sio.start_background_task(keepalive)
sio.start_background_task(notification_history)
sio.start_background_task(record_users_activity)
sio.start_background_task(backup_exercises_progress)
return app
app.router.add_static('/assets', 'dist/assets')
app.router.add_get('/', index)
if __name__ == "__main__":
exercises_loaded = exercise_model.load_exercises()
@ -194,8 +273,6 @@ if __name__ == "__main__":
logger.critical('Could not load exercises')
sys.exit(1)
# Start the forwarding in a separate thread
eventlet.spawn_n(forward_zmq_to_socketio)
exercise_model.restore_exercices_progress()
# Run the Socket.IO server
eventlet.wsgi.server(eventlet.listen((config.server_host, config.server_port)), app)
web.run_app(init_app(), host=config.server_host, port=config.server_port)

View file

@ -5,18 +5,20 @@ import TheAdminPanel from './components/TheAdminPanel.vue'
import TheSocketConnectionState from './components/TheSocketConnectionState.vue'
import TheDahboard from './TheDahboard.vue'
import { socketConnected } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
onMounted(() => {
document.getElementsByTagName('body')[0].classList.add('dark')
document.getElementById('app').classList.add('w-5/6')
if (darkModeEnabled.value) {
document.getElementsByTagName('body')[0].classList.add('dark')
}
})
</script>
<template>
<main>
<h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">MISP Exercise Dashboard</h1>
<h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">Exercise Dashboard</h1>
<div class="absolute top-1 right-1">
<div class="flex gap-2">
<TheThemeButton></TheThemeButton>
@ -37,4 +39,12 @@ body {
@apply dark:text-slate-300;
}
#app {
@apply 3xl:container mx-auto;
@apply mx-auto;
@apply mt-4;
@apply 3xl:w-11/12;
@apply lg:w-5/6;
}
</style>

View file

@ -1,86 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -1,20 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
/*
@media (min-width: 1024px) {
body {
display: flex;
}
#app {
display: flex;
}
} */

View file

@ -0,0 +1,129 @@
<script setup>
import { ref, watch, computed } from "vue"
import { userActivity, userActivityConfig } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
const props = defineProps(['user_id'])
const theChart = ref(null)
const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size)
const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min)
const chartInitSeries = Array.from(Array(bufferSize.value)).map(() => 0)
const hasActivity = computed(() => userActivity.value.length != 0)
const chartSeries = computed(() => {
return !hasActivity.value ? chartInitSeries : activitySeries.value
})
const activitySeries = computed(() => {
const data = userActivity.value[props.user_id] === undefined ? chartInitSeries : userActivity.value[props.user_id]
return [{data: Array.from(data)}]
})
const colorRanges = [1, 3, 5, 7, 9, 1000]
const chartOptions = computed(() => {
return {
chart: {
height: 12,
width: 224,
type: 'heatmap',
sparkline: {
enabled: true
},
animations: {
enabled: false,
easing: 'easeinout',
speed: 200,
},
},
dataLabels: {
enabled: false,
style: {
fontSize: '10px',
fontWeight: '400',
}
},
plotOptions: {
heatmap: {
radius: 2,
enableShades: false,
shadeIntensity: 0.5,
reverseNegativeShade: true,
distributed: false,
useFillColorAsStroke: false,
colorScale: {
ranges: [
{
from: 0,
to: colorRanges[0],
color: darkModeEnabled.value ? '#1e3a8a' : '#bfdbfe',
},
{
from: colorRanges[0] + 1,
to: colorRanges[1],
color: darkModeEnabled.value ? '#1d4ed8' : '#93c5fd',
},
{
from: colorRanges[1] + 1,
to: colorRanges[2],
color: darkModeEnabled.value ? '#2563eb' : '#60a5fa',
},
{
from: colorRanges[2] + 1,
to: colorRanges[3],
color: darkModeEnabled.value ? '#3b82f6' : '#3b82f6',
},
{
from: colorRanges[3] + 1,
to: colorRanges[4],
color: darkModeEnabled.value ? '#60a5fa' : '#2563eb',
},
{
from: colorRanges[4] + 1,
to: colorRanges[5],
color: darkModeEnabled.value ? '#93c5fd' : '#1d4ed8',
},
],
// inverse: false,
min: 0,
max: 1000
},
},
},
states: {
hover: {
filter: {
type: 'none',
}
},
active: {
filter: {
type: 'none',
}
},
},
grid: {
show: false,
},
legend: {
show: true,
},
stroke: {
width: 0,
},
tooltip: {
enabled: false,
},
}
})
</script>
<template>
<span
class="h-3 w-52"
:title="`Activity over ${bufferSizeMin}min`"
>
<apexchart type="heatmap" height="12" width="224" :options="chartOptions" :series="chartSeries"></apexchart>
</span>
</template>

View file

@ -1,16 +1,22 @@
<script setup>
import { ref, watch } from "vue"
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode } from "@/socket";
import { ref, watch, computed } from "vue"
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue";
const verbose = ref(false)
const api_query = ref(false)
watch(verbose, (newValue) => {
toggleVerboseMode(newValue == true)
})
watch(api_query, (newValue) => {
toggleApiQueryMode(newValue == true)
})
function getClassFromResponseCode(response_code) {
if (String(response_code).startsWith('2')) {
return 'text-green-500'
@ -33,7 +39,7 @@
<span class="rounded-lg py-1 px-2 dark:bg-sky-700 bg-sky-400 text-slate-800 dark:text-slate-200">
<span class="mr-1">
<FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
User online:
Players:
</span>
<span class="font-bold">{{ userCount }}</span>
</span>
@ -52,13 +58,22 @@
<span class="font-bold">{{ notificationAPICounter }}</span>
</span>
<span class="flex items-center">
<label class="mr-1 flex items-center cursor-pointer">
<input type="checkbox" class="toggle toggle-success [--fallback-su:#22c55e] mr-1" :checked="verbose" @change="verbose = !verbose"/>
<label class="mr-1 flex items-center cursor-pointer text-slate-700 dark:text-slate-300">
<input type="checkbox" class="toggle toggle-warning [--fallback-su:#22c55e] mr-1" :checked="verbose" @change="verbose = !verbose"/>
Verbose
</label>
</span>
<span class="flex items-center">
<label class="mr-1 flex items-center cursor-pointer text-slate-700 dark:text-slate-300">
<input type="checkbox" class="toggle toggle-success [--fallback-su:#22c55e] mr-1" :checked="api_query" @change="api_query = !api_query"/>
<FontAwesomeIcon :icon="faCog" size="sm" :mask="faCloud" transform="shrink-7 left-1" class="mr-1"></FontAwesomeIcon>
API Queries
</label>
</span>
</div>
<TheLiveLogsActivityGraphVue></TheLiveLogsActivityGraphVue>
<table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full">
<thead>
<tr class="font-medium dark:text-slate-200 text-slate-600 ">

View file

@ -0,0 +1,83 @@
<script setup>
import { ref, watch, computed } from "vue"
import { notificationHistory, notificationHistoryConfig } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
const theChart = ref(null)
const chartInitSeries = [
{data: Array.from(Array(12*20)).map(()=> 0)}
]
const hasActivity = computed(() => notificationHistory.value.length > 0)
const chartSeries = computed(() => {
return notificationHistory.value ? notificationHistorySeries.value : chartInitSeries.value
})
const notificationHistorySeries = computed(() => {
return [{data: Array.from(notificationHistory.value)}]
})
const chartOptions = computed(() => {
return {
chart: {
type: 'bar',
width: '100%',
height: 32,
sparkline: {
enabled: true
},
dropShadow: {
enabled: true,
enabledOnSeries: undefined,
top: 2,
left: 1,
blur: 2,
color: '#000',
opacity: darkModeEnabled.value ? 0.35 : 0.15
},
animations: {
enabled: false,
easing: 'easeinout',
speed: 200,
},
},
colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'],
plotOptions: {
bar: {
columnWidth: '80%'
}
},
yaxis: {
min: 0,
labels: {
show: false,
}
},
tooltip: {
enabled: false,
},
}
})
</script>
<template>
<div class="my-2 --ml-1 bg-slate-50 dark:bg-slate-600 py-1 pl-1 pr-3 rounded-md relative flex flex-col">
<div :class="`${!hasActivity ? 'hidden' : 'absolute'} h-10 -mt-1 w-full z-40`">
<div class="text-xxs flex justify-between h-full items-center text-slate-500 dark:text-slate-300">
<span class="-rotate-90 w-8 -ml-3">- {{ notificationHistoryConfig.buffer_timestamp_min }}min</span>
<span class="-rotate-90 w-8 text-xs"></span>
<span class="-rotate-90 w-8 text-lg"></span>
<span class="-rotate-90 w-8 text-xs"></span>
<span class="-rotate-90 w-8 -mr-1.5">- 0min</span>
</div>
</div>
<i :class="['text-center text-slate-600 dark:text-slate-400', hasActivity ? 'hidden' : 'block']">
- No recorded activity -
</i>
<apexchart
ref="theChart" :class="hasActivity ? 'block' : 'absolute h-8 w-full'" height="32" width="100%"
:options="chartOptions"
:series="chartSeries"
></apexchart>
</div>
</template>

View file

@ -2,7 +2,8 @@
import { ref, computed } from "vue";
import { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCheck, faTimes, faGraduationCap } from '@fortawesome/free-solid-svg-icons'
import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
const collapsed_panels = ref([])
@ -63,10 +64,11 @@
<th
v-for="(task, task_index) in exercise.tasks"
:key="task.name"
class="border-b border-slate-100 dark:border-slate-700 p-3"
class="border-b border-slate-100 dark:border-slate-700 p-3 align-top"
:title="task.description"
>
<div class="flex flex-col">
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500">Task {{ task_index + 1 }}</span>
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500 text-nowrap">Task {{ task_index + 1 }}</span>
<i class="text-center">{{ task.name }}</i>
</div>
</th>
@ -84,29 +86,58 @@
</tr>
<template v-else>
<tr v-for="(progress, user_id) in progresses" :key="user_id" class="bg-slate-100 dark:bg-slate-900">
<td class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6">
<span :title="user_id">
<span class="text-lg font-bold font-mono">{{ progress.email.split('@')[0] }}</span>
<span class="text-xs font-mono">@{{ progress.email.split('@')[1] }}</span>
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative">
<span class="flex flex-col max-w-60">
<span :title="user_id" class="text-nowrap inline-block leading-5 truncate">
<FontAwesomeIcon v-if="progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1" :icon="faMedal" class="mr-1 text-amber-300"></FontAwesomeIcon>
<span class="text-lg font-bold font-mono leading-5 tracking-tight">{{ progress.email.split('@')[0] }}</span>
<span class="text-xs font-mono tracking-tight">@{{ progress.email.split('@')[1] }}</span>
</span>
<LiveLogsUserActivityGraph :user_id="user_id"></LiveLogsUserActivityGraph>
</span>
</td>
<td
v-for="(task, task_index) in exercise.tasks"
:key="task_index"
class="text-center border-b border-slate-100 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3"
class="text-center border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-2"
>
<span
class="select-none cursor-pointer text-nowrap"
class="select-none cursor-pointer flex justify-center content-center flex-wrap h-9"
@click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], user_id, exercise.uuid, task.uuid)"
>
<FontAwesomeIcon
:icon="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? faCheck : faTimes"
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<small :class="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'"> (+{{ task.score }})</small>
<span class="flex flex-col">
<span class="text-nowrap">
<FontAwesomeIcon
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
:icon="faCheck"
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<FontAwesomeIcon
v-else-if="task.requirements?.inject_uuid !== undefined && !progress.exercises[exercise.uuid].tasks_completion[task.requirements.inject_uuid]"
title="All requirements for that task haven't been fullfilled yet"
:icon="faHourglassHalf"
:class="`text-lg ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<FontAwesomeIcon
v-else
:icon="faTimes"
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<small :class="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'"> (+{{ task.score }})</small>
</span>
<span class="text-sm leading-3">
<span
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp"
:class="progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? 'font-bold' : 'font-extralight'"
>
{{ (new Date(progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp * 1000)).toTimeString().split(' ', 1)[0] }}
</span>
<span v-else></span>
</span>
</span>
</span>
</td>
<td class="border-b border-slate-100 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3">
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3">
<div class="flex w-full h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600" role="progressbar" :aria-valuenow="progress.exercises[exercise.uuid].score" :aria-valuemin="0" aria-valuemax="100">
<div
class="flex flex-col justify-center rounded-full overflow-hidden bg-green-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-green-500 transition-width transition-slowest ease"

View file

@ -1,16 +1,46 @@
<script setup>
import { socketConnected } from "@/socket";
import { ref, onMounted } from "vue"
import { socketConnected, zmqLastTime } from "@/socket";
const zmqLastTimeSecond = ref('?')
function refreshLastTime() {
if (zmqLastTime.value !== false) {
zmqLastTimeSecond.value = parseInt(((new Date()).getTime() - zmqLastTime.value * 1000) / 1000)
} else {
zmqLastTimeSecond.value = '?'
}
}
onMounted(() => {
setInterval(() => {
refreshLastTime()
}, 1000)
})
</script>
<template>
<span :class="{
'px-2 py-1 rounded-md inline-block w-48': true,
'text-slate-900 dark:text-slate-400': socketConnected,
'text-slate-50 bg-red-600': !socketConnected,
}">
<span class="mr-1">Socket.IO:</span>
<span v-show="socketConnected" class="font-semibold text-green-600 dark:text-green-400">Connected</span>
<span v-show="!socketConnected" class="font-semibold text-slate-50">Disconnected</span>
<span class="flex flex-col justify-center mt-1">
<span :class="{
'px-2 rounded-md inline-block w-48 leading-4': true,
'text-slate-900 dark:text-slate-400': socketConnected,
'text-slate-50 bg-red-600 px-2 py-1': !socketConnected,
}">
<span class="mr-1">Socket.IO:</span>
<span v-show="socketConnected" class="font-semibold text-green-600 dark:text-green-400">Connected</span>
<span v-show="!socketConnected" class="font-semibold text-slate-50">Disconnected</span>
</span>
<span
v-if="socketConnected"
class="text-xs font-thin leading-3 inline-block text-center"
>
<template v-if="zmqLastTimeSecond == 0">
online
</template>
<template v-else>
Last keep-alive: {{ zmqLastTimeSecond }}s ago
</template>
</span>
</span>
</template>

View file

@ -2,14 +2,16 @@
import { ref, watch } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'
import { darkModeOn } from "@/settings.js"
const darkMode = ref(true)
const darkMode = ref(darkModeOn.value)
watch(darkMode, (newValue) => {
darkModeOn.value = newValue
if (newValue) {
document.getElementsByTagName('body')[0].classList.add('dark')
} else {
document.getElementsByTagName('body')[0].classList.remove('dark')
document.getElementsByTagName('body')[0].classList.add('dark')
} else {
document.getElementsByTagName('body')[0].classList.remove('dark')
}
})
</script>

View file

@ -1,6 +1,10 @@
import './assets/main.css'
import VueApexCharts from "vue3-apexcharts";
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
const app = createApp(App)
app.use(VueApexCharts)
app.mount('#app')

4
src/settings.js Normal file
View file

@ -0,0 +1,4 @@
import { ref, computed } from 'vue'
export const darkModeOn = ref(true)
export const darkModeEnabled = computed(() => darkModeOn.value)

View file

@ -3,13 +3,17 @@ import { io } from "socket.io-client";
import debounce from 'lodash.debounce'
// "undefined" means the URL will be computed from the `window.location` object
const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:4000";
const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:40001";
const MAX_LIVE_LOG = 30
const initial_state = {
notificationEvents: [],
notificationCounter: 0,
notificationAPICounter: 0,
notificationHistory: [],
notificationHistoryConfig: {},
userActivity: {},
userActivityConfig: {},
exercises: [],
selected_exercises: [],
progresses: {},
@ -18,7 +22,8 @@ const initial_state = {
const state = reactive({ ...initial_state });
const connectionState = reactive({
connected: false
connected: false,
zmq_last_time: false,
})
@ -39,7 +44,12 @@ export const notificationCounter = computed(() => state.notificationCounter)
export const notificationAPICounter = computed(() => state.notificationAPICounter)
export const userCount = computed(() => Object.keys(state.progresses).length)
export const diagnostic = computed(() => state.diagnostic)
export const notificationHistory = computed(() => state.notificationHistory)
export const notificationHistoryConfig = computed(() => state.notificationHistoryConfig)
export const userActivity = computed(() => state.userActivity)
export const userActivityConfig = computed(() => state.userActivityConfig)
export const socketConnected = computed(() => connectionState.connected)
export const zmqLastTime = computed(() => connectionState.zmq_last_time)
export function resetState() {
Object.assign(state, initial_state);
@ -50,6 +60,7 @@ export function fullReload() {
getSelectedExercises()
getNotifications()
getProgress()
getUsersActivity()
}
export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
@ -81,6 +92,10 @@ export function toggleVerboseMode(enabled) {
sendToggleVerboseMode(enabled)
}
export function toggleApiQueryMode(enabled) {
sendToggleApiQueryMode(enabled)
}
export const debouncedGetProgress = debounce(getProgress, 200, {leading: true})
export const debouncedGetDiangostic = debounce(getDiangostic, 1000, {leading: true})
@ -112,6 +127,14 @@ function getProgress() {
})
}
function getUsersActivity() {
socket.emit("get_users_activity", (user_activity_bundle) => {
state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config
});
}
function getDiangostic() {
state.diagnostic = {}
socket.emit("get_diagnostic", (diagnostic) => {
@ -151,6 +174,13 @@ function sendToggleVerboseMode(enabled) {
socket.emit("toggle_verbose_mode", payload, () => {})
}
function sendToggleApiQueryMode(enabled) {
const payload = {
apiquery: enabled
}
socket.emit("toggle_apiquery_mode", payload, () => {})
}
/* Event listener */
socket.on("connect", () => {
@ -177,6 +207,20 @@ socket.on("refresh_score", (new_user) => {
debouncedGetProgress()
});
socket.on("keep_alive", (keep_alive) => {
connectionState.zmq_last_time = keep_alive['zmq_last_time']
});
socket.on("update_notification_history", (notification_history_bundle) => {
state.notificationHistory = notification_history_bundle.history
state.notificationHistoryConfig = notification_history_bundle.config
});
socket.on("update_users_activity", (user_activity_bundle) => {
state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config
});
function addLimited(target, message, maxCount) {
target.unshift(message)
if (target.length > maxCount) {

View file

@ -8,7 +8,13 @@ export default {
extend: {
transitionProperty: {
'width': 'width'
} ,
},
screens: {
'3xl': '1800px',
},
fontSize: {
'xxs': '0.6rem',
},
},
},
plugins: [