Compare commits

..

38 commits

Author SHA1 Message Date
Sami Mokaddem
014f55a2c4 chg: [README] Added logo to readme 2024-07-18 15:01:09 +02:00
Sami Mokaddem
9e308c1cfd chg: [app] Renamed project to SkillAegis 2024-07-18 14:51:36 +02:00
Sami Mokaddem
d9f21f3606 new: [app] Added Application name and logo 2024-07-18 14:46:19 +02:00
Sami Mokaddem
74a2d4f196 chg: [front:player-grid] Improved player grid UI for light theme 2024-07-16 14:16:42 +02:00
Sami Mokaddem
555ff14e29 chg: [front] UI improvements 2024-07-16 12:06:47 +02:00
Sami Mokaddem
8e2cac27fa chg: [app] Few improvements here and there 2024-07-16 12:04:36 +02:00
Sami Mokaddem
22ef4fa77e chg: [app] Added build files 2024-07-15 16:54:46 +02:00
Sami Mokaddem
5e2951efd0 new: [app] Added reset all feature 2024-07-15 16:19:50 +02:00
Sami Mokaddem
cc8f955b02 chg: [front:score-panel] Improved UI for first completion 2024-07-15 15:53:07 +02:00
Sami Mokaddem
ed1a6dedd3 new: [front:score] Added task completion in score panel 2024-07-15 15:02:19 +02:00
Sami Mokaddem
218a74b1cf chg: [front] Small UI improvement for colors and sizing 2024-07-15 14:08:04 +02:00
Sami Mokaddem
cd28a560cc chg: [front] Small UI improvements 2024-07-15 12:15:36 +02:00
Sami Mokaddem
2e96f58cce new: [front:active players] Added UI to show active players before an exercise is selected 2024-07-15 12:07:49 +02:00
Sami Mokaddem
f4f67656ad chg: [front] Improved UI for small screens 2024-07-15 11:07:42 +02:00
Sami Mokaddem
87401f7ac0 new: [exercises] Added spearphishing-incident simple exercise 2024-07-15 10:57:57 +02:00
Sami Mokaddem
caade24f8c fix: [backend] Fixed bugs related to message parsing and issue with wait and debounce 2024-07-15 10:57:06 +02:00
Sami Mokaddem
e3df5d715e chg: [backend:condig] Removed setting log_new_audit 2024-07-12 15:49:23 +02:00
Sami Mokaddem
e2cd688eff fix: [backend] Fixed unbound variable and AuditLog support 2024-07-12 15:47:57 +02:00
Sami Mokaddem
e662e5b7aa chg: [app] Added build files 2024-07-12 15:47:17 +02:00
Sami Mokaddem
7a6f64fce0 new: [backend:backup] More clever back up system 2024-07-12 15:12:42 +02:00
Sami Mokaddem
a0fbd7aa9b new: [front:scores] Added support of ultra-compact view in fullscreen mode 2024-07-12 15:03:40 +02:00
Sami Mokaddem
179cdc9bd7 new: [font:dashboard] Added fullscreen mode for scores to support large amount of users 2024-07-12 14:40:39 +02:00
Sami Mokaddem
a00770c8a9 new: [front:scores] Automatically resize score table if large amount of users 2024-07-12 09:15:10 +02:00
Sami Mokaddem
9af5572346 fix: [backend:exercise] If restoration from backup failed, reset the state 2024-07-10 12:19:12 +02:00
Sami Mokaddem
4dc0156f23 chg: [front:build] Only keep all bg-clue colors in the three shaking safelist 2024-07-10 12:16:33 +02:00
Sami Mokaddem
d64a6b5652 chg: [app:user-activity] Replaced ApexChart heatmap by homemade heatmap 2024-07-10 12:15:23 +02:00
Sami Mokaddem
2cd4820f1c new: [app] Better setting split and added misp setting remediation 2024-07-10 10:14:44 +02:00
Sami Mokaddem
81bb48ff54 chg: [bacjend:exercise] More details about task status 2024-07-09 16:33:18 +02:00
Sami Mokaddem
bab31cb0f8 fix: [backend:server] Ensure no user are registered as user_id=0 2024-07-09 16:28:15 +02:00
Sami Mokaddem
747c11ac85 fix: [backend:exercise] Correctly restore email mapping 2024-07-09 16:13:45 +02:00
Sami Mokaddem
f4a3a3d86a chg: [front:user-activity] Improved color ranges 2024-07-09 15:27:31 +02:00
Sami Mokaddem
e41627ffc7 chg: [build] Added build files 2024-07-09 14:40:44 +02:00
Sami Mokaddem
2a601849dc chg: [app:the-scores] Sort players by email 2024-07-09 14:38:23 +02:00
Sami Mokaddem
cffb761c4d fix: [backend:app] Make sure to cast uesr_id into integer and updated config.sample 2024-07-09 14:20:28 +02:00
Sami Mokaddem
d637f2a0ee new: [backend:user_activity] Improved user activity filtering 2024-07-09 14:05:40 +02:00
Sami Mokaddem
7ed839d391 fix: [backend:exercise] Gracefully catch user_id without emails 2024-07-09 14:05:00 +02:00
Sami Mokaddem
8a64f84140 fix: [backend:exercise] Make sure to iterate over keys 2024-07-09 13:52:44 +02:00
Sami Mokaddem
202f7b7eb6 chg: [build] Rebuild assets 2024-07-09 13:22:06 +02:00
36 changed files with 3076 additions and 1879 deletions

View file

@ -1,4 +1,6 @@
# misp-exercise-dashboard # SkillAegis
<img alt="SkillAegis Logo" src="src/assets/skillaegis-logo.svg"/>
## Installation ## Installation
```bash ```bash

View file

@ -0,0 +1 @@
../exercises/spearphishing-incident.json

38
appConfig.py Normal file
View file

@ -0,0 +1,38 @@
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)

View file

@ -6,20 +6,3 @@ 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 Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1487
dist/assets/index-BS0mgB3_.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -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-BJjpd8Qi.js"></script> <script type="module" crossorigin src="/assets/index-BS0mgB3_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CiEfiGI-.css"> <link rel="stylesheet" crossorigin href="/assets/index-7ybfbefL.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -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
import config from appConfig import logger
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,26 +61,46 @@ def read_exercise_dir():
def backup_exercises_progress(): def backup_exercises_progress():
with open('backup.json', 'w') as f: global LAST_BACKUP
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,
} }
json.dump(toBackup, f) if toBackup != LAST_BACKUP:
with open('backup.json', 'w') as 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 = data['USER_ID_TO_EMAIL_MAPPING'] db.USER_ID_TO_EMAIL_MAPPING = {}
db.USER_ID_TO_AUTHKEY_MAPPING = data['USER_ID_TO_AUTHKEY_MAPPING'] for user_id_str, email in data['USER_ID_TO_EMAIL_MAPPING'].items():
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:
@ -207,15 +227,20 @@ 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()[user_id] completion = get_completion_for_users().get(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()[user_id] completion = get_completion_for_users().get(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]
@ -236,8 +261,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: if 'Log' in data or 'AuditLog' in data:
data = data['Log'] data = data['Log'] if 'Log' in data else data['AuditLog']
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,)
@ -246,14 +271,12 @@ 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']:
# # improved condition below. It blocks some queries if 'Log' in data:
# if data['Log']['change'].startswith('attribute_count'): if data['Log']['change'].startswith('Validation errors:'):
# return False return False
if data['Log']['change'].startswith('Validation errors:'):
return False
return True return True
if data.get('user_agent', None) == 'misp-exercise-dashboard': if data.get('user_agent', None) == 'SkillAegis':
return None return None
url = data.get('url', None) url = data.get('url', None)
if url is not None: if url is not None:
@ -274,8 +297,9 @@ 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 = entry['user_id'] user_id = int(entry['user_id'])
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry if user_id in completion_per_user: # Ensure the user_id is known in USER_ID_TO_EMAIL_MAPPING
completion_per_user[user_id][exercise_status['uuid']][task['uuid']] = entry
return completion_per_user return completion_per_user
@ -321,9 +345,13 @@ 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: for user_id in completion_for_users.keys():
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():
@ -339,10 +367,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: {inject['uuid']}") logger.info(f"Task not completed[{user_id}]: {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: {inject['uuid']}") logger.info(f"Task success[{user_id}]: {inject['uuid']}")
return True return True
@ -359,13 +387,14 @@ 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 False return True
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)
@ -414,6 +443,13 @@ 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

View file

@ -54,8 +54,7 @@
"followed_by": [ "followed_by": [
"3e61a340-0314-4622-91cc-042f3ff8543a" "3e61a340-0314-4622-91cc-042f3ff8543a"
], ],
"trigger": [ "trigger": []
]
}, },
"timing": { "timing": {
"triggered_at": null "triggered_at": null
@ -66,7 +65,7 @@
"inject_uuid": "3e61a340-0314-4622-91cc-042f3ff8543a", "inject_uuid": "3e61a340-0314-4622-91cc-042f3ff8543a",
"reporting_callback": [], "reporting_callback": [],
"requirements": { "requirements": {
"inject_uuid": "8f636640-e4f0-4ffb-abff-4e85597aa1bd" "inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
}, },
"sequence": { "sequence": {
"completion_trigger": [ "completion_trigger": [
@ -76,8 +75,7 @@
"followed_by": [ "followed_by": [
"8a2d58c8-2b3a-4ba2-bb77-15bcfa704828" "8a2d58c8-2b3a-4ba2-bb77-15bcfa704828"
], ],
"trigger": [ "trigger": []
]
}, },
"timing": { "timing": {
"triggered_at": null "triggered_at": null
@ -88,7 +86,7 @@
"inject_uuid": "8a2d58c8-2b3a-4ba2-bb77-15bcfa704828", "inject_uuid": "8a2d58c8-2b3a-4ba2-bb77-15bcfa704828",
"reporting_callback": [], "reporting_callback": [],
"requirements": { "requirements": {
"inject_uuid": "3e61a340-0314-4622-91cc-042f3ff8543a" "inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
}, },
"sequence": { "sequence": {
"completion_trigger": [ "completion_trigger": [
@ -98,8 +96,7 @@
"followed_by": [ "followed_by": [
"9df13cc8-b61b-4c9f-a1a8-66def8b64439" "9df13cc8-b61b-4c9f-a1a8-66def8b64439"
], ],
"trigger": [ "trigger": []
]
}, },
"timing": { "timing": {
"triggered_at": null "triggered_at": null
@ -110,7 +107,7 @@
"inject_uuid": "9df13cc8-b61b-4c9f-a1a8-66def8b64439", "inject_uuid": "9df13cc8-b61b-4c9f-a1a8-66def8b64439",
"reporting_callback": [], "reporting_callback": [],
"requirements": { "requirements": {
"inject_uuid": "8a2d58c8-2b3a-4ba2-bb77-15bcfa704828" "inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
}, },
"sequence": { "sequence": {
"completion_trigger": [ "completion_trigger": [
@ -120,8 +117,7 @@
"followed_by": [ "followed_by": [
"c5c03af1-7ef3-44e7-819a-6c4fd402148a" "c5c03af1-7ef3-44e7-819a-6c4fd402148a"
], ],
"trigger": [ "trigger": []
]
}, },
"timing": { "timing": {
"triggered_at": null "triggered_at": null
@ -132,7 +128,7 @@
"inject_uuid": "c5c03af1-7ef3-44e7-819a-6c4fd402148a", "inject_uuid": "c5c03af1-7ef3-44e7-819a-6c4fd402148a",
"reporting_callback": [], "reporting_callback": [],
"requirements": { "requirements": {
"inject_uuid": "9df13cc8-b61b-4c9f-a1a8-66def8b64439" "inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
}, },
"sequence": { "sequence": {
"completion_trigger": [ "completion_trigger": [
@ -142,8 +138,7 @@
"followed_by": [ "followed_by": [
"11f6f0c2-8813-42ee-a312-136649d3f077" "11f6f0c2-8813-42ee-a312-136649d3f077"
], ],
"trigger": [ "trigger": []
]
}, },
"timing": { "timing": {
"triggered_at": null "triggered_at": null
@ -154,7 +149,7 @@
"inject_uuid": "11f6f0c2-8813-42ee-a312-136649d3f077", "inject_uuid": "11f6f0c2-8813-42ee-a312-136649d3f077",
"reporting_callback": [], "reporting_callback": [],
"requirements": { "requirements": {
"inject_uuid": "c5c03af1-7ef3-44e7-819a-6c4fd402148a" "inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
}, },
"sequence": { "sequence": {
"completion_trigger": [ "completion_trigger": [
@ -164,8 +159,7 @@
"followed_by": [ "followed_by": [
"e3ef4e5f-454a-48c8-a5d7-b3d1d25ecc9f" "e3ef4e5f-454a-48c8-a5d7-b3d1d25ecc9f"
], ],
"trigger": [ "trigger": []
]
}, },
"timing": { "timing": {
"triggered_at": null "triggered_at": null
@ -176,23 +170,21 @@
"inject_uuid": "e3ef4e5f-454a-48c8-a5d7-b3d1d25ecc9f", "inject_uuid": "e3ef4e5f-454a-48c8-a5d7-b3d1d25ecc9f",
"reporting_callback": [], "reporting_callback": [],
"requirements": { "requirements": {
"inject_uuid": "11f6f0c2-8813-42ee-a312-136649d3f077" "inject_uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
}, },
"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",
@ -210,8 +202,7 @@
], ],
"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
@ -248,8 +239,7 @@
], ],
"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
@ -285,8 +275,7 @@
], ],
"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
@ -322,8 +311,7 @@
], ],
"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
@ -352,15 +340,14 @@
"extract_type": "all", "extract_type": "all",
"comparison": "contains-regex", "comparison": "contains-regex",
"values": [ "values": [
"HKCU.+SOFTWARE.+CryptoLocker.*" "HKCU.+SOFTWARE.+CryptoLocker.*"
] ]
} }
} }
], ],
"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
@ -389,15 +376,14 @@
"extract_type": "all", "extract_type": "all",
"comparison": "contains-regex", "comparison": "contains-regex",
"values": [ "values": [
"-----BEGIN PUBLIC KEY-----.*" "-----BEGIN PUBLIC KEY-----.*"
] ]
} }
} }
], ],
"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
@ -433,8 +419,7 @@
], ],
"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
@ -469,8 +454,7 @@
], ],
"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

View file

@ -0,0 +1,519 @@
{
"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"
}
]
}

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/skillaegis-logo.png">
<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>

View file

@ -3,7 +3,7 @@ from typing import Union
import jq import jq
import re import re
import operator import operator
from config import logger from appConfig 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): if regex.match(candidate) is not None:
return True return True
return False return False
elif comparison_type == 'count': elif comparison_type == 'count':

View file

@ -11,7 +11,8 @@ 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, logger from config import misp_url, misp_apikey, misp_skipssl
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)
@ -21,7 +22,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': 'misp-exercise-dashboard', 'User-Agent': 'SkillAegis',
"Authorization": api_key, "Authorization": api_key,
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json" "Content-Type": "application/json"
@ -45,7 +46,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': 'misp-exercise-dashboard', 'User-Agent': 'SkillAegis',
"Authorization": api_key, "Authorization": api_key,
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json" "Content-Type": "application/json"
@ -83,20 +84,25 @@ 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
return { data = {}
setting['setting']: setting['value'] for setting in settings.get('finalSettings', []) if setting['setting'] in SETTING_TO_QUERY for settingName, expectedSettingValue in misp_settings.items():
} 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)

View file

@ -5,6 +5,7 @@ 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
@ -74,6 +75,10 @@ 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
@ -182,7 +187,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'] == 'misp-exercise-dashboard': # Ignore message generated from this app if notification['user_agent'] == 'SkillAegis': # Ignore message generated from this app
return False return False
if VERBOSE_MODE: if VERBOSE_MODE:
return True return True
@ -192,9 +197,26 @@ 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 config.live_logs_accepted_scope: if scope in appConfig.live_logs_accepted_scope:
if config.live_logs_accepted_scope == '*': if appConfig.live_logs_accepted_scope == '*':
return True return True
elif action in config.live_logs_accepted_scope[scope]: elif action in appConfig.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
View file

@ -1,11 +1,11 @@
{ {
"name": "misp-exercise-dashboard", "name": "SkillAegis",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "misp-exercise-dashboard", "name": "SkillAegis",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/fontawesome-svg-core": "^6.5.2",

View file

@ -1,5 +1,5 @@
{ {
"name": "misp-exercise-dashboard", "name": "SkillAegis",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",

BIN
public/skillaegis-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -5,6 +5,7 @@ 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
@ -14,7 +15,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 config import logger from appConfig import logger
import misp_api import misp_api
@ -43,6 +44,20 @@ 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()
@ -103,6 +118,10 @@ 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()
@ -123,6 +142,10 @@ 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)
@ -134,13 +157,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 '@' in email: if user_id is not None and user_id != 0 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: if user_id is not None and user_id != 0:
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
@ -150,18 +173,22 @@ 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)
succeeded_once = await exercise_model.check_active_tasks(user_id, data, context) checking_task = exercise_model.check_active_tasks(user_id, data, context)
if succeeded_once: if checking_task is not None: # Make sure check_active_tasks was not debounced
await sendRefreshScore() succeeded_once = await checking_task
if succeeded_once:
sendRefreshScoreTask = 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)
@ -200,6 +227,11 @@ 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:
@ -245,17 +277,58 @@ 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():
sio.start_background_task(forward_zmq_to_socketio) 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(keepalive) sio.start_background_task(keepalive)
sio.start_background_task(notification_history) sio.start_background_task(notification_history)
sio.start_background_task(record_users_activity) sio.start_background_task(record_users_activity)
@ -273,6 +346,4 @@ 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)

View file

@ -18,7 +18,12 @@ onMounted(() => {
<template> <template>
<main> <main>
<h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">Exercise Dashboard</h1> <h1 class="text-xl text-center text-slate-500 dark:text-slate-400 absolute inset-x-0 top-0">
<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>
@ -26,7 +31,9 @@ onMounted(() => {
<TheSocketConnectionState></TheSocketConnectionState> <TheSocketConnectionState></TheSocketConnectionState>
</div> </div>
</div> </div>
<TheDahboard></TheDahboard> <div class="mt-12">
<TheDahboard></TheDahboard>
</div>
</main> </main>
</template> </template>
@ -43,8 +50,18 @@ body {
@apply 3xl:container mx-auto; @apply 3xl:container mx-auto;
@apply mx-auto; @apply mx-auto;
@apply mt-4; @apply mt-4;
@apply 3xl:w-11/12; @apply lg:w-11/12;
@apply lg:w-5/6; @apply 3xl: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>

View file

@ -3,6 +3,7 @@ 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) => {
@ -19,6 +20,8 @@ onMounted(() => {
</script> </script>
<template> <template>
<TheScores></TheScores> <div class="mb-3">
<TheLiveLogs></TheLiveLogs> <TheScores></TheScores>
<TheLiveLogs v-show="!fullscreenModeOn"></TheLiveLogs>
</div>
</template> </template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -3,127 +3,65 @@
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']) const props = defineProps(['user_id', 'compact_view', 'ultra_compact_view'])
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 = Array.from(Array(bufferSize.value)).map(() => 0) const chartInitSeries = computed(() => 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 : activitySeries.value return !hasActivity.value ? chartInitSeries.value : activitySeries.value
}) })
const activitySeries = computed(() => { const activitySeries = computed(() => {
const data = userActivity.value[props.user_id] === undefined ? chartInitSeries : userActivity.value[props.user_id] const data = userActivity.value[props.user_id] === undefined ? chartInitSeries.value : userActivity.value[props.user_id]
return [{data: Array.from(data)}] return data
}) })
const colorRanges = [1, 3, 5, 7, 9, 1000]
const chartOptions = computed(() => { const colorRanges = [0, 1, 2, 3, 4, 5, 1000]
return { const palleteColor = 'blue'
chart: { const colorPalleteIndexDark = [
height: 12, '900',
width: 224, '700',
type: 'heatmap', '600',
sparkline: { '500',
enabled: true '400',
}, '300',
animations: { '200',
enabled: false, ]
easing: 'easeinout', const colorPalleteIndexLight = [
speed: 200, '50',
}, '100',
}, '300',
dataLabels: { '400',
enabled: false, '500',
style: { '600',
fontSize: '10px', '700',
fontWeight: '400', ]
}
}, function getPalleteIndexFromValue(value) {
plotOptions: { for (let palleteIndex = 0; palleteIndex < colorRanges.length; palleteIndex++) {
heatmap: { const colorRangeValue = colorRanges[palleteIndex];
radius: 2, if (value <= colorRangeValue) {
enableShades: false, return darkModeEnabled.value ? colorPalleteIndexDark[palleteIndex] : colorPalleteIndexLight[palleteIndex]
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="h-3 w-52" :class="`${props.ultra_compact_view ? 'w-[120px]' : 'w-60'} ${props.compact_view ? 'h-1.5 inline-flex' : 'h-3'}`"
:title="`Activity over ${bufferSizeMin}min`" :title="`Activity over ${bufferSizeMin}min`"
> >
<apexchart type="heatmap" height="12" width="224" :options="chartOptions" :series="chartSeries"></apexchart> <span
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>

View file

@ -1,10 +1,11 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { exercises, selected_exercises, diagnostic, fullReload, resetAllExerciseProgress, resetLiveLogs, changeExerciseSelection, debouncedGetDiangostic } from "@/socket"; import { exercises, selected_exercises, diagnostic, fullReload, resetAllExerciseProgress, resetAll, resetLiveLogs, changeExerciseSelection, debouncedGetDiangostic, remediateSetting } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faScrewdriverWrench, faTrash, faSuitcaseMedical, faGraduationCap, faBan, faRotate } from '@fortawesome/free-solid-svg-icons' import { faScrewdriverWrench, faTrash, faSuitcaseMedical, faGraduationCap, faBan, faRotate, faHammer, faCheck } 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)
@ -15,10 +16,16 @@
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>
@ -58,6 +65,13 @@
<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"
@ -131,29 +145,60 @@
<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>
<div <table v-else class="bg-white dark:bg-slate-700 rounded-lg shadow-xl w-full mt-2">
v-for="(value, setting) in diagnostic['settings']" <thead>
:key="setting" <tr>
> <th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Setting</th>
<div> <th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Value</th>
<label class="label cursor-pointer justify-start p-0 pt-1"> <th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Expected Value</th>
<input <th class="border-b border-slate-200 dark:border-slate-600 p-2 text-center">Action</th>
type="checkbox" </tr>
:checked="value" </thead>
:value="setting" <tbody>
:class="`checkbox ${value ? 'checkbox-success' : 'checkbox-danger'} [--fallback-bc:#cbd5e1]`" <tr
disabled v-for="(settingValues, setting) in diagnostic['settings']"
/> :key="setting"
<span class="font-mono font-semibold text-base ml-3">{{ setting }}</span> >
</label> <td class="font-mono font-semibold text-base px-2">{{ setting }}</td>
</div> <td
</div> :class="`font-mono text-base tracking-tight px-2 ${settingValues.expected_value != settingValues.value ? 'text-red-600 dark:text-red-600' : ''}`"
>
<i v-if="settingValues.value === undefined || settingValues.value === null" class="text-nowrap">- none -</i>
{{ settingValues.value }}
</td>
<td class="font-mono text-base tracking-tight px-2">{{ settingValues.expected_value }}</td>
<td class="px-2 text-center">
<span v-if="settingValues.error === true"
class="text-red-600 dark:text-red-600"
>Error: {{ settingValues.errorMessage }}</span>
<button
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"> <form method="dialog" class="modal-backdrop backdrop-blur">
<button>close</button> <button>close</button>
</form> </form>
</dialog> </dialog>

View file

@ -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, faUser, faCircle } from '@fortawesome/free-solid-svg-icons' import { faSignal, faCloud, faCog, faUsers, 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')) { if (String(response_code).startsWith('2') || response_code == 302) {
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,6 +30,7 @@
</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
@ -38,7 +39,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="faUser" size="sm"></FontAwesomeIcon> <FontAwesomeIcon :icon="faUsers" size="sm"></FontAwesomeIcon>
Players: Players:
</span> </span>
<span class="font-bold">{{ userCount }}</span> <span class="font-bold">{{ userCount }}</span>
@ -76,7 +77,7 @@
<table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full"> <table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full">
<thead> <thead>
<tr class="font-medium dark:text-slate-200 text-slate-600 "> <tr class="font-medium dark:text-slate-200 text-slate-600">
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left"></th> <th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left"></th>
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-2 text-left">User</th> <th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-2 text-left">User</th>
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Time</th> <th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Time</th>
@ -150,4 +151,5 @@
</template> </template>
</tbody> </tbody>
</table> </table>
</div>
</template> </template>

View file

@ -48,6 +48,7 @@
}, },
yaxis: { yaxis: {
min: 0, min: 0,
max: 20,
labels: { labels: {
show: false, show: false,
} }
@ -62,8 +63,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-40`"> <div :class="`${!hasActivity ? 'hidden' : 'absolute'} h-10 -mt-1 w-full z-30`">
<div class="text-xxs flex justify-between h-full items-center text-slate-500 dark:text-slate-300"> <div class="text-xxs flex justify-between h-full items-center text-slate-500 dark:text-slate-300 select-none">
<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>

View file

@ -0,0 +1,58 @@
<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>

View file

@ -1,28 +1,25 @@
<script setup> <script setup>
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { active_exercises as exercises, progresses, setCompletedState } from "@/socket"; import { active_exercises as exercises } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons' import { faGraduationCap, faUpRightAndDownLeftFromCenter, faDownLeftAndUpRightToCenter, faWarning } from '@fortawesome/free-solid-svg-icons'
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue" import TheScoreTable from "./scoreViews/TheScoreTable.vue"
import TheFullScreenScoreGrid from "./scoreViews/TheFullScreenScoreGrid.vue"
const collapsed_panels = ref([]) import ThePlayerGrid from "./ThePlayerGrid.vue"
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 hasProgress = computed(() => Object.keys(progresses.value).length > 0) const fullscreen_panel = ref(false)
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>
@ -33,120 +30,56 @@
<div <div
v-if="!hasExercises" v-if="!hasExercises"
class="text-center text-slate-600 dark:text-slate-400 p-3 pl-6" class="text-slate-600 dark:text-slate-400 p-3 pl-6"
> >
<i>- No Exercise available -</i> <div class="
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>
<ThePlayerGrid></ThePlayerGrid>
</div> </div>
<table
<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"
> >
<thead> <div :class="fullscreen_panel === false ? 'relative min-w-fit' : ''">
<tr @click="collapse(exercise_index)" class="cursor-pointer"> <span
<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"> v-show="fullscreen_panel === false || fullscreen_panel === exercise_index"
<div class="flex justify-between items-center"> :class="['inline-block absolute shadow-lg z-50', fullscreen_panel === false ? 'top-0 -right-7' : 'top-2 right-2']"
<span class="dark:text-blue-200 text-slate-200 "># {{ exercise_index + 1 }}</span> >
<span class="text-lg">{{ exercise.name }}</span> <button
<span class=""> @click="toggleFullScreen(exercise_index)"
Level: <span :class="{ title="Toggle fullscreen mode"
'rounded-lg px-1 ml-2': true, :class="`
'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner', w-7 p-1 focus-outline font-semibold
'dark:bg-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced', 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-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert', ${fullscreen_panel === false ? 'rounded-r-md' : 'rounded-bl-md'}
}">{{ exercise.level }}</span> `"
</span> >
</div> <FontAwesomeIcon :icon="fullscreen_panel !== exercise_index ? faUpRightAndDownLeftFromCenter : faDownLeftAndUpRightToCenter" fixed-width></FontAwesomeIcon>
</th> </button>
</tr> </span>
<tr :class="`font-medium text-slate-600 dark:text-slate-200 ${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`"> <KeepAlive>
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left">User</th> <TheScoreTable
<th v-show="fullscreen_panel === false"
v-for="(task, task_index) in exercise.tasks" :exercise="exercise"
:key="task.name" :exercise_index="exercise_index"
class="border-b border-slate-100 dark:border-slate-700 p-3 align-top" ></TheScoreTable>
:title="task.description" </KeepAlive>
> <KeepAlive>
<div class="flex flex-col"> <TheFullScreenScoreGrid
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500 text-nowrap">Task {{ task_index + 1 }}</span> v-if="fullscreen_panel !== false"
<i class="text-center">{{ task.name }}</i> :exercise="exercises[fullscreen_panel]"
</div> :exercise_index="exercise_index"
</th> ></TheFullScreenScoreGrid>
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Progress</th> </KeepAlive>
</tr> </div>
</thead> </template>
<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>
</tbody>
</table>
</template> </template>

View file

@ -0,0 +1,251 @@
<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>

View file

@ -0,0 +1,186 @@
<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>

View file

@ -2,3 +2,5 @@ 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)

View file

@ -76,6 +76,10 @@ export function resetAllExerciseProgress() {
sendResetAllExerciseProgress() sendResetAllExerciseProgress()
} }
export function resetAll() {
sendResetAll()
}
export function resetLiveLogs() { export function resetLiveLogs() {
sendResetLiveLogs() sendResetLiveLogs()
} }
@ -96,6 +100,17 @@ 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})
@ -129,7 +144,6 @@ 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
}); });
@ -156,6 +170,12 @@ function sendResetAllExerciseProgress() {
}) })
} }
function sendResetAll() {
socket.emit("reset_all", () => {
getProgress()
})
}
function sendResetLiveLogs() { function sendResetLiveLogs() {
socket.emit("reset_notifications", () => { socket.emit("reset_notifications", () => {
getNotifications() getNotifications()
@ -182,6 +202,15 @@ 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", () => {

View file

@ -4,6 +4,11 @@ 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: {