new: [app:user_activity] Added user activity chart
This commit is contained in:
parent
bc78e2f2cb
commit
58da718c5d
7 changed files with 223 additions and 14 deletions
30
db.py
30
db.py
|
@ -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)
|
|
@ -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'])
|
||||||
|
|
22
server.py
22
server.py
|
@ -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
|
||||||
|
|
||||||
|
|
129
src/components/LiveLogsUserActivityGraph.vue
Normal file
129
src/components/LiveLogsUserActivityGraph.vue
Normal 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>
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Reference in a new issue