Compare commits

..

51 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
Sami Mokaddem
0c2fdf5591 chg: [front:live-logs_user-activity-graph] Further improved UI 2024-07-09 13:19:27 +02:00
Sami Mokaddem
4d445f3408 chg: [build] Build assets 2024-07-09 13:02:24 +02:00
Sami Mokaddem
3d928b6437 chg: [front:live-logs_user_activity_graph] Improved colors 2024-07-09 13:00:26 +02:00
Sami Mokaddem
58da718c5d new: [app:user_activity] Added user activity chart 2024-07-09 12:19:20 +02:00
Sami Mokaddem
bc78e2f2cb chg: [app] Build frontend files 2024-07-08 15:06:38 +02:00
Sami Mokaddem
f1a0ed3ab1 chg: [front:theme] Better support of theme choice 2024-07-08 14:59:52 +02:00
Sami Mokaddem
e1010793dc chg: [front:live-logs-activity-graph] Split graph into its own component 2024-07-08 13:47:57 +02:00
Sami Mokaddem
58d4af812d chg: [app:live-logs] Improved notification activity chart 2024-07-08 13:17:12 +02:00
Sami Mokaddem
33bc5ca0bb chg: [app:config] Added eventReports as accepted notification scope 2024-07-08 10:25:19 +02:00
Sami Mokaddem
1277dbb132 new: [app:backup] Added backup feature that saves exercise progress every 5sec 2024-07-08 10:21:59 +02:00
Sami Mokaddem
6178592d10 chg: [app:live-logs] Increased refresh frequency of activity bars 2024-07-08 10:06:50 +02:00
Sami Mokaddem
dc1f7b6376 chg: [front:scores] Improved heading of the table 2024-07-08 10:06:33 +02:00
Sami Mokaddem
abca50d615 new: [app:notification_history] Added notification history support 2024-07-04 19:46:05 +02:00
39 changed files with 3490 additions and 1066 deletions

1
.gitignore vendored
View file

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

View file

@ -1,4 +1,6 @@
# misp-exercise-dashboard
# SkillAegis
<img alt="SkillAegis Logo" src="src/assets/skillaegis-logo.svg"/>
## Installation
```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,19 +6,3 @@ zmq_url = 'tcp://localhost:50000'
misp_url = 'https://localhost/'
misp_apikey = 'FI4gCRghRZvLVjlLPLTFZ852x2njkkgPSz0zQ3E0'
misp_skipssl = True
live_logs_accepted_scope = {
'events': ['add', 'edit', 'delete', 'restSearch',],
'attributes': ['add', 'edit', 'delete', 'restSearch',],
'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)

36
db.py
View file

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

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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<script type="module" crossorigin src="/assets/index-C72pAF6z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZKBaN7W.css">
<script type="module" crossorigin src="/assets/index-BS0mgB3_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7ybfbefL.css">
</head>
<body>
<div id="app"></div>

View file

@ -11,11 +11,11 @@ import jq
import db
from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search
import misp_api
import config
from config import logger
from appConfig import logger
ACTIVE_EXERCISES_DIR = "active_exercises"
LAST_BACKUP = {}
def debounce_check_active_tasks(debounce_seconds: int = 1):
func_last_execution_time = {}
@ -60,6 +60,49 @@ def read_exercise_dir():
return exercises
def backup_exercises_progress():
global LAST_BACKUP
toBackup = {
'EXERCISES_STATUS': db.EXERCISES_STATUS,
'SELECTED_EXERCISES': db.SELECTED_EXERCISES,
'USER_ID_TO_EMAIL_MAPPING': db.USER_ID_TO_EMAIL_MAPPING,
'USER_ID_TO_AUTHKEY_MAPPING': db.USER_ID_TO_AUTHKEY_MAPPING,
}
if toBackup != LAST_BACKUP:
with open('backup.json', 'w') as f:
json.dump(toBackup, f)
LAST_BACKUP = toBackup
def restore_exercices_progress():
try:
with open('backup.json', 'r') as f:
data = json.load(f)
db.EXERCISES_STATUS = data['EXERCISES_STATUS']
db.SELECTED_EXERCISES = data['SELECTED_EXERCISES']
db.USER_ID_TO_EMAIL_MAPPING = {}
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:
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:
exercises_uuid = set()
tasks_uuid = set()
@ -181,17 +224,23 @@ def resetAllExerciseProgress():
for exercise_status in db.EXERCISES_STATUS.values():
for task in exercise_status['tasks'].values():
mark_task_incomplete(user_id, exercise_status['uuid'], task['uuid'])
backup_exercises_progress()
def resetAllCommand():
resetAll()
backup_exercises_progress()
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 = {}
for exec_uuid, tasks in completion.items():
completed_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if completed]
return completed_tasks
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 = {}
for exec_uuid, tasks in completion.items():
incomplete_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if not completed]
@ -212,8 +261,8 @@ def get_available_tasks_for_user(user_id: int) -> list[str]:
def get_model_action(data: dict):
if 'Log' in data:
data = data['Log']
if 'Log' in data or 'AuditLog' in data:
data = data['Log'] if 'Log' in data else data['AuditLog']
if 'model' in data and 'action' in data:
return (data['model'], data['action'],)
return (None, None,)
@ -222,14 +271,12 @@ def is_accepted_query(data: dict) -> bool:
model, action = get_model_action(data)
if model in ['Event', 'Attribute', 'Object', 'Tag',]:
if action in ['add', 'edit', 'delete', 'publish', 'tag']:
# # improved condition below. It blocks some queries
# if data['Log']['change'].startswith('attribute_count'):
# return False
if data['Log']['change'].startswith('Validation errors:'):
return False
if 'Log' in data:
if data['Log']['change'].startswith('Validation errors:'):
return False
return True
if data.get('user_agent', None) == 'misp-exercise-dashboard':
if data.get('user_agent', None) == 'SkillAegis':
return None
url = data.get('url', None)
if url is not None:
@ -250,8 +297,9 @@ def get_completion_for_users():
for user_id in completion_per_user.keys():
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
for entry in task['completed_by_user']:
user_id = entry['user_id']
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
user_id = int(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[user_id][exercise_status['uuid']][task['uuid']] = entry
return completion_per_user
@ -297,9 +345,13 @@ def mark_task_incomplete(user_id: int, exercise_uuid: str , task_uuid: str):
def get_progress():
completion_for_users = get_completion_for_users()
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] = {
'email': db.USER_ID_TO_EMAIL_MAPPING[user_id],
'user_id': user_id,
'exercises': {},
}
for exec_uuid, tasks_completion in completion_for_users[user_id].items():
@ -315,10 +367,10 @@ async def check_inject(user_id: int, inject: dict, data: dict, context: dict) ->
for inject_evaluation in inject['inject_evaluation']:
success = await inject_checker_router(user_id, inject_evaluation, data, context)
if not success:
logger.info(f"Task not completed: {inject['uuid']}")
logger.info(f"Task not completed[{user_id}]: {inject['uuid']}")
return False
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
@ -335,13 +387,14 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
else:
logger.debug('Unknown request type')
return False
return False
return True
async def inject_checker_router(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
if not is_valid_evaluation_context(user_id, inject_evaluation, data, context):
return False
if 'evaluation_strategy' not in inject_evaluation:
logger.warning('Evaluation strategy not specified in inject')
return False
data_to_validate = await get_data_to_validate(user_id, inject_evaluation, data)
@ -390,6 +443,13 @@ def parse_event_id_from_log(data: dict) -> Union[int, None]:
if event_id_search is not None:
event_id = event_id_search.group(1)
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

View file

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

View file

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

View file

@ -11,7 +11,8 @@ from requests_cache import CachedSession
from requests.packages.urllib3.exceptions import InsecureRequestWarning # type: ignore
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))
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):
headers = {
'User-Agent': 'misp-exercise-dashboard',
'User-Agent': 'SkillAegis',
"Authorization": api_key,
"Accept": "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):
headers = {
'User-Agent': 'misp-exercise-dashboard',
'User-Agent': 'SkillAegis',
"Authorization": api_key,
"Accept": "application/json",
"Content-Type": "application/json"
@ -83,20 +84,25 @@ async def getVersion() -> 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')
if not settings:
return None
return {
setting['setting']: setting['value'] for setting in settings.get('finalSettings', []) if setting['setting'] in SETTING_TO_QUERY
}
data = {}
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
import db
import config
import appConfig
from urllib.parse import parse_qs
@ -27,6 +28,30 @@ def get_notifications() -> list[dict]:
return list(db.NOTIFICATION_MESSAGES)
def get_notifications_history() -> dict:
return {
'history': list(db.NOTIFICATION_HISTORY),
'config': {
'buffer_resolution_per_minute': db.NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN,
'buffer_timestamp_min': db.NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN,
'frequency': db.NOTIFICATION_HISTORY_FREQUENCY,
'notification_history_size': db.notification_history_buffer_size,
},
}
def get_users_activity() -> dict:
return {
'activity': {user_id: list(activity) for user_id, activity in db.USER_ACTIVITY.items()},
'config': {
'timestamp_min': db.USER_ACTIVITY_TIMESPAN_MIN,
'buffer_resolution_per_minute': db.USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN,
'frequency': db.USER_ACTIVITY_FREQUENCY,
'activity_buffer_size': db.user_activity_buffer_size,
},
}
def reset_notifications():
db.resetNotificationMessage()
@ -35,6 +60,14 @@ def record_notification(notification: dict):
db.NOTIFICATION_MESSAGES.appendleft(notification)
def record_notification_history(message_count: int):
db.NOTIFICATION_HISTORY.append(message_count)
def record_user_activity(user_id: int, count: int):
db.addUserActivity(user_id, count)
def get_user_id(data: dict):
if 'user_id' in data:
return int(data['user_id'])
@ -42,6 +75,10 @@ def get_user_id(data: dict):
data = data['Log']
if 'user_id' in data:
return int(data['user_id'])
if 'AuditLog' in data:
data = data['AuditLog']
if 'user_id' in data:
return int(data['user_id'])
return None
@ -150,7 +187,7 @@ def get_scope_action_from_url(url) -> Union[str, None]:
def is_accepted_notification(notification) -> bool:
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
if VERBOSE_MODE:
return True
@ -160,9 +197,26 @@ def is_accepted_notification(notification) -> bool:
return False
scope, action = get_scope_action_from_url(notification['url'])
if scope in config.live_logs_accepted_scope:
if config.live_logs_accepted_scope == '*':
if scope in appConfig.live_logs_accepted_scope:
if appConfig.live_logs_accepted_scope == '*':
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 False

130
package-lock.json generated
View file

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

View file

@ -1,5 +1,5 @@
{
"name": "misp-exercise-dashboard",
"name": "SkillAegis",
"version": "0.0.0",
"private": true,
"type": "module",
@ -16,8 +16,10 @@
"@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"apexcharts": "^3.49.2",
"lodash.debounce": "^4.0.8",
"vue": "^3.4.29"
"vue": "^3.4.29",
"vue3-apexcharts": "^1.5.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",

BIN
public/skillaegis-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

133
server.py
View file

@ -1,9 +1,11 @@
#!/usr/bin/env python3
import collections
import functools
import json
import sys
import time
import traceback
import zmq
import socketio
from aiohttp import web
@ -13,12 +15,14 @@ import exercise as exercise_model
import notification as notification_model
import db
import config
from config import logger
from appConfig import logger
import misp_api
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
ZMQ_MESSAGE_COUNT = 0
ZMQ_LAST_TIME = None
USER_ACTIVITY = collections.defaultdict(int)
def debounce(debounce_seconds: int = 1):
@ -40,6 +44,20 @@ def debounce(debounce_seconds: int = 1):
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
context = zmq.asyncio.Context()
@ -50,7 +68,6 @@ zsocket.setsockopt_string(zmq.SUBSCRIBE, '')
# Initialize Socket.IO server
# sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet')
sio = socketio.AsyncServer(cors_allowed_origins='*', async_mode='aiohttp')
app = web.Application()
sio.attach(app)
@ -101,6 +118,10 @@ async def mark_task_incomplete(sid, payload):
async def reset_all_exercise_progress(sid):
return exercise_model.resetAllExerciseProgress()
@sio.event
async def reset_all(sid):
return exercise_model.resetAllCommand()
@sio.event
async def reset_notifications(sid):
return notification_model.reset_notifications()
@ -109,6 +130,10 @@ async def reset_notifications(sid):
async def get_diagnostic(sid):
return await getDiagnostic()
@sio.event
async def get_users_activity(sid):
return notification_model.get_users_activity()
@sio.event
async def toggle_verbose_mode(sid, payload):
return notification_model.set_verbose_mode(payload['verbose'])
@ -117,22 +142,28 @@ async def toggle_verbose_mode(sid, payload):
async def toggle_apiquery_mode(sid, payload):
return notification_model.set_apiquery_mode(payload['apiquery'])
@sio.event
async def remediate_setting(sid, payload):
return await doSettingRemediation(payload['name'])
@sio.on('*')
async def any_event(event, sid, data={}):
logger.info('>> Unhandled event %s', event)
async def handleMessage(topic, s, message):
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
data = json.loads(message)
if topic == 'misp_json_audit':
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:
db.USER_ID_TO_EMAIL_MAPPING[user_id] = email
await sio.emit('new_user', email)
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:
db.USER_ID_TO_AUTHKEY_MAPPING[user_id] = authkey
return
@ -141,15 +172,23 @@ async def handleMessage(topic, s, message):
notification = notification_model.get_notification_message(data)
if notification_model.is_accepted_notification(notification):
notification_model.record_notification(notification)
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)
if user_id is not None:
USER_ACTIVITY[user_id] += 1
user_id = notification_model.get_user_id(data)
if user_id is not None:
if exercise_model.is_accepted_query(data):
context = get_context(topic, user_id, data)
succeeded_once = await exercise_model.check_active_tasks(user_id, data, context)
if succeeded_once:
await sendRefreshScore()
checking_task = 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:
sendRefreshScoreTask = sendRefreshScore()
await sendRefreshScoreTask if sendRefreshScoreTask is not None else None # Make sure check_active_tasks was not debounced
@debounce(debounce_seconds=1)
@ -188,6 +227,33 @@ async def getDiagnostic() -> dict:
return diagnostic
async def doSettingRemediation(setting) -> dict:
result = await misp_api.remediateSetting(setting)
return result
async def notification_history():
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
while True:
await sio.sleep(db.NOTIFICATION_HISTORY_FREQUENCY)
notification_model.record_notification_history(ZMQ_MESSAGE_COUNT_LAST_TIMESPAN)
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
payload = notification_model.get_notifications_history()
await sio.emit('update_notification_history', payload)
async def record_users_activity():
global USER_ACTIVITY
while True:
await sio.sleep(db.USER_ACTIVITY_FREQUENCY)
for user_id, activity in USER_ACTIVITY.items():
notification_model.record_user_activity(user_id, activity)
USER_ACTIVITY[user_id] = 0
payload = notification_model.get_users_activity()
await sio.emit('update_users_activity', payload)
async def keepalive():
global ZMQ_LAST_TIME
while True:
@ -198,6 +264,12 @@ async def keepalive():
await sio.emit('keep_alive', payload)
async def backup_exercises_progress():
while True:
await sio.sleep(5)
exercise_model.backup_exercises_progress()
# Function to forward zmq messages to Socket.IO
async def forward_zmq_to_socketio():
global ZMQ_MESSAGE_COUNT, ZMQ_LAST_TIME
@ -210,12 +282,57 @@ async def forward_zmq_to_socketio():
ZMQ_LAST_TIME = time.time()
await handleMessage(topic, s, m)
except Exception as e:
print(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():
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(notification_history)
sio.start_background_task(record_users_activity)
sio.start_background_task(backup_exercises_progress)
return app

View file

@ -5,17 +5,25 @@ import TheAdminPanel from './components/TheAdminPanel.vue'
import TheSocketConnectionState from './components/TheSocketConnectionState.vue'
import TheDahboard from './TheDahboard.vue'
import { socketConnected } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
onMounted(() => {
document.getElementsByTagName('body')[0].classList.add('dark')
if (darkModeEnabled.value) {
document.getElementsByTagName('body')[0].classList.add('dark')
}
})
</script>
<template>
<main>
<h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">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="flex gap-2">
<TheThemeButton></TheThemeButton>
@ -23,7 +31,9 @@ onMounted(() => {
<TheSocketConnectionState></TheSocketConnectionState>
</div>
</div>
<TheDahboard></TheDahboard>
<div class="mt-12">
<TheDahboard></TheDahboard>
</div>
</main>
</template>
@ -40,8 +50,18 @@ body {
@apply 3xl:container mx-auto;
@apply mx-auto;
@apply mt-4;
@apply 3xl:w-11/12;
@apply lg:w-5/6;
@apply lg:w-11/12;
@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>

View file

@ -3,6 +3,7 @@ import { onMounted, watch } from 'vue'
import TheLiveLogs from './components/TheLiveLogs.vue'
import TheScores from './components/TheScores.vue'
import { resetState, fullReload, socketConnected } from "@/socket";
import { fullscreenModeOn } from "@/settings.js"
watch(socketConnected, (isConnected) => {
@ -19,6 +20,8 @@ onMounted(() => {
</script>
<template>
<TheScores></TheScores>
<TheLiveLogs></TheLiveLogs>
<div class="mb-3">
<TheScores></TheScores>
<TheLiveLogs v-show="!fullscreenModeOn"></TheLiveLogs>
</div>
</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

@ -0,0 +1,67 @@
<script setup>
import { ref, watch, computed } from "vue"
import { userActivity, userActivityConfig } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
const props = defineProps(['user_id', 'compact_view', 'ultra_compact_view'])
const theChart = ref(null)
const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size)
const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min)
const chartInitSeries = computed(() => Array.from(Array(bufferSize.value)).map(() => 0))
const hasActivity = computed(() => userActivity.value.length != 0)
const chartSeries = computed(() => {
return !hasActivity.value ? chartInitSeries.value : activitySeries.value
})
const activitySeries = computed(() => {
const data = userActivity.value[props.user_id] === undefined ? chartInitSeries.value : userActivity.value[props.user_id]
return data
})
const colorRanges = [0, 1, 2, 3, 4, 5, 1000]
const palleteColor = 'blue'
const colorPalleteIndexDark = [
'900',
'700',
'600',
'500',
'400',
'300',
'200',
]
const colorPalleteIndexLight = [
'50',
'100',
'300',
'400',
'500',
'600',
'700',
]
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]
}
}
}
</script>
<template>
<span
:class="`${props.ultra_compact_view ? 'w-[120px]' : 'w-60'} ${props.compact_view ? 'h-1.5 inline-flex' : 'h-3'}`"
:title="`Activity over ${bufferSizeMin}min`"
>
<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>
</template>

View file

@ -1,10 +1,11 @@
<script setup>
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 { 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 clickedButtons = ref([])
const diagnosticLoading = computed(() => Object.keys(diagnostic.value).length == 0)
const isMISPOnline = computed(() => diagnostic.value.version?.version !== undefined)
@ -15,10 +16,16 @@
changeExerciseSelection(exec_uuid, state_enabled);
}
function settingHandler(setting) {
remediateSetting(setting)
}
function showTheModal() {
admin_modal.value.showModal()
clickedButtons.value = []
debouncedGetDiangostic()
}
</script>
<template>
@ -58,6 +65,13 @@
<FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
Reset All Exercises
</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
@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"
@ -131,29 +145,60 @@
<div v-if="diagnosticLoading" class="flex justify-center">
<span class="loading loading-dots loading-lg"></span>
</div>
<div
v-for="(value, setting) in diagnostic['settings']"
:key="setting"
>
<div>
<label class="label cursor-pointer justify-start p-0 pt-1">
<input
type="checkbox"
:checked="value"
:value="setting"
:class="`checkbox ${value ? 'checkbox-success' : 'checkbox-danger'} [--fallback-bc:#cbd5e1]`"
disabled
/>
<span class="font-mono font-semibold text-base ml-3">{{ setting }}</span>
</label>
</div>
</div>
<table v-else class="bg-white dark:bg-slate-700 rounded-lg shadow-xl w-full mt-2">
<thead>
<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"
>
<td class="font-mono font-semibold text-base px-2">{{ setting }}</td>
<td
: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>
</template>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<form method="dialog" class="modal-backdrop backdrop-blur">
<button>close</button>
</form>
</dialog>

View file

@ -1,8 +1,9 @@
<script setup>
import { ref, watch } from "vue"
import { ref, watch, computed } from "vue"
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
import { faSignal, faCloud, faCog, faUsers, faCircle } from '@fortawesome/free-solid-svg-icons'
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue";
const verbose = ref(false)
@ -17,7 +18,7 @@
})
function getClassFromResponseCode(response_code) {
if (String(response_code).startsWith('2')) {
if (String(response_code).startsWith('2') || response_code == 302) {
return 'text-green-500'
} else if (String(response_code).startsWith('5')) {
return 'text-red-600'
@ -29,6 +30,7 @@
</script>
<template>
<div>
<h3 class="text-2xl mt-6 mb-2 font-bold text-blue-500 dark:text-blue-400">
<FontAwesomeIcon :icon="faSignal"></FontAwesomeIcon>
Live logs
@ -37,7 +39,7 @@
<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="mr-1">
<FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
<FontAwesomeIcon :icon="faUsers" size="sm"></FontAwesomeIcon>
Players:
</span>
<span class="font-bold">{{ userCount }}</span>
@ -71,9 +73,11 @@
</span>
</div>
<TheLiveLogsActivityGraphVue></TheLiveLogsActivityGraphVue>
<table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full">
<thead>
<tr class="font-medium dark:text-slate-200 text-slate-600 ">
<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-2 text-left">User</th>
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Time</th>
@ -147,4 +151,5 @@
</template>
</tbody>
</table>
</div>
</template>

View file

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

View file

@ -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,27 +1,25 @@
<script setup>
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 { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
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)
}
}
import { faGraduationCap, faUpRightAndDownLeftFromCenter, faDownLeftAndUpRightToCenter, faWarning } from '@fortawesome/free-solid-svg-icons'
import TheScoreTable from "./scoreViews/TheScoreTable.vue"
import TheFullScreenScoreGrid from "./scoreViews/TheFullScreenScoreGrid.vue"
import ThePlayerGrid from "./ThePlayerGrid.vue"
import { fullscreenModeOn } from "@/settings.js"
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>
<template>
@ -32,117 +30,56 @@
<div
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>
<table
<template
v-for="(exercise, exercise_index) in exercises"
:key="exercise.name"
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-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">
<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"
:title="task.description"
>
<div class="flex flex-col">
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500">Task {{ task_index + 1 }}</span>
<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-3 pl-6">
<span :title="user_id" class="text-nowrap">
<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">{{ progress.email.split('@')[0] }}</span>
<span class="text-xs font-mono">@{{ progress.email.split('@')[1] }}</span>
</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>
<div :class="fullscreen_panel === false ? 'relative min-w-fit' : ''">
<span
v-show="fullscreen_panel === false || fullscreen_panel === exercise_index"
:class="['inline-block absolute shadow-lg z-50', fullscreen_panel === false ? 'top-0 -right-7' : 'top-2 right-2']"
>
<button
@click="toggleFullScreen(exercise_index)"
title="Toggle fullscreen mode"
:class="`
w-7 p-1 focus-outline font-semibold
text-slate-800 bg-slate-100 hover:bg-slate-200 dark:text-slate-200 dark:bg-slate-800 dark:hover:bg-slate-900
${fullscreen_panel === false ? 'rounded-r-md' : 'rounded-bl-md'}
`"
>
<FontAwesomeIcon :icon="fullscreen_panel !== exercise_index ? faUpRightAndDownLeftFromCenter : faDownLeftAndUpRightToCenter" fixed-width></FontAwesomeIcon>
</button>
</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>
</template>
</template>

View file

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

View file

@ -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

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

6
src/settings.js Normal file
View file

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

View file

@ -3,13 +3,17 @@ import { io } from "socket.io-client";
import debounce from 'lodash.debounce'
// "undefined" means the URL will be computed from the `window.location` object
const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:4000";
const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:40001";
const MAX_LIVE_LOG = 30
const initial_state = {
notificationEvents: [],
notificationCounter: 0,
notificationAPICounter: 0,
notificationHistory: [],
notificationHistoryConfig: {},
userActivity: {},
userActivityConfig: {},
exercises: [],
selected_exercises: [],
progresses: {},
@ -40,6 +44,10 @@ export const notificationCounter = computed(() => state.notificationCounter)
export const notificationAPICounter = computed(() => state.notificationAPICounter)
export const userCount = computed(() => Object.keys(state.progresses).length)
export const diagnostic = computed(() => state.diagnostic)
export const notificationHistory = computed(() => state.notificationHistory)
export const notificationHistoryConfig = computed(() => state.notificationHistoryConfig)
export const userActivity = computed(() => state.userActivity)
export const userActivityConfig = computed(() => state.userActivityConfig)
export const socketConnected = computed(() => connectionState.connected)
export const zmqLastTime = computed(() => connectionState.zmq_last_time)
@ -52,6 +60,7 @@ export function fullReload() {
getSelectedExercises()
getNotifications()
getProgress()
getUsersActivity()
}
export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
@ -67,6 +76,10 @@ export function resetAllExerciseProgress() {
sendResetAllExerciseProgress()
}
export function resetAll() {
sendResetAll()
}
export function resetLiveLogs() {
sendResetLiveLogs()
}
@ -87,6 +100,17 @@ export function toggleApiQueryMode(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 debouncedGetDiangostic = debounce(getDiangostic, 1000, {leading: true})
@ -118,6 +142,14 @@ function getProgress() {
})
}
function getUsersActivity() {
socket.emit("get_users_activity", (user_activity_bundle) => {
state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config
});
}
function getDiangostic() {
state.diagnostic = {}
socket.emit("get_diagnostic", (diagnostic) => {
@ -138,6 +170,12 @@ function sendResetAllExerciseProgress() {
})
}
function sendResetAll() {
socket.emit("reset_all", () => {
getProgress()
})
}
function sendResetLiveLogs() {
socket.emit("reset_notifications", () => {
getNotifications()
@ -164,6 +202,15 @@ function sendToggleApiQueryMode(enabled) {
socket.emit("toggle_apiquery_mode", payload, () => {})
}
function sendRemediateSetting(setting, cb) {
const payload = {
name: setting
}
socket.emit("remediate_setting", payload, (result) => {
cb(result)
})
}
/* Event listener */
socket.on("connect", () => {
@ -194,6 +241,16 @@ socket.on("keep_alive", (keep_alive) => {
connectionState.zmq_last_time = keep_alive['zmq_last_time']
});
socket.on("update_notification_history", (notification_history_bundle) => {
state.notificationHistory = notification_history_bundle.history
state.notificationHistoryConfig = notification_history_bundle.config
});
socket.on("update_users_activity", (user_activity_bundle) => {
state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config
});
function addLimited(target, message, maxCount) {
target.unshift(message)
if (target.length > maxCount) {

View file

@ -4,14 +4,22 @@ export default {
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
safelist: [
{
pattern: /bg-blue+/, // Includes bg of all colors and shades
},
],
theme: {
extend: {
transitionProperty: {
'width': 'width'
} ,
},
screens: {
'3xl': '1800px',
}
},
fontSize: {
'xxs': '0.6rem',
},
},
},
plugins: [