Compare commits
No commits in common. "main" and "feature/chart" have entirely different histories.
main
...
feature/ch
36 changed files with 1877 additions and 3074 deletions
|
@ -1,6 +1,4 @@
|
||||||
# SkillAegis
|
# misp-exercise-dashboard
|
||||||
|
|
||||||
<img alt="SkillAegis Logo" src="src/assets/skillaegis-logo.svg"/>
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
```bash
|
```bash
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
../exercises/spearphishing-incident.json
|
|
38
appConfig.py
38
appConfig.py
|
@ -1,38 +0,0 @@
|
||||||
|
|
||||||
live_logs_accepted_scope = {
|
|
||||||
'events': ['add', 'edit', 'delete', 'restSearch',],
|
|
||||||
'attributes': ['add', 'add_attachment', 'edit', 'revise_object', 'delete', 'restSearch',],
|
|
||||||
'eventReports': ['add', 'edit', 'delete',],
|
|
||||||
'tags': '*',
|
|
||||||
}
|
|
||||||
|
|
||||||
user_activity_accepted_scope = {
|
|
||||||
'events': ['view', 'add', 'edit', 'delete', 'restSearch',],
|
|
||||||
'attributes': ['add', 'add_attachment', 'edit', 'delete', 'restSearch',],
|
|
||||||
'objects': ['add', 'edit', 'revise_object', 'delete',],
|
|
||||||
'eventReports': ['view', 'add', 'edit', 'delete',],
|
|
||||||
'tags': '*',
|
|
||||||
}
|
|
||||||
|
|
||||||
misp_settings = {
|
|
||||||
'Plugin.ZeroMQ_enable': True,
|
|
||||||
'Plugin.ZeroMQ_audit_notifications_enable': True,
|
|
||||||
'Plugin.ZeroMQ_event_notifications_enable': True,
|
|
||||||
'Plugin.ZeroMQ_attribute_notifications_enable': True,
|
|
||||||
'MISP.log_paranoid': True,
|
|
||||||
'MISP.log_paranoid_skip_db': True,
|
|
||||||
'MISP.log_paranoid_include_post_body': True,
|
|
||||||
'MISP.log_auth': True,
|
|
||||||
'Security.allow_unsafe_cleartext_apikey_logging': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger('SkillAegis')
|
|
||||||
format = '[%(levelname)s] %(asctime)s - %(message)s'
|
|
||||||
formatter = logging.Formatter(format)
|
|
||||||
logging.basicConfig(filename='SkillAegis.log', encoding='utf-8', level=logging.DEBUG, format=format)
|
|
||||||
# create console handler and set level to debug
|
|
||||||
ch = logging.StreamHandler()
|
|
||||||
ch.setLevel(logging.INFO)
|
|
||||||
ch.setFormatter(formatter)
|
|
||||||
logger.addHandler(ch)
|
|
|
@ -6,3 +6,20 @@ zmq_url = 'tcp://localhost:50000'
|
||||||
misp_url = 'https://localhost/'
|
misp_url = 'https://localhost/'
|
||||||
misp_apikey = 'FI4gCRghRZvLVjlLPLTFZ852x2njkkgPSz0zQ3E0'
|
misp_apikey = 'FI4gCRghRZvLVjlLPLTFZ852x2njkkgPSz0zQ3E0'
|
||||||
misp_skipssl = True
|
misp_skipssl = True
|
||||||
|
|
||||||
|
live_logs_accepted_scope = {
|
||||||
|
'events': ['add', 'edit', 'delete', 'restSearch',],
|
||||||
|
'attributes': ['add', 'edit', 'delete', 'restSearch',],
|
||||||
|
'eventReports': ['add', 'edit', 'delete',],
|
||||||
|
'tags': '*',
|
||||||
|
}
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger('misp-exercise-dashboard')
|
||||||
|
format = '[%(levelname)s] %(asctime)s - %(message)s'
|
||||||
|
formatter = logging.Formatter(format)
|
||||||
|
logging.basicConfig(filename='misp-exercise-dashboard.log', encoding='utf-8', level=logging.DEBUG, format=format)
|
||||||
|
ch = logging.StreamHandler()
|
||||||
|
ch.setLevel(logging.INFO)
|
||||||
|
ch.setFormatter(formatter)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
1
dist/assets/index-7ybfbefL.css
vendored
1
dist/assets/index-7ybfbefL.css
vendored
File diff suppressed because one or more lines are too long
1480
dist/assets/index-BJjpd8Qi.js
vendored
Normal file
1480
dist/assets/index-BJjpd8Qi.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1487
dist/assets/index-BS0mgB3_.js
vendored
1487
dist/assets/index-BS0mgB3_.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-CiEfiGI-.css
vendored
Normal file
1
dist/assets/index-CiEfiGI-.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-BS0mgB3_.js"></script>
|
<script type="module" crossorigin src="/assets/index-BJjpd8Qi.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-7ybfbefL.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CiEfiGI-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
74
exercise.py
74
exercise.py
|
@ -11,11 +11,11 @@ import jq
|
||||||
import db
|
import db
|
||||||
from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search
|
from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search
|
||||||
import misp_api
|
import misp_api
|
||||||
from appConfig import logger
|
import config
|
||||||
|
from config import logger
|
||||||
|
|
||||||
|
|
||||||
ACTIVE_EXERCISES_DIR = "active_exercises"
|
ACTIVE_EXERCISES_DIR = "active_exercises"
|
||||||
LAST_BACKUP = {}
|
|
||||||
|
|
||||||
def debounce_check_active_tasks(debounce_seconds: int = 1):
|
def debounce_check_active_tasks(debounce_seconds: int = 1):
|
||||||
func_last_execution_time = {}
|
func_last_execution_time = {}
|
||||||
|
@ -61,46 +61,26 @@ def read_exercise_dir():
|
||||||
|
|
||||||
|
|
||||||
def backup_exercises_progress():
|
def backup_exercises_progress():
|
||||||
global LAST_BACKUP
|
with open('backup.json', 'w') as f:
|
||||||
toBackup = {
|
toBackup = {
|
||||||
'EXERCISES_STATUS': db.EXERCISES_STATUS,
|
'EXERCISES_STATUS': db.EXERCISES_STATUS,
|
||||||
'SELECTED_EXERCISES': db.SELECTED_EXERCISES,
|
'SELECTED_EXERCISES': db.SELECTED_EXERCISES,
|
||||||
'USER_ID_TO_EMAIL_MAPPING': db.USER_ID_TO_EMAIL_MAPPING,
|
'USER_ID_TO_EMAIL_MAPPING': db.USER_ID_TO_EMAIL_MAPPING,
|
||||||
'USER_ID_TO_AUTHKEY_MAPPING': db.USER_ID_TO_AUTHKEY_MAPPING,
|
'USER_ID_TO_AUTHKEY_MAPPING': db.USER_ID_TO_AUTHKEY_MAPPING,
|
||||||
}
|
}
|
||||||
if toBackup != LAST_BACKUP:
|
|
||||||
with open('backup.json', 'w') as f:
|
|
||||||
json.dump(toBackup, f)
|
json.dump(toBackup, f)
|
||||||
LAST_BACKUP = toBackup
|
|
||||||
|
|
||||||
|
|
||||||
def restore_exercices_progress():
|
def restore_exercices_progress():
|
||||||
try:
|
try:
|
||||||
|
|
||||||
with open('backup.json', 'r') as f:
|
with open('backup.json', 'r') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
db.EXERCISES_STATUS = data['EXERCISES_STATUS']
|
db.EXERCISES_STATUS = data['EXERCISES_STATUS']
|
||||||
db.SELECTED_EXERCISES = data['SELECTED_EXERCISES']
|
db.SELECTED_EXERCISES = data['SELECTED_EXERCISES']
|
||||||
db.USER_ID_TO_EMAIL_MAPPING = {}
|
db.USER_ID_TO_EMAIL_MAPPING = data['USER_ID_TO_EMAIL_MAPPING']
|
||||||
for user_id_str, email in data['USER_ID_TO_EMAIL_MAPPING'].items():
|
db.USER_ID_TO_AUTHKEY_MAPPING = data['USER_ID_TO_AUTHKEY_MAPPING']
|
||||||
db.USER_ID_TO_EMAIL_MAPPING[int(user_id_str)] = email
|
|
||||||
db.USER_ID_TO_AUTHKEY_MAPPING = {}
|
|
||||||
for user_id_str, authkey in data['USER_ID_TO_AUTHKEY_MAPPING'].items():
|
|
||||||
db.USER_ID_TO_AUTHKEY_MAPPING[int(user_id_str)] = authkey
|
|
||||||
except:
|
except:
|
||||||
logger.info('Could not restore exercise progress')
|
logger.info('Could not restore exercise progress')
|
||||||
resetAll()
|
|
||||||
|
|
||||||
if len(db.EXERCISES_STATUS) == 0:
|
|
||||||
init_exercises_tasks()
|
|
||||||
|
|
||||||
|
|
||||||
def resetAll():
|
|
||||||
db.EXERCISES_STATUS = {}
|
|
||||||
db.SELECTED_EXERCISES = []
|
|
||||||
db.USER_ID_TO_EMAIL_MAPPING = {}
|
|
||||||
db.USER_ID_TO_AUTHKEY_MAPPING = {}
|
|
||||||
init_exercises_tasks()
|
|
||||||
|
|
||||||
|
|
||||||
def is_validate_exercises(exercises: list) -> bool:
|
def is_validate_exercises(exercises: list) -> bool:
|
||||||
|
@ -227,20 +207,15 @@ def resetAllExerciseProgress():
|
||||||
backup_exercises_progress()
|
backup_exercises_progress()
|
||||||
|
|
||||||
|
|
||||||
def resetAllCommand():
|
|
||||||
resetAll()
|
|
||||||
backup_exercises_progress()
|
|
||||||
|
|
||||||
|
|
||||||
def get_completed_tasks_for_user(user_id: int):
|
def get_completed_tasks_for_user(user_id: int):
|
||||||
completion = get_completion_for_users().get(user_id, {})
|
completion = get_completion_for_users()[user_id]
|
||||||
completed_tasks = {}
|
completed_tasks = {}
|
||||||
for exec_uuid, tasks in completion.items():
|
for exec_uuid, tasks in completion.items():
|
||||||
completed_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if completed]
|
completed_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if completed]
|
||||||
return completed_tasks
|
return completed_tasks
|
||||||
|
|
||||||
def get_incomplete_tasks_for_user(user_id: int):
|
def get_incomplete_tasks_for_user(user_id: int):
|
||||||
completion = get_completion_for_users().get(user_id, {})
|
completion = get_completion_for_users()[user_id]
|
||||||
incomplete_tasks = {}
|
incomplete_tasks = {}
|
||||||
for exec_uuid, tasks in completion.items():
|
for exec_uuid, tasks in completion.items():
|
||||||
incomplete_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if not completed]
|
incomplete_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if not completed]
|
||||||
|
@ -261,8 +236,8 @@ def get_available_tasks_for_user(user_id: int) -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def get_model_action(data: dict):
|
def get_model_action(data: dict):
|
||||||
if 'Log' in data or 'AuditLog' in data:
|
if 'Log' in data:
|
||||||
data = data['Log'] if 'Log' in data else data['AuditLog']
|
data = data['Log']
|
||||||
if 'model' in data and 'action' in data:
|
if 'model' in data and 'action' in data:
|
||||||
return (data['model'], data['action'],)
|
return (data['model'], data['action'],)
|
||||||
return (None, None,)
|
return (None, None,)
|
||||||
|
@ -271,12 +246,14 @@ def is_accepted_query(data: dict) -> bool:
|
||||||
model, action = get_model_action(data)
|
model, action = get_model_action(data)
|
||||||
if model in ['Event', 'Attribute', 'Object', 'Tag',]:
|
if model in ['Event', 'Attribute', 'Object', 'Tag',]:
|
||||||
if action in ['add', 'edit', 'delete', 'publish', 'tag']:
|
if action in ['add', 'edit', 'delete', 'publish', 'tag']:
|
||||||
if 'Log' in data:
|
# # improved condition below. It blocks some queries
|
||||||
|
# if data['Log']['change'].startswith('attribute_count'):
|
||||||
|
# return False
|
||||||
if data['Log']['change'].startswith('Validation errors:'):
|
if data['Log']['change'].startswith('Validation errors:'):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if data.get('user_agent', None) == 'SkillAegis':
|
if data.get('user_agent', None) == 'misp-exercise-dashboard':
|
||||||
return None
|
return None
|
||||||
url = data.get('url', None)
|
url = data.get('url', None)
|
||||||
if url is not None:
|
if url is not None:
|
||||||
|
@ -297,9 +274,8 @@ def get_completion_for_users():
|
||||||
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 entry in task['completed_by_user']:
|
for entry in task['completed_by_user']:
|
||||||
user_id = int(entry['user_id'])
|
user_id = entry['user_id']
|
||||||
if user_id in completion_per_user: # Ensure the user_id is known in USER_ID_TO_EMAIL_MAPPING
|
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
|
||||||
completion_per_user[user_id][exercise_status['uuid']][task['uuid']] = entry
|
|
||||||
|
|
||||||
return completion_per_user
|
return completion_per_user
|
||||||
|
|
||||||
|
@ -345,13 +321,9 @@ def mark_task_incomplete(user_id: int, exercise_uuid: str , task_uuid: str):
|
||||||
def get_progress():
|
def get_progress():
|
||||||
completion_for_users = get_completion_for_users()
|
completion_for_users = get_completion_for_users()
|
||||||
progress = {}
|
progress = {}
|
||||||
for user_id in completion_for_users.keys():
|
for user_id in completion_for_users:
|
||||||
if user_id not in db.USER_ID_TO_EMAIL_MAPPING:
|
|
||||||
print('unknown user id', user_id)
|
|
||||||
continue
|
|
||||||
progress[user_id] = {
|
progress[user_id] = {
|
||||||
'email': db.USER_ID_TO_EMAIL_MAPPING[user_id],
|
'email': db.USER_ID_TO_EMAIL_MAPPING[user_id],
|
||||||
'user_id': user_id,
|
|
||||||
'exercises': {},
|
'exercises': {},
|
||||||
}
|
}
|
||||||
for exec_uuid, tasks_completion in completion_for_users[user_id].items():
|
for exec_uuid, tasks_completion in completion_for_users[user_id].items():
|
||||||
|
@ -367,10 +339,10 @@ async def check_inject(user_id: int, inject: dict, data: dict, context: dict) ->
|
||||||
for inject_evaluation in inject['inject_evaluation']:
|
for inject_evaluation in inject['inject_evaluation']:
|
||||||
success = await 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[{user_id}]: {inject['uuid']}")
|
logger.info(f"Task not completed: {inject['uuid']}")
|
||||||
return False
|
return False
|
||||||
mark_task_completed(user_id, inject['exercise_uuid'], inject['uuid'])
|
mark_task_completed(user_id, inject['exercise_uuid'], inject['uuid'])
|
||||||
logger.info(f"Task success[{user_id}]: {inject['uuid']}")
|
logger.info(f"Task success: {inject['uuid']}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -387,14 +359,13 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
|
||||||
else:
|
else:
|
||||||
logger.debug('Unknown request type')
|
logger.debug('Unknown request type')
|
||||||
return False
|
return False
|
||||||
return True
|
return False
|
||||||
|
|
||||||
async 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:
|
||||||
logger.warning('Evaluation strategy not specified in inject')
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
data_to_validate = await get_data_to_validate(user_id, inject_evaluation, data)
|
data_to_validate = await get_data_to_validate(user_id, inject_evaluation, data)
|
||||||
|
@ -443,13 +414,6 @@ def parse_event_id_from_log(data: dict) -> Union[int, None]:
|
||||||
if event_id_search is not None:
|
if event_id_search is not None:
|
||||||
event_id = event_id_search.group(1)
|
event_id = event_id_search.group(1)
|
||||||
return event_id
|
return event_id
|
||||||
elif 'AuditLog' in data:
|
|
||||||
log = data['AuditLog']
|
|
||||||
if 'model' in log and 'model_id' in log and log['model'] == 'Event':
|
|
||||||
return int(log['model_id'])
|
|
||||||
if 'change' in log:
|
|
||||||
if 'event_id' in log and log['event_id'] is not None:
|
|
||||||
return int(log['event_id'])
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,8 @@
|
||||||
"followed_by": [
|
"followed_by": [
|
||||||
"3e61a340-0314-4622-91cc-042f3ff8543a"
|
"3e61a340-0314-4622-91cc-042f3ff8543a"
|
||||||
],
|
],
|
||||||
"trigger": []
|
"trigger": [
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"timing": {
|
"timing": {
|
||||||
"triggered_at": null
|
"triggered_at": null
|
||||||
|
@ -65,7 +66,7 @@
|
||||||
"inject_uuid": "3e61a340-0314-4622-91cc-042f3ff8543a",
|
"inject_uuid": "3e61a340-0314-4622-91cc-042f3ff8543a",
|
||||||
"reporting_callback": [],
|
"reporting_callback": [],
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
"inject_uuid": "8f636640-e4f0-4ffb-abff-4e85597aa1bd"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"completion_trigger": [
|
"completion_trigger": [
|
||||||
|
@ -75,7 +76,8 @@
|
||||||
"followed_by": [
|
"followed_by": [
|
||||||
"8a2d58c8-2b3a-4ba2-bb77-15bcfa704828"
|
"8a2d58c8-2b3a-4ba2-bb77-15bcfa704828"
|
||||||
],
|
],
|
||||||
"trigger": []
|
"trigger": [
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"timing": {
|
"timing": {
|
||||||
"triggered_at": null
|
"triggered_at": null
|
||||||
|
@ -86,7 +88,7 @@
|
||||||
"inject_uuid": "8a2d58c8-2b3a-4ba2-bb77-15bcfa704828",
|
"inject_uuid": "8a2d58c8-2b3a-4ba2-bb77-15bcfa704828",
|
||||||
"reporting_callback": [],
|
"reporting_callback": [],
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
"inject_uuid": "3e61a340-0314-4622-91cc-042f3ff8543a"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"completion_trigger": [
|
"completion_trigger": [
|
||||||
|
@ -96,7 +98,8 @@
|
||||||
"followed_by": [
|
"followed_by": [
|
||||||
"9df13cc8-b61b-4c9f-a1a8-66def8b64439"
|
"9df13cc8-b61b-4c9f-a1a8-66def8b64439"
|
||||||
],
|
],
|
||||||
"trigger": []
|
"trigger": [
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"timing": {
|
"timing": {
|
||||||
"triggered_at": null
|
"triggered_at": null
|
||||||
|
@ -107,7 +110,7 @@
|
||||||
"inject_uuid": "9df13cc8-b61b-4c9f-a1a8-66def8b64439",
|
"inject_uuid": "9df13cc8-b61b-4c9f-a1a8-66def8b64439",
|
||||||
"reporting_callback": [],
|
"reporting_callback": [],
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
"inject_uuid": "8a2d58c8-2b3a-4ba2-bb77-15bcfa704828"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"completion_trigger": [
|
"completion_trigger": [
|
||||||
|
@ -117,7 +120,8 @@
|
||||||
"followed_by": [
|
"followed_by": [
|
||||||
"c5c03af1-7ef3-44e7-819a-6c4fd402148a"
|
"c5c03af1-7ef3-44e7-819a-6c4fd402148a"
|
||||||
],
|
],
|
||||||
"trigger": []
|
"trigger": [
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"timing": {
|
"timing": {
|
||||||
"triggered_at": null
|
"triggered_at": null
|
||||||
|
@ -128,7 +132,7 @@
|
||||||
"inject_uuid": "c5c03af1-7ef3-44e7-819a-6c4fd402148a",
|
"inject_uuid": "c5c03af1-7ef3-44e7-819a-6c4fd402148a",
|
||||||
"reporting_callback": [],
|
"reporting_callback": [],
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
"inject_uuid": "9df13cc8-b61b-4c9f-a1a8-66def8b64439"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"completion_trigger": [
|
"completion_trigger": [
|
||||||
|
@ -138,7 +142,8 @@
|
||||||
"followed_by": [
|
"followed_by": [
|
||||||
"11f6f0c2-8813-42ee-a312-136649d3f077"
|
"11f6f0c2-8813-42ee-a312-136649d3f077"
|
||||||
],
|
],
|
||||||
"trigger": []
|
"trigger": [
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"timing": {
|
"timing": {
|
||||||
"triggered_at": null
|
"triggered_at": null
|
||||||
|
@ -149,7 +154,7 @@
|
||||||
"inject_uuid": "11f6f0c2-8813-42ee-a312-136649d3f077",
|
"inject_uuid": "11f6f0c2-8813-42ee-a312-136649d3f077",
|
||||||
"reporting_callback": [],
|
"reporting_callback": [],
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
"inject_uuid": "c5c03af1-7ef3-44e7-819a-6c4fd402148a"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"completion_trigger": [
|
"completion_trigger": [
|
||||||
|
@ -159,7 +164,8 @@
|
||||||
"followed_by": [
|
"followed_by": [
|
||||||
"e3ef4e5f-454a-48c8-a5d7-b3d1d25ecc9f"
|
"e3ef4e5f-454a-48c8-a5d7-b3d1d25ecc9f"
|
||||||
],
|
],
|
||||||
"trigger": []
|
"trigger": [
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"timing": {
|
"timing": {
|
||||||
"triggered_at": null
|
"triggered_at": null
|
||||||
|
@ -170,21 +176,23 @@
|
||||||
"inject_uuid": "e3ef4e5f-454a-48c8-a5d7-b3d1d25ecc9f",
|
"inject_uuid": "e3ef4e5f-454a-48c8-a5d7-b3d1d25ecc9f",
|
||||||
"reporting_callback": [],
|
"reporting_callback": [],
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
"inject_uuid": "11f6f0c2-8813-42ee-a312-136649d3f077"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"completion_trigger": [
|
"completion_trigger": [
|
||||||
"time_expiration",
|
"time_expiration",
|
||||||
"completion"
|
"completion"
|
||||||
],
|
],
|
||||||
"trigger": []
|
"trigger": [
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"timing": {
|
"timing": {
|
||||||
"triggered_at": null
|
"triggered_at": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"inject_payloads": [],
|
"inject_payloads": [
|
||||||
|
],
|
||||||
"injects": [
|
"injects": [
|
||||||
{
|
{
|
||||||
"action": "event-creation",
|
"action": "event-creation",
|
||||||
|
@ -202,7 +210,8 @@
|
||||||
],
|
],
|
||||||
"result": "MISP Event created",
|
"result": "MISP Event created",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "data_filtering",
|
||||||
"evaluation_context": {},
|
"evaluation_context": {
|
||||||
|
},
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
|
@ -239,7 +248,8 @@
|
||||||
],
|
],
|
||||||
"result": "Infection Email added",
|
"result": "Infection Email added",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "data_filtering",
|
||||||
"evaluation_context": {},
|
"evaluation_context": {
|
||||||
|
},
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
|
@ -275,7 +285,8 @@
|
||||||
],
|
],
|
||||||
"result": "Malicious payload added",
|
"result": "Malicious payload added",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "data_filtering",
|
||||||
"evaluation_context": {},
|
"evaluation_context": {
|
||||||
|
},
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
|
@ -311,7 +322,8 @@
|
||||||
],
|
],
|
||||||
"result": "C2 IP added",
|
"result": "C2 IP added",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "data_filtering",
|
||||||
"evaluation_context": {},
|
"evaluation_context": {
|
||||||
|
},
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
|
@ -347,7 +359,8 @@
|
||||||
],
|
],
|
||||||
"result": "Registry key added",
|
"result": "Registry key added",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "data_filtering",
|
||||||
"evaluation_context": {},
|
"evaluation_context": {
|
||||||
|
},
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
|
@ -383,7 +396,8 @@
|
||||||
],
|
],
|
||||||
"result": "Public key added",
|
"result": "Public key added",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "data_filtering",
|
||||||
"evaluation_context": {},
|
"evaluation_context": {
|
||||||
|
},
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
|
@ -419,7 +433,8 @@
|
||||||
],
|
],
|
||||||
"result": "Context added",
|
"result": "Context added",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "data_filtering",
|
||||||
"evaluation_context": {},
|
"evaluation_context": {
|
||||||
|
},
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
|
@ -454,7 +469,8 @@
|
||||||
],
|
],
|
||||||
"result": "Event published",
|
"result": "Event published",
|
||||||
"evaluation_strategy": "data_filtering",
|
"evaluation_strategy": "data_filtering",
|
||||||
"evaluation_context": {},
|
"evaluation_context": {
|
||||||
|
},
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
|
|
|
@ -1,519 +0,0 @@
|
||||||
{
|
|
||||||
"exercise": {
|
|
||||||
"description": "MISP Encoding Exercise : Spearphishing Incident",
|
|
||||||
"expanded": "MISP Encoding Exercise : Spearphishing Incident",
|
|
||||||
"meta": {
|
|
||||||
"author": "MISP Project",
|
|
||||||
"level": "beginner",
|
|
||||||
"priority": 5
|
|
||||||
},
|
|
||||||
"name": "MISP Encoding Exercise : Spearphishing Incident",
|
|
||||||
"namespace": "data-model",
|
|
||||||
"tags": [
|
|
||||||
"exercise:software-scope=\"misp\"",
|
|
||||||
"state:production"
|
|
||||||
],
|
|
||||||
"total_duration": "7200",
|
|
||||||
"uuid": "53b20321-ac8c-4a3e-9c56-e772caf669e6",
|
|
||||||
"version": "20240715"
|
|
||||||
},
|
|
||||||
"inject_flow": [
|
|
||||||
{
|
|
||||||
"description": "event-creation",
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": [
|
|
||||||
"startex"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "IP-address",
|
|
||||||
"inject_uuid": "92fc404b-2dce-4815-8a7e-b68a582c3569",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": []
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "malicious-payloads",
|
|
||||||
"inject_uuid": "cfc47f7c-590c-4897-bfb9-cc72965fee24",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": []
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Download URL",
|
|
||||||
"inject_uuid": "e849a314-3394-4501-a9e1-126e0e61f11d",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": []
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "CVE",
|
|
||||||
"inject_uuid": "32141393-adce-4007-950c-77b4c7c60a39",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": []
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "C2",
|
|
||||||
"inject_uuid": "a0d7f076-1737-4c1c-af36-c2717885299e",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": []
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Person",
|
|
||||||
"inject_uuid": "92a55537-0e4c-44f8-8bcd-102c38d343a9",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": []
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Contextualization",
|
|
||||||
"inject_uuid": "b19e8d39-e64e-4a51-94ee-462cd74b8d24",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": []
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Published",
|
|
||||||
"inject_uuid": "930459b8-ed61-4e62-b072-071577ea0430",
|
|
||||||
"reporting_callback": [],
|
|
||||||
"requirements": {
|
|
||||||
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
"sequence": {
|
|
||||||
"completion_trigger": [
|
|
||||||
"time_expiration",
|
|
||||||
"completion"
|
|
||||||
],
|
|
||||||
"followed_by": [],
|
|
||||||
"trigger": []
|
|
||||||
},
|
|
||||||
"timing": {
|
|
||||||
"triggered_at": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"inject_payloads": [],
|
|
||||||
"injects": [
|
|
||||||
{
|
|
||||||
"action": "event-creation",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "MISP Event created",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
10
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Event Creation",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "ip-address",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"[.Event.Object[].Attribute[], .Event.Attribute[]] | .[] | select(.value == \"john.doe@luxembourg.edu\")": {
|
|
||||||
"extract_type": "all",
|
|
||||||
"comparison": "count",
|
|
||||||
"values": [
|
|
||||||
">0"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "Email address spoofed",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Email address",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "92fc404b-2dce-4815-8a7e-b68a582c3569"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "malware-sample",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
".Event.Object[].Attribute[].value": {
|
|
||||||
"extract_type": "all",
|
|
||||||
"comparison": "contains",
|
|
||||||
"values": [
|
|
||||||
"7c08ddb3b57cf9a00f02a484e23a4b6c8a6d738d"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "Malware samples added",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Malware sample",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "cfc47f7c-590c-4897-bfb9-cc72965fee24"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "download url",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
".Event.Object[].Attribute[] | select((.type == \"url\")).value": {
|
|
||||||
"extract_type": "all",
|
|
||||||
"comparison": "contains",
|
|
||||||
"values": [
|
|
||||||
"https://evilprovider.com/this-is-not-malicious.exe"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
".Event.Object[].Attribute[] | select((.type == \"domain\") or (.type == \"hostname\")).value": {
|
|
||||||
"extract_type": "all",
|
|
||||||
"comparison": "equals",
|
|
||||||
"values": [
|
|
||||||
"evilprovider.com"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "Download URL added",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Download URL",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "e849a314-3394-4501-a9e1-126e0e61f11d"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "CVE",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"[.Event.Object[].Attribute[], .Event.Attribute[]] | .[].value": {
|
|
||||||
"extract_type": "all",
|
|
||||||
"comparison": "contains",
|
|
||||||
"values": [
|
|
||||||
"CVE-2015-5465"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "CVE",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "CVE",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "32141393-adce-4007-950c-77b4c7c60a39"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "C2",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
".Event.Object[] | select((.name == \"url\")).Attribute[] | select(.type == \"url\").value": {
|
|
||||||
"extract_type": "all",
|
|
||||||
"comparison": "contains-regex",
|
|
||||||
"values": [
|
|
||||||
"https:\\/\\/another\\.evil\\.provider\\.com(:57666)?"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "C2 added",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "C2",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "a0d7f076-1737-4c1c-af36-c2717885299e"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "Email Provider",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"[(.Event.Object[] | select((.name == \"email\")).Attribute[]), .Event.Attribute[]] | .[].value": {
|
|
||||||
"extract_type": "all",
|
|
||||||
"comparison": "contains",
|
|
||||||
"values": [
|
|
||||||
"throwaway-email-provider.com"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "Email Provider added",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Email Provider",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "92a55537-0e4c-44f8-8bcd-102c38d343a9"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "context",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
".Event.Tag | select(length > 0) | .[].name": {
|
|
||||||
"extract_type": "all",
|
|
||||||
"comparison": "count",
|
|
||||||
"values": [
|
|
||||||
">=3"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "Context added",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Contextualization",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "b19e8d39-e64e-4a51-94ee-462cd74b8d24"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "published",
|
|
||||||
"inject_evaluation": [
|
|
||||||
{
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
".Event.info": {
|
|
||||||
"comparison": "regex",
|
|
||||||
"values": [
|
|
||||||
".*[sS]pear[-\\s]?phishing.*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
".Event.published": {
|
|
||||||
"comparison": "equals",
|
|
||||||
"values": [
|
|
||||||
"1"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"result": "Event published",
|
|
||||||
"evaluation_strategy": "data_filtering",
|
|
||||||
"evaluation_context": {},
|
|
||||||
"score_range": [
|
|
||||||
0,
|
|
||||||
20
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Published",
|
|
||||||
"target_tool": "MISP",
|
|
||||||
"uuid": "930459b8-ed61-4e62-b072-071577ea0430"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/skillaegis-logo.png">
|
<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>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
@ -3,7 +3,7 @@ from typing import Union
|
||||||
import jq
|
import jq
|
||||||
import re
|
import re
|
||||||
import operator
|
import operator
|
||||||
from appConfig import logger
|
from config import logger
|
||||||
|
|
||||||
|
|
||||||
def jq_extract(path: str, data: dict, extract_type='first'):
|
def jq_extract(path: str, data: dict, extract_type='first'):
|
||||||
|
@ -95,7 +95,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str, context:
|
||||||
if comparison_type == 'contains-regex':
|
if comparison_type == 'contains-regex':
|
||||||
regex = re.compile(values[0])
|
regex = re.compile(values[0])
|
||||||
for candidate in data_to_validate:
|
for candidate in data_to_validate:
|
||||||
if regex.match(candidate) is not None:
|
if regex.match(candidate):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
elif comparison_type == 'count':
|
elif comparison_type == 'count':
|
||||||
|
|
38
misp_api.py
38
misp_api.py
|
@ -11,8 +11,7 @@ from requests_cache import CachedSession
|
||||||
from requests.packages.urllib3.exceptions import InsecureRequestWarning # type: ignore
|
from requests.packages.urllib3.exceptions import InsecureRequestWarning # type: ignore
|
||||||
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
||||||
|
|
||||||
from config import misp_url, misp_apikey, misp_skipssl
|
from config import misp_url, misp_apikey, misp_skipssl, logger
|
||||||
from appConfig import logger, misp_settings
|
|
||||||
|
|
||||||
requestSession = CachedSession(cache_name='misp_cache', expire_after=timedelta(seconds=5))
|
requestSession = CachedSession(cache_name='misp_cache', expire_after=timedelta(seconds=5))
|
||||||
adapterCache = requests.adapters.HTTPAdapter(pool_connections=50, pool_maxsize=50)
|
adapterCache = requests.adapters.HTTPAdapter(pool_connections=50, pool_maxsize=50)
|
||||||
|
@ -22,7 +21,7 @@ requestSession.mount('http://', adapterCache)
|
||||||
|
|
||||||
async def get(url, data={}, api_key=misp_apikey):
|
async def get(url, data={}, api_key=misp_apikey):
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'SkillAegis',
|
'User-Agent': 'misp-exercise-dashboard',
|
||||||
"Authorization": api_key,
|
"Authorization": api_key,
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
|
@ -46,7 +45,7 @@ async def get(url, data={}, api_key=misp_apikey):
|
||||||
|
|
||||||
async def post(url, data={}, api_key=misp_apikey):
|
async def post(url, data={}, api_key=misp_apikey):
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'SkillAegis',
|
'User-Agent': 'misp-exercise-dashboard',
|
||||||
"Authorization": api_key,
|
"Authorization": api_key,
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
|
@ -84,25 +83,20 @@ async def getVersion() -> Union[None, dict]:
|
||||||
|
|
||||||
|
|
||||||
async def getSettings() -> Union[None, dict]:
|
async def getSettings() -> Union[None, dict]:
|
||||||
|
SETTING_TO_QUERY = [
|
||||||
|
'Plugin.ZeroMQ_enable',
|
||||||
|
'Plugin.ZeroMQ_audit_notifications_enable',
|
||||||
|
'Plugin.ZeroMQ_event_notifications_enable',
|
||||||
|
'Plugin.ZeroMQ_attribute_notifications_enable',
|
||||||
|
'MISP.log_paranoid',
|
||||||
|
'MISP.log_paranoid_skip_db',
|
||||||
|
'MISP.log_paranoid_include_post_body',
|
||||||
|
'MISP.log_auth',
|
||||||
|
'Security.allow_unsafe_cleartext_apikey_logging',
|
||||||
|
]
|
||||||
settings = await get(f'/servers/serverSettings.json')
|
settings = await get(f'/servers/serverSettings.json')
|
||||||
if not settings:
|
if not settings:
|
||||||
return None
|
return None
|
||||||
data = {}
|
return {
|
||||||
for settingName, expectedSettingValue in misp_settings.items():
|
setting['setting']: setting['value'] for setting in settings.get('finalSettings', []) if setting['setting'] in SETTING_TO_QUERY
|
||||||
data[settingName] = {
|
|
||||||
'expected_value': expectedSettingValue,
|
|
||||||
'value': None
|
|
||||||
}
|
}
|
||||||
for setting in settings.get('finalSettings', []):
|
|
||||||
if setting['setting'] in misp_settings:
|
|
||||||
data[setting['setting']]['value'] = setting['value']
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def remediateSetting(setting) ->dict:
|
|
||||||
if setting in misp_settings:
|
|
||||||
payload = {
|
|
||||||
'value': misp_settings[setting],
|
|
||||||
'force': 1,
|
|
||||||
}
|
|
||||||
return await post(f'/servers/serverSettingsEdit/{setting}', payload)
|
|
|
@ -5,7 +5,6 @@ import re
|
||||||
from typing import Union
|
from typing import Union
|
||||||
import db
|
import db
|
||||||
import config
|
import config
|
||||||
import appConfig
|
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
|
||||||
|
@ -75,10 +74,6 @@ def get_user_id(data: dict):
|
||||||
data = data['Log']
|
data = data['Log']
|
||||||
if 'user_id' in data:
|
if 'user_id' in data:
|
||||||
return int(data['user_id'])
|
return int(data['user_id'])
|
||||||
if 'AuditLog' in data:
|
|
||||||
data = data['AuditLog']
|
|
||||||
if 'user_id' in data:
|
|
||||||
return int(data['user_id'])
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@ -187,7 +182,7 @@ def get_scope_action_from_url(url) -> Union[str, None]:
|
||||||
def is_accepted_notification(notification) -> bool:
|
def is_accepted_notification(notification) -> bool:
|
||||||
global VERBOSE_MODE
|
global VERBOSE_MODE
|
||||||
|
|
||||||
if notification['user_agent'] == 'SkillAegis': # Ignore message generated from this app
|
if notification['user_agent'] == 'misp-exercise-dashboard': # Ignore message generated from this app
|
||||||
return False
|
return False
|
||||||
if VERBOSE_MODE:
|
if VERBOSE_MODE:
|
||||||
return True
|
return True
|
||||||
|
@ -197,26 +192,9 @@ def is_accepted_notification(notification) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
scope, action = get_scope_action_from_url(notification['url'])
|
scope, action = get_scope_action_from_url(notification['url'])
|
||||||
if scope in appConfig.live_logs_accepted_scope:
|
if scope in config.live_logs_accepted_scope:
|
||||||
if appConfig.live_logs_accepted_scope == '*':
|
if config.live_logs_accepted_scope == '*':
|
||||||
return True
|
return True
|
||||||
elif action in appConfig.live_logs_accepted_scope[scope]:
|
elif action in config.live_logs_accepted_scope[scope]:
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_accepted_user_activity(notification) -> bool:
|
|
||||||
global VERBOSE_MODE
|
|
||||||
|
|
||||||
if notification['user_agent'] == 'SkillAegis': # Ignore message generated from this app
|
|
||||||
return False
|
|
||||||
if '@' not in notification['user']: # Ignore message from system
|
|
||||||
return False
|
|
||||||
|
|
||||||
scope, action = get_scope_action_from_url(notification['url'])
|
|
||||||
if scope in appConfig.user_activity_accepted_scope:
|
|
||||||
if appConfig.user_activity_accepted_scope == '*':
|
|
||||||
return True
|
|
||||||
elif action in appConfig.user_activity_accepted_scope[scope]:
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "SkillAegis",
|
"name": "misp-exercise-dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "SkillAegis",
|
"name": "misp-exercise-dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "SkillAegis",
|
"name": "misp-exercise-dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 5.6 KiB |
91
server.py
91
server.py
|
@ -5,7 +5,6 @@ import functools
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
|
||||||
import zmq
|
import zmq
|
||||||
import socketio
|
import socketio
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
@ -15,7 +14,7 @@ import exercise as exercise_model
|
||||||
import notification as notification_model
|
import notification as notification_model
|
||||||
import db
|
import db
|
||||||
import config
|
import config
|
||||||
from appConfig import logger
|
from config import logger
|
||||||
import misp_api
|
import misp_api
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,20 +43,6 @@ def debounce(debounce_seconds: int = 1):
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def timer():
|
|
||||||
def decorator(func):
|
|
||||||
@functools.wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
t1 = time.time()
|
|
||||||
res = func(*args, **kwargs)
|
|
||||||
elapsed = time.time() - t1
|
|
||||||
if elapsed > 0.1:
|
|
||||||
print(elapsed)
|
|
||||||
return res
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Initialize ZeroMQ context and subscriber socket
|
# Initialize ZeroMQ context and subscriber socket
|
||||||
context = zmq.asyncio.Context()
|
context = zmq.asyncio.Context()
|
||||||
|
@ -118,10 +103,6 @@ async def mark_task_incomplete(sid, payload):
|
||||||
async def reset_all_exercise_progress(sid):
|
async def reset_all_exercise_progress(sid):
|
||||||
return exercise_model.resetAllExerciseProgress()
|
return exercise_model.resetAllExerciseProgress()
|
||||||
|
|
||||||
@sio.event
|
|
||||||
async def reset_all(sid):
|
|
||||||
return exercise_model.resetAllCommand()
|
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
async def reset_notifications(sid):
|
async def reset_notifications(sid):
|
||||||
return notification_model.reset_notifications()
|
return notification_model.reset_notifications()
|
||||||
|
@ -142,10 +123,6 @@ async def toggle_verbose_mode(sid, payload):
|
||||||
async def toggle_apiquery_mode(sid, payload):
|
async def toggle_apiquery_mode(sid, payload):
|
||||||
return notification_model.set_apiquery_mode(payload['apiquery'])
|
return notification_model.set_apiquery_mode(payload['apiquery'])
|
||||||
|
|
||||||
@sio.event
|
|
||||||
async def remediate_setting(sid, payload):
|
|
||||||
return await doSettingRemediation(payload['name'])
|
|
||||||
|
|
||||||
@sio.on('*')
|
@sio.on('*')
|
||||||
async 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)
|
||||||
|
@ -157,13 +134,13 @@ async def handleMessage(topic, s, message):
|
||||||
|
|
||||||
if topic == 'misp_json_audit':
|
if topic == 'misp_json_audit':
|
||||||
user_id, email = notification_model.get_user_email_id_pair(data)
|
user_id, email = notification_model.get_user_email_id_pair(data)
|
||||||
if user_id is not None and user_id != 0 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
|
||||||
await 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 and user_id != 0:
|
if user_id is not None:
|
||||||
if authkey not in db.USER_ID_TO_AUTHKEY_MAPPING:
|
if authkey not in db.USER_ID_TO_AUTHKEY_MAPPING:
|
||||||
db.USER_ID_TO_AUTHKEY_MAPPING[user_id] = authkey
|
db.USER_ID_TO_AUTHKEY_MAPPING[user_id] = authkey
|
||||||
return
|
return
|
||||||
|
@ -173,22 +150,18 @@ async def handleMessage(topic, s, message):
|
||||||
if notification_model.is_accepted_notification(notification):
|
if notification_model.is_accepted_notification(notification):
|
||||||
notification_model.record_notification(notification)
|
notification_model.record_notification(notification)
|
||||||
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN += 1
|
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN += 1
|
||||||
await sio.emit('notification', notification)
|
|
||||||
if notification_model.is_accepted_user_activity(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:
|
||||||
USER_ACTIVITY[user_id] += 1
|
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(topic, user_id, data)
|
context = get_context(topic, user_id, data)
|
||||||
checking_task = exercise_model.check_active_tasks(user_id, data, context)
|
succeeded_once = await exercise_model.check_active_tasks(user_id, data, context)
|
||||||
if checking_task is not None: # Make sure check_active_tasks was not debounced
|
|
||||||
succeeded_once = await checking_task
|
|
||||||
if succeeded_once:
|
if succeeded_once:
|
||||||
sendRefreshScoreTask = sendRefreshScore()
|
await sendRefreshScore()
|
||||||
await sendRefreshScoreTask if sendRefreshScoreTask is not None else None # Make sure check_active_tasks was not debounced
|
|
||||||
|
|
||||||
|
|
||||||
@debounce(debounce_seconds=1)
|
@debounce(debounce_seconds=1)
|
||||||
|
@ -227,11 +200,6 @@ async def getDiagnostic() -> dict:
|
||||||
return diagnostic
|
return diagnostic
|
||||||
|
|
||||||
|
|
||||||
async def doSettingRemediation(setting) -> dict:
|
|
||||||
result = await misp_api.remediateSetting(setting)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def notification_history():
|
async def notification_history():
|
||||||
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
|
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
|
||||||
while True:
|
while True:
|
||||||
|
@ -277,57 +245,16 @@ async def forward_zmq_to_socketio():
|
||||||
while True:
|
while True:
|
||||||
message = await zsocket.recv_string()
|
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
|
||||||
ZMQ_LAST_TIME = time.time()
|
ZMQ_LAST_TIME = time.time()
|
||||||
await handleMessage(topic, s, m)
|
# await handleMessage(topic, s, m)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
logger.error('Error handling message %s', e)
|
logger.error('Error handling message %s', e)
|
||||||
|
|
||||||
|
|
||||||
# Function to forward zmq messages to Socket.IO
|
|
||||||
async def forward_fake_zmq_to_socketio():
|
|
||||||
global ZMQ_MESSAGE_COUNT, ZMQ_LAST_TIME
|
|
||||||
filename = sys.argv[1]
|
|
||||||
line_number = sum(1 for _ in open(filename))
|
|
||||||
print(f'Preparing to feed {line_number} lines..')
|
|
||||||
await sio.sleep(2)
|
|
||||||
|
|
||||||
print('Feeding started')
|
|
||||||
line_count = 0
|
|
||||||
last_print = time.time()
|
|
||||||
with open(filename) as f:
|
|
||||||
for line in f:
|
|
||||||
line_count += 1
|
|
||||||
now = time.time()
|
|
||||||
if line_count % (int(line_number/100)) == 0 or (now - last_print >= 5):
|
|
||||||
last_print = now
|
|
||||||
print(f'Feeding {line_count} / {line_number} - ({100* line_count / line_number:.1f}%)')
|
|
||||||
split = line.split(' ', 1)
|
|
||||||
topic = split[0]
|
|
||||||
s = ''
|
|
||||||
m = split[1]
|
|
||||||
if topic != 'misp_json_self':
|
|
||||||
await sio.sleep(0.01)
|
|
||||||
try:
|
|
||||||
ZMQ_MESSAGE_COUNT += 1
|
|
||||||
ZMQ_LAST_TIME = time.time()
|
|
||||||
await handleMessage(topic, s, m)
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print(line)
|
|
||||||
print(traceback.format_exc())
|
|
||||||
logger.error('Error handling message: %s', e)
|
|
||||||
await sio.sleep(5)
|
|
||||||
print('Feeding done.')
|
|
||||||
|
|
||||||
|
|
||||||
async def init_app():
|
async def init_app():
|
||||||
if len(sys.argv) == 2:
|
|
||||||
sio.start_background_task(forward_fake_zmq_to_socketio)
|
|
||||||
else:
|
|
||||||
exercise_model.restore_exercices_progress()
|
|
||||||
sio.start_background_task(forward_zmq_to_socketio)
|
sio.start_background_task(forward_zmq_to_socketio)
|
||||||
sio.start_background_task(keepalive)
|
sio.start_background_task(keepalive)
|
||||||
sio.start_background_task(notification_history)
|
sio.start_background_task(notification_history)
|
||||||
|
@ -346,4 +273,6 @@ if __name__ == "__main__":
|
||||||
logger.critical('Could not load exercises')
|
logger.critical('Could not load exercises')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
exercise_model.restore_exercices_progress()
|
||||||
|
|
||||||
web.run_app(init_app(), host=config.server_host, port=config.server_port)
|
web.run_app(init_app(), host=config.server_host, port=config.server_port)
|
||||||
|
|
23
src/App.vue
23
src/App.vue
|
@ -18,12 +18,7 @@ onMounted(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<h1 class="text-xl text-center text-slate-500 dark:text-slate-400 absolute inset-x-0 top-0">
|
<h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">Exercise Dashboard</h1>
|
||||||
<div class="flex flex-col items-center mt-2">
|
|
||||||
<span id="logo" class="hover:cursor-pointer"></span>
|
|
||||||
<span>SkillAegis</span>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
@ -31,9 +26,7 @@ onMounted(() => {
|
||||||
<TheSocketConnectionState></TheSocketConnectionState>
|
<TheSocketConnectionState></TheSocketConnectionState>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-12">
|
|
||||||
<TheDahboard></TheDahboard>
|
<TheDahboard></TheDahboard>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -50,18 +43,8 @@ body {
|
||||||
@apply 3xl:container mx-auto;
|
@apply 3xl:container mx-auto;
|
||||||
@apply mx-auto;
|
@apply mx-auto;
|
||||||
@apply mt-4;
|
@apply mt-4;
|
||||||
@apply lg:w-11/12;
|
@apply 3xl:w-11/12;
|
||||||
@apply 3xl:w-5/6;
|
@apply lg:w-5/6;
|
||||||
}
|
|
||||||
|
|
||||||
#logo {
|
|
||||||
background-image: url(@/assets/skillaegis-logo.svg);
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
display: block;
|
|
||||||
background-size: 64px;
|
|
||||||
/* cyan-400 */
|
|
||||||
/* filter: invert(71%) sepia(97%) saturate(1333%) hue-rotate(147deg) brightness(95%) contrast(96%); */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
|
@ -3,7 +3,6 @@ import { onMounted, watch } from 'vue'
|
||||||
import TheLiveLogs from './components/TheLiveLogs.vue'
|
import TheLiveLogs from './components/TheLiveLogs.vue'
|
||||||
import TheScores from './components/TheScores.vue'
|
import TheScores from './components/TheScores.vue'
|
||||||
import { resetState, fullReload, socketConnected } from "@/socket";
|
import { resetState, fullReload, socketConnected } from "@/socket";
|
||||||
import { fullscreenModeOn } from "@/settings.js"
|
|
||||||
|
|
||||||
|
|
||||||
watch(socketConnected, (isConnected) => {
|
watch(socketConnected, (isConnected) => {
|
||||||
|
@ -20,8 +19,6 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mb-3">
|
|
||||||
<TheScores></TheScores>
|
<TheScores></TheScores>
|
||||||
<TheLiveLogs v-show="!fullscreenModeOn"></TheLiveLogs>
|
<TheLiveLogs></TheLiveLogs>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 27 KiB |
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 27 KiB |
|
@ -3,65 +3,127 @@
|
||||||
import { userActivity, userActivityConfig } from "@/socket";
|
import { userActivity, userActivityConfig } from "@/socket";
|
||||||
import { darkModeEnabled } from "@/settings.js"
|
import { darkModeEnabled } from "@/settings.js"
|
||||||
|
|
||||||
const props = defineProps(['user_id', 'compact_view', 'ultra_compact_view'])
|
const props = defineProps(['user_id'])
|
||||||
|
|
||||||
const theChart = ref(null)
|
const theChart = ref(null)
|
||||||
const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size)
|
const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size)
|
||||||
const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min)
|
const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min)
|
||||||
const chartInitSeries = computed(() => Array.from(Array(bufferSize.value)).map(() => 0))
|
const chartInitSeries = Array.from(Array(bufferSize.value)).map(() => 0)
|
||||||
|
|
||||||
const hasActivity = computed(() => userActivity.value.length != 0)
|
const hasActivity = computed(() => userActivity.value.length != 0)
|
||||||
const chartSeries = computed(() => {
|
const chartSeries = computed(() => {
|
||||||
return !hasActivity.value ? chartInitSeries.value : activitySeries.value
|
return !hasActivity.value ? chartInitSeries : activitySeries.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const activitySeries = computed(() => {
|
const activitySeries = computed(() => {
|
||||||
const data = userActivity.value[props.user_id] === undefined ? chartInitSeries.value : userActivity.value[props.user_id]
|
const data = userActivity.value[props.user_id] === undefined ? chartInitSeries : userActivity.value[props.user_id]
|
||||||
return data
|
return [{data: Array.from(data)}]
|
||||||
})
|
})
|
||||||
|
const colorRanges = [1, 3, 5, 7, 9, 1000]
|
||||||
|
|
||||||
const colorRanges = [0, 1, 2, 3, 4, 5, 1000]
|
const chartOptions = computed(() => {
|
||||||
const palleteColor = 'blue'
|
return {
|
||||||
const colorPalleteIndexDark = [
|
chart: {
|
||||||
'900',
|
height: 12,
|
||||||
'700',
|
width: 224,
|
||||||
'600',
|
type: 'heatmap',
|
||||||
'500',
|
sparkline: {
|
||||||
'400',
|
enabled: true
|
||||||
'300',
|
},
|
||||||
'200',
|
animations: {
|
||||||
]
|
enabled: false,
|
||||||
const colorPalleteIndexLight = [
|
easing: 'easeinout',
|
||||||
'50',
|
speed: 200,
|
||||||
'100',
|
},
|
||||||
'300',
|
},
|
||||||
'400',
|
dataLabels: {
|
||||||
'500',
|
enabled: false,
|
||||||
'600',
|
style: {
|
||||||
'700',
|
fontSize: '10px',
|
||||||
]
|
fontWeight: '400',
|
||||||
|
|
||||||
function getPalleteIndexFromValue(value) {
|
|
||||||
for (let palleteIndex = 0; palleteIndex < colorRanges.length; palleteIndex++) {
|
|
||||||
const colorRangeValue = colorRanges[palleteIndex];
|
|
||||||
if (value <= colorRangeValue) {
|
|
||||||
return darkModeEnabled.value ? colorPalleteIndexDark[palleteIndex] : colorPalleteIndexLight[palleteIndex]
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:class="`${props.ultra_compact_view ? 'w-[120px]' : 'w-60'} ${props.compact_view ? 'h-1.5 inline-flex' : 'h-3'}`"
|
class="h-3 w-52"
|
||||||
:title="`Activity over ${bufferSizeMin}min`"
|
:title="`Activity over ${bufferSizeMin}min`"
|
||||||
>
|
>
|
||||||
<span
|
<apexchart type="heatmap" height="12" width="224" :options="chartOptions" :series="chartSeries"></apexchart>
|
||||||
v-for="(value, i) in chartSeries"
|
|
||||||
:key="i"
|
|
||||||
:class="[`inline-block rounded-[1px] mr-px`, props.compact_view ? 'h-1.5' : 'h-3', `bg-${palleteColor}-${getPalleteIndexFromValue(value)}`]"
|
|
||||||
:style="`width: ${(((props.ultra_compact_view ? 120 : 240) - chartSeries.length) / chartSeries.length).toFixed(1)}px`"
|
|
||||||
></span>
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
|
@ -1,11 +1,10 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { exercises, selected_exercises, diagnostic, fullReload, resetAllExerciseProgress, resetAll, resetLiveLogs, changeExerciseSelection, debouncedGetDiangostic, remediateSetting } from "@/socket";
|
import { exercises, selected_exercises, diagnostic, fullReload, resetAllExerciseProgress, resetLiveLogs, changeExerciseSelection, debouncedGetDiangostic } from "@/socket";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
import { faScrewdriverWrench, faTrash, faSuitcaseMedical, faGraduationCap, faBan, faRotate, faHammer, faCheck } from '@fortawesome/free-solid-svg-icons'
|
import { faScrewdriverWrench, faTrash, faSuitcaseMedical, faGraduationCap, faBan, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
const admin_modal = ref(null)
|
const admin_modal = ref(null)
|
||||||
const clickedButtons = ref([])
|
|
||||||
|
|
||||||
const diagnosticLoading = computed(() => Object.keys(diagnostic.value).length == 0)
|
const diagnosticLoading = computed(() => Object.keys(diagnostic.value).length == 0)
|
||||||
const isMISPOnline = computed(() => diagnostic.value.version?.version !== undefined)
|
const isMISPOnline = computed(() => diagnostic.value.version?.version !== undefined)
|
||||||
|
@ -16,16 +15,10 @@
|
||||||
changeExerciseSelection(exec_uuid, state_enabled);
|
changeExerciseSelection(exec_uuid, state_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingHandler(setting) {
|
|
||||||
remediateSetting(setting)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTheModal() {
|
function showTheModal() {
|
||||||
admin_modal.value.showModal()
|
admin_modal.value.showModal()
|
||||||
clickedButtons.value = []
|
|
||||||
debouncedGetDiangostic()
|
debouncedGetDiangostic()
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -65,13 +58,6 @@
|
||||||
<FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
|
<FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
|
||||||
Reset All Exercises
|
Reset All Exercises
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
@click="resetAll()"
|
|
||||||
class="h-10 min-h-10 px-2 py-1 font-semibold bg-red-600 text-slate-200 hover:bg-red-700 btn btn-sm gap-1"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
|
|
||||||
Reset All
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
@click="resetLiveLogs()"
|
@click="resetLiveLogs()"
|
||||||
class="h-10 min-h-10 px-2 py-1 font-semibold bg-amber-600 text-slate-200 hover:bg-amber-700 btn btn-sm gap-1"
|
class="h-10 min-h-10 px-2 py-1 font-semibold bg-amber-600 text-slate-200 hover:bg-amber-700 btn btn-sm gap-1"
|
||||||
|
@ -145,60 +131,29 @@
|
||||||
<div v-if="diagnosticLoading" class="flex justify-center">
|
<div v-if="diagnosticLoading" class="flex justify-center">
|
||||||
<span class="loading loading-dots loading-lg"></span>
|
<span class="loading loading-dots loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
<table v-else class="bg-white dark:bg-slate-700 rounded-lg shadow-xl w-full mt-2">
|
<div
|
||||||
<thead>
|
v-for="(value, setting) in diagnostic['settings']"
|
||||||
<tr>
|
|
||||||
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Setting</th>
|
|
||||||
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Value</th>
|
|
||||||
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Expected Value</th>
|
|
||||||
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-center">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="(settingValues, setting) in diagnostic['settings']"
|
|
||||||
:key="setting"
|
:key="setting"
|
||||||
>
|
>
|
||||||
<td class="font-mono font-semibold text-base px-2">{{ setting }}</td>
|
<div>
|
||||||
<td
|
<label class="label cursor-pointer justify-start p-0 pt-1">
|
||||||
:class="`font-mono text-base tracking-tight px-2 ${settingValues.expected_value != settingValues.value ? 'text-red-600 dark:text-red-600' : ''}`"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<i v-if="settingValues.value === undefined || settingValues.value === null" class="text-nowrap">- none -</i>
|
:checked="value"
|
||||||
{{ settingValues.value }}
|
:value="setting"
|
||||||
</td>
|
:class="`checkbox ${value ? 'checkbox-success' : 'checkbox-danger'} [--fallback-bc:#cbd5e1]`"
|
||||||
<td class="font-mono text-base tracking-tight px-2">{{ settingValues.expected_value }}</td>
|
disabled
|
||||||
<td class="px-2 text-center">
|
/>
|
||||||
<span v-if="settingValues.error === true"
|
<span class="font-mono font-semibold text-base ml-3">{{ setting }}</span>
|
||||||
class="text-red-600 dark:text-red-600"
|
</label>
|
||||||
>Error: {{ settingValues.errorMessage }}</span>
|
</div>
|
||||||
<button
|
</div>
|
||||||
v-else-if="settingValues.expected_value != settingValues.value"
|
|
||||||
@click="clickedButtons.push(setting) && settingHandler(setting)"
|
|
||||||
:disabled="clickedButtons.includes(setting)"
|
|
||||||
class="h-8 min-h-8 px-2 font-semibold bg-green-600 text-slate-200 hover:bg-green-700 btn gap-1"
|
|
||||||
>
|
|
||||||
<template v-if="!clickedButtons.includes(setting)">
|
|
||||||
<FontAwesomeIcon :icon="faHammer" size="sm" fixed-width></FontAwesomeIcon>
|
|
||||||
Remediate
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="loading loading-dots loading-sm"></span>
|
|
||||||
</template>
|
|
||||||
</button>
|
|
||||||
<span v-else class="text-base font-bold text-green-600 dark:text-green-600">
|
|
||||||
<FontAwesomeIcon :icon="faCheck" class=""></FontAwesomeIcon>
|
|
||||||
OK
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop backdrop-blur">
|
<form method="dialog" class="modal-backdrop">
|
||||||
<button>close</button>
|
<button>close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import { ref, watch, computed } from "vue"
|
import { ref, watch, computed } from "vue"
|
||||||
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } 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, faUsers, faCircle } from '@fortawesome/free-solid-svg-icons'
|
import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue";
|
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue";
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
function getClassFromResponseCode(response_code) {
|
function getClassFromResponseCode(response_code) {
|
||||||
if (String(response_code).startsWith('2') || response_code == 302) {
|
if (String(response_code).startsWith('2')) {
|
||||||
return 'text-green-500'
|
return 'text-green-500'
|
||||||
} else if (String(response_code).startsWith('5')) {
|
} else if (String(response_code).startsWith('5')) {
|
||||||
return 'text-red-600'
|
return 'text-red-600'
|
||||||
|
@ -30,7 +30,6 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
|
||||||
<h3 class="text-2xl mt-6 mb-2 font-bold text-blue-500 dark:text-blue-400">
|
<h3 class="text-2xl mt-6 mb-2 font-bold text-blue-500 dark:text-blue-400">
|
||||||
<FontAwesomeIcon :icon="faSignal"></FontAwesomeIcon>
|
<FontAwesomeIcon :icon="faSignal"></FontAwesomeIcon>
|
||||||
Live logs
|
Live logs
|
||||||
|
@ -39,7 +38,7 @@
|
||||||
<div class="mb-2 flex flex-wrap gap-x-3">
|
<div class="mb-2 flex flex-wrap gap-x-3">
|
||||||
<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="faUsers" size="sm"></FontAwesomeIcon>
|
<FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
|
||||||
Players:
|
Players:
|
||||||
</span>
|
</span>
|
||||||
<span class="font-bold">{{ userCount }}</span>
|
<span class="font-bold">{{ userCount }}</span>
|
||||||
|
@ -151,5 +150,4 @@
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
|
@ -48,7 +48,6 @@
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 20,
|
|
||||||
labels: {
|
labels: {
|
||||||
show: false,
|
show: false,
|
||||||
}
|
}
|
||||||
|
@ -63,8 +62,8 @@
|
||||||
|
|
||||||
<template>
|
<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="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-30`">
|
<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 select-none">
|
<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 -ml-3">- {{ notificationHistoryConfig.buffer_timestamp_min }}min</span>
|
||||||
<span class="-rotate-90 w-8 text-xs">–</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-lg">–</span>
|
||||||
|
|
|
@ -1,58 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import { progresses, userCount } from "@/socket";
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
|
||||||
import { faUsers } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { darkModeEnabled } from "@/settings.js"
|
|
||||||
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
|
|
||||||
|
|
||||||
|
|
||||||
const compactGrid = computed(() => { return userCount.value > 70 })
|
|
||||||
const sortedProgress = computed(() => Object.values(progresses.value).sort((a, b) => {
|
|
||||||
if (a.email < b.email) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (a.email > b.email) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="
|
|
||||||
mt-2 px-2 pt-1 pb-2 rounded border
|
|
||||||
bg-slate-100 border-slate-300 dark:bg-slate-600 dark:border-slate-800
|
|
||||||
">
|
|
||||||
|
|
||||||
<h4 class="text-xl mb-2 font-bold text-blue-500 dark:text-blue-400">
|
|
||||||
<FontAwesomeIcon :icon="faUsers"></FontAwesomeIcon>
|
|
||||||
Active Players
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div :class="`flex flex-wrap ${compactGrid ? 'gap-1' : 'gap-2'}`">
|
|
||||||
<span
|
|
||||||
v-for="(progress) in sortedProgress"
|
|
||||||
:key="progress.user_id"
|
|
||||||
class="bg-slate-200 dark:bg-slate-900 rounded border drop-shadow-lg border-slate-700"
|
|
||||||
>
|
|
||||||
<span class="
|
|
||||||
flex p-2 mb-1
|
|
||||||
text-slate-600 dark:text-slate-400
|
|
||||||
">
|
|
||||||
<span :class="`flex flex-col ${compactGrid ? 'w-[120px]' : 'w-60'}`">
|
|
||||||
<span :title="progress.user_id" class="text-nowrap inline-block leading-5 truncate">
|
|
||||||
<span :class="`${compactGrid ? 'text-base' : 'text-lg'} font-bold font-mono leading-5 tracking-tight`">{{ progress.email.split('@')[0] }}</span>
|
|
||||||
<span :class="`${compactGrid ? 'text-xs' : 'text-xs'} font-mono tracking-tight`">@{{ progress.email.split('@')[1] }}</span>
|
|
||||||
</span>
|
|
||||||
<LiveLogsUserActivityGraph
|
|
||||||
:user_id="progress.user_id"
|
|
||||||
:compact_view="compactGrid"
|
|
||||||
:ultra_compact_view="false"
|
|
||||||
></LiveLogsUserActivityGraph>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -1,25 +1,28 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { active_exercises as exercises } from "@/socket";
|
import { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
import { faGraduationCap, faUpRightAndDownLeftFromCenter, faDownLeftAndUpRightToCenter, faWarning } from '@fortawesome/free-solid-svg-icons'
|
import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
|
||||||
import TheScoreTable from "./scoreViews/TheScoreTable.vue"
|
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
|
||||||
import TheFullScreenScoreGrid from "./scoreViews/TheFullScreenScoreGrid.vue"
|
|
||||||
import ThePlayerGrid from "./ThePlayerGrid.vue"
|
const collapsed_panels = ref([])
|
||||||
import { fullscreenModeOn } from "@/settings.js"
|
|
||||||
|
function toggleCompleted(completed, user_id, exec_uuid, task_uuid) {
|
||||||
|
setCompletedState(completed, user_id, exec_uuid, task_uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapse(exercise_index) {
|
||||||
|
const index = collapsed_panels.value.indexOf(exercise_index)
|
||||||
|
if (index >= 0) {
|
||||||
|
collapsed_panels.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
collapsed_panels.value.push(exercise_index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasExercises = computed(() => exercises.value.length > 0)
|
const hasExercises = computed(() => exercises.value.length > 0)
|
||||||
const fullscreen_panel = ref(false)
|
const hasProgress = computed(() => Object.keys(progresses.value).length > 0)
|
||||||
|
|
||||||
function toggleFullScreen(exercise_index) {
|
|
||||||
if (fullscreen_panel.value === exercise_index) {
|
|
||||||
fullscreen_panel.value = false
|
|
||||||
fullscreenModeOn.value = false
|
|
||||||
} else {
|
|
||||||
fullscreen_panel.value = exercise_index
|
|
||||||
fullscreenModeOn.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -30,56 +33,120 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!hasExercises"
|
v-if="!hasExercises"
|
||||||
class="text-slate-600 dark:text-slate-400 p-3 pl-6"
|
class="text-center text-slate-600 dark:text-slate-400 p-3 pl-6"
|
||||||
>
|
>
|
||||||
<div class="
|
<i>- No Exercise available -</i>
|
||||||
p-2 border-l-4 text-left rounded
|
|
||||||
dark:bg-yellow-300 dark:text-slate-900 dark:border-yellow-700
|
|
||||||
bg-yellow-200 text-slate-900 border-yellow-700
|
|
||||||
">
|
|
||||||
<FontAwesomeIcon :icon="faWarning" class="text-yellow-700 text-lg mx-3"></FontAwesomeIcon>
|
|
||||||
<strong class="">No Exercise available.</strong>
|
|
||||||
<span class="ml-1">Select an exercise in the <i class="underline">Admin panel</i>.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<table
|
||||||
<ThePlayerGrid></ThePlayerGrid>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template
|
|
||||||
v-for="(exercise, exercise_index) in exercises"
|
v-for="(exercise, exercise_index) in exercises"
|
||||||
:key="exercise.name"
|
:key="exercise.name"
|
||||||
|
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full mb-4"
|
||||||
>
|
>
|
||||||
<div :class="fullscreen_panel === false ? 'relative min-w-fit' : ''">
|
<thead>
|
||||||
<span
|
<tr @click="collapse(exercise_index)" class="cursor-pointer">
|
||||||
v-show="fullscreen_panel === false || fullscreen_panel === exercise_index"
|
<th :colspan="2 + exercise.tasks.length" class="rounded-t-lg border-b border-slate-100 dark:border-slate-700 text-md p-3 pl-6 text-center dark:bg-blue-800 bg-blue-500 dark:text-slate-300 text-slate-100">
|
||||||
:class="['inline-block absolute shadow-lg z-50', fullscreen_panel === false ? 'top-0 -right-7' : 'top-2 right-2']"
|
<div class="flex justify-between items-center">
|
||||||
>
|
<span class="dark:text-blue-200 text-slate-200 "># {{ exercise_index + 1 }}</span>
|
||||||
<button
|
<span class="text-lg">{{ exercise.name }}</span>
|
||||||
@click="toggleFullScreen(exercise_index)"
|
<span class="">
|
||||||
title="Toggle fullscreen mode"
|
Level: <span :class="{
|
||||||
:class="`
|
'rounded-lg px-1 ml-2': true,
|
||||||
w-7 p-1 focus-outline font-semibold
|
'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner',
|
||||||
text-slate-800 bg-slate-100 hover:bg-slate-200 dark:text-slate-200 dark:bg-slate-800 dark:hover:bg-slate-900
|
'dark:bg-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced',
|
||||||
${fullscreen_panel === false ? 'rounded-r-md' : 'rounded-bl-md'}
|
'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert',
|
||||||
`"
|
}">{{ exercise.level }}</span>
|
||||||
>
|
|
||||||
<FontAwesomeIcon :icon="fullscreen_panel !== exercise_index ? faUpRightAndDownLeftFromCenter : faDownLeftAndUpRightToCenter" fixed-width></FontAwesomeIcon>
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
<KeepAlive>
|
|
||||||
<TheScoreTable
|
|
||||||
v-show="fullscreen_panel === false"
|
|
||||||
:exercise="exercise"
|
|
||||||
:exercise_index="exercise_index"
|
|
||||||
></TheScoreTable>
|
|
||||||
</KeepAlive>
|
|
||||||
<KeepAlive>
|
|
||||||
<TheFullScreenScoreGrid
|
|
||||||
v-if="fullscreen_panel !== false"
|
|
||||||
:exercise="exercises[fullscreen_panel]"
|
|
||||||
:exercise_index="exercise_index"
|
|
||||||
></TheFullScreenScoreGrid>
|
|
||||||
</KeepAlive>
|
|
||||||
</div>
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr :class="`font-medium text-slate-600 dark:text-slate-200 ${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`">
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left">User</th>
|
||||||
|
<th
|
||||||
|
v-for="(task, task_index) in exercise.tasks"
|
||||||
|
:key="task.name"
|
||||||
|
class="border-b border-slate-100 dark:border-slate-700 p-3 align-top"
|
||||||
|
:title="task.description"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500 text-nowrap">Task {{ task_index + 1 }}</span>
|
||||||
|
<i class="text-center">{{ task.name }}</i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Progress</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody :class="`${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`">
|
||||||
|
<tr v-if="!hasProgress">
|
||||||
|
<td
|
||||||
|
:colspan="2 + exercise.tasks.length"
|
||||||
|
class="text-center border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6"
|
||||||
|
>
|
||||||
|
<i>- No user yet -</i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<template v-else>
|
||||||
|
<tr v-for="(progress, user_id) in progresses" :key="user_id" class="bg-slate-100 dark:bg-slate-900">
|
||||||
|
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative">
|
||||||
|
<span class="flex flex-col max-w-60">
|
||||||
|
<span :title="user_id" class="text-nowrap inline-block leading-5 truncate">
|
||||||
|
<FontAwesomeIcon v-if="progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1" :icon="faMedal" class="mr-1 text-amber-300"></FontAwesomeIcon>
|
||||||
|
<span class="text-lg font-bold font-mono leading-5 tracking-tight">{{ progress.email.split('@')[0] }}</span>
|
||||||
|
<span class="text-xs font-mono tracking-tight">@{{ progress.email.split('@')[1] }}</span>
|
||||||
|
</span>
|
||||||
|
<LiveLogsUserActivityGraph :user_id="user_id"></LiveLogsUserActivityGraph>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-for="(task, task_index) in exercise.tasks"
|
||||||
|
:key="task_index"
|
||||||
|
class="text-center border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-col">
|
||||||
|
<span class="text-nowrap">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
|
||||||
|
:icon="faCheck"
|
||||||
|
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
v-else-if="task.requirements?.inject_uuid !== undefined && !progress.exercises[exercise.uuid].tasks_completion[task.requirements.inject_uuid]"
|
||||||
|
title="All requirements for that task haven't been fullfilled yet"
|
||||||
|
:icon="faHourglassHalf"
|
||||||
|
:class="`text-lg ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
v-else
|
||||||
|
:icon="faTimes"
|
||||||
|
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
|
||||||
|
/>
|
||||||
|
<small :class="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'"> (+{{ task.score }})</small>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm leading-3">
|
||||||
|
<span
|
||||||
|
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp"
|
||||||
|
:class="progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? 'font-bold' : 'font-extralight'"
|
||||||
|
>
|
||||||
|
{{ (new Date(progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp * 1000)).toTimeString().split(' ', 1)[0] }}
|
||||||
|
</span>
|
||||||
|
<span v-else></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3">
|
||||||
|
<div class="flex w-full h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600" role="progressbar" :aria-valuenow="progress.exercises[exercise.uuid].score" :aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-center rounded-full overflow-hidden bg-green-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-green-500 transition-width transition-slowest ease"
|
||||||
|
:style="`width: ${100 * (progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score)}%`"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</template>
|
</template>
|
|
@ -1,251 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import { active_exercises as exercises, progresses, userCount, setCompletedState } from "@/socket";
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
|
||||||
import { faCheck, faTimes, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { faCircleCheck } from '@fortawesome/free-regular-svg-icons'
|
|
||||||
import { darkModeEnabled } from "@/settings.js"
|
|
||||||
import LiveLogsUserActivityGraph from "../LiveLogsUserActivityGraph.vue"
|
|
||||||
|
|
||||||
const props = defineProps(['exercise', 'exercise_index'])
|
|
||||||
const collapsed_panels = ref([])
|
|
||||||
|
|
||||||
const chartOptions = computed(() => {
|
|
||||||
return {
|
|
||||||
chart: {
|
|
||||||
type: 'radialBar',
|
|
||||||
height: 120,
|
|
||||||
sparkline: {
|
|
||||||
enabled: true
|
|
||||||
},
|
|
||||||
animations: {
|
|
||||||
enabled: false,
|
|
||||||
easing: 'easeinout',
|
|
||||||
speed: 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'],
|
|
||||||
plotOptions: {
|
|
||||||
radialBar: {
|
|
||||||
startAngle: -110,
|
|
||||||
endAngle: 110,
|
|
||||||
hollow: {
|
|
||||||
margin: 0,
|
|
||||||
size: '30%',
|
|
||||||
background: '#64748b',
|
|
||||||
position: 'front',
|
|
||||||
dropShadow: {
|
|
||||||
enabled: true,
|
|
||||||
top: 3,
|
|
||||||
left: 0,
|
|
||||||
blur: 4,
|
|
||||||
opacity: 0.24
|
|
||||||
}
|
|
||||||
},
|
|
||||||
track: {
|
|
||||||
background: '#475569',
|
|
||||||
strokeWidth: '97%',
|
|
||||||
margin: 0,
|
|
||||||
dropShadow: {
|
|
||||||
enabled: true,
|
|
||||||
top: 3,
|
|
||||||
left: 0,
|
|
||||||
blur: 3,
|
|
||||||
opacity: 0.35
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dataLabels: {
|
|
||||||
show: true,
|
|
||||||
name: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
formatter: function(val) {
|
|
||||||
return parseInt(val*userCount.value / 100);
|
|
||||||
},
|
|
||||||
offsetY: 7,
|
|
||||||
color: darkModeEnabled.value ? '#cbd5e1' : '#f1f5f9',
|
|
||||||
fontSize: '1.25rem',
|
|
||||||
show: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
lineCap: 'smooth'
|
|
||||||
},
|
|
||||||
colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'],
|
|
||||||
labels: ['Progress'],
|
|
||||||
tooltip: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleCompleted(completed, user_id, exec_uuid, task_uuid) {
|
|
||||||
setCompletedState(completed, user_id, exec_uuid, task_uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapse(exercise_index) {
|
|
||||||
const index = collapsed_panels.value.indexOf(exercise_index)
|
|
||||||
if (index >= 0) {
|
|
||||||
collapsed_panels.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
collapsed_panels.value.push(exercise_index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const compactGrid = computed(() => { return userCount.value > 70 })
|
|
||||||
const ultraCompactGrid = computed(() => { return userCount.value > 100 })
|
|
||||||
const hasProgress = computed(() => Object.keys(progresses.value).length > 0)
|
|
||||||
const sortedProgress = computed(() => Object.values(progresses.value).sort((a, b) => {
|
|
||||||
if (a.email < b.email) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (a.email > b.email) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}))
|
|
||||||
|
|
||||||
const taskCompletionPercentages = computed(() => {
|
|
||||||
const completions = {}
|
|
||||||
Object.values(props.exercise.tasks).forEach(task => {
|
|
||||||
completions[task.uuid] = 0
|
|
||||||
})
|
|
||||||
|
|
||||||
sortedProgress.value.forEach(progress => {
|
|
||||||
for (const [taskUuid, taskCompletion] of Object.entries(progress.exercises[props.exercise.uuid].tasks_completion)) {
|
|
||||||
if (taskCompletion !== false) {
|
|
||||||
completions[taskUuid] += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [taskUuid, taskCompletionSum] of Object.entries(completions)) {
|
|
||||||
completions[taskUuid] = 100 * (taskCompletionSum / userCount.value)
|
|
||||||
}
|
|
||||||
return completions
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="
|
|
||||||
fixed inset-2 z-40 h-100 overflow-x-hidden
|
|
||||||
rounded-lg bg-slate-300 dark:bg-slate-800 border border-slate-400 dark:border-slate-800
|
|
||||||
">
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="
|
|
||||||
rounded-t-lg text-md p-3 pl-6 text-center
|
|
||||||
dark:bg-blue-800 bg-blue-500 dark:text-slate-300 text-slate-100
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<!-- Modal header -->
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-lg font-semibold">{{ exercise.name }}</span>
|
|
||||||
<span class="mr-8">
|
|
||||||
Level: <span :class="{
|
|
||||||
'rounded-lg px-1 ml-2': true,
|
|
||||||
'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner',
|
|
||||||
'dark:bg-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced',
|
|
||||||
'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert',
|
|
||||||
}">{{ exercise.level }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tasks name and pie charts -->
|
|
||||||
<div class="p-2">
|
|
||||||
<div class="flex justify-between mb-3">
|
|
||||||
<span
|
|
||||||
v-for="(task, task_index) in exercise.tasks"
|
|
||||||
:key="task.name"
|
|
||||||
class="p-1 inline-block"
|
|
||||||
:title="task.description"
|
|
||||||
>
|
|
||||||
<span class="flex flex-col">
|
|
||||||
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-800 text-nowrap">Task {{ task_index + 1 }}</span>
|
|
||||||
<i class="text-center leading-4 text-slate-600 dark:text-slate-400">{{ task.name }}</i>
|
|
||||||
<span class="inline-block h-18 -mt-4 mx-auto">
|
|
||||||
<apexchart
|
|
||||||
ref="theChart" class="" height="120" width="100"
|
|
||||||
:options="chartOptions"
|
|
||||||
:series="[taskCompletionPercentages[task.uuid]]"
|
|
||||||
></apexchart>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User grid -->
|
|
||||||
<div :class="`flex flex-wrap ${compactGrid ? 'gap-1' : 'gap-2'}`">
|
|
||||||
<span
|
|
||||||
v-for="(progress) in sortedProgress"
|
|
||||||
:key="progress.user_id"
|
|
||||||
:class="[
|
|
||||||
'bg-slate-200 dark:bg-slate-900 rounded border drop-shadow-lg',
|
|
||||||
progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1 ? 'border-green-700' : 'border-slate-700',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<span class="
|
|
||||||
flex p-2 mb-1
|
|
||||||
text-slate-600 dark:text-slate-400
|
|
||||||
">
|
|
||||||
<span :class="`flex flex-col ${compactGrid ? 'w-[120px]' : 'w-60'} ${compactGrid ? '' : 'mb-1'}`">
|
|
||||||
<span :title="progress.user_id" class="text-nowrap inline-block leading-5 truncate mb-1">
|
|
||||||
<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="`${compactGrid ? 'text-base' : 'text-lg'} font-bold font-mono leading-5 tracking-tight`">{{ progress.email.split('@')[0] }}</span>
|
|
||||||
<span :class="`${compactGrid ? 'text-xs' : 'text-xs'} font-mono tracking-tight`">@{{ progress.email.split('@')[1] }}</span>
|
|
||||||
</span>
|
|
||||||
<LiveLogsUserActivityGraph
|
|
||||||
:user_id="progress.user_id"
|
|
||||||
:compact_view="compactGrid"
|
|
||||||
:ultra_compact_view="ultraCompactGrid"
|
|
||||||
></LiveLogsUserActivityGraph>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="
|
|
||||||
flex flex-row justify-between px-2
|
|
||||||
text-slate-500 dark:text-slate-400
|
|
||||||
">
|
|
||||||
<span
|
|
||||||
v-for="(task, task_index) in exercise.tasks"
|
|
||||||
:key="task_index"
|
|
||||||
class="select-none cursor-pointer"
|
|
||||||
@click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], progress.user_id, exercise.uuid, task.uuid)"
|
|
||||||
:title="task.name"
|
|
||||||
>
|
|
||||||
<span class="text-nowrap">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
|
|
||||||
:icon="(progress.exercises[exercise.uuid].tasks_completion[task.uuid] && progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion) ? faCircleCheck : faCheck"
|
|
||||||
:class="`${compactGrid ? 'text-xs' : 'text-xl'} dark:text-green-400 text-green-600`"
|
|
||||||
fixed-width
|
|
||||||
/>
|
|
||||||
<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="`${compactGrid ? 'text-xs' : 'text-lg'} dark:text-slate-500 text-slate-400`"
|
|
||||||
fixed-width
|
|
||||||
/>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
v-else
|
|
||||||
:icon="faTimes"
|
|
||||||
:class="`${compactGrid ? 'text-xs' : 'text-xl'} dark:text-slate-500 text-slate-400`"
|
|
||||||
fixed-width
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -1,186 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import { active_exercises as exercises, progresses, userCount, setCompletedState } from "@/socket";
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
|
||||||
import { faCheck, faTimes, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { faCircleCheck } from '@fortawesome/free-regular-svg-icons'
|
|
||||||
import LiveLogsUserActivityGraph from "../LiveLogsUserActivityGraph.vue"
|
|
||||||
|
|
||||||
const props = defineProps(['exercise', 'exercise_index'])
|
|
||||||
const collapsed_panels = ref([])
|
|
||||||
|
|
||||||
function toggleCompleted(completed, user_id, exec_uuid, task_uuid) {
|
|
||||||
setCompletedState(completed, user_id, exec_uuid, task_uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapse(exercise_index) {
|
|
||||||
const index = collapsed_panels.value.indexOf(exercise_index)
|
|
||||||
if (index >= 0) {
|
|
||||||
collapsed_panels.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
collapsed_panels.value.push(exercise_index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const compactTable = computed(() => { return userCount.value > 20 })
|
|
||||||
const hasProgress = computed(() => Object.keys(progresses.value).length > 0)
|
|
||||||
const sortedProgress = computed(() => Object.values(progresses.value).sort((a, b) => {
|
|
||||||
if (a.email < b.email) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (a.email > b.email) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}))
|
|
||||||
|
|
||||||
const taskCompletionPercentages = computed(() => {
|
|
||||||
const completions = {}
|
|
||||||
Object.values(props.exercise.tasks).forEach(task => {
|
|
||||||
completions[task.uuid] = 0
|
|
||||||
})
|
|
||||||
|
|
||||||
sortedProgress.value.forEach(progress => {
|
|
||||||
for (const [taskUuid, taskCompletion] of Object.entries(progress.exercises[props.exercise.uuid].tasks_completion)) {
|
|
||||||
if (taskCompletion !== false) {
|
|
||||||
completions[taskUuid] += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [taskUuid, taskCompletionSum] of Object.entries(completions)) {
|
|
||||||
completions[taskUuid] = 100 * (taskCompletionSum / userCount.value)
|
|
||||||
}
|
|
||||||
return completions
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<table
|
|
||||||
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full mb-4"
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr @click="collapse(exercise_index)" class="cursor-pointer">
|
|
||||||
<th :colspan="2 + exercise.tasks.length" class="rounded-tl-lg border-b border-slate-100 dark:border-slate-700 text-md p-3 pl-6 text-center dark:bg-blue-800 bg-blue-500 dark:text-slate-300 text-slate-100">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="dark:text-blue-200 text-slate-200 "># {{ exercise_index + 1 }}</span>
|
|
||||||
<span class="text-lg">{{ exercise.name }}</span>
|
|
||||||
<span class="">
|
|
||||||
Level: <span :class="{
|
|
||||||
'rounded-lg px-1 ml-2': true,
|
|
||||||
'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner',
|
|
||||||
'dark:bg-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced',
|
|
||||||
'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert',
|
|
||||||
}">{{ exercise.level }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
<tr :class="`font-medium text-slate-600 dark:text-slate-200 ${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`">
|
|
||||||
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left">User</th>
|
|
||||||
<th
|
|
||||||
v-for="(task, task_index) in exercise.tasks"
|
|
||||||
:key="task.name"
|
|
||||||
class="border-b border-slate-100 dark:border-slate-700 p-3 align-top"
|
|
||||||
:title="task.description"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500 text-nowrap">Task {{ task_index + 1 }}</span>
|
|
||||||
<i class="text-center">{{ task.name }}</i>
|
|
||||||
<div
|
|
||||||
role="progressbar"
|
|
||||||
class="flex w-full h-1 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600"
|
|
||||||
:aria-valuenow="taskCompletionPercentages[task.uuid]" :aria-valuemin="0" aria-valuemax="100"
|
|
||||||
:title="`${taskCompletionPercentages[task.uuid].toFixed(0)}%`"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col justify-center rounded-full overflow-hidden bg-blue-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-blue-500 transition-width transition-slowest ease"
|
|
||||||
:style="`width: ${taskCompletionPercentages[task.uuid]}%`"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Progress</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody :class="`${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`">
|
|
||||||
<tr v-if="!hasProgress">
|
|
||||||
<td
|
|
||||||
:colspan="2 + exercise.tasks.length"
|
|
||||||
class="text-center border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6"
|
|
||||||
>
|
|
||||||
<i>- No user yet -</i>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<template v-else>
|
|
||||||
<tr v-for="(progress) in sortedProgress" :key="progress.user_id" class="bg-slate-100 dark:bg-slate-900">
|
|
||||||
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative">
|
|
||||||
<span class="flex flex-col max-w-60">
|
|
||||||
<span :title="progress.user_id" class="text-nowrap inline-block leading-5 truncate">
|
|
||||||
<FontAwesomeIcon v-if="progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1" :icon="faMedal" class="mr-1 text-amber-300"></FontAwesomeIcon>
|
|
||||||
<span class="text-lg font-bold font-mono leading-5 tracking-tight">{{ progress.email.split('@')[0] }}</span>
|
|
||||||
<span class="text-xs font-mono tracking-tight">@{{ progress.email.split('@')[1] }}</span>
|
|
||||||
</span>
|
|
||||||
<LiveLogsUserActivityGraph
|
|
||||||
:user_id="progress.user_id"
|
|
||||||
:compact_view="compactTable"
|
|
||||||
></LiveLogsUserActivityGraph>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
v-for="(task, task_index) in exercise.tasks"
|
|
||||||
:key="task_index"
|
|
||||||
:class="`text-center border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 ${compactTable ? 'p-0' : 'p-2'}`"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="select-none cursor-pointer flex justify-center content-center flex-wrap h-9"
|
|
||||||
@click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], progress.user_id, exercise.uuid, task.uuid)"
|
|
||||||
>
|
|
||||||
<span class="flex flex-col">
|
|
||||||
<span class="text-nowrap">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
|
|
||||||
:icon="progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? faCircleCheck : faCheck"
|
|
||||||
:class="`
|
|
||||||
${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}
|
|
||||||
${progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? 'text-lg' : 'text-xl'}
|
|
||||||
`"
|
|
||||||
/>
|
|
||||||
<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="['leading-3', !compactTable ? 'text-sm' : 'text-xs']">
|
|
||||||
<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>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3">
|
|
||||||
<div class="flex w-full h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600" role="progressbar" :aria-valuenow="progress.exercises[exercise.uuid].score" :aria-valuemin="0" aria-valuemax="100">
|
|
||||||
<div
|
|
||||||
class="flex flex-col justify-center rounded-full overflow-hidden bg-green-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-green-500 transition-width transition-slowest ease"
|
|
||||||
:style="`width: ${100 * (progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score)}%`"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
|
@ -2,5 +2,3 @@ import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export const darkModeOn = ref(true)
|
export const darkModeOn = ref(true)
|
||||||
export const darkModeEnabled = computed(() => darkModeOn.value)
|
export const darkModeEnabled = computed(() => darkModeOn.value)
|
||||||
|
|
||||||
export const fullscreenModeOn = ref(false)
|
|
|
@ -76,10 +76,6 @@ export function resetAllExerciseProgress() {
|
||||||
sendResetAllExerciseProgress()
|
sendResetAllExerciseProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetAll() {
|
|
||||||
sendResetAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetLiveLogs() {
|
export function resetLiveLogs() {
|
||||||
sendResetLiveLogs()
|
sendResetLiveLogs()
|
||||||
}
|
}
|
||||||
|
@ -100,17 +96,6 @@ export function toggleApiQueryMode(enabled) {
|
||||||
sendToggleApiQueryMode(enabled)
|
sendToggleApiQueryMode(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remediateSetting(setting) {
|
|
||||||
sendRemediateSetting(setting, (result) => {
|
|
||||||
if (result.success) {
|
|
||||||
state.diagnostic['settings'][setting].value = state.diagnostic['settings'][setting].expected_value
|
|
||||||
} else {
|
|
||||||
state.diagnostic['settings'][setting].error = true
|
|
||||||
state.diagnostic['settings'][setting].errorMessage = result.message
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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})
|
||||||
|
|
||||||
|
@ -144,6 +129,7 @@ function getProgress() {
|
||||||
|
|
||||||
function getUsersActivity() {
|
function getUsersActivity() {
|
||||||
socket.emit("get_users_activity", (user_activity_bundle) => {
|
socket.emit("get_users_activity", (user_activity_bundle) => {
|
||||||
|
console.log(user_activity_bundle);
|
||||||
state.userActivity = user_activity_bundle.activity
|
state.userActivity = user_activity_bundle.activity
|
||||||
state.userActivityConfig = user_activity_bundle.config
|
state.userActivityConfig = user_activity_bundle.config
|
||||||
});
|
});
|
||||||
|
@ -170,12 +156,6 @@ function sendResetAllExerciseProgress() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendResetAll() {
|
|
||||||
socket.emit("reset_all", () => {
|
|
||||||
getProgress()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendResetLiveLogs() {
|
function sendResetLiveLogs() {
|
||||||
socket.emit("reset_notifications", () => {
|
socket.emit("reset_notifications", () => {
|
||||||
getNotifications()
|
getNotifications()
|
||||||
|
@ -202,15 +182,6 @@ function sendToggleApiQueryMode(enabled) {
|
||||||
socket.emit("toggle_apiquery_mode", payload, () => {})
|
socket.emit("toggle_apiquery_mode", payload, () => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendRemediateSetting(setting, cb) {
|
|
||||||
const payload = {
|
|
||||||
name: setting
|
|
||||||
}
|
|
||||||
socket.emit("remediate_setting", payload, (result) => {
|
|
||||||
cb(result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Event listener */
|
/* Event listener */
|
||||||
|
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => {
|
||||||
|
|
|
@ -4,11 +4,6 @@ export default {
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
safelist: [
|
|
||||||
{
|
|
||||||
pattern: /bg-blue+/, // Includes bg of all colors and shades
|
|
||||||
},
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
transitionProperty: {
|
transitionProperty: {
|
||||||
|
|
Loading…
Reference in a new issue