Compare commits

..

No commits in common. "main" and "feature/chart" have entirely different histories.

36 changed files with 1877 additions and 3074 deletions

View file

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

View file

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

View file

@ -1,38 +0,0 @@
live_logs_accepted_scope = {
'events': ['add', 'edit', 'delete', 'restSearch',],
'attributes': ['add', 'add_attachment', 'edit', 'revise_object', 'delete', 'restSearch',],
'eventReports': ['add', 'edit', 'delete',],
'tags': '*',
}
user_activity_accepted_scope = {
'events': ['view', 'add', 'edit', 'delete', 'restSearch',],
'attributes': ['add', 'add_attachment', 'edit', 'delete', 'restSearch',],
'objects': ['add', 'edit', 'revise_object', 'delete',],
'eventReports': ['view', 'add', 'edit', 'delete',],
'tags': '*',
}
misp_settings = {
'Plugin.ZeroMQ_enable': True,
'Plugin.ZeroMQ_audit_notifications_enable': True,
'Plugin.ZeroMQ_event_notifications_enable': True,
'Plugin.ZeroMQ_attribute_notifications_enable': True,
'MISP.log_paranoid': True,
'MISP.log_paranoid_skip_db': True,
'MISP.log_paranoid_include_post_body': True,
'MISP.log_auth': True,
'Security.allow_unsafe_cleartext_apikey_logging': True,
}
import logging
logger = logging.getLogger('SkillAegis')
format = '[%(levelname)s] %(asctime)s - %(message)s'
formatter = logging.Formatter(format)
logging.basicConfig(filename='SkillAegis.log', encoding='utf-8', level=logging.DEBUG, format=format)
# create console handler and set level to debug
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
ch.setFormatter(formatter)
logger.addHandler(ch)

View file

@ -6,3 +6,20 @@ zmq_url = 'tcp://localhost:50000'
misp_url = 'https://localhost/' misp_url = 'https://localhost/'
misp_apikey = 'FI4gCRghRZvLVjlLPLTFZ852x2njkkgPSz0zQ3E0' misp_apikey = 'FI4gCRghRZvLVjlLPLTFZ852x2njkkgPSz0zQ3E0'
misp_skipssl = True misp_skipssl = True
live_logs_accepted_scope = {
'events': ['add', 'edit', 'delete', 'restSearch',],
'attributes': ['add', 'edit', 'delete', 'restSearch',],
'eventReports': ['add', 'edit', 'delete',],
'tags': '*',
}
import logging
logger = logging.getLogger('misp-exercise-dashboard')
format = '[%(levelname)s] %(asctime)s - %(message)s'
formatter = logging.Formatter(format)
logging.basicConfig(filename='misp-exercise-dashboard.log', encoding='utf-8', level=logging.DEBUG, format=format)
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
ch.setFormatter(formatter)
logger.addHandler(ch)

File diff suppressed because one or more lines are too long

1480
dist/assets/index-BJjpd8Qi.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

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

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

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

View file

@ -11,11 +11,11 @@ import jq
import db import db
from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search
import misp_api import misp_api
from appConfig import logger import config
from config import logger
ACTIVE_EXERCISES_DIR = "active_exercises" ACTIVE_EXERCISES_DIR = "active_exercises"
LAST_BACKUP = {}
def debounce_check_active_tasks(debounce_seconds: int = 1): def debounce_check_active_tasks(debounce_seconds: int = 1):
func_last_execution_time = {} func_last_execution_time = {}
@ -61,46 +61,26 @@ def read_exercise_dir():
def backup_exercises_progress(): def backup_exercises_progress():
global LAST_BACKUP with open('backup.json', 'w') as f:
toBackup = { toBackup = {
'EXERCISES_STATUS': db.EXERCISES_STATUS, 'EXERCISES_STATUS': db.EXERCISES_STATUS,
'SELECTED_EXERCISES': db.SELECTED_EXERCISES, 'SELECTED_EXERCISES': db.SELECTED_EXERCISES,
'USER_ID_TO_EMAIL_MAPPING': db.USER_ID_TO_EMAIL_MAPPING, 'USER_ID_TO_EMAIL_MAPPING': db.USER_ID_TO_EMAIL_MAPPING,
'USER_ID_TO_AUTHKEY_MAPPING': db.USER_ID_TO_AUTHKEY_MAPPING, 'USER_ID_TO_AUTHKEY_MAPPING': db.USER_ID_TO_AUTHKEY_MAPPING,
} }
if toBackup != LAST_BACKUP:
with open('backup.json', 'w') as f:
json.dump(toBackup, f) json.dump(toBackup, f)
LAST_BACKUP = toBackup
def restore_exercices_progress(): def restore_exercices_progress():
try: try:
with open('backup.json', 'r') as f: with open('backup.json', 'r') as f:
data = json.load(f) data = json.load(f)
db.EXERCISES_STATUS = data['EXERCISES_STATUS'] db.EXERCISES_STATUS = data['EXERCISES_STATUS']
db.SELECTED_EXERCISES = data['SELECTED_EXERCISES'] db.SELECTED_EXERCISES = data['SELECTED_EXERCISES']
db.USER_ID_TO_EMAIL_MAPPING = {} db.USER_ID_TO_EMAIL_MAPPING = data['USER_ID_TO_EMAIL_MAPPING']
for user_id_str, email in data['USER_ID_TO_EMAIL_MAPPING'].items(): db.USER_ID_TO_AUTHKEY_MAPPING = data['USER_ID_TO_AUTHKEY_MAPPING']
db.USER_ID_TO_EMAIL_MAPPING[int(user_id_str)] = email
db.USER_ID_TO_AUTHKEY_MAPPING = {}
for user_id_str, authkey in data['USER_ID_TO_AUTHKEY_MAPPING'].items():
db.USER_ID_TO_AUTHKEY_MAPPING[int(user_id_str)] = authkey
except: except:
logger.info('Could not restore exercise progress') logger.info('Could not restore exercise progress')
resetAll()
if len(db.EXERCISES_STATUS) == 0:
init_exercises_tasks()
def resetAll():
db.EXERCISES_STATUS = {}
db.SELECTED_EXERCISES = []
db.USER_ID_TO_EMAIL_MAPPING = {}
db.USER_ID_TO_AUTHKEY_MAPPING = {}
init_exercises_tasks()
def is_validate_exercises(exercises: list) -> bool: def is_validate_exercises(exercises: list) -> bool:
@ -227,20 +207,15 @@ def resetAllExerciseProgress():
backup_exercises_progress() backup_exercises_progress()
def resetAllCommand():
resetAll()
backup_exercises_progress()
def get_completed_tasks_for_user(user_id: int): def get_completed_tasks_for_user(user_id: int):
completion = get_completion_for_users().get(user_id, {}) completion = get_completion_for_users()[user_id]
completed_tasks = {} completed_tasks = {}
for exec_uuid, tasks in completion.items(): for exec_uuid, tasks in completion.items():
completed_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if completed] completed_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if completed]
return completed_tasks return completed_tasks
def get_incomplete_tasks_for_user(user_id: int): def get_incomplete_tasks_for_user(user_id: int):
completion = get_completion_for_users().get(user_id, {}) completion = get_completion_for_users()[user_id]
incomplete_tasks = {} incomplete_tasks = {}
for exec_uuid, tasks in completion.items(): for exec_uuid, tasks in completion.items():
incomplete_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if not completed] incomplete_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if not completed]
@ -261,8 +236,8 @@ def get_available_tasks_for_user(user_id: int) -> list[str]:
def get_model_action(data: dict): def get_model_action(data: dict):
if 'Log' in data or 'AuditLog' in data: if 'Log' in data:
data = data['Log'] if 'Log' in data else data['AuditLog'] data = data['Log']
if 'model' in data and 'action' in data: if 'model' in data and 'action' in data:
return (data['model'], data['action'],) return (data['model'], data['action'],)
return (None, None,) return (None, None,)
@ -271,12 +246,14 @@ def is_accepted_query(data: dict) -> bool:
model, action = get_model_action(data) model, action = get_model_action(data)
if model in ['Event', 'Attribute', 'Object', 'Tag',]: if model in ['Event', 'Attribute', 'Object', 'Tag',]:
if action in ['add', 'edit', 'delete', 'publish', 'tag']: if action in ['add', 'edit', 'delete', 'publish', 'tag']:
if 'Log' in data: # # improved condition below. It blocks some queries
# if data['Log']['change'].startswith('attribute_count'):
# return False
if data['Log']['change'].startswith('Validation errors:'): if data['Log']['change'].startswith('Validation errors:'):
return False return False
return True return True
if data.get('user_agent', None) == 'SkillAegis': if data.get('user_agent', None) == 'misp-exercise-dashboard':
return None return None
url = data.get('url', None) url = data.get('url', None)
if url is not None: if url is not None:
@ -297,9 +274,8 @@ def get_completion_for_users():
for user_id in completion_per_user.keys(): for user_id in completion_per_user.keys():
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
for entry in task['completed_by_user']: for entry in task['completed_by_user']:
user_id = int(entry['user_id']) user_id = entry['user_id']
if user_id in completion_per_user: # Ensure the user_id is known in USER_ID_TO_EMAIL_MAPPING completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
completion_per_user[user_id][exercise_status['uuid']][task['uuid']] = entry
return completion_per_user return completion_per_user
@ -345,13 +321,9 @@ def mark_task_incomplete(user_id: int, exercise_uuid: str , task_uuid: str):
def get_progress(): def get_progress():
completion_for_users = get_completion_for_users() completion_for_users = get_completion_for_users()
progress = {} progress = {}
for user_id in completion_for_users.keys(): for user_id in completion_for_users:
if user_id not in db.USER_ID_TO_EMAIL_MAPPING:
print('unknown user id', user_id)
continue
progress[user_id] = { progress[user_id] = {
'email': db.USER_ID_TO_EMAIL_MAPPING[user_id], 'email': db.USER_ID_TO_EMAIL_MAPPING[user_id],
'user_id': user_id,
'exercises': {}, 'exercises': {},
} }
for exec_uuid, tasks_completion in completion_for_users[user_id].items(): for exec_uuid, tasks_completion in completion_for_users[user_id].items():
@ -367,10 +339,10 @@ async def check_inject(user_id: int, inject: dict, data: dict, context: dict) ->
for inject_evaluation in inject['inject_evaluation']: for inject_evaluation in inject['inject_evaluation']:
success = await inject_checker_router(user_id, inject_evaluation, data, context) success = await inject_checker_router(user_id, inject_evaluation, data, context)
if not success: if not success:
logger.info(f"Task not completed[{user_id}]: {inject['uuid']}") logger.info(f"Task not completed: {inject['uuid']}")
return False return False
mark_task_completed(user_id, inject['exercise_uuid'], inject['uuid']) mark_task_completed(user_id, inject['exercise_uuid'], inject['uuid'])
logger.info(f"Task success[{user_id}]: {inject['uuid']}") logger.info(f"Task success: {inject['uuid']}")
return True return True
@ -387,14 +359,13 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
else: else:
logger.debug('Unknown request type') logger.debug('Unknown request type')
return False return False
return True return False
async def inject_checker_router(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool: async def inject_checker_router(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
if not is_valid_evaluation_context(user_id, inject_evaluation, data, context): if not is_valid_evaluation_context(user_id, inject_evaluation, data, context):
return False return False
if 'evaluation_strategy' not in inject_evaluation: if 'evaluation_strategy' not in inject_evaluation:
logger.warning('Evaluation strategy not specified in inject')
return False return False
data_to_validate = await get_data_to_validate(user_id, inject_evaluation, data) data_to_validate = await get_data_to_validate(user_id, inject_evaluation, data)
@ -443,13 +414,6 @@ def parse_event_id_from_log(data: dict) -> Union[int, None]:
if event_id_search is not None: if event_id_search is not None:
event_id = event_id_search.group(1) event_id = event_id_search.group(1)
return event_id return event_id
elif 'AuditLog' in data:
log = data['AuditLog']
if 'model' in log and 'model_id' in log and log['model'] == 'Event':
return int(log['model_id'])
if 'change' in log:
if 'event_id' in log and log['event_id'] is not None:
return int(log['event_id'])
return None return None

View file

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

View file

@ -1,519 +0,0 @@
{
"exercise": {
"description": "MISP Encoding Exercise : Spearphishing Incident",
"expanded": "MISP Encoding Exercise : Spearphishing Incident",
"meta": {
"author": "MISP Project",
"level": "beginner",
"priority": 5
},
"name": "MISP Encoding Exercise : Spearphishing Incident",
"namespace": "data-model",
"tags": [
"exercise:software-scope=\"misp\"",
"state:production"
],
"total_duration": "7200",
"uuid": "53b20321-ac8c-4a3e-9c56-e772caf669e6",
"version": "20240715"
},
"inject_flow": [
{
"description": "event-creation",
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd",
"reporting_callback": [],
"requirements": {},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": [
"startex"
]
},
"timing": {
"triggered_at": null
}
},
{
"description": "IP-address",
"inject_uuid": "92fc404b-2dce-4815-8a7e-b68a582c3569",
"reporting_callback": [],
"requirements": {
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": []
},
"timing": {
"triggered_at": null
}
},
{
"description": "malicious-payloads",
"inject_uuid": "cfc47f7c-590c-4897-bfb9-cc72965fee24",
"reporting_callback": [],
"requirements": {
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": []
},
"timing": {
"triggered_at": null
}
},
{
"description": "Download URL",
"inject_uuid": "e849a314-3394-4501-a9e1-126e0e61f11d",
"reporting_callback": [],
"requirements": {
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": []
},
"timing": {
"triggered_at": null
}
},
{
"description": "CVE",
"inject_uuid": "32141393-adce-4007-950c-77b4c7c60a39",
"reporting_callback": [],
"requirements": {
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": []
},
"timing": {
"triggered_at": null
}
},
{
"description": "C2",
"inject_uuid": "a0d7f076-1737-4c1c-af36-c2717885299e",
"reporting_callback": [],
"requirements": {
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": []
},
"timing": {
"triggered_at": null
}
},
{
"description": "Person",
"inject_uuid": "92a55537-0e4c-44f8-8bcd-102c38d343a9",
"reporting_callback": [],
"requirements": {
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": []
},
"timing": {
"triggered_at": null
}
},
{
"description": "Contextualization",
"inject_uuid": "b19e8d39-e64e-4a51-94ee-462cd74b8d24",
"reporting_callback": [],
"requirements": {
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": []
},
"timing": {
"triggered_at": null
}
},
{
"description": "Published",
"inject_uuid": "930459b8-ed61-4e62-b072-071577ea0430",
"reporting_callback": [],
"requirements": {
"inject_uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
"sequence": {
"completion_trigger": [
"time_expiration",
"completion"
],
"followed_by": [],
"trigger": []
},
"timing": {
"triggered_at": null
}
}
],
"inject_payloads": [],
"injects": [
{
"action": "event-creation",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
}
],
"result": "MISP Event created",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
10
]
}
],
"name": "Event Creation",
"target_tool": "MISP",
"uuid": "a95726bb-2761-442d-8b5c-842e384df2bd"
},
{
"action": "ip-address",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
},
{
"[.Event.Object[].Attribute[], .Event.Attribute[]] | .[] | select(.value == \"john.doe@luxembourg.edu\")": {
"extract_type": "all",
"comparison": "count",
"values": [
">0"
]
}
}
],
"result": "Email address spoofed",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
20
]
}
],
"name": "Email address",
"target_tool": "MISP",
"uuid": "92fc404b-2dce-4815-8a7e-b68a582c3569"
},
{
"action": "malware-sample",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
},
{
".Event.Object[].Attribute[].value": {
"extract_type": "all",
"comparison": "contains",
"values": [
"7c08ddb3b57cf9a00f02a484e23a4b6c8a6d738d"
]
}
}
],
"result": "Malware samples added",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
20
]
}
],
"name": "Malware sample",
"target_tool": "MISP",
"uuid": "cfc47f7c-590c-4897-bfb9-cc72965fee24"
},
{
"action": "download url",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
},
{
".Event.Object[].Attribute[] | select((.type == \"url\")).value": {
"extract_type": "all",
"comparison": "contains",
"values": [
"https://evilprovider.com/this-is-not-malicious.exe"
]
}
},
{
".Event.Object[].Attribute[] | select((.type == \"domain\") or (.type == \"hostname\")).value": {
"extract_type": "all",
"comparison": "equals",
"values": [
"evilprovider.com"
]
}
}
],
"result": "Download URL added",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
20
]
}
],
"name": "Download URL",
"target_tool": "MISP",
"uuid": "e849a314-3394-4501-a9e1-126e0e61f11d"
},
{
"action": "CVE",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
},
{
"[.Event.Object[].Attribute[], .Event.Attribute[]] | .[].value": {
"extract_type": "all",
"comparison": "contains",
"values": [
"CVE-2015-5465"
]
}
}
],
"result": "CVE",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
20
]
}
],
"name": "CVE",
"target_tool": "MISP",
"uuid": "32141393-adce-4007-950c-77b4c7c60a39"
},
{
"action": "C2",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
},
{
".Event.Object[] | select((.name == \"url\")).Attribute[] | select(.type == \"url\").value": {
"extract_type": "all",
"comparison": "contains-regex",
"values": [
"https:\\/\\/another\\.evil\\.provider\\.com(:57666)?"
]
}
}
],
"result": "C2 added",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
20
]
}
],
"name": "C2",
"target_tool": "MISP",
"uuid": "a0d7f076-1737-4c1c-af36-c2717885299e"
},
{
"action": "Email Provider",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
},
{
"[(.Event.Object[] | select((.name == \"email\")).Attribute[]), .Event.Attribute[]] | .[].value": {
"extract_type": "all",
"comparison": "contains",
"values": [
"throwaway-email-provider.com"
]
}
}
],
"result": "Email Provider added",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
20
]
}
],
"name": "Email Provider",
"target_tool": "MISP",
"uuid": "92a55537-0e4c-44f8-8bcd-102c38d343a9"
},
{
"action": "context",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
},
{
".Event.Tag | select(length > 0) | .[].name": {
"extract_type": "all",
"comparison": "count",
"values": [
">=3"
]
}
}
],
"result": "Context added",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
20
]
}
],
"name": "Contextualization",
"target_tool": "MISP",
"uuid": "b19e8d39-e64e-4a51-94ee-462cd74b8d24"
},
{
"action": "published",
"inject_evaluation": [
{
"parameters": [
{
".Event.info": {
"comparison": "regex",
"values": [
".*[sS]pear[-\\s]?phishing.*"
]
}
},
{
".Event.published": {
"comparison": "equals",
"values": [
"1"
]
}
}
],
"result": "Event published",
"evaluation_strategy": "data_filtering",
"evaluation_context": {},
"score_range": [
0,
20
]
}
],
"name": "Published",
"target_tool": "MISP",
"uuid": "930459b8-ed61-4e62-b072-071577ea0430"
}
]
}

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/skillaegis-logo.png"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Vite App</title>
</head> </head>

View file

@ -3,7 +3,7 @@ from typing import Union
import jq import jq
import re import re
import operator import operator
from appConfig import logger from config import logger
def jq_extract(path: str, data: dict, extract_type='first'): def jq_extract(path: str, data: dict, extract_type='first'):
@ -95,7 +95,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str, context:
if comparison_type == 'contains-regex': if comparison_type == 'contains-regex':
regex = re.compile(values[0]) regex = re.compile(values[0])
for candidate in data_to_validate: for candidate in data_to_validate:
if regex.match(candidate) is not None: if regex.match(candidate):
return True return True
return False return False
elif comparison_type == 'count': elif comparison_type == 'count':

View file

@ -11,8 +11,7 @@ from requests_cache import CachedSession
from requests.packages.urllib3.exceptions import InsecureRequestWarning # type: ignore from requests.packages.urllib3.exceptions import InsecureRequestWarning # type: ignore
requests.packages.urllib3.disable_warnings(InsecureRequestWarning) requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
from config import misp_url, misp_apikey, misp_skipssl from config import misp_url, misp_apikey, misp_skipssl, logger
from appConfig import logger, misp_settings
requestSession = CachedSession(cache_name='misp_cache', expire_after=timedelta(seconds=5)) requestSession = CachedSession(cache_name='misp_cache', expire_after=timedelta(seconds=5))
adapterCache = requests.adapters.HTTPAdapter(pool_connections=50, pool_maxsize=50) adapterCache = requests.adapters.HTTPAdapter(pool_connections=50, pool_maxsize=50)
@ -22,7 +21,7 @@ requestSession.mount('http://', adapterCache)
async def get(url, data={}, api_key=misp_apikey): async def get(url, data={}, api_key=misp_apikey):
headers = { headers = {
'User-Agent': 'SkillAegis', 'User-Agent': 'misp-exercise-dashboard',
"Authorization": api_key, "Authorization": api_key,
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json" "Content-Type": "application/json"
@ -46,7 +45,7 @@ async def get(url, data={}, api_key=misp_apikey):
async def post(url, data={}, api_key=misp_apikey): async def post(url, data={}, api_key=misp_apikey):
headers = { headers = {
'User-Agent': 'SkillAegis', 'User-Agent': 'misp-exercise-dashboard',
"Authorization": api_key, "Authorization": api_key,
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json" "Content-Type": "application/json"
@ -84,25 +83,20 @@ async def getVersion() -> Union[None, dict]:
async def getSettings() -> Union[None, dict]: async def getSettings() -> Union[None, dict]:
SETTING_TO_QUERY = [
'Plugin.ZeroMQ_enable',
'Plugin.ZeroMQ_audit_notifications_enable',
'Plugin.ZeroMQ_event_notifications_enable',
'Plugin.ZeroMQ_attribute_notifications_enable',
'MISP.log_paranoid',
'MISP.log_paranoid_skip_db',
'MISP.log_paranoid_include_post_body',
'MISP.log_auth',
'Security.allow_unsafe_cleartext_apikey_logging',
]
settings = await get(f'/servers/serverSettings.json') settings = await get(f'/servers/serverSettings.json')
if not settings: if not settings:
return None return None
data = {} return {
for settingName, expectedSettingValue in misp_settings.items(): setting['setting']: setting['value'] for setting in settings.get('finalSettings', []) if setting['setting'] in SETTING_TO_QUERY
data[settingName] = {
'expected_value': expectedSettingValue,
'value': None
} }
for setting in settings.get('finalSettings', []):
if setting['setting'] in misp_settings:
data[setting['setting']]['value'] = setting['value']
return data
async def remediateSetting(setting) ->dict:
if setting in misp_settings:
payload = {
'value': misp_settings[setting],
'force': 1,
}
return await post(f'/servers/serverSettingsEdit/{setting}', payload)

View file

@ -5,7 +5,6 @@ import re
from typing import Union from typing import Union
import db import db
import config import config
import appConfig
from urllib.parse import parse_qs from urllib.parse import parse_qs
@ -75,10 +74,6 @@ def get_user_id(data: dict):
data = data['Log'] data = data['Log']
if 'user_id' in data: if 'user_id' in data:
return int(data['user_id']) return int(data['user_id'])
if 'AuditLog' in data:
data = data['AuditLog']
if 'user_id' in data:
return int(data['user_id'])
return None return None
@ -187,7 +182,7 @@ def get_scope_action_from_url(url) -> Union[str, None]:
def is_accepted_notification(notification) -> bool: def is_accepted_notification(notification) -> bool:
global VERBOSE_MODE global VERBOSE_MODE
if notification['user_agent'] == 'SkillAegis': # Ignore message generated from this app if notification['user_agent'] == 'misp-exercise-dashboard': # Ignore message generated from this app
return False return False
if VERBOSE_MODE: if VERBOSE_MODE:
return True return True
@ -197,26 +192,9 @@ def is_accepted_notification(notification) -> bool:
return False return False
scope, action = get_scope_action_from_url(notification['url']) scope, action = get_scope_action_from_url(notification['url'])
if scope in appConfig.live_logs_accepted_scope: if scope in config.live_logs_accepted_scope:
if appConfig.live_logs_accepted_scope == '*': if config.live_logs_accepted_scope == '*':
return True return True
elif action in appConfig.live_logs_accepted_scope[scope]: elif action in config.live_logs_accepted_scope[scope]:
return True
return False
def is_accepted_user_activity(notification) -> bool:
global VERBOSE_MODE
if notification['user_agent'] == 'SkillAegis': # Ignore message generated from this app
return False
if '@' not in notification['user']: # Ignore message from system
return False
scope, action = get_scope_action_from_url(notification['url'])
if scope in appConfig.user_activity_accepted_scope:
if appConfig.user_activity_accepted_scope == '*':
return True
elif action in appConfig.user_activity_accepted_scope[scope]:
return True return True
return False return False

4
package-lock.json generated
View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -5,7 +5,6 @@ import functools
import json import json
import sys import sys
import time import time
import traceback
import zmq import zmq
import socketio import socketio
from aiohttp import web from aiohttp import web
@ -15,7 +14,7 @@ import exercise as exercise_model
import notification as notification_model import notification as notification_model
import db import db
import config import config
from appConfig import logger from config import logger
import misp_api import misp_api
@ -44,20 +43,6 @@ def debounce(debounce_seconds: int = 1):
return decorator return decorator
def timer():
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
t1 = time.time()
res = func(*args, **kwargs)
elapsed = time.time() - t1
if elapsed > 0.1:
print(elapsed)
return res
return wrapper
return decorator
# Initialize ZeroMQ context and subscriber socket # Initialize ZeroMQ context and subscriber socket
context = zmq.asyncio.Context() context = zmq.asyncio.Context()
@ -118,10 +103,6 @@ async def mark_task_incomplete(sid, payload):
async def reset_all_exercise_progress(sid): async def reset_all_exercise_progress(sid):
return exercise_model.resetAllExerciseProgress() return exercise_model.resetAllExerciseProgress()
@sio.event
async def reset_all(sid):
return exercise_model.resetAllCommand()
@sio.event @sio.event
async def reset_notifications(sid): async def reset_notifications(sid):
return notification_model.reset_notifications() return notification_model.reset_notifications()
@ -142,10 +123,6 @@ async def toggle_verbose_mode(sid, payload):
async def toggle_apiquery_mode(sid, payload): async def toggle_apiquery_mode(sid, payload):
return notification_model.set_apiquery_mode(payload['apiquery']) return notification_model.set_apiquery_mode(payload['apiquery'])
@sio.event
async def remediate_setting(sid, payload):
return await doSettingRemediation(payload['name'])
@sio.on('*') @sio.on('*')
async def any_event(event, sid, data={}): async def any_event(event, sid, data={}):
logger.info('>> Unhandled event %s', event) logger.info('>> Unhandled event %s', event)
@ -157,13 +134,13 @@ async def handleMessage(topic, s, message):
if topic == 'misp_json_audit': if topic == 'misp_json_audit':
user_id, email = notification_model.get_user_email_id_pair(data) user_id, email = notification_model.get_user_email_id_pair(data)
if user_id is not None and user_id != 0 and '@' in email: if user_id is not None and '@' in email:
if user_id not in db.USER_ID_TO_EMAIL_MAPPING: if user_id not in db.USER_ID_TO_EMAIL_MAPPING:
db.USER_ID_TO_EMAIL_MAPPING[user_id] = email db.USER_ID_TO_EMAIL_MAPPING[user_id] = email
await sio.emit('new_user', email) await sio.emit('new_user', email)
user_id, authkey = notification_model.get_user_authkey_id_pair(data) user_id, authkey = notification_model.get_user_authkey_id_pair(data)
if user_id is not None and user_id != 0: if user_id is not None:
if authkey not in db.USER_ID_TO_AUTHKEY_MAPPING: if authkey not in db.USER_ID_TO_AUTHKEY_MAPPING:
db.USER_ID_TO_AUTHKEY_MAPPING[user_id] = authkey db.USER_ID_TO_AUTHKEY_MAPPING[user_id] = authkey
return return
@ -173,22 +150,18 @@ async def handleMessage(topic, s, message):
if notification_model.is_accepted_notification(notification): if notification_model.is_accepted_notification(notification):
notification_model.record_notification(notification) notification_model.record_notification(notification)
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN += 1 ZMQ_MESSAGE_COUNT_LAST_TIMESPAN += 1
await sio.emit('notification', notification)
if notification_model.is_accepted_user_activity(notification):
user_id = notification_model.get_user_id(data) user_id = notification_model.get_user_id(data)
if user_id is not None: if user_id is not None:
USER_ACTIVITY[user_id] += 1 USER_ACTIVITY[user_id] += 1
await sio.emit('notification', notification)
user_id = notification_model.get_user_id(data) user_id = notification_model.get_user_id(data)
if user_id is not None: if user_id is not None:
if exercise_model.is_accepted_query(data): if exercise_model.is_accepted_query(data):
context = get_context(topic, user_id, data) context = get_context(topic, user_id, data)
checking_task = exercise_model.check_active_tasks(user_id, data, context) succeeded_once = await exercise_model.check_active_tasks(user_id, data, context)
if checking_task is not None: # Make sure check_active_tasks was not debounced
succeeded_once = await checking_task
if succeeded_once: if succeeded_once:
sendRefreshScoreTask = sendRefreshScore() await sendRefreshScore()
await sendRefreshScoreTask if sendRefreshScoreTask is not None else None # Make sure check_active_tasks was not debounced
@debounce(debounce_seconds=1) @debounce(debounce_seconds=1)
@ -227,11 +200,6 @@ async def getDiagnostic() -> dict:
return diagnostic return diagnostic
async def doSettingRemediation(setting) -> dict:
result = await misp_api.remediateSetting(setting)
return result
async def notification_history(): async def notification_history():
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
while True: while True:
@ -277,57 +245,16 @@ async def forward_zmq_to_socketio():
while True: while True:
message = await zsocket.recv_string() message = await zsocket.recv_string()
topic, s, m = message.partition(" ") topic, s, m = message.partition(" ")
await handleMessage(topic, s, m)
try: try:
ZMQ_MESSAGE_COUNT += 1 ZMQ_MESSAGE_COUNT += 1
ZMQ_LAST_TIME = time.time() ZMQ_LAST_TIME = time.time()
await handleMessage(topic, s, m) # await handleMessage(topic, s, m)
except Exception as e: except Exception as e:
print(e)
logger.error('Error handling message %s', e) logger.error('Error handling message %s', e)
# Function to forward zmq messages to Socket.IO
async def forward_fake_zmq_to_socketio():
global ZMQ_MESSAGE_COUNT, ZMQ_LAST_TIME
filename = sys.argv[1]
line_number = sum(1 for _ in open(filename))
print(f'Preparing to feed {line_number} lines..')
await sio.sleep(2)
print('Feeding started')
line_count = 0
last_print = time.time()
with open(filename) as f:
for line in f:
line_count += 1
now = time.time()
if line_count % (int(line_number/100)) == 0 or (now - last_print >= 5):
last_print = now
print(f'Feeding {line_count} / {line_number} - ({100* line_count / line_number:.1f}%)')
split = line.split(' ', 1)
topic = split[0]
s = ''
m = split[1]
if topic != 'misp_json_self':
await sio.sleep(0.01)
try:
ZMQ_MESSAGE_COUNT += 1
ZMQ_LAST_TIME = time.time()
await handleMessage(topic, s, m)
except Exception as e:
print(e)
print(line)
print(traceback.format_exc())
logger.error('Error handling message: %s', e)
await sio.sleep(5)
print('Feeding done.')
async def init_app(): async def init_app():
if len(sys.argv) == 2:
sio.start_background_task(forward_fake_zmq_to_socketio)
else:
exercise_model.restore_exercices_progress()
sio.start_background_task(forward_zmq_to_socketio) sio.start_background_task(forward_zmq_to_socketio)
sio.start_background_task(keepalive) sio.start_background_task(keepalive)
sio.start_background_task(notification_history) sio.start_background_task(notification_history)
@ -346,4 +273,6 @@ if __name__ == "__main__":
logger.critical('Could not load exercises') logger.critical('Could not load exercises')
sys.exit(1) sys.exit(1)
exercise_model.restore_exercices_progress()
web.run_app(init_app(), host=config.server_host, port=config.server_port) web.run_app(init_app(), host=config.server_host, port=config.server_port)

View file

@ -18,12 +18,7 @@ onMounted(() => {
<template> <template>
<main> <main>
<h1 class="text-xl text-center text-slate-500 dark:text-slate-400 absolute inset-x-0 top-0"> <h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">Exercise Dashboard</h1>
<div class="flex flex-col items-center mt-2">
<span id="logo" class="hover:cursor-pointer"></span>
<span>SkillAegis</span>
</div>
</h1>
<div class="absolute top-1 right-1"> <div class="absolute top-1 right-1">
<div class="flex gap-2"> <div class="flex gap-2">
<TheThemeButton></TheThemeButton> <TheThemeButton></TheThemeButton>
@ -31,9 +26,7 @@ onMounted(() => {
<TheSocketConnectionState></TheSocketConnectionState> <TheSocketConnectionState></TheSocketConnectionState>
</div> </div>
</div> </div>
<div class="mt-12">
<TheDahboard></TheDahboard> <TheDahboard></TheDahboard>
</div>
</main> </main>
</template> </template>
@ -50,18 +43,8 @@ body {
@apply 3xl:container mx-auto; @apply 3xl:container mx-auto;
@apply mx-auto; @apply mx-auto;
@apply mt-4; @apply mt-4;
@apply lg:w-11/12; @apply 3xl:w-11/12;
@apply 3xl:w-5/6; @apply lg:w-5/6;
}
#logo {
background-image: url(@/assets/skillaegis-logo.svg);
width: 64px;
height: 64px;
display: block;
background-size: 64px;
/* cyan-400 */
/* filter: invert(71%) sepia(97%) saturate(1333%) hue-rotate(147deg) brightness(95%) contrast(96%); */
} }
</style> </style>

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 27 KiB

View file

@ -3,65 +3,127 @@
import { userActivity, userActivityConfig } from "@/socket"; import { userActivity, userActivityConfig } from "@/socket";
import { darkModeEnabled } from "@/settings.js" import { darkModeEnabled } from "@/settings.js"
const props = defineProps(['user_id', 'compact_view', 'ultra_compact_view']) const props = defineProps(['user_id'])
const theChart = ref(null) const theChart = ref(null)
const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size) const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size)
const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min) const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min)
const chartInitSeries = computed(() => Array.from(Array(bufferSize.value)).map(() => 0)) const chartInitSeries = Array.from(Array(bufferSize.value)).map(() => 0)
const hasActivity = computed(() => userActivity.value.length != 0) const hasActivity = computed(() => userActivity.value.length != 0)
const chartSeries = computed(() => { const chartSeries = computed(() => {
return !hasActivity.value ? chartInitSeries.value : activitySeries.value return !hasActivity.value ? chartInitSeries : activitySeries.value
}) })
const activitySeries = computed(() => { const activitySeries = computed(() => {
const data = userActivity.value[props.user_id] === undefined ? chartInitSeries.value : userActivity.value[props.user_id] const data = userActivity.value[props.user_id] === undefined ? chartInitSeries : userActivity.value[props.user_id]
return data return [{data: Array.from(data)}]
}) })
const colorRanges = [1, 3, 5, 7, 9, 1000]
const colorRanges = [0, 1, 2, 3, 4, 5, 1000] const chartOptions = computed(() => {
const palleteColor = 'blue' return {
const colorPalleteIndexDark = [ chart: {
'900', height: 12,
'700', width: 224,
'600', type: 'heatmap',
'500', sparkline: {
'400', enabled: true
'300', },
'200', animations: {
] enabled: false,
const colorPalleteIndexLight = [ easing: 'easeinout',
'50', speed: 200,
'100', },
'300', },
'400', dataLabels: {
'500', enabled: false,
'600', style: {
'700', fontSize: '10px',
] fontWeight: '400',
function getPalleteIndexFromValue(value) {
for (let palleteIndex = 0; palleteIndex < colorRanges.length; palleteIndex++) {
const colorRangeValue = colorRanges[palleteIndex];
if (value <= colorRangeValue) {
return darkModeEnabled.value ? colorPalleteIndexDark[palleteIndex] : colorPalleteIndexLight[palleteIndex]
} }
},
plotOptions: {
heatmap: {
radius: 2,
enableShades: false,
shadeIntensity: 0.5,
reverseNegativeShade: true,
distributed: false,
useFillColorAsStroke: false,
colorScale: {
ranges: [
{
from: 0,
to: colorRanges[0],
color: darkModeEnabled.value ? '#1e3a8a' : '#bfdbfe',
},
{
from: colorRanges[0] + 1,
to: colorRanges[1],
color: darkModeEnabled.value ? '#1d4ed8' : '#93c5fd',
},
{
from: colorRanges[1] + 1,
to: colorRanges[2],
color: darkModeEnabled.value ? '#2563eb' : '#60a5fa',
},
{
from: colorRanges[2] + 1,
to: colorRanges[3],
color: darkModeEnabled.value ? '#3b82f6' : '#3b82f6',
},
{
from: colorRanges[3] + 1,
to: colorRanges[4],
color: darkModeEnabled.value ? '#60a5fa' : '#2563eb',
},
{
from: colorRanges[4] + 1,
to: colorRanges[5],
color: darkModeEnabled.value ? '#93c5fd' : '#1d4ed8',
},
],
// inverse: false,
min: 0,
max: 1000
},
},
},
states: {
hover: {
filter: {
type: 'none',
} }
},
active: {
filter: {
type: 'none',
} }
},
},
grid: {
show: false,
},
legend: {
show: true,
},
stroke: {
width: 0,
},
tooltip: {
enabled: false,
},
}
})
</script> </script>
<template> <template>
<span <span
:class="`${props.ultra_compact_view ? 'w-[120px]' : 'w-60'} ${props.compact_view ? 'h-1.5 inline-flex' : 'h-3'}`" class="h-3 w-52"
:title="`Activity over ${bufferSizeMin}min`" :title="`Activity over ${bufferSizeMin}min`"
> >
<span <apexchart type="heatmap" height="12" width="224" :options="chartOptions" :series="chartSeries"></apexchart>
v-for="(value, i) in chartSeries"
:key="i"
:class="[`inline-block rounded-[1px] mr-px`, props.compact_view ? 'h-1.5' : 'h-3', `bg-${palleteColor}-${getPalleteIndexFromValue(value)}`]"
:style="`width: ${(((props.ultra_compact_view ? 120 : 240) - chartSeries.length) / chartSeries.length).toFixed(1)}px`"
></span>
</span> </span>
</template> </template>

View file

@ -1,11 +1,10 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { exercises, selected_exercises, diagnostic, fullReload, resetAllExerciseProgress, resetAll, resetLiveLogs, changeExerciseSelection, debouncedGetDiangostic, remediateSetting } from "@/socket"; import { exercises, selected_exercises, diagnostic, fullReload, resetAllExerciseProgress, resetLiveLogs, changeExerciseSelection, debouncedGetDiangostic } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faScrewdriverWrench, faTrash, faSuitcaseMedical, faGraduationCap, faBan, faRotate, faHammer, faCheck } from '@fortawesome/free-solid-svg-icons' import { faScrewdriverWrench, faTrash, faSuitcaseMedical, faGraduationCap, faBan, faRotate } from '@fortawesome/free-solid-svg-icons'
const admin_modal = ref(null) const admin_modal = ref(null)
const clickedButtons = ref([])
const diagnosticLoading = computed(() => Object.keys(diagnostic.value).length == 0) const diagnosticLoading = computed(() => Object.keys(diagnostic.value).length == 0)
const isMISPOnline = computed(() => diagnostic.value.version?.version !== undefined) const isMISPOnline = computed(() => diagnostic.value.version?.version !== undefined)
@ -16,16 +15,10 @@
changeExerciseSelection(exec_uuid, state_enabled); changeExerciseSelection(exec_uuid, state_enabled);
} }
function settingHandler(setting) {
remediateSetting(setting)
}
function showTheModal() { function showTheModal() {
admin_modal.value.showModal() admin_modal.value.showModal()
clickedButtons.value = []
debouncedGetDiangostic() debouncedGetDiangostic()
} }
</script> </script>
<template> <template>
@ -65,13 +58,6 @@
<FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon> <FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
Reset All Exercises Reset All Exercises
</button> </button>
<button
@click="resetAll()"
class="h-10 min-h-10 px-2 py-1 font-semibold bg-red-600 text-slate-200 hover:bg-red-700 btn btn-sm gap-1"
>
<FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
Reset All
</button>
<button <button
@click="resetLiveLogs()" @click="resetLiveLogs()"
class="h-10 min-h-10 px-2 py-1 font-semibold bg-amber-600 text-slate-200 hover:bg-amber-700 btn btn-sm gap-1" class="h-10 min-h-10 px-2 py-1 font-semibold bg-amber-600 text-slate-200 hover:bg-amber-700 btn btn-sm gap-1"
@ -145,60 +131,29 @@
<div v-if="diagnosticLoading" class="flex justify-center"> <div v-if="diagnosticLoading" class="flex justify-center">
<span class="loading loading-dots loading-lg"></span> <span class="loading loading-dots loading-lg"></span>
</div> </div>
<table v-else class="bg-white dark:bg-slate-700 rounded-lg shadow-xl w-full mt-2"> <div
<thead> v-for="(value, setting) in diagnostic['settings']"
<tr>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Setting</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Value</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Expected Value</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-center">Action</th>
</tr>
</thead>
<tbody>
<tr
v-for="(settingValues, setting) in diagnostic['settings']"
:key="setting" :key="setting"
> >
<td class="font-mono font-semibold text-base px-2">{{ setting }}</td> <div>
<td <label class="label cursor-pointer justify-start p-0 pt-1">
:class="`font-mono text-base tracking-tight px-2 ${settingValues.expected_value != settingValues.value ? 'text-red-600 dark:text-red-600' : ''}`" <input
> type="checkbox"
<i v-if="settingValues.value === undefined || settingValues.value === null" class="text-nowrap">- none -</i> :checked="value"
{{ settingValues.value }} :value="setting"
</td> :class="`checkbox ${value ? 'checkbox-success' : 'checkbox-danger'} [--fallback-bc:#cbd5e1]`"
<td class="font-mono text-base tracking-tight px-2">{{ settingValues.expected_value }}</td> disabled
<td class="px-2 text-center"> />
<span v-if="settingValues.error === true" <span class="font-mono font-semibold text-base ml-3">{{ setting }}</span>
class="text-red-600 dark:text-red-600" </label>
>Error: {{ settingValues.errorMessage }}</span> </div>
<button </div>
v-else-if="settingValues.expected_value != settingValues.value"
@click="clickedButtons.push(setting) && settingHandler(setting)"
:disabled="clickedButtons.includes(setting)"
class="h-8 min-h-8 px-2 font-semibold bg-green-600 text-slate-200 hover:bg-green-700 btn gap-1"
>
<template v-if="!clickedButtons.includes(setting)">
<FontAwesomeIcon :icon="faHammer" size="sm" fixed-width></FontAwesomeIcon>
Remediate
</template>
<template v-else>
<span class="loading loading-dots loading-sm"></span>
</template>
</button>
<span v-else class="text-base font-bold text-green-600 dark:text-green-600">
<FontAwesomeIcon :icon="faCheck" class=""></FontAwesomeIcon>
OK
</span>
</td>
</tr>
</tbody>
</table>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop backdrop-blur"> <form method="dialog" class="modal-backdrop">
<button>close</button> <button>close</button>
</form> </form>
</dialog> </dialog>

View file

@ -2,7 +2,7 @@
import { ref, watch, computed } from "vue" import { ref, watch, computed } from "vue"
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } from "@/socket"; import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faSignal, faCloud, faCog, faUsers, faCircle } from '@fortawesome/free-solid-svg-icons' import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue"; import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue";
@ -18,7 +18,7 @@
}) })
function getClassFromResponseCode(response_code) { function getClassFromResponseCode(response_code) {
if (String(response_code).startsWith('2') || response_code == 302) { if (String(response_code).startsWith('2')) {
return 'text-green-500' return 'text-green-500'
} else if (String(response_code).startsWith('5')) { } else if (String(response_code).startsWith('5')) {
return 'text-red-600' return 'text-red-600'
@ -30,7 +30,6 @@
</script> </script>
<template> <template>
<div>
<h3 class="text-2xl mt-6 mb-2 font-bold text-blue-500 dark:text-blue-400"> <h3 class="text-2xl mt-6 mb-2 font-bold text-blue-500 dark:text-blue-400">
<FontAwesomeIcon :icon="faSignal"></FontAwesomeIcon> <FontAwesomeIcon :icon="faSignal"></FontAwesomeIcon>
Live logs Live logs
@ -39,7 +38,7 @@
<div class="mb-2 flex flex-wrap gap-x-3"> <div class="mb-2 flex flex-wrap gap-x-3">
<span class="rounded-lg py-1 px-2 dark:bg-sky-700 bg-sky-400 text-slate-800 dark:text-slate-200"> <span class="rounded-lg py-1 px-2 dark:bg-sky-700 bg-sky-400 text-slate-800 dark:text-slate-200">
<span class="mr-1"> <span class="mr-1">
<FontAwesomeIcon :icon="faUsers" size="sm"></FontAwesomeIcon> <FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
Players: Players:
</span> </span>
<span class="font-bold">{{ userCount }}</span> <span class="font-bold">{{ userCount }}</span>
@ -151,5 +150,4 @@
</template> </template>
</tbody> </tbody>
</table> </table>
</div>
</template> </template>

View file

@ -48,7 +48,6 @@
}, },
yaxis: { yaxis: {
min: 0, min: 0,
max: 20,
labels: { labels: {
show: false, show: false,
} }
@ -63,8 +62,8 @@
<template> <template>
<div class="my-2 --ml-1 bg-slate-50 dark:bg-slate-600 py-1 pl-1 pr-3 rounded-md relative flex flex-col"> <div class="my-2 --ml-1 bg-slate-50 dark:bg-slate-600 py-1 pl-1 pr-3 rounded-md relative flex flex-col">
<div :class="`${!hasActivity ? 'hidden' : 'absolute'} h-10 -mt-1 w-full z-30`"> <div :class="`${!hasActivity ? 'hidden' : 'absolute'} h-10 -mt-1 w-full z-40`">
<div class="text-xxs flex justify-between h-full items-center text-slate-500 dark:text-slate-300 select-none"> <div class="text-xxs flex justify-between h-full items-center text-slate-500 dark:text-slate-300">
<span class="-rotate-90 w-8 -ml-3">- {{ notificationHistoryConfig.buffer_timestamp_min }}min</span> <span class="-rotate-90 w-8 -ml-3">- {{ notificationHistoryConfig.buffer_timestamp_min }}min</span>
<span class="-rotate-90 w-8 text-xs"></span> <span class="-rotate-90 w-8 text-xs"></span>
<span class="-rotate-90 w-8 text-lg"></span> <span class="-rotate-90 w-8 text-lg"></span>

View file

@ -1,58 +0,0 @@
<script setup>
import { ref, computed } from "vue";
import { progresses, userCount } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faUsers } from '@fortawesome/free-solid-svg-icons'
import { darkModeEnabled } from "@/settings.js"
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
const compactGrid = computed(() => { return userCount.value > 70 })
const sortedProgress = computed(() => Object.values(progresses.value).sort((a, b) => {
if (a.email < b.email) {
return -1;
}
if (a.email > b.email) {
return 1;
}
return 0;
}))
</script>
<template>
<div class="
mt-2 px-2 pt-1 pb-2 rounded border
bg-slate-100 border-slate-300 dark:bg-slate-600 dark:border-slate-800
">
<h4 class="text-xl mb-2 font-bold text-blue-500 dark:text-blue-400">
<FontAwesomeIcon :icon="faUsers"></FontAwesomeIcon>
Active Players
</h4>
<div :class="`flex flex-wrap ${compactGrid ? 'gap-1' : 'gap-2'}`">
<span
v-for="(progress) in sortedProgress"
:key="progress.user_id"
class="bg-slate-200 dark:bg-slate-900 rounded border drop-shadow-lg border-slate-700"
>
<span class="
flex p-2 mb-1
text-slate-600 dark:text-slate-400
">
<span :class="`flex flex-col ${compactGrid ? 'w-[120px]' : 'w-60'}`">
<span :title="progress.user_id" class="text-nowrap inline-block leading-5 truncate">
<span :class="`${compactGrid ? 'text-base' : 'text-lg'} font-bold font-mono leading-5 tracking-tight`">{{ progress.email.split('@')[0] }}</span>
<span :class="`${compactGrid ? 'text-xs' : 'text-xs'} font-mono tracking-tight`">@{{ progress.email.split('@')[1] }}</span>
</span>
<LiveLogsUserActivityGraph
:user_id="progress.user_id"
:compact_view="compactGrid"
:ultra_compact_view="false"
></LiveLogsUserActivityGraph>
</span>
</span>
</span>
</div>
</div>
</template>

View file

@ -1,25 +1,28 @@
<script setup> <script setup>
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { active_exercises as exercises } from "@/socket"; import { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faGraduationCap, faUpRightAndDownLeftFromCenter, faDownLeftAndUpRightToCenter, faWarning } from '@fortawesome/free-solid-svg-icons' import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
import TheScoreTable from "./scoreViews/TheScoreTable.vue" import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
import TheFullScreenScoreGrid from "./scoreViews/TheFullScreenScoreGrid.vue"
import ThePlayerGrid from "./ThePlayerGrid.vue" const collapsed_panels = ref([])
import { fullscreenModeOn } from "@/settings.js"
function toggleCompleted(completed, user_id, exec_uuid, task_uuid) {
setCompletedState(completed, user_id, exec_uuid, task_uuid)
}
function collapse(exercise_index) {
const index = collapsed_panels.value.indexOf(exercise_index)
if (index >= 0) {
collapsed_panels.value.splice(index, 1)
} else {
collapsed_panels.value.push(exercise_index)
}
}
const hasExercises = computed(() => exercises.value.length > 0) const hasExercises = computed(() => exercises.value.length > 0)
const fullscreen_panel = ref(false) const hasProgress = computed(() => Object.keys(progresses.value).length > 0)
function toggleFullScreen(exercise_index) {
if (fullscreen_panel.value === exercise_index) {
fullscreen_panel.value = false
fullscreenModeOn.value = false
} else {
fullscreen_panel.value = exercise_index
fullscreenModeOn.value = true
}
}
</script> </script>
<template> <template>
@ -30,56 +33,120 @@
<div <div
v-if="!hasExercises" v-if="!hasExercises"
class="text-slate-600 dark:text-slate-400 p-3 pl-6" class="text-center text-slate-600 dark:text-slate-400 p-3 pl-6"
> >
<div class=" <i>- No Exercise available -</i>
p-2 border-l-4 text-left rounded
dark:bg-yellow-300 dark:text-slate-900 dark:border-yellow-700
bg-yellow-200 text-slate-900 border-yellow-700
">
<FontAwesomeIcon :icon="faWarning" class="text-yellow-700 text-lg mx-3"></FontAwesomeIcon>
<strong class="">No Exercise available.</strong>
<span class="ml-1">Select an exercise in the <i class="underline">Admin panel</i>.</span>
</div> </div>
<table
<ThePlayerGrid></ThePlayerGrid>
</div>
<template
v-for="(exercise, exercise_index) in exercises" v-for="(exercise, exercise_index) in exercises"
:key="exercise.name" :key="exercise.name"
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full mb-4"
> >
<div :class="fullscreen_panel === false ? 'relative min-w-fit' : ''"> <thead>
<span <tr @click="collapse(exercise_index)" class="cursor-pointer">
v-show="fullscreen_panel === false || fullscreen_panel === exercise_index" <th :colspan="2 + exercise.tasks.length" class="rounded-t-lg border-b border-slate-100 dark:border-slate-700 text-md p-3 pl-6 text-center dark:bg-blue-800 bg-blue-500 dark:text-slate-300 text-slate-100">
:class="['inline-block absolute shadow-lg z-50', fullscreen_panel === false ? 'top-0 -right-7' : 'top-2 right-2']" <div class="flex justify-between items-center">
> <span class="dark:text-blue-200 text-slate-200 "># {{ exercise_index + 1 }}</span>
<button <span class="text-lg">{{ exercise.name }}</span>
@click="toggleFullScreen(exercise_index)" <span class="">
title="Toggle fullscreen mode" Level: <span :class="{
:class="` 'rounded-lg px-1 ml-2': true,
w-7 p-1 focus-outline font-semibold 'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner',
text-slate-800 bg-slate-100 hover:bg-slate-200 dark:text-slate-200 dark:bg-slate-800 dark:hover:bg-slate-900 'dark:bg-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced',
${fullscreen_panel === false ? 'rounded-r-md' : 'rounded-bl-md'} 'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert',
`" }">{{ exercise.level }}</span>
>
<FontAwesomeIcon :icon="fullscreen_panel !== exercise_index ? faUpRightAndDownLeftFromCenter : faDownLeftAndUpRightToCenter" fixed-width></FontAwesomeIcon>
</button>
</span> </span>
<KeepAlive>
<TheScoreTable
v-show="fullscreen_panel === false"
:exercise="exercise"
:exercise_index="exercise_index"
></TheScoreTable>
</KeepAlive>
<KeepAlive>
<TheFullScreenScoreGrid
v-if="fullscreen_panel !== false"
:exercise="exercises[fullscreen_panel]"
:exercise_index="exercise_index"
></TheFullScreenScoreGrid>
</KeepAlive>
</div> </div>
</th>
</tr>
<tr :class="`font-medium text-slate-600 dark:text-slate-200 ${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`">
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left">User</th>
<th
v-for="(task, task_index) in exercise.tasks"
:key="task.name"
class="border-b border-slate-100 dark:border-slate-700 p-3 align-top"
:title="task.description"
>
<div class="flex flex-col">
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500 text-nowrap">Task {{ task_index + 1 }}</span>
<i class="text-center">{{ task.name }}</i>
</div>
</th>
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Progress</th>
</tr>
</thead>
<tbody :class="`${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`">
<tr v-if="!hasProgress">
<td
:colspan="2 + exercise.tasks.length"
class="text-center border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6"
>
<i>- No user yet -</i>
</td>
</tr>
<template v-else>
<tr v-for="(progress, user_id) in progresses" :key="user_id" class="bg-slate-100 dark:bg-slate-900">
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative">
<span class="flex flex-col max-w-60">
<span :title="user_id" class="text-nowrap inline-block leading-5 truncate">
<FontAwesomeIcon v-if="progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1" :icon="faMedal" class="mr-1 text-amber-300"></FontAwesomeIcon>
<span class="text-lg font-bold font-mono leading-5 tracking-tight">{{ progress.email.split('@')[0] }}</span>
<span class="text-xs font-mono tracking-tight">@{{ progress.email.split('@')[1] }}</span>
</span>
<LiveLogsUserActivityGraph :user_id="user_id"></LiveLogsUserActivityGraph>
</span>
</td>
<td
v-for="(task, task_index) in exercise.tasks"
:key="task_index"
class="text-center border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-2"
>
<span
class="select-none cursor-pointer flex justify-center content-center flex-wrap h-9"
@click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], user_id, exercise.uuid, task.uuid)"
>
<span class="flex flex-col">
<span class="text-nowrap">
<FontAwesomeIcon
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
:icon="faCheck"
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<FontAwesomeIcon
v-else-if="task.requirements?.inject_uuid !== undefined && !progress.exercises[exercise.uuid].tasks_completion[task.requirements.inject_uuid]"
title="All requirements for that task haven't been fullfilled yet"
:icon="faHourglassHalf"
:class="`text-lg ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<FontAwesomeIcon
v-else
:icon="faTimes"
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<small :class="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'"> (+{{ task.score }})</small>
</span>
<span class="text-sm leading-3">
<span
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp"
:class="progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? 'font-bold' : 'font-extralight'"
>
{{ (new Date(progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp * 1000)).toTimeString().split(' ', 1)[0] }}
</span>
<span v-else></span>
</span>
</span>
</span>
</td>
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3">
<div class="flex w-full h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600" role="progressbar" :aria-valuenow="progress.exercises[exercise.uuid].score" :aria-valuemin="0" aria-valuemax="100">
<div
class="flex flex-col justify-center rounded-full overflow-hidden bg-green-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-green-500 transition-width transition-slowest ease"
:style="`width: ${100 * (progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score)}%`"
></div>
</div>
</td>
</tr>
</template> </template>
</tbody>
</table>
</template> </template>

View file

@ -1,251 +0,0 @@
<script setup>
import { ref, computed } from "vue";
import { active_exercises as exercises, progresses, userCount, setCompletedState } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCheck, faTimes, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
import { faCircleCheck } from '@fortawesome/free-regular-svg-icons'
import { darkModeEnabled } from "@/settings.js"
import LiveLogsUserActivityGraph from "../LiveLogsUserActivityGraph.vue"
const props = defineProps(['exercise', 'exercise_index'])
const collapsed_panels = ref([])
const chartOptions = computed(() => {
return {
chart: {
type: 'radialBar',
height: 120,
sparkline: {
enabled: true
},
animations: {
enabled: false,
easing: 'easeinout',
speed: 200,
},
},
colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'],
plotOptions: {
radialBar: {
startAngle: -110,
endAngle: 110,
hollow: {
margin: 0,
size: '30%',
background: '#64748b',
position: 'front',
dropShadow: {
enabled: true,
top: 3,
left: 0,
blur: 4,
opacity: 0.24
}
},
track: {
background: '#475569',
strokeWidth: '97%',
margin: 0,
dropShadow: {
enabled: true,
top: 3,
left: 0,
blur: 3,
opacity: 0.35
}
},
dataLabels: {
show: true,
name: {
show: false,
},
value: {
formatter: function(val) {
return parseInt(val*userCount.value / 100);
},
offsetY: 7,
color: darkModeEnabled.value ? '#cbd5e1' : '#f1f5f9',
fontSize: '1.25rem',
show: true,
}
}
}
},
stroke: {
lineCap: 'smooth'
},
colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'],
labels: ['Progress'],
tooltip: {
enabled: false,
},
}
})
function toggleCompleted(completed, user_id, exec_uuid, task_uuid) {
setCompletedState(completed, user_id, exec_uuid, task_uuid)
}
function collapse(exercise_index) {
const index = collapsed_panels.value.indexOf(exercise_index)
if (index >= 0) {
collapsed_panels.value.splice(index, 1)
} else {
collapsed_panels.value.push(exercise_index)
}
}
const compactGrid = computed(() => { return userCount.value > 70 })
const ultraCompactGrid = computed(() => { return userCount.value > 100 })
const hasProgress = computed(() => Object.keys(progresses.value).length > 0)
const sortedProgress = computed(() => Object.values(progresses.value).sort((a, b) => {
if (a.email < b.email) {
return -1;
}
if (a.email > b.email) {
return 1;
}
return 0;
}))
const taskCompletionPercentages = computed(() => {
const completions = {}
Object.values(props.exercise.tasks).forEach(task => {
completions[task.uuid] = 0
})
sortedProgress.value.forEach(progress => {
for (const [taskUuid, taskCompletion] of Object.entries(progress.exercises[props.exercise.uuid].tasks_completion)) {
if (taskCompletion !== false) {
completions[taskUuid] += 1
}
}
});
for (const [taskUuid, taskCompletionSum] of Object.entries(completions)) {
completions[taskUuid] = 100 * (taskCompletionSum / userCount.value)
}
return completions
})
</script>
<template>
<div class="
fixed inset-2 z-40 h-100 overflow-x-hidden
rounded-lg bg-slate-300 dark:bg-slate-800 border border-slate-400 dark:border-slate-800
">
<div
class="
rounded-t-lg text-md p-3 pl-6 text-center
dark:bg-blue-800 bg-blue-500 dark:text-slate-300 text-slate-100
"
>
<!-- Modal header -->
<div class="flex justify-between items-center">
<span class="text-lg font-semibold">{{ exercise.name }}</span>
<span class="mr-8">
Level: <span :class="{
'rounded-lg px-1 ml-2': true,
'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner',
'dark:bg-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced',
'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert',
}">{{ exercise.level }}</span>
</span>
</div>
</div>
<!-- Tasks name and pie charts -->
<div class="p-2">
<div class="flex justify-between mb-3">
<span
v-for="(task, task_index) in exercise.tasks"
:key="task.name"
class="p-1 inline-block"
:title="task.description"
>
<span class="flex flex-col">
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-800 text-nowrap">Task {{ task_index + 1 }}</span>
<i class="text-center leading-4 text-slate-600 dark:text-slate-400">{{ task.name }}</i>
<span class="inline-block h-18 -mt-4 mx-auto">
<apexchart
ref="theChart" class="" height="120" width="100"
:options="chartOptions"
:series="[taskCompletionPercentages[task.uuid]]"
></apexchart>
</span>
</span>
</span>
</div>
<!-- User grid -->
<div :class="`flex flex-wrap ${compactGrid ? 'gap-1' : 'gap-2'}`">
<span
v-for="(progress) in sortedProgress"
:key="progress.user_id"
:class="[
'bg-slate-200 dark:bg-slate-900 rounded border drop-shadow-lg',
progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1 ? 'border-green-700' : 'border-slate-700',
]"
>
<span class="
flex p-2 mb-1
text-slate-600 dark:text-slate-400
">
<span :class="`flex flex-col ${compactGrid ? 'w-[120px]' : 'w-60'} ${compactGrid ? '' : 'mb-1'}`">
<span :title="progress.user_id" class="text-nowrap inline-block leading-5 truncate mb-1">
<FontAwesomeIcon
v-if="progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1"
:icon="faMedal" class="mr-1 text-amber-300"
></FontAwesomeIcon>
<span :class="`${compactGrid ? 'text-base' : 'text-lg'} font-bold font-mono leading-5 tracking-tight`">{{ progress.email.split('@')[0] }}</span>
<span :class="`${compactGrid ? 'text-xs' : 'text-xs'} font-mono tracking-tight`">@{{ progress.email.split('@')[1] }}</span>
</span>
<LiveLogsUserActivityGraph
:user_id="progress.user_id"
:compact_view="compactGrid"
:ultra_compact_view="ultraCompactGrid"
></LiveLogsUserActivityGraph>
</span>
</span>
<span class="
flex flex-row justify-between px-2
text-slate-500 dark:text-slate-400
">
<span
v-for="(task, task_index) in exercise.tasks"
:key="task_index"
class="select-none cursor-pointer"
@click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], progress.user_id, exercise.uuid, task.uuid)"
:title="task.name"
>
<span class="text-nowrap">
<FontAwesomeIcon
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
:icon="(progress.exercises[exercise.uuid].tasks_completion[task.uuid] && progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion) ? faCircleCheck : faCheck"
:class="`${compactGrid ? 'text-xs' : 'text-xl'} dark:text-green-400 text-green-600`"
fixed-width
/>
<FontAwesomeIcon
v-else-if="task.requirements?.inject_uuid !== undefined && !progress.exercises[exercise.uuid].tasks_completion[task.requirements.inject_uuid]"
title="All requirements for that task haven't been fullfilled yet"
:icon="faHourglassHalf"
:class="`${compactGrid ? 'text-xs' : 'text-lg'} dark:text-slate-500 text-slate-400`"
fixed-width
/>
<FontAwesomeIcon
v-else
:icon="faTimes"
:class="`${compactGrid ? 'text-xs' : 'text-xl'} dark:text-slate-500 text-slate-400`"
fixed-width
/>
</span>
</span>
</span>
</span>
</div>
</div>
</div>
</template>

View file

@ -1,186 +0,0 @@
<script setup>
import { ref, computed } from "vue";
import { active_exercises as exercises, progresses, userCount, setCompletedState } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCheck, faTimes, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
import { faCircleCheck } from '@fortawesome/free-regular-svg-icons'
import LiveLogsUserActivityGraph from "../LiveLogsUserActivityGraph.vue"
const props = defineProps(['exercise', 'exercise_index'])
const collapsed_panels = ref([])
function toggleCompleted(completed, user_id, exec_uuid, task_uuid) {
setCompletedState(completed, user_id, exec_uuid, task_uuid)
}
function collapse(exercise_index) {
const index = collapsed_panels.value.indexOf(exercise_index)
if (index >= 0) {
collapsed_panels.value.splice(index, 1)
} else {
collapsed_panels.value.push(exercise_index)
}
}
const compactTable = computed(() => { return userCount.value > 20 })
const hasProgress = computed(() => Object.keys(progresses.value).length > 0)
const sortedProgress = computed(() => Object.values(progresses.value).sort((a, b) => {
if (a.email < b.email) {
return -1;
}
if (a.email > b.email) {
return 1;
}
return 0;
}))
const taskCompletionPercentages = computed(() => {
const completions = {}
Object.values(props.exercise.tasks).forEach(task => {
completions[task.uuid] = 0
})
sortedProgress.value.forEach(progress => {
for (const [taskUuid, taskCompletion] of Object.entries(progress.exercises[props.exercise.uuid].tasks_completion)) {
if (taskCompletion !== false) {
completions[taskUuid] += 1
}
}
});
for (const [taskUuid, taskCompletionSum] of Object.entries(completions)) {
completions[taskUuid] = 100 * (taskCompletionSum / userCount.value)
}
return completions
})
</script>
<template>
<table
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full mb-4"
>
<thead>
<tr @click="collapse(exercise_index)" class="cursor-pointer">
<th :colspan="2 + exercise.tasks.length" class="rounded-tl-lg border-b border-slate-100 dark:border-slate-700 text-md p-3 pl-6 text-center dark:bg-blue-800 bg-blue-500 dark:text-slate-300 text-slate-100">
<div class="flex justify-between items-center">
<span class="dark:text-blue-200 text-slate-200 "># {{ exercise_index + 1 }}</span>
<span class="text-lg">{{ exercise.name }}</span>
<span class="">
Level: <span :class="{
'rounded-lg px-1 ml-2': true,
'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner',
'dark:bg-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced',
'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert',
}">{{ exercise.level }}</span>
</span>
</div>
</th>
</tr>
<tr :class="`font-medium text-slate-600 dark:text-slate-200 ${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`">
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left">User</th>
<th
v-for="(task, task_index) in exercise.tasks"
:key="task.name"
class="border-b border-slate-100 dark:border-slate-700 p-3 align-top"
:title="task.description"
>
<div class="flex flex-col">
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500 text-nowrap">Task {{ task_index + 1 }}</span>
<i class="text-center">{{ task.name }}</i>
<div
role="progressbar"
class="flex w-full h-1 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600"
:aria-valuenow="taskCompletionPercentages[task.uuid]" :aria-valuemin="0" aria-valuemax="100"
:title="`${taskCompletionPercentages[task.uuid].toFixed(0)}%`"
>
<div
class="flex flex-col justify-center rounded-full overflow-hidden bg-blue-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-blue-500 transition-width transition-slowest ease"
:style="`width: ${taskCompletionPercentages[task.uuid]}%`"
></div>
</div>
</div>
</th>
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Progress</th>
</tr>
</thead>
<tbody :class="`${collapsed_panels.includes(exercise_index) ? 'hidden' : ''}`">
<tr v-if="!hasProgress">
<td
:colspan="2 + exercise.tasks.length"
class="text-center border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6"
>
<i>- No user yet -</i>
</td>
</tr>
<template v-else>
<tr v-for="(progress) in sortedProgress" :key="progress.user_id" class="bg-slate-100 dark:bg-slate-900">
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative">
<span class="flex flex-col max-w-60">
<span :title="progress.user_id" class="text-nowrap inline-block leading-5 truncate">
<FontAwesomeIcon v-if="progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1" :icon="faMedal" class="mr-1 text-amber-300"></FontAwesomeIcon>
<span class="text-lg font-bold font-mono leading-5 tracking-tight">{{ progress.email.split('@')[0] }}</span>
<span class="text-xs font-mono tracking-tight">@{{ progress.email.split('@')[1] }}</span>
</span>
<LiveLogsUserActivityGraph
:user_id="progress.user_id"
:compact_view="compactTable"
></LiveLogsUserActivityGraph>
</span>
</td>
<td
v-for="(task, task_index) in exercise.tasks"
:key="task_index"
:class="`text-center border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 ${compactTable ? 'p-0' : 'p-2'}`"
>
<span
class="select-none cursor-pointer flex justify-center content-center flex-wrap h-9"
@click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], progress.user_id, exercise.uuid, task.uuid)"
>
<span class="flex flex-col">
<span class="text-nowrap">
<FontAwesomeIcon
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
:icon="progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? faCircleCheck : faCheck"
:class="`
${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}
${progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? 'text-lg' : 'text-xl'}
`"
/>
<FontAwesomeIcon
v-else-if="task.requirements?.inject_uuid !== undefined && !progress.exercises[exercise.uuid].tasks_completion[task.requirements.inject_uuid]"
title="All requirements for that task haven't been fullfilled yet"
:icon="faHourglassHalf"
:class="`text-lg ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<FontAwesomeIcon
v-else
:icon="faTimes"
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<small :class="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'"> (+{{ task.score }})</small>
</span>
<span :class="['leading-3', !compactTable ? 'text-sm' : 'text-xs']">
<span
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp"
:class="progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? 'font-bold' : 'font-extralight'"
>
{{ (new Date(progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp * 1000)).toTimeString().split(' ', 1)[0] }}
</span>
</span>
</span>
</span>
</td>
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3">
<div class="flex w-full h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600" role="progressbar" :aria-valuenow="progress.exercises[exercise.uuid].score" :aria-valuemin="0" aria-valuemax="100">
<div
class="flex flex-col justify-center rounded-full overflow-hidden bg-green-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-green-500 transition-width transition-slowest ease"
:style="`width: ${100 * (progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score)}%`"
></div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</template>

View file

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

View file

@ -76,10 +76,6 @@ export function resetAllExerciseProgress() {
sendResetAllExerciseProgress() sendResetAllExerciseProgress()
} }
export function resetAll() {
sendResetAll()
}
export function resetLiveLogs() { export function resetLiveLogs() {
sendResetLiveLogs() sendResetLiveLogs()
} }
@ -100,17 +96,6 @@ export function toggleApiQueryMode(enabled) {
sendToggleApiQueryMode(enabled) sendToggleApiQueryMode(enabled)
} }
export function remediateSetting(setting) {
sendRemediateSetting(setting, (result) => {
if (result.success) {
state.diagnostic['settings'][setting].value = state.diagnostic['settings'][setting].expected_value
} else {
state.diagnostic['settings'][setting].error = true
state.diagnostic['settings'][setting].errorMessage = result.message
}
})
}
export const debouncedGetProgress = debounce(getProgress, 200, {leading: true}) export const debouncedGetProgress = debounce(getProgress, 200, {leading: true})
export const debouncedGetDiangostic = debounce(getDiangostic, 1000, {leading: true}) export const debouncedGetDiangostic = debounce(getDiangostic, 1000, {leading: true})
@ -144,6 +129,7 @@ function getProgress() {
function getUsersActivity() { function getUsersActivity() {
socket.emit("get_users_activity", (user_activity_bundle) => { socket.emit("get_users_activity", (user_activity_bundle) => {
console.log(user_activity_bundle);
state.userActivity = user_activity_bundle.activity state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config state.userActivityConfig = user_activity_bundle.config
}); });
@ -170,12 +156,6 @@ function sendResetAllExerciseProgress() {
}) })
} }
function sendResetAll() {
socket.emit("reset_all", () => {
getProgress()
})
}
function sendResetLiveLogs() { function sendResetLiveLogs() {
socket.emit("reset_notifications", () => { socket.emit("reset_notifications", () => {
getNotifications() getNotifications()
@ -202,15 +182,6 @@ function sendToggleApiQueryMode(enabled) {
socket.emit("toggle_apiquery_mode", payload, () => {}) socket.emit("toggle_apiquery_mode", payload, () => {})
} }
function sendRemediateSetting(setting, cb) {
const payload = {
name: setting
}
socket.emit("remediate_setting", payload, (result) => {
cb(result)
})
}
/* Event listener */ /* Event listener */
socket.on("connect", () => { socket.on("connect", () => {

View file

@ -4,11 +4,6 @@ export default {
"./index.html", "./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}", "./src/**/*.{vue,js,ts,jsx,tsx}",
], ],
safelist: [
{
pattern: /bg-blue+/, // Includes bg of all colors and shades
},
],
theme: { theme: {
extend: { extend: {
transitionProperty: { transitionProperty: {