new: [app:user_activity] Added user activity chart

This commit is contained in:
Sami Mokaddem 2024-07-09 12:19:20 +02:00
parent bc78e2f2cb
commit 58da718c5d
7 changed files with 223 additions and 14 deletions

30
db.py
View file

@ -19,9 +19,16 @@ NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE)
NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN = 12 NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN = 12
NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN = 20 NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN = 20
NOTIFICATION_HISTORY_FREQUENCY = 60 / NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN NOTIFICATION_HISTORY_FREQUENCY = 60 / NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN
notification_history_size = NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN * NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN notification_history_buffer_size = NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN * NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN
NOTIFICATION_HISTORY = collections.deque([], notification_history_size) NOTIFICATION_HISTORY = collections.deque([], notification_history_buffer_size)
NOTIFICATION_HISTORY.extend([0] * notification_history_size) NOTIFICATION_HISTORY.extend([0] * notification_history_buffer_size)
USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN = 2
USER_ACTIVITY_TIMESPAN_MIN = 20
USER_ACTIVITY_FREQUENCY = 60 / USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN
USER_ACTIVITY = {}
user_activity_buffer_size = USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN * USER_ACTIVITY_TIMESPAN_MIN
def resetNotificationMessage(): def resetNotificationMessage():
global NOTIFICATION_MESSAGES global NOTIFICATION_MESSAGES
@ -29,5 +36,18 @@ def resetNotificationMessage():
def resetNotificationHistory(): def resetNotificationHistory():
global NOTIFICATION_HISTORY global NOTIFICATION_HISTORY
NOTIFICATION_HISTORY = collections.deque([], notification_history_size) NOTIFICATION_HISTORY = collections.deque([], notification_history_buffer_size)
NOTIFICATION_HISTORY.extend([0] * notification_history_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)

View file

@ -27,14 +27,26 @@ def get_notifications() -> list[dict]:
return list(db.NOTIFICATION_MESSAGES) return list(db.NOTIFICATION_MESSAGES)
def get_notifications_history() -> list[dict]: def get_notifications_history() -> dict:
return { return {
'history': list(db.NOTIFICATION_HISTORY), 'history': list(db.NOTIFICATION_HISTORY),
'config': { 'config': {
'buffer_resolution_per_minute': db.NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN, 'buffer_resolution_per_minute': db.NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN,
'buffer_timestamp_min': db.NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN, 'buffer_timestamp_min': db.NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN,
'frequency': db.NOTIFICATION_HISTORY_FREQUENCY, 'frequency': db.NOTIFICATION_HISTORY_FREQUENCY,
'notification_history_size': db.notification_history_size, '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,
}, },
} }
@ -51,6 +63,10 @@ def record_notification_history(message_count: int):
db.NOTIFICATION_HISTORY.append(message_count) db.NOTIFICATION_HISTORY.append(message_count)
def record_user_activity(user_id: int, count: int):
db.addUserActivity(user_id, count)
def get_user_id(data: dict): def get_user_id(data: dict):
if 'user_id' in data: if 'user_id' in data:
return int(data['user_id']) return int(data['user_id'])

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import collections
import functools import functools
import json import json
import sys import sys
@ -20,6 +21,7 @@ import misp_api
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0 ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
ZMQ_MESSAGE_COUNT = 0 ZMQ_MESSAGE_COUNT = 0
ZMQ_LAST_TIME = None ZMQ_LAST_TIME = None
USER_ACTIVITY = collections.defaultdict(int)
def debounce(debounce_seconds: int = 1): def debounce(debounce_seconds: int = 1):
@ -109,6 +111,10 @@ async def reset_notifications(sid):
async def get_diagnostic(sid): async def get_diagnostic(sid):
return await getDiagnostic() return await getDiagnostic()
@sio.event
async def get_users_activity(sid):
return notification_model.get_users_activity()
@sio.event @sio.event
async def toggle_verbose_mode(sid, payload): async def toggle_verbose_mode(sid, payload):
return notification_model.set_verbose_mode(payload['verbose']) return notification_model.set_verbose_mode(payload['verbose'])
@ -144,6 +150,9 @@ 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
user_id = notification_model.get_user_id(data)
if user_id is not None:
USER_ACTIVITY[user_id] += 1
await sio.emit('notification', notification) await sio.emit('notification', notification)
user_id = notification_model.get_user_id(data) user_id = notification_model.get_user_id(data)
@ -201,6 +210,18 @@ async def notification_history():
await sio.emit('update_notification_history', payload) 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(): async def keepalive():
global ZMQ_LAST_TIME global ZMQ_LAST_TIME
while True: while True:
@ -237,6 +258,7 @@ async def init_app():
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)
sio.start_background_task(record_users_activity)
sio.start_background_task(backup_exercises_progress) sio.start_background_task(backup_exercises_progress)
return app return app

View file

@ -0,0 +1,129 @@
<script setup>
import { ref, watch, computed } from "vue"
import { userActivity, userActivityConfig } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
const props = defineProps(['user_id'])
const theChart = ref(null)
const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size)
const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min)
const chartInitSeries = Array.from(Array(bufferSize.value)).map(() => 0)
const hasActivity = computed(() => userActivity.value.length != 0)
const chartSeries = computed(() => {
return !hasActivity.value ? chartInitSeries : activitySeries.value
})
const activitySeries = computed(() => {
const data = userActivity.value[props.user_id] === undefined ? chartInitSeries : userActivity.value[props.user_id]
return [{data: Array.from(data)}]
})
const colorRanges = [1, 3, 5, 7, 9, 1000]
const chartOptions = computed(() => {
return {
chart: {
height: 20,
width: 208,
type: 'heatmap',
sparkline: {
enabled: true
},
animations: {
enabled: false,
easing: 'easeinout',
speed: 200,
},
},
dataLabels: {
enabled: false,
style: {
fontSize: '10px',
fontWeight: '400',
}
},
plotOptions: {
heatmap: {
radius: 2,
enableShades: !true,
shadeIntensity: 0.5,
reverseNegativeShade: true,
distributed: false,
useFillColorAsStroke: false,
colorScale: {
ranges: [
{
from: 0,
to: colorRanges[0],
color: darkModeEnabled.value ? '#172554' : '#bfdbfe',
},
{
from: colorRanges[0] + 1,
to: colorRanges[1],
color: darkModeEnabled.value ? '#1e40af' : '#93c5fd',
},
{
from: colorRanges[1] + 1,
to: colorRanges[2],
color: darkModeEnabled.value ? '#2563eb' : '#60a5fa',
},
{
from: colorRanges[2] + 1,
to: colorRanges[3],
color: darkModeEnabled.value ? '#3b82f6' : '#3b82f6',
},
{
from: colorRanges[3] + 1,
to: colorRanges[4],
color: darkModeEnabled.value ? '#60a5fa' : '#2563eb',
},
{
from: colorRanges[4] + 1,
to: colorRanges[5],
color: darkModeEnabled.value ? '#93c5fd' : '#1d4ed8',
},
],
// inverse: false,
min: 0,
max: 1000
},
},
},
states: {
hover: {
filter: {
type: 'none',
}
},
active: {
filter: {
type: 'none',
}
},
},
grid: {
show: false,
},
legend: {
show: true,
},
stroke: {
width: 0,
},
tooltip: {
enabled: false,
},
}
})
</script>
<template>
<span
class="h-3 w-52"
:title="`Activity over ${bufferSizeMin}min`"
>
<apexchart type="heatmap" height="12" width="208" :options="chartOptions" :series="chartSeries"></apexchart>
</span>
</template>

View file

@ -5,7 +5,6 @@
const theChart = ref(null) const theChart = ref(null)
const chartInitSeries = [ const chartInitSeries = [
// {data: Array.apply(null, {length: 240}).map(Function.call, Math.random)}
{data: Array.from(Array(12*20)).map(()=> 0)} {data: Array.from(Array(12*20)).map(()=> 0)}
] ]
const hasActivity = computed(() => notificationHistory.value.length > 0) const hasActivity = computed(() => notificationHistory.value.length > 0)

View file

@ -3,6 +3,7 @@
import { active_exercises as exercises, progresses, setCompletedState } from "@/socket"; import { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons' import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
const collapsed_panels = ref([]) const collapsed_panels = ref([])
@ -85,12 +86,15 @@
</tr> </tr>
<template v-else> <template v-else>
<tr v-for="(progress, user_id) in progresses" :key="user_id" class="bg-slate-100 dark:bg-slate-900"> <tr v-for="(progress, user_id) in progresses" :key="user_id" class="bg-slate-100 dark:bg-slate-900">
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6"> <td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative">
<span :title="user_id" class="text-nowrap"> <span class="flex flex-col">
<span :title="user_id" class="text-nowrap inline-block leading-5">
<FontAwesomeIcon v-if="progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1" :icon="faMedal" class="mr-1 text-amber-300"></FontAwesomeIcon> <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-lg font-bold font-mono leading-5">{{ progress.email.split('@')[0] }}</span>
<span class="text-xs font-mono">@{{ progress.email.split('@')[1] }}</span> <span class="text-xs font-mono">@{{ progress.email.split('@')[1] }}</span>
</span> </span>
<LiveLogsUserActivityGraph :user_id="user_id"></LiveLogsUserActivityGraph>
</span>
</td> </td>
<td <td
v-for="(task, task_index) in exercise.tasks" v-for="(task, task_index) in exercise.tasks"

View file

@ -3,7 +3,7 @@ import { io } from "socket.io-client";
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
// "undefined" means the URL will be computed from the `window.location` object // "undefined" means the URL will be computed from the `window.location` object
const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:4000"; const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:40001";
const MAX_LIVE_LOG = 30 const MAX_LIVE_LOG = 30
const initial_state = { const initial_state = {
@ -12,6 +12,8 @@ const initial_state = {
notificationAPICounter: 0, notificationAPICounter: 0,
notificationHistory: [], notificationHistory: [],
notificationHistoryConfig: {}, notificationHistoryConfig: {},
userActivity: {},
userActivityConfig: {},
exercises: [], exercises: [],
selected_exercises: [], selected_exercises: [],
progresses: {}, progresses: {},
@ -44,6 +46,8 @@ export const userCount = computed(() => Object.keys(state.progresses).length)
export const diagnostic = computed(() => state.diagnostic) export const diagnostic = computed(() => state.diagnostic)
export const notificationHistory = computed(() => state.notificationHistory) export const notificationHistory = computed(() => state.notificationHistory)
export const notificationHistoryConfig = computed(() => state.notificationHistoryConfig) export const notificationHistoryConfig = computed(() => state.notificationHistoryConfig)
export const userActivity = computed(() => state.userActivity)
export const userActivityConfig = computed(() => state.userActivityConfig)
export const socketConnected = computed(() => connectionState.connected) export const socketConnected = computed(() => connectionState.connected)
export const zmqLastTime = computed(() => connectionState.zmq_last_time) export const zmqLastTime = computed(() => connectionState.zmq_last_time)
@ -56,6 +60,7 @@ export function fullReload() {
getSelectedExercises() getSelectedExercises()
getNotifications() getNotifications()
getProgress() getProgress()
getUsersActivity()
} }
export function setCompletedState(completed, user_id, exec_uuid, task_uuid) { export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
@ -122,6 +127,15 @@ function getProgress() {
}) })
} }
function getUsersActivity() {
socket.emit("get_users_activity", (user_activity_bundle) => {
console.log(user_activity_bundle);
state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config
});
}
function getDiangostic() { function getDiangostic() {
state.diagnostic = {} state.diagnostic = {}
socket.emit("get_diagnostic", (diagnostic) => { socket.emit("get_diagnostic", (diagnostic) => {
@ -203,6 +217,11 @@ socket.on("update_notification_history", (notification_history_bundle) => {
state.notificationHistoryConfig = notification_history_bundle.config state.notificationHistoryConfig = notification_history_bundle.config
}); });
socket.on("update_users_activity", (user_activity_bundle) => {
state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config
});
function addLimited(target, message, maxCount) { function addLimited(target, message, maxCount) {
target.unshift(message) target.unshift(message)
if (target.length > maxCount) { if (target.length > maxCount) {