Compare commits
35 commits
f236da0055
...
202f7b7eb6
Author | SHA1 | Date | |
---|---|---|---|
|
202f7b7eb6 | ||
|
0c2fdf5591 | ||
|
4d445f3408 | ||
|
3d928b6437 | ||
|
58da718c5d | ||
|
bc78e2f2cb | ||
|
f1a0ed3ab1 | ||
|
e1010793dc | ||
|
58d4af812d | ||
|
33bc5ca0bb | ||
|
1277dbb132 | ||
|
6178592d10 | ||
|
dc1f7b6376 | ||
|
abca50d615 | ||
|
06f179378f | ||
|
a07a0de89e | ||
|
84ab2665d7 | ||
|
99f9751e22 | ||
|
8774d70759 | ||
|
b79152a6e5 | ||
|
44d040c70b | ||
|
a21549f587 | ||
|
8cca38f527 | ||
|
0a89423d31 | ||
|
f0d079ea32 | ||
|
9b0cb51643 | ||
|
34a1242ed9 | ||
|
7231a55356 | ||
|
d4f148bb69 | ||
|
e16fc0c7cd | ||
|
34869497a1 | ||
|
fb32b59abe | ||
|
6814294e77 | ||
|
96d5b6d89e | ||
|
f191af8573 |
33 changed files with 2391 additions and 1042 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,6 +1,8 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
venv
|
venv
|
||||||
|
config.py
|
||||||
misp_cache.sqlite
|
misp_cache.sqlite
|
||||||
|
backup.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|
|
@ -8,12 +8,15 @@ source venv/bin/activate
|
||||||
|
|
||||||
# Install deps
|
# Install deps
|
||||||
pip3 install -r REQUIREMENTS
|
pip3 install -r REQUIREMENTS
|
||||||
|
|
||||||
|
# Create config file and adapt it to your needs
|
||||||
|
cp config.py.sample config.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running the PROD setup
|
## Running the PROD setup
|
||||||
```bash
|
```bash
|
||||||
python3 server.py
|
python3 server.py
|
||||||
# Access the page http://localhost:3000 with your browser
|
# Access the page http://localhost:4000 with your browser
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
pyzmq
|
pyzmq
|
||||||
python-socketio
|
python-socketio
|
||||||
eventlet
|
aiohttp
|
||||||
requests
|
requests
|
||||||
requests-cache
|
requests-cache
|
||||||
jq
|
jq
|
|
@ -10,6 +10,7 @@ misp_skipssl = True
|
||||||
live_logs_accepted_scope = {
|
live_logs_accepted_scope = {
|
||||||
'events': ['add', 'edit', 'delete', 'restSearch',],
|
'events': ['add', 'edit', 'delete', 'restSearch',],
|
||||||
'attributes': ['add', 'edit', 'delete', 'restSearch',],
|
'attributes': ['add', 'edit', 'delete', 'restSearch',],
|
||||||
|
'eventReports': ['add', 'edit', 'delete',],
|
||||||
'tags': '*',
|
'tags': '*',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,7 +19,6 @@ logger = logging.getLogger('misp-exercise-dashboard')
|
||||||
format = '[%(levelname)s] %(asctime)s - %(message)s'
|
format = '[%(levelname)s] %(asctime)s - %(message)s'
|
||||||
formatter = logging.Formatter(format)
|
formatter = logging.Formatter(format)
|
||||||
logging.basicConfig(filename='misp-exercise-dashboard.log', encoding='utf-8', level=logging.DEBUG, format=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 = logging.StreamHandler()
|
||||||
ch.setLevel(logging.INFO)
|
ch.setLevel(logging.INFO)
|
||||||
ch.setFormatter(formatter)
|
ch.setFormatter(formatter)
|
36
db.py
36
db.py
|
@ -5,17 +5,49 @@ import collections
|
||||||
|
|
||||||
USER_ID_TO_EMAIL_MAPPING = {}
|
USER_ID_TO_EMAIL_MAPPING = {}
|
||||||
USER_ID_TO_AUTHKEY_MAPPING = {}
|
USER_ID_TO_AUTHKEY_MAPPING = {}
|
||||||
|
|
||||||
ALL_EXERCISES = []
|
ALL_EXERCISES = []
|
||||||
SELECTED_EXERCISES = []
|
SELECTED_EXERCISES = []
|
||||||
INJECT_BY_UUID = {}
|
INJECT_BY_UUID = {}
|
||||||
INJECT_SEQUENCE_BY_INJECT_UUID = {}
|
INJECT_SEQUENCE_BY_INJECT_UUID = {}
|
||||||
INJECT_REQUIREMENTS_BY_INJECT_UUID = {}
|
INJECT_REQUIREMENTS_BY_INJECT_UUID = {}
|
||||||
EXERCISES_STATUS = {}
|
EXERCISES_STATUS = {}
|
||||||
PROGRESS = {
|
|
||||||
}
|
|
||||||
NOTIFICATION_BUFFER_SIZE = 30
|
NOTIFICATION_BUFFER_SIZE = 30
|
||||||
NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE)
|
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():
|
def resetNotificationMessage():
|
||||||
global NOTIFICATION_MESSAGES
|
global NOTIFICATION_MESSAGES
|
||||||
NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE)
|
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
1480
dist/assets/index-BbC5Fp4k.js
vendored
Normal file
File diff suppressed because one or more lines are too long
778
dist/assets/index-Bk6s3GdT.js
vendored
778
dist/assets/index-Bk6s3GdT.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-DlglK08D.css
vendored
1
dist/assets/index-DlglK08D.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-XAPeN3Gs.css
vendored
Normal file
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
4
dist/index.html
vendored
|
@ -5,8 +5,8 @@
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>Vite App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Bk6s3GdT.js"></script>
|
<script type="module" crossorigin src="/assets/index-BbC5Fp4k.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DlglK08D.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-XAPeN3Gs.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
109
exercise.py
109
exercise.py
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
@ -10,7 +9,7 @@ from typing import Union
|
||||||
import jq
|
import jq
|
||||||
|
|
||||||
import db
|
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 misp_api
|
||||||
import config
|
import config
|
||||||
from config import logger
|
from config import logger
|
||||||
|
@ -61,6 +60,29 @@ def read_exercise_dir():
|
||||||
return exercises
|
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:
|
def is_validate_exercises(exercises: list) -> bool:
|
||||||
exercises_uuid = set()
|
exercises_uuid = set()
|
||||||
tasks_uuid = set()
|
tasks_uuid = set()
|
||||||
|
@ -140,11 +162,14 @@ def get_exercises():
|
||||||
tasks = []
|
tasks = []
|
||||||
for inject in exercise['injects']:
|
for inject in exercise['injects']:
|
||||||
score = db.EXERCISES_STATUS[exercise['exercise']['uuid']]['tasks'][inject['uuid']]['score']
|
score = db.EXERCISES_STATUS[exercise['exercise']['uuid']]['tasks'][inject['uuid']]['score']
|
||||||
|
requirements = db.INJECT_REQUIREMENTS_BY_INJECT_UUID[inject['uuid']]
|
||||||
tasks.append(
|
tasks.append(
|
||||||
{
|
{
|
||||||
"name": inject['name'],
|
"name": inject['name'],
|
||||||
"uuid": inject['uuid'],
|
"uuid": inject['uuid'],
|
||||||
|
"description": inject.get('description', ''),
|
||||||
"score": score,
|
"score": score,
|
||||||
|
"requirements": requirements,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
exercises.append(
|
exercises.append(
|
||||||
|
@ -179,6 +204,7 @@ def resetAllExerciseProgress():
|
||||||
for exercise_status in db.EXERCISES_STATUS.values():
|
for exercise_status in db.EXERCISES_STATUS.values():
|
||||||
for task in exercise_status['tasks'].values():
|
for task in exercise_status['tasks'].values():
|
||||||
mark_task_incomplete(user_id, exercise_status['uuid'], task['uuid'])
|
mark_task_incomplete(user_id, exercise_status['uuid'], task['uuid'])
|
||||||
|
backup_exercises_progress()
|
||||||
|
|
||||||
|
|
||||||
def get_completed_tasks_for_user(user_id: int):
|
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 task in exercise_status['tasks'].values():
|
||||||
for user_id in completion_per_user.keys():
|
for user_id in completion_per_user.keys():
|
||||||
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
|
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
|
||||||
for user_id in task['completed_by_user']:
|
for entry in task['completed_by_user']:
|
||||||
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = True
|
user_id = entry['user_id']
|
||||||
|
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
|
||||||
|
|
||||||
return completion_per_user
|
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):
|
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']:
|
is_completed = any(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'].append(user_id)
|
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):
|
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']:
|
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'].remove(user_id)
|
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'] = completed_without_user
|
||||||
|
|
||||||
|
|
||||||
def get_progress():
|
def get_progress():
|
||||||
|
@ -294,9 +335,9 @@ def get_progress():
|
||||||
return 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']:
|
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:
|
if not success:
|
||||||
logger.info(f"Task not completed: {inject['uuid']}")
|
logger.info(f"Task not completed: {inject['uuid']}")
|
||||||
return False
|
return False
|
||||||
|
@ -320,35 +361,39 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
|
||||||
return False
|
return False
|
||||||
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):
|
if not is_valid_evaluation_context(user_id, inject_evaluation, data, context):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if 'evaluation_strategy' not in inject_evaluation:
|
if 'evaluation_strategy' not in inject_evaluation:
|
||||||
return False
|
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:
|
if data_to_validate is None:
|
||||||
logger.debug('Could not fetch data to validate')
|
logger.debug('Could not fetch data to validate')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
|
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
|
||||||
return eval_data_filtering(user_id, inject_evaluation, data_to_validate)
|
return eval_data_filtering(user_id, inject_evaluation, data_to_validate, context)
|
||||||
elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
|
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
|
||||||
expected_data = data_to_validate['expected_data']
|
expected_data = data_to_validate['expected_data']
|
||||||
data_to_validate = data_to_validate['data_to_validate']
|
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
|
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
|
data_to_validate = None
|
||||||
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
|
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
|
||||||
event_id = parse_event_id_from_log(data)
|
event_id = parse_event_id_from_log(data)
|
||||||
data_to_validate = fetch_data_for_data_filtering(event_id=event_id)
|
data_to_validate = await fetch_data_for_data_filtering(event_id=event_id)
|
||||||
elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
|
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
|
||||||
perfomed_query = parse_performed_query_from_log(data)
|
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
|
return data_to_validate
|
||||||
|
|
||||||
|
|
||||||
|
@ -394,14 +439,14 @@ def parse_performed_query_from_log(data: dict) -> Union[dict, None]:
|
||||||
return 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
|
data = None
|
||||||
if event_id is not None:
|
if event_id is not None:
|
||||||
data = misp_api.getEvent(event_id)
|
data = await misp_api.getEvent(event_id)
|
||||||
return data
|
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
|
data = None
|
||||||
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
|
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
|
||||||
if perfomed_query is not None:
|
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_method = query_context['request_method']
|
||||||
expected_url = query_context['url']
|
expected_url = query_context['url']
|
||||||
expected_payload = inject_evaluation['parameters'][0]
|
expected_payload = inject_evaluation['parameters'][0]
|
||||||
expected_data = misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload)
|
expected_data = await 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'])
|
data_to_validate = await misp_api.doRestQuery(authkey, perfomed_query['request_method'], perfomed_query['url'], perfomed_query['payload'])
|
||||||
data = {
|
data = {
|
||||||
'expected_data' : expected_data,
|
'expected_data' : expected_data,
|
||||||
'data_to_validate' : data_to_validate,
|
'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
|
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)
|
@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
|
succeeded_once = False
|
||||||
available_tasks = get_available_tasks_for_user(user_id)
|
available_tasks = get_available_tasks_for_user(user_id)
|
||||||
for task_uuid in available_tasks:
|
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:
|
if inject['exercise_uuid'] not in db.SELECTED_EXERCISES:
|
||||||
continue
|
continue
|
||||||
logger.debug(f"[{task_uuid}] :: checking: {inject['name']}")
|
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:
|
if completed:
|
||||||
succeeded_once = True
|
succeeded_once = True
|
||||||
return succeeded_once
|
return succeeded_once
|
|
@ -138,7 +138,15 @@
|
||||||
{
|
{
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
".Event.info": {
|
".response[].Event.event_creator_email": {
|
||||||
|
"comparison": "equals",
|
||||||
|
"values": [
|
||||||
|
"{{user_email}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
".response[].Event.info": {
|
||||||
"comparison": "contains",
|
"comparison": "contains",
|
||||||
"values": [
|
"values": [
|
||||||
"event",
|
"event",
|
||||||
|
@ -148,9 +156,17 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"result": "MISP Event created",
|
"result": "MISP Event created",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "query_search",
|
||||||
"evaluation_context": {
|
"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": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
|
|
|
@ -119,7 +119,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"result": "Published 48h retreived",
|
"result": "Published 48h retreived",
|
||||||
"evaluation_strategy": "query_comparison",
|
"evaluation_strategy": "query_mirror",
|
||||||
"evaluation_context": {
|
"evaluation_context": {
|
||||||
"request_is_rest": true,
|
"request_is_rest": true,
|
||||||
"query_context": {
|
"query_context": {
|
||||||
|
@ -134,7 +134,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"name": "Get Published in the past 48h",
|
"name": "Get Published in the past 48h",
|
||||||
"target_tool": "MISP-query",
|
"target_tool": "MISP",
|
||||||
"uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4"
|
"uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -150,7 +150,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"result": "IP CSV retrieved",
|
"result": "IP CSV retrieved",
|
||||||
"evaluation_strategy": "query_comparison",
|
"evaluation_strategy": "query_mirror",
|
||||||
"evaluation_context": {
|
"evaluation_context": {
|
||||||
"request_is_rest": true,
|
"request_is_rest": true,
|
||||||
"query_context": {
|
"query_context": {
|
||||||
|
@ -165,7 +165,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"name": "IP IoCs changed in the past 48h in CSV",
|
"name": "IP IoCs changed in the past 48h in CSV",
|
||||||
"target_tool": "MISP-query",
|
"target_tool": "MISP",
|
||||||
"uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba"
|
"uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -180,7 +180,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"result": "20 Attribute tagged retrieved",
|
"result": "20 Attribute tagged retrieved",
|
||||||
"evaluation_strategy": "query_comparison",
|
"evaluation_strategy": "query_mirror",
|
||||||
"evaluation_context": {
|
"evaluation_context": {
|
||||||
"request_is_rest": true,
|
"request_is_rest": true,
|
||||||
"query_context": {
|
"query_context": {
|
||||||
|
@ -195,7 +195,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"name": "First 20 Attribute with TLP lower than `amber`",
|
"name": "First 20 Attribute with TLP lower than `amber`",
|
||||||
"target_tool": "MISP-query",
|
"target_tool": "MISP",
|
||||||
"uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88"
|
"uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -209,7 +209,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"result": "Phising counted",
|
"result": "Phising counted",
|
||||||
"evaluation_strategy": "query_comparison",
|
"evaluation_strategy": "query_mirror",
|
||||||
"evaluation_context": {
|
"evaluation_context": {
|
||||||
"request_is_rest": true,
|
"request_is_rest": true,
|
||||||
"query_context": {
|
"query_context": {
|
||||||
|
@ -224,7 +224,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"name": "Event count with `Phishing - T1566` involved",
|
"name": "Event count with `Phishing - T1566` involved",
|
||||||
"target_tool": "MISP-query",
|
"target_tool": "MISP",
|
||||||
"uuid": "1da0fdc8-9d0d-4618-a811-66491e196833"
|
"uuid": "1da0fdc8-9d0d-4618-a811-66491e196833"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -219,6 +219,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"name": "Event Creation",
|
"name": "Event Creation",
|
||||||
|
"description": "Create an Event containing `ransomware`",
|
||||||
"target_tool": "MISP",
|
"target_tool": "MISP",
|
||||||
"uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
"uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,7 +6,6 @@ import operator
|
||||||
from config import logger
|
from config import logger
|
||||||
|
|
||||||
|
|
||||||
# .Event.Attribute[] | select(.value == "evil.exe") | .Tag
|
|
||||||
def jq_extract(path: str, data: dict, extract_type='first'):
|
def jq_extract(path: str, data: dict, extract_type='first'):
|
||||||
query = jq.compile(path).input_value(data)
|
query = jq.compile(path).input_value(data)
|
||||||
try:
|
try:
|
||||||
|
@ -15,28 +14,42 @@ def jq_extract(path: str, data: dict, extract_type='first'):
|
||||||
return None
|
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
|
## 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:
|
if type(data_to_validate) is bool:
|
||||||
data_to_validate = "1" if data_to_validate else "0"
|
data_to_validate = "1" if data_to_validate else "0"
|
||||||
if type(data_to_validate) is str:
|
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:
|
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:
|
elif type(data_to_validate) is dict:
|
||||||
# Not sure how we could have condition on this
|
# 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
|
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']
|
comparison_type = evaluation_config['comparison']
|
||||||
values = evaluation_config['values']
|
values = evaluation_config['values']
|
||||||
if len(values) == 0:
|
if len(values) == 0:
|
||||||
return False
|
return False
|
||||||
|
values = [apply_replacement_from_context(v, context) for v in values]
|
||||||
|
|
||||||
if comparison_type == 'contains':
|
if comparison_type == 'contains':
|
||||||
values = [v.lower() for v in values]
|
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
|
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']
|
comparison_type = evaluation_config['comparison']
|
||||||
values = evaluation_config['values']
|
values = evaluation_config['values']
|
||||||
comparators = {
|
comparators = {
|
||||||
|
@ -69,7 +82,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
|
||||||
|
|
||||||
if len(values) == 0:
|
if len(values) == 0:
|
||||||
return False
|
return False
|
||||||
|
values = [apply_replacement_from_context(v, context) for v in values]
|
||||||
|
|
||||||
if comparison_type == 'contains' or comparison_type == 'equals':
|
if comparison_type == 'contains' or comparison_type == 'equals':
|
||||||
data_to_validate_set = set(data_to_validate)
|
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
|
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']
|
comparison_type = evaluation_config['comparison']
|
||||||
values = evaluation_config['values']
|
values = evaluation_config['values']
|
||||||
comparators = {
|
comparators = {
|
||||||
|
@ -113,6 +126,10 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
|
||||||
'=': operator.eq,
|
'=': operator.eq,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(values) == 0:
|
||||||
|
return False
|
||||||
|
values = [apply_replacement_from_context(v, context) for v in values]
|
||||||
|
|
||||||
comparison_type = evaluation_config['comparison']
|
comparison_type = evaluation_config['comparison']
|
||||||
if comparison_type == 'contains':
|
if comparison_type == 'contains':
|
||||||
pass
|
pass
|
||||||
|
@ -129,21 +146,31 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
|
||||||
return False
|
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_params in inject_evaluation['parameters']:
|
||||||
for evaluation_path, evaluation_config in evaluation_params.items():
|
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'))
|
data_to_validate = jq_extract(evaluation_path, data, evaluation_config.get('extract_type', 'first'))
|
||||||
if data_to_validate is None:
|
if data_to_validate is None:
|
||||||
logger.debug('Could not extract data')
|
logger.debug('Could not extract data')
|
||||||
return False
|
return False
|
||||||
if not condition_satisfied(evaluation_config, data_to_validate):
|
if not condition_satisfied(evaluation_config, data_to_validate, context):
|
||||||
return False
|
return False
|
||||||
return True
|
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
|
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)
|
37
misp_api.py
37
misp_api.py
|
@ -4,6 +4,7 @@ import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
import asyncio
|
||||||
import requests # type: ignore
|
import requests # type: ignore
|
||||||
import requests.adapters # type: ignore
|
import requests.adapters # type: ignore
|
||||||
from requests_cache import CachedSession
|
from requests_cache import CachedSession
|
||||||
|
@ -18,7 +19,7 @@ requestSession.mount('https://', adapterCache)
|
||||||
requestSession.mount('http://', adapterCache)
|
requestSession.mount('http://', adapterCache)
|
||||||
|
|
||||||
|
|
||||||
def get(url, data={}, api_key=misp_apikey):
|
async def get(url, data={}, api_key=misp_apikey):
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'misp-exercise-dashboard',
|
'User-Agent': 'misp-exercise-dashboard',
|
||||||
"Authorization": api_key,
|
"Authorization": api_key,
|
||||||
|
@ -27,17 +28,22 @@ def get(url, data={}, api_key=misp_apikey):
|
||||||
}
|
}
|
||||||
full_url = urljoin(misp_url, url)
|
full_url = urljoin(misp_url, url)
|
||||||
try:
|
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:
|
except requests.exceptions.ConnectionError as e:
|
||||||
logger.info('Could not perform request on MISP. %s', e)
|
logger.info('Could not perform request on MISP. %s', e)
|
||||||
return None
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Could not perform request on MISP. %s', e)
|
||||||
try:
|
try:
|
||||||
return response.json() if response.headers['content-type'].startswith('application/json') else response.text
|
return response.json() if response.headers['content-type'].startswith('application/json') else response.text
|
||||||
except requests.exceptions.JSONDecodeError:
|
except requests.exceptions.JSONDecodeError:
|
||||||
return response.text
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
def post(url, data={}, api_key=misp_apikey):
|
async def post(url, data={}, api_key=misp_apikey):
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'misp-exercise-dashboard',
|
'User-Agent': 'misp-exercise-dashboard',
|
||||||
"Authorization": api_key,
|
"Authorization": api_key,
|
||||||
|
@ -46,32 +52,37 @@ def post(url, data={}, api_key=misp_apikey):
|
||||||
}
|
}
|
||||||
full_url = urljoin(misp_url, url)
|
full_url = urljoin(misp_url, url)
|
||||||
try:
|
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:
|
except requests.exceptions.ConnectionError as e:
|
||||||
logger.info('Could not perform request on MISP. %s', e)
|
logger.info('Could not perform request on MISP. %s', e)
|
||||||
return None
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Could not perform request on MISP. %s', e)
|
||||||
try:
|
try:
|
||||||
return response.json() if response.headers['content-type'].startswith('application/json') else response.text
|
return response.json() if response.headers['content-type'].startswith('application/json') else response.text
|
||||||
except requests.exceptions.JSONDecodeError:
|
except requests.exceptions.JSONDecodeError:
|
||||||
return response.text
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
def getEvent(event_id: int) -> Union[None, dict]:
|
async def getEvent(event_id: int) -> Union[None, dict]:
|
||||||
return get(f'/events/view/{event_id}')
|
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':
|
if request_method == 'POST':
|
||||||
return post(url, payload, api_key=authkey)
|
return await post(url, payload, api_key=authkey)
|
||||||
else:
|
else:
|
||||||
return get(url, payload, api_key=authkey)
|
return await get(url, payload, api_key=authkey)
|
||||||
|
|
||||||
|
|
||||||
def getVersion() -> Union[None, dict]:
|
async def getVersion() -> Union[None, dict]:
|
||||||
return get(f'/servers/getVersion.json')
|
return await get(f'/servers/getVersion.json')
|
||||||
|
|
||||||
|
|
||||||
def getSettings() -> Union[None, dict]:
|
async def getSettings() -> Union[None, dict]:
|
||||||
SETTING_TO_QUERY = [
|
SETTING_TO_QUERY = [
|
||||||
'Plugin.ZeroMQ_enable',
|
'Plugin.ZeroMQ_enable',
|
||||||
'Plugin.ZeroMQ_audit_notifications_enable',
|
'Plugin.ZeroMQ_audit_notifications_enable',
|
||||||
|
@ -83,7 +94,7 @@ def getSettings() -> Union[None, dict]:
|
||||||
'MISP.log_auth',
|
'MISP.log_auth',
|
||||||
'Security.allow_unsafe_cleartext_apikey_logging',
|
'Security.allow_unsafe_cleartext_apikey_logging',
|
||||||
]
|
]
|
||||||
settings = get(f'/servers/serverSettings.json')
|
settings = await get(f'/servers/serverSettings.json')
|
||||||
if not settings:
|
if not settings:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -9,6 +9,7 @@ from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
|
||||||
VERBOSE_MODE = False
|
VERBOSE_MODE = False
|
||||||
|
APIQUERY_MODE = False
|
||||||
NOTIFICATION_COUNT = 1
|
NOTIFICATION_COUNT = 1
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,10 +18,39 @@ def set_verbose_mode(enabled: bool):
|
||||||
VERBOSE_MODE = enabled
|
VERBOSE_MODE = enabled
|
||||||
|
|
||||||
|
|
||||||
|
def set_apiquery_mode(enabled: bool):
|
||||||
|
global APIQUERY_MODE
|
||||||
|
APIQUERY_MODE = enabled
|
||||||
|
|
||||||
|
|
||||||
def get_notifications() -> list[dict]:
|
def get_notifications() -> list[dict]:
|
||||||
return list(db.NOTIFICATION_MESSAGES)
|
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():
|
def reset_notifications():
|
||||||
db.resetNotificationMessage()
|
db.resetNotificationMessage()
|
||||||
|
|
||||||
|
@ -29,6 +59,14 @@ def record_notification(notification: dict):
|
||||||
db.NOTIFICATION_MESSAGES.appendleft(notification)
|
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):
|
def get_user_id(data: dict):
|
||||||
if 'user_id' in data:
|
if 'user_id' in data:
|
||||||
return int(data['user_id'])
|
return int(data['user_id'])
|
||||||
|
@ -148,6 +186,8 @@ def is_accepted_notification(notification) -> bool:
|
||||||
return False
|
return False
|
||||||
if VERBOSE_MODE:
|
if VERBOSE_MODE:
|
||||||
return True
|
return True
|
||||||
|
if APIQUERY_MODE and not notification['is_api_request']:
|
||||||
|
return False
|
||||||
if '@' not in notification['user']: # Ignore message from system
|
if '@' not in notification['user']: # Ignore message from system
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
126
package-lock.json
generated
126
package-lock.json
generated
|
@ -13,8 +13,10 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.5.2",
|
"@fortawesome/free-regular-svg-icons": "^6.5.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
|
"apexcharts": "^3.49.2",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"vue": "^3.4.29"
|
"vue": "^3.4.29",
|
||||||
|
"vue3-apexcharts": "^1.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.8.0",
|
"@rushstack/eslint-patch": "^1.8.0",
|
||||||
|
@ -1077,6 +1079,12 @@
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz",
|
||||||
"integrity": "sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA=="
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
|
||||||
|
@ -1157,6 +1165,21 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
|
@ -3292,6 +3315,97 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/synckit": {
|
||||||
"version": "0.8.8",
|
"version": "0.8.8",
|
||||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
|
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
|
||||||
|
@ -3564,6 +3678,16 @@
|
||||||
"eslint": ">=6.0.0"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
@ -16,8 +16,10 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.5.2",
|
"@fortawesome/free-regular-svg-icons": "^6.5.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
|
"apexcharts": "^3.49.2",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"vue": "^3.4.29"
|
"vue": "^3.4.29",
|
||||||
|
"vue3-apexcharts": "^1.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.8.0",
|
"@rushstack/eslint-patch": "^1.8.0",
|
||||||
|
|
171
server.py
171
server.py
|
@ -1,13 +1,14 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import collections
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import zmq
|
import zmq
|
||||||
import socketio
|
import socketio
|
||||||
import eventlet
|
from aiohttp import web
|
||||||
from eventlet.green import zmq as gzmq
|
import zmq.asyncio
|
||||||
|
|
||||||
import exercise as exercise_model
|
import exercise as exercise_model
|
||||||
import notification as notification_model
|
import notification as notification_model
|
||||||
|
@ -17,7 +18,10 @@ from config import logger
|
||||||
import misp_api
|
import misp_api
|
||||||
|
|
||||||
|
|
||||||
|
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
|
||||||
ZMQ_MESSAGE_COUNT = 0
|
ZMQ_MESSAGE_COUNT = 0
|
||||||
|
ZMQ_LAST_TIME = None
|
||||||
|
USER_ACTIVITY = collections.defaultdict(int)
|
||||||
|
|
||||||
|
|
||||||
def debounce(debounce_seconds: int = 1):
|
def debounce(debounce_seconds: int = 1):
|
||||||
|
@ -41,77 +45,91 @@ def debounce(debounce_seconds: int = 1):
|
||||||
|
|
||||||
|
|
||||||
# Initialize ZeroMQ context and subscriber socket
|
# Initialize ZeroMQ context and subscriber socket
|
||||||
context = gzmq.Context()
|
context = zmq.asyncio.Context()
|
||||||
zsocket = context.socket(gzmq.SUB)
|
zsocket = context.socket(zmq.SUB)
|
||||||
zmq_url = config.zmq_url
|
zmq_url = config.zmq_url
|
||||||
zsocket.connect(zmq_url)
|
zsocket.connect(zmq_url)
|
||||||
zsocket.setsockopt_string(gzmq.SUBSCRIBE, '')
|
zsocket.setsockopt_string(zmq.SUBSCRIBE, '')
|
||||||
|
|
||||||
|
|
||||||
# Initialize Socket.IO server
|
# Initialize Socket.IO server
|
||||||
sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet')
|
sio = socketio.AsyncServer(cors_allowed_origins='*', async_mode='aiohttp')
|
||||||
app = socketio.WSGIApp(sio, static_files={
|
app = web.Application()
|
||||||
'/': {'content_type': 'text/html', 'filename': 'dist/index.html'},
|
sio.attach(app)
|
||||||
'/assets': './dist/assets',
|
|
||||||
})
|
|
||||||
|
async def index(request):
|
||||||
|
with open('dist/index.html') as f:
|
||||||
|
return web.Response(text=f.read(), content_type='text/html')
|
||||||
|
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def connect(sid, environ):
|
async def connect(sid, environ):
|
||||||
logger.debug("Client connected: %s", sid)
|
logger.debug("Client connected: %s", sid)
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def disconnect(sid):
|
async def disconnect(sid):
|
||||||
logger.debug("Client disconnected: %s", sid)
|
logger.debug("Client disconnected: %s", sid)
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def get_exercises(sid):
|
async def get_exercises(sid):
|
||||||
return exercise_model.get_exercises()
|
return exercise_model.get_exercises()
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def get_selected_exercises(sid):
|
async def get_selected_exercises(sid):
|
||||||
return exercise_model.get_selected_exercises()
|
return exercise_model.get_selected_exercises()
|
||||||
|
|
||||||
@sio.event
|
@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'])
|
return exercise_model.change_exercise_selection(payload['exercise_uuid'], payload['selected'])
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def get_progress(sid):
|
async def get_progress(sid):
|
||||||
return exercise_model.get_progress()
|
return exercise_model.get_progress()
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def get_notifications(sid):
|
async def get_notifications(sid):
|
||||||
return notification_model.get_notifications()
|
return notification_model.get_notifications()
|
||||||
|
|
||||||
@sio.event
|
@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'])
|
return exercise_model.mark_task_completed(int(payload['user_id']), payload['exercise_uuid'], payload['task_uuid'])
|
||||||
|
|
||||||
@sio.event
|
@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'])
|
return exercise_model.mark_task_incomplete(int(payload['user_id']), payload['exercise_uuid'], payload['task_uuid'])
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def reset_all_exercise_progress(sid):
|
async def reset_all_exercise_progress(sid):
|
||||||
return exercise_model.resetAllExerciseProgress()
|
return exercise_model.resetAllExerciseProgress()
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def reset_notifications(sid):
|
async def reset_notifications(sid):
|
||||||
return notification_model.reset_notifications()
|
return notification_model.reset_notifications()
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
def get_diagnostic(sid):
|
async def get_diagnostic(sid):
|
||||||
return getDiagnostic()
|
return await getDiagnostic()
|
||||||
|
|
||||||
@sio.event
|
@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'])
|
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('*')
|
@sio.on('*')
|
||||||
def any_event(event, sid, data={}):
|
async def any_event(event, sid, data={}):
|
||||||
logger.info('>> Unhandled event %s', event)
|
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)
|
data = json.loads(message)
|
||||||
|
|
||||||
if topic == 'misp_json_audit':
|
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 is not None and '@' in email:
|
||||||
if user_id not in db.USER_ID_TO_EMAIL_MAPPING:
|
if user_id not in db.USER_ID_TO_EMAIL_MAPPING:
|
||||||
db.USER_ID_TO_EMAIL_MAPPING[user_id] = email
|
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)
|
user_id, authkey = notification_model.get_user_authkey_id_pair(data)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
|
@ -131,24 +149,33 @@ def handleMessage(topic, s, message):
|
||||||
notification = notification_model.get_notification_message(data)
|
notification = notification_model.get_notification_message(data)
|
||||||
if notification_model.is_accepted_notification(notification):
|
if notification_model.is_accepted_notification(notification):
|
||||||
notification_model.record_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)
|
user_id = notification_model.get_user_id(data)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
if exercise_model.is_accepted_query(data):
|
if exercise_model.is_accepted_query(data):
|
||||||
context = get_context(data)
|
context = get_context(topic, user_id, data)
|
||||||
succeeded_once = exercise_model.check_active_tasks(user_id, data, context)
|
succeeded_once = await exercise_model.check_active_tasks(user_id, data, context)
|
||||||
if succeeded_once:
|
if succeeded_once:
|
||||||
sendRefreshScore()
|
await sendRefreshScore()
|
||||||
|
|
||||||
|
|
||||||
@debounce(debounce_seconds=1)
|
@debounce(debounce_seconds=1)
|
||||||
def sendRefreshScore():
|
async def sendRefreshScore():
|
||||||
sio.emit('refresh_score')
|
await sio.emit('refresh_score')
|
||||||
|
|
||||||
|
|
||||||
def get_context(data: dict) -> dict:
|
def get_context(topic: str, user_id: int, data: dict) -> dict:
|
||||||
context = {}
|
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 'Log' in data:
|
||||||
if 'request_is_rest' in data['Log']:
|
if 'request_is_rest' in data['Log']:
|
||||||
context['request_is_rest'] = data['Log']['request_is_rest']
|
context['request_is_rest'] = data['Log']['request_is_rest']
|
||||||
|
@ -158,35 +185,87 @@ def get_context(data: dict) -> dict:
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
def getDiagnostic() -> dict:
|
async def getDiagnostic() -> dict:
|
||||||
global ZMQ_MESSAGE_COUNT
|
global ZMQ_MESSAGE_COUNT
|
||||||
|
|
||||||
diagnostic = {}
|
diagnostic = {}
|
||||||
misp_version = misp_api.getVersion()
|
misp_version = await misp_api.getVersion()
|
||||||
if misp_version is None:
|
if misp_version is None:
|
||||||
diagnostic['online'] = False
|
diagnostic['online'] = False
|
||||||
return diagnostic
|
return diagnostic
|
||||||
diagnostic['version'] = misp_version
|
diagnostic['version'] = misp_version
|
||||||
misp_settings = misp_api.getSettings()
|
misp_settings = await misp_api.getSettings()
|
||||||
diagnostic['settings'] = misp_settings
|
diagnostic['settings'] = misp_settings
|
||||||
diagnostic['zmq_message_count'] = ZMQ_MESSAGE_COUNT
|
diagnostic['zmq_message_count'] = ZMQ_MESSAGE_COUNT
|
||||||
return diagnostic
|
return diagnostic
|
||||||
|
|
||||||
|
|
||||||
# Function to forward zmq messages to Socket.IO
|
async def notification_history():
|
||||||
def forward_zmq_to_socketio():
|
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
|
||||||
global ZMQ_MESSAGE_COUNT
|
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:
|
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(" ")
|
topic, s, m = message.partition(" ")
|
||||||
|
await handleMessage(topic, s, m)
|
||||||
try:
|
try:
|
||||||
ZMQ_MESSAGE_COUNT += 1
|
ZMQ_MESSAGE_COUNT += 1
|
||||||
handleMessage(topic, s, m)
|
ZMQ_LAST_TIME = time.time()
|
||||||
|
# await handleMessage(topic, s, m)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Error handling message %s', 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__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
exercises_loaded = exercise_model.load_exercises()
|
exercises_loaded = exercise_model.load_exercises()
|
||||||
|
@ -194,8 +273,6 @@ if __name__ == "__main__":
|
||||||
logger.critical('Could not load exercises')
|
logger.critical('Could not load exercises')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Start the forwarding in a separate thread
|
exercise_model.restore_exercices_progress()
|
||||||
eventlet.spawn_n(forward_zmq_to_socketio)
|
|
||||||
|
|
||||||
# Run the Socket.IO server
|
web.run_app(init_app(), host=config.server_host, port=config.server_port)
|
||||||
eventlet.wsgi.server(eventlet.listen((config.server_host, config.server_port)), app)
|
|
||||||
|
|
16
src/App.vue
16
src/App.vue
|
@ -5,18 +5,20 @@ import TheAdminPanel from './components/TheAdminPanel.vue'
|
||||||
import TheSocketConnectionState from './components/TheSocketConnectionState.vue'
|
import TheSocketConnectionState from './components/TheSocketConnectionState.vue'
|
||||||
import TheDahboard from './TheDahboard.vue'
|
import TheDahboard from './TheDahboard.vue'
|
||||||
import { socketConnected } from "@/socket";
|
import { socketConnected } from "@/socket";
|
||||||
|
import { darkModeEnabled } from "@/settings.js"
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.getElementsByTagName('body')[0].classList.add('dark')
|
if (darkModeEnabled.value) {
|
||||||
document.getElementById('app').classList.add('w-5/6')
|
document.getElementsByTagName('body')[0].classList.add('dark')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<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="absolute top-1 right-1">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<TheThemeButton></TheThemeButton>
|
<TheThemeButton></TheThemeButton>
|
||||||
|
@ -37,4 +39,12 @@ body {
|
||||||
@apply dark:text-slate-300;
|
@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>
|
</style>
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,20 +1,3 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
#app {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
|
|
129
src/components/LiveLogsUserActivityGraph.vue
Normal file
129
src/components/LiveLogsUserActivityGraph.vue
Normal 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>
|
|
@ -1,16 +1,22 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from "vue"
|
import { ref, watch, computed } from "vue"
|
||||||
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode } from "@/socket";
|
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } from "@/socket";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
|
import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue";
|
||||||
|
|
||||||
|
|
||||||
const verbose = ref(false)
|
const verbose = ref(false)
|
||||||
|
const api_query = ref(false)
|
||||||
|
|
||||||
watch(verbose, (newValue) => {
|
watch(verbose, (newValue) => {
|
||||||
toggleVerboseMode(newValue == true)
|
toggleVerboseMode(newValue == true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(api_query, (newValue) => {
|
||||||
|
toggleApiQueryMode(newValue == true)
|
||||||
|
})
|
||||||
|
|
||||||
function getClassFromResponseCode(response_code) {
|
function getClassFromResponseCode(response_code) {
|
||||||
if (String(response_code).startsWith('2')) {
|
if (String(response_code).startsWith('2')) {
|
||||||
return 'text-green-500'
|
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="rounded-lg py-1 px-2 dark:bg-sky-700 bg-sky-400 text-slate-800 dark:text-slate-200">
|
||||||
<span class="mr-1">
|
<span class="mr-1">
|
||||||
<FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
|
<FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
|
||||||
User online:
|
Players:
|
||||||
</span>
|
</span>
|
||||||
<span class="font-bold">{{ userCount }}</span>
|
<span class="font-bold">{{ userCount }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -52,13 +58,22 @@
|
||||||
<span class="font-bold">{{ notificationAPICounter }}</span>
|
<span class="font-bold">{{ notificationAPICounter }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<label class="mr-1 flex items-center cursor-pointer">
|
<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="verbose" @change="verbose = !verbose"/>
|
<input type="checkbox" class="toggle toggle-warning [--fallback-su:#22c55e] mr-1" :checked="verbose" @change="verbose = !verbose"/>
|
||||||
Verbose
|
Verbose
|
||||||
</label>
|
</label>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<TheLiveLogsActivityGraphVue></TheLiveLogsActivityGraphVue>
|
||||||
|
|
||||||
<table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full">
|
<table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="font-medium dark:text-slate-200 text-slate-600 ">
|
<tr class="font-medium dark:text-slate-200 text-slate-600 ">
|
||||||
|
|
83
src/components/TheLiveLogsActivityGraph.vue
Normal file
83
src/components/TheLiveLogsActivityGraph.vue
Normal 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>
|
|
@ -2,7 +2,8 @@
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
|
import { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
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([])
|
const collapsed_panels = ref([])
|
||||||
|
|
||||||
|
@ -63,10 +64,11 @@
|
||||||
<th
|
<th
|
||||||
v-for="(task, task_index) in exercise.tasks"
|
v-for="(task, task_index) in exercise.tasks"
|
||||||
:key="task.name"
|
: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">
|
<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>
|
<i class="text-center">{{ task.name }}</i>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
@ -84,29 +86,58 @@
|
||||||
</tr>
|
</tr>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr v-for="(progress, user_id) in progresses" :key="user_id" class="bg-slate-100 dark:bg-slate-900">
|
<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">
|
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative">
|
||||||
<span :title="user_id">
|
<span class="flex flex-col max-w-60">
|
||||||
<span class="text-lg font-bold font-mono">{{ progress.email.split('@')[0] }}</span>
|
<span :title="user_id" class="text-nowrap inline-block leading-5 truncate">
|
||||||
<span class="text-xs font-mono">@{{ progress.email.split('@')[1] }}</span>
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-for="(task, task_index) in exercise.tasks"
|
v-for="(task, task_index) in exercise.tasks"
|
||||||
:key="task_index"
|
: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
|
<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)"
|
@click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], user_id, exercise.uuid, task.uuid)"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<span class="flex flex-col">
|
||||||
:icon="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? faCheck : faTimes"
|
<span class="text-nowrap">
|
||||||
: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-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
|
||||||
<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>
|
: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>
|
</span>
|
||||||
</td>
|
</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 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
|
<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"
|
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"
|
||||||
|
|
|
@ -1,16 +1,46 @@
|
||||||
<script setup>
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span :class="{
|
<span class="flex flex-col justify-center mt-1">
|
||||||
'px-2 py-1 rounded-md inline-block w-48': true,
|
<span :class="{
|
||||||
'text-slate-900 dark:text-slate-400': socketConnected,
|
'px-2 rounded-md inline-block w-48 leading-4': true,
|
||||||
'text-slate-50 bg-red-600': !socketConnected,
|
'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 class="mr-1">Socket.IO:</span>
|
||||||
<span v-show="!socketConnected" class="font-semibold text-slate-50">Disconnected</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>
|
</span>
|
||||||
</template>
|
</template>
|
|
@ -2,14 +2,16 @@
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'
|
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) => {
|
watch(darkMode, (newValue) => {
|
||||||
|
darkModeOn.value = newValue
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
document.getElementsByTagName('body')[0].classList.add('dark')
|
document.getElementsByTagName('body')[0].classList.add('dark')
|
||||||
} else {
|
} else {
|
||||||
document.getElementsByTagName('body')[0].classList.remove('dark')
|
document.getElementsByTagName('body')[0].classList.remove('dark')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
import VueApexCharts from "vue3-apexcharts";
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.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
4
src/settings.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export const darkModeOn = ref(true)
|
||||||
|
export const darkModeEnabled = computed(() => darkModeOn.value)
|
|
@ -3,13 +3,17 @@ import { io } from "socket.io-client";
|
||||||
import debounce from 'lodash.debounce'
|
import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
// "undefined" means the URL will be computed from the `window.location` object
|
// "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 MAX_LIVE_LOG = 30
|
||||||
|
|
||||||
const initial_state = {
|
const initial_state = {
|
||||||
notificationEvents: [],
|
notificationEvents: [],
|
||||||
notificationCounter: 0,
|
notificationCounter: 0,
|
||||||
notificationAPICounter: 0,
|
notificationAPICounter: 0,
|
||||||
|
notificationHistory: [],
|
||||||
|
notificationHistoryConfig: {},
|
||||||
|
userActivity: {},
|
||||||
|
userActivityConfig: {},
|
||||||
exercises: [],
|
exercises: [],
|
||||||
selected_exercises: [],
|
selected_exercises: [],
|
||||||
progresses: {},
|
progresses: {},
|
||||||
|
@ -18,7 +22,8 @@ const initial_state = {
|
||||||
|
|
||||||
const state = reactive({ ...initial_state });
|
const state = reactive({ ...initial_state });
|
||||||
const connectionState = reactive({
|
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 notificationAPICounter = computed(() => state.notificationAPICounter)
|
||||||
export const userCount = computed(() => Object.keys(state.progresses).length)
|
export const userCount = computed(() => Object.keys(state.progresses).length)
|
||||||
export const diagnostic = computed(() => state.diagnostic)
|
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 socketConnected = computed(() => connectionState.connected)
|
||||||
|
export const zmqLastTime = computed(() => connectionState.zmq_last_time)
|
||||||
|
|
||||||
export function resetState() {
|
export function resetState() {
|
||||||
Object.assign(state, initial_state);
|
Object.assign(state, initial_state);
|
||||||
|
@ -50,6 +60,7 @@ export function fullReload() {
|
||||||
getSelectedExercises()
|
getSelectedExercises()
|
||||||
getNotifications()
|
getNotifications()
|
||||||
getProgress()
|
getProgress()
|
||||||
|
getUsersActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
|
export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
|
||||||
|
@ -81,6 +92,10 @@ export function toggleVerboseMode(enabled) {
|
||||||
sendToggleVerboseMode(enabled)
|
sendToggleVerboseMode(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleApiQueryMode(enabled) {
|
||||||
|
sendToggleApiQueryMode(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
export const debouncedGetProgress = debounce(getProgress, 200, {leading: true})
|
export const debouncedGetProgress = debounce(getProgress, 200, {leading: true})
|
||||||
export const debouncedGetDiangostic = debounce(getDiangostic, 1000, {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() {
|
function getDiangostic() {
|
||||||
state.diagnostic = {}
|
state.diagnostic = {}
|
||||||
socket.emit("get_diagnostic", (diagnostic) => {
|
socket.emit("get_diagnostic", (diagnostic) => {
|
||||||
|
@ -151,6 +174,13 @@ function sendToggleVerboseMode(enabled) {
|
||||||
socket.emit("toggle_verbose_mode", payload, () => {})
|
socket.emit("toggle_verbose_mode", payload, () => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendToggleApiQueryMode(enabled) {
|
||||||
|
const payload = {
|
||||||
|
apiquery: enabled
|
||||||
|
}
|
||||||
|
socket.emit("toggle_apiquery_mode", payload, () => {})
|
||||||
|
}
|
||||||
|
|
||||||
/* Event listener */
|
/* Event listener */
|
||||||
|
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => {
|
||||||
|
@ -177,6 +207,20 @@ socket.on("refresh_score", (new_user) => {
|
||||||
debouncedGetProgress()
|
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) {
|
function addLimited(target, message, maxCount) {
|
||||||
target.unshift(message)
|
target.unshift(message)
|
||||||
if (target.length > maxCount) {
|
if (target.length > maxCount) {
|
||||||
|
|
|
@ -8,7 +8,13 @@ export default {
|
||||||
extend: {
|
extend: {
|
||||||
transitionProperty: {
|
transitionProperty: {
|
||||||
'width': 'width'
|
'width': 'width'
|
||||||
} ,
|
},
|
||||||
|
screens: {
|
||||||
|
'3xl': '1800px',
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
'xxs': '0.6rem',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
Loading…
Reference in a new issue