Compare commits

...

4 commits

Author SHA1 Message Date
Sami Mokaddem
586a5585be chg: [front] Formatted all files 2024-07-25 11:33:58 +02:00
Sami Mokaddem
76ce0607f7 chg: [front] Usage of Alert 2024-07-25 11:27:35 +02:00
Sami Mokaddem
0370ad08a7 chg: [front] Added support of toaster 2024-07-25 11:23:17 +02:00
Sami Mokaddem
b040f85d59 chg: [front] Removed daisyUI dependency 2024-07-25 11:10:39 +02:00
32 changed files with 1874 additions and 1009 deletions

View file

@ -0,0 +1,8 @@
{
"hash": "d30f2833",
"configHash": "e49d25ea",
"lockfileHash": "e3b0c442",
"browserHash": "f22e7dd7",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View file

@ -0,0 +1,3 @@
{
"type": "module"
}

45
package-lock.json generated
View file

@ -23,7 +23,6 @@
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-prettier": "^9.0.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"daisyui": "^4.12.10",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0", "eslint-plugin-vue": "^9.23.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
@ -1444,16 +1443,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-selector-tokenizer": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz",
"integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"fastparse": "^1.1.2"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1471,34 +1460,6 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
}, },
"node_modules/culori": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz",
"integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/daisyui": {
"version": "4.12.10",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-4.12.10.tgz",
"integrity": "sha512-jp1RAuzbHhGdXmn957Z2XsTZStXGHzFfF0FgIOZj3Wv9sH7OZgLfXTRZNfKVYxltGUOBsG1kbWAdF5SrqjebvA==",
"dev": true,
"dependencies": {
"css-selector-tokenizer": "^0.8",
"culori": "^3",
"picocolors": "^1",
"postcss-js": "^4"
},
"engines": {
"node": ">=16.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/daisyui"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.5", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
@ -1919,12 +1880,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true "dev": true
}, },
"node_modules/fastparse": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==",
"dev": true
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.17.1", "version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",

View file

@ -26,7 +26,6 @@
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-prettier": "^9.0.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"daisyui": "^4.12.10",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0", "eslint-plugin-vue": "^9.23.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",

View file

@ -4,16 +4,15 @@ import TheThemeButton from './components/TheThemeButton.vue'
import TheAdminPanel from './components/TheAdminPanel.vue' import TheAdminPanel from './components/TheAdminPanel.vue'
import TheSocketConnectionState from './components/TheSocketConnectionState.vue' import TheSocketConnectionState from './components/TheSocketConnectionState.vue'
import TheDahboard from './TheDahboard.vue' import TheDahboard from './TheDahboard.vue'
import { socketConnected } from "@/socket"; import Toaster from '@/components/elements/Toaster.vue'
import { darkModeEnabled } from "@/settings.js" import { socketConnected } from '@/socket'
import { darkModeEnabled } from '@/settings.js'
onMounted(() => { onMounted(() => {
if (darkModeEnabled.value) { if (darkModeEnabled.value) {
document.getElementsByTagName('body')[0].classList.add('dark') document.getElementsByTagName('body')[0].classList.add('dark')
} }
}) })
</script> </script>
<template> <template>
@ -34,6 +33,7 @@ onMounted(() => {
<div class="mt-12"> <div class="mt-12">
<TheDahboard></TheDahboard> <TheDahboard></TheDahboard>
</div> </div>
<Toaster></Toaster>
</main> </main>
</template> </template>
@ -63,5 +63,4 @@ body {
/* cyan-400 */ /* cyan-400 */
/* filter: invert(71%) sepia(97%) saturate(1333%) hue-rotate(147deg) brightness(95%) contrast(96%); */ /* filter: invert(71%) sepia(97%) saturate(1333%) hue-rotate(147deg) brightness(95%) contrast(96%); */
} }
</style> </style>

View file

@ -2,9 +2,8 @@
import { onMounted, watch } from 'vue' 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" import { fullscreenModeOn } from '@/settings.js'
watch(socketConnected, (isConnected) => { watch(socketConnected, (isConnected) => {
if (isConnected) { if (isConnected) {
@ -16,7 +15,6 @@ watch(socketConnected, (isConnected) => {
onMounted(() => { onMounted(() => {
fullReload() fullReload()
}) })
</script> </script>
<template> <template>

View file

@ -1,3 +1,7 @@
@tailwind base; @import 'tailwindcss/base';
@tailwind components; @import 'tailwindcss/components';
@tailwind utilities; @import 'tailwindcss/utilities';
@import './styled-components/toggle.css';
@import './styled-components/button.css';
@import './styled-components/transitions.css';

View file

@ -0,0 +1,82 @@
.btn {
@apply px-2 py-1 font-semibold rounded border text-nowrap select-none;
@apply transition-all duration-75;
@apply border-slate-300;
&.btn-sm {
@apply px-1 py-0;
}
&.btn-xs {
@apply px-0.5 py-0;
@apply text-sm;
}
&.btn-lg {
@apply px-3 py-1;
@apply text-lg;
}
&.btn:not(:disabled) {
@apply hover:bg-slate-200 hover:border-slate-300;
@apply active:scale-90;
}
&.btn:disabled {
@apply cursor-not-allowed opacity-60;
}
&.btn-primary:not(:disabled) {
@apply border-none;
@apply bg-blue-600 text-white hover:bg-blue-700;
}
&.btn-info:not(:disabled) {
@apply border-none;
@apply bg-blue-500 text-white hover:bg-blue-600;
}
&.btn-danger:not(:disabled) {
@apply border-none;
@apply bg-red-600 text-white hover:bg-red-700;
}
&.btn-success:not(:disabled) {
@apply border-none;
@apply bg-green-600 text-white hover:bg-green-700;
}
&.btn-warning:not(:disabled) {
@apply border-none;
@apply bg-amber-600 text-white hover:bg-amber-700;
}
&.btn-outline-primary:not(:disabled) {
@apply hover:bg-blue-600 hover:border-blue-700 hover:text-white;
@apply border-blue-700 hover:border-blue-800;
}
&.btn-outline-info:not(:disabled) {
@apply hover:bg-blue-500 hover:border-blue-700 hover:text-white;
@apply border-blue-700 hover:border-blue-800;
}
&.btn-outline-danger:not(:disabled) {
@apply hover:bg-red-500 hover:border-red-700 hover:text-white;
@apply border-red-700 hover:border-red-800;
}
&.btn-outline-success:not(:disabled) {
@apply hover:bg-green-500 hover:border-green-700 hover:text-white;
@apply border-green-700 hover:border-green-800;
}
&.btn-outline-warning:not(:disabled) {
@apply hover:bg-amber-500 hover:border-amber-700 hover:text-white;
@apply border-amber-700 hover:border-amber-800;
}
&.btn-link:not(:disabled) {
@apply border-none;
@apply hover:bg-transparent hover:border-transparent hover:text-inherit;
}
}

View file

@ -0,0 +1,90 @@
.toggle:where(.dark, .dark *) {
--tglbg: theme(colors.slate.800) !important;
}
.toggle {
--tglbg: theme(colors.slate.200);
--animation-input: 0.2s;
--handleoffset: 1.5rem;
--handleoffsetcalculator: calc(var(--handleoffset) * -1);
--togglehandleborder: 0 0;
@apply h-6 w-12 rounded-3xl cursor-pointer appearance-none border border-current bg-current;
transition:
background,
box-shadow var(--animation-input, 0.2s) ease-out;
box-shadow:
var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,
0 0 0 2px var(--tglbg) inset,
var(--togglehandleborder);
@apply text-slate-500;
&:focus-visible {
@apply outline outline-2 outline-offset-2;
}
&:hover {
@apply bg-current;
}
&:checked,
&[aria-checked='true'] {
background-image: none;
--handleoffsetcalculator: var(--handleoffset);
}
&:indeterminate {
box-shadow:
calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset,
calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset,
0 0 0 2px var(--tglbg) inset;
}
&:disabled {
@apply cursor-not-allowed bg-transparent opacity-30;
--togglehandleborder: 0 0 0 3px #000 inset, var(--handleoffsetcalculator) 0 0 3px #000 inset;
}
&.toggle-success {
&:focus-visible {
@apply outline-green-400;
}
&:checked,
&[aria-checked='true'] {
@apply border-green-500 bg-green-400 text-slate-900 border-opacity-10;
}
}
&.toggle-warning {
&:focus-visible {
@apply outline-amber-400;
}
&:checked,
&[aria-checked='true'] {
@apply border-amber-500 bg-amber-400 text-slate-900 border-opacity-10;
}
}
&.toggle-info {
&:focus-visible {
@apply outline-blue-400;
}
&:checked,
&[aria-checked='true'] {
@apply border-blue-500 bg-blue-400 text-slate-900 border-opacity-10;
}
}
&.toggle-danger {
&:focus-visible {
@apply outline-red-400;
}
&:checked,
&[aria-checked='true'] {
@apply border-red-500 bg-red-400 text-slate-900 border-opacity-10;
}
}
}

View file

@ -0,0 +1,27 @@
.slide-fade-enter-active {
transition: all 0.2s ease-out;
}
.slide-fade-leave-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
.slide-fade-reverse-enter-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-reverse-leave-active {
transition: all 0.2s ease-out;
}
.slide-fade-reverse-enter-from,
.slide-fade-reverse-leave-to {
transform: translateX(-20px);
opacity: 0;
}

View file

@ -1,55 +1,43 @@
<script setup> <script setup>
import { ref, watch, computed } from "vue" import { ref, watch, computed } from 'vue'
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', 'compact_view', 'ultra_compact_view'])
const theChart = ref(null) const theChart = ref(null)
const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size) const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size)
const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min) const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min)
const chartInitSeries = computed(() => Array.from(Array(bufferSize.value)).map(() => 0)) const chartInitSeries = computed(() => Array.from(Array(bufferSize.value)).map(() => 0))
const hasActivity = computed(() => userActivity.value.length != 0) const hasActivity = computed(() => userActivity.value.length != 0)
const chartSeries = computed(() => { const chartSeries = computed(() => {
return !hasActivity.value ? chartInitSeries.value : activitySeries.value return !hasActivity.value ? chartInitSeries.value : 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 =
return data userActivity.value[props.user_id] === undefined
}) ? chartInitSeries.value
: userActivity.value[props.user_id]
return data
})
const colorRanges = [0, 1, 2, 3, 4, 5, 1000] const colorRanges = [0, 1, 2, 3, 4, 5, 1000]
const palleteColor = 'blue' const palleteColor = 'blue'
const colorPalleteIndexDark = [ const colorPalleteIndexDark = ['900', '700', '600', '500', '400', '300', '200']
'900', const colorPalleteIndexLight = ['50', '100', '300', '400', '500', '600', '700']
'700',
'600',
'500',
'400',
'300',
'200',
]
const colorPalleteIndexLight = [
'50',
'100',
'300',
'400',
'500',
'600',
'700',
]
function getPalleteIndexFromValue(value) { function getPalleteIndexFromValue(value) {
for (let palleteIndex = 0; palleteIndex < colorRanges.length; palleteIndex++) { for (let palleteIndex = 0; palleteIndex < colorRanges.length; palleteIndex++) {
const colorRangeValue = colorRanges[palleteIndex]; const colorRangeValue = colorRanges[palleteIndex]
if (value <= colorRangeValue) { if (value <= colorRangeValue) {
return darkModeEnabled.value ? colorPalleteIndexDark[palleteIndex] : colorPalleteIndexLight[palleteIndex] return darkModeEnabled.value
} ? colorPalleteIndexDark[palleteIndex]
: colorPalleteIndexLight[palleteIndex]
} }
} }
}
</script> </script>
<template> <template>
@ -60,7 +48,11 @@
<span <span
v-for="(value, i) in chartSeries" v-for="(value, i) in chartSeries"
:key="i" :key="i"
:class="[`inline-block rounded-[1px] mr-px`, props.compact_view ? 'h-1.5' : 'h-3', `bg-${palleteColor}-${getPalleteIndexFromValue(value)}`]" :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`" :style="`width: ${(((props.ultra_compact_view ? 120 : 240) - chartSeries.length) / chartSeries.length).toFixed(1)}px`"
></span> ></span>
</span> </span>

View file

@ -1,205 +1,238 @@
<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 {
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' exercises,
import { faScrewdriverWrench, faTrash, faSuitcaseMedical, faGraduationCap, faBan, faRotate, faHammer, faCheck } from '@fortawesome/free-solid-svg-icons' selected_exercises,
diagnostic,
fullReload,
resetAllExerciseProgress,
resetAll,
resetLiveLogs,
changeExerciseSelection,
debouncedGetDiangostic,
remediateSetting
} from '@/socket'
import {
faScrewdriverWrench,
faTrash,
faSuitcaseMedical,
faGraduationCap,
faBan,
faRotate,
faHammer,
faCheck
} from '@fortawesome/free-solid-svg-icons'
import { toast } from '@/utils.js'
const admin_modal = ref(null) const admin_modal = ref(null)
const clickedButtons = ref([]) 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)
const isZMQActive = computed(() => diagnostic.value.zmq_message_count > 0) const isZMQActive = computed(() => diagnostic.value.zmq_message_count > 0)
const ZMQMessageCount = computed(() => diagnostic.value.zmq_message_count) const ZMQMessageCount = computed(() => diagnostic.value.zmq_message_count)
function changeSelectionState(state_enabled, exec_uuid) { function changeSelectionState(state_enabled, exec_uuid) {
changeExerciseSelection(exec_uuid, state_enabled); changeExerciseSelection(exec_uuid, state_enabled)
} }
function settingHandler(setting) { function settingHandler(setting) {
remediateSetting(setting) remediateSetting(setting)
} }
function showTheModal() {
admin_modal.value.showModal()
clickedButtons.value = []
debouncedGetDiangostic()
}
const showModal = ref(false)
function showTheModal() {
showModal.value = true
clickedButtons.value = []
debouncedGetDiangostic()
}
</script> </script>
<template> <template>
<button <button @click="showTheModal()" class="btn btn-primary">
@click="showTheModal()"
class="px-2 py-1 rounded-md focus-outline font-semibold bg-blue-600 text-slate-200 hover:bg-blue-700"
>
<FontAwesomeIcon :icon="faScrewdriverWrench" class="mr-1"></FontAwesomeIcon> <FontAwesomeIcon :icon="faScrewdriverWrench" class="mr-1"></FontAwesomeIcon>
Admin panel Admin panel
</button> </button>
<dialog ref="admin_modal" class="modal"> <Modal :showModal="showModal" @modal-close="showModal = false">
<div class="modal-box w-11/12 max-w-6xl top-24 absolute bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200"> <template #header>
<h2 class="text-2xl font-bold"> <h2 class="text-2xl font-bold">
<FontAwesomeIcon :icon="faScrewdriverWrench" class=""></FontAwesomeIcon> <FontAwesomeIcon :icon="faScrewdriverWrench" class=""></FontAwesomeIcon>
Admin panel Admin panel
</h2> </h2>
<div class="modal-action"> </template>
<form method="dialog"> <template #body>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button> <div class="dark:text-slate-700 text-slate-700">
</form> <div class="flex mb-5 gap-2">
</div> <button @click="fullReload()" class="h-10 min-h-10 font-semibold btn-info btn gap-1">
<div> <FontAwesomeIcon :icon="faRotate" size="lg" fixed-width></FontAwesomeIcon>
Full refresh
<div class="flex mb-5 gap-2"> </button>
<button <button
@click="fullReload()" @click="resetAllExerciseProgress()"
class="h-10 min-h-10 px-2 py-1 font-semibold bg-blue-600 text-slate-200 hover:bg-blue-700 btn btn-sm gap-1" class="h-10 min-h-10 font-semibold btn-danger btn gap-1"
>
<FontAwesomeIcon :icon="faRotate" size="lg" fixed-width></FontAwesomeIcon>
Full refresh
</button>
<button
@click="resetAllExerciseProgress()"
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 Exercises
</button>
<button
@click="resetAll()"
class="h-10 min-h-10 px-2 py-1 font-semibold bg-red-600 text-slate-200 hover:bg-red-700 btn btn-sm gap-1"
>
<FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
Reset All
</button>
<button
@click="resetLiveLogs()"
class="h-10 min-h-10 px-2 py-1 font-semibold bg-amber-600 text-slate-200 hover:bg-amber-700 btn btn-sm gap-1"
>
<FontAwesomeIcon :icon="faBan" size="lg"> fixed-width</FontAwesomeIcon>
Clear Live Logs
</button>
</div>
<h3 class="text-lg font-semibold">
<FontAwesomeIcon :icon="faGraduationCap" class="mr-1"></FontAwesomeIcon>
Selected Exercises
</h3>
<div
v-for="(exercise) in exercises"
:key="exercise.name"
class="form-control pl-3"
> >
<label class="label cursor-pointer justify-start"> <FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
<input Reset All Exercises
@change="changeSelectionState($event.target.checked, exercise.uuid)" </button>
type="checkbox" <button @click="resetAll()" class="h-10 min-h-10 font-semibold btn-danger btn gap-1">
:checked="selected_exercises.includes(exercise.uuid)" <FontAwesomeIcon :icon="faTrash" size="lg" fixed-width></FontAwesomeIcon>
:value="exercise.uuid" Reset All
:class="`checkbox ${selected_exercises.includes(exercise.uuid) ? 'checkbox-success' : ''} [--fallback-bc:#94a3b8]`" </button>
/> <button
<span class="font-mono font-semibold text-base ml-3">{{ exercise.name }}</span> @click="resetLiveLogs()"
</label> class="h-10 min-h-10 font-semibold btn-warning btn gap-1"
</div> >
<FontAwesomeIcon :icon="faBan" size="lg"> fixed-width</FontAwesomeIcon>
<h3 class="text-lg font-semibold mt-4"> Clear Live Logs
<FontAwesomeIcon :icon="faSuitcaseMedical" class="mr-1"></FontAwesomeIcon> </button>
Diagnostic
</h3>
<h4 class="font-semibold ml-1 my-3">
<strong>MISP Status:</strong>
<span class="ml-2">
<span :class="{
'rounded-lg py-1 px-2': true,
'dark:bg-neutral-800 bg-neutral-400 text-slate-800 dark:text-slate-200': diagnosticLoading,
'dark:bg-green-700 bg-green-500 text-slate-800 dark:text-slate-200': !diagnosticLoading && isMISPOnline,
'dark:bg-red-700 bg-red-700 text-slate-200 dark:text-slate-200': !diagnosticLoading && !isMISPOnline,
}">
<span v-if="diagnosticLoading" class="loading loading-dots loading-sm h-4 inline-block align-middle"></span>
<span v-else class="font-bold">
{{ !isMISPOnline ? 'Unreachable' : `Online (${diagnostic['version']['version']})` }}
</span>
</span>
</span>
</h4>
<h4 class="font-semibold ml-1 my-3">
<strong>ZMQ Status:</strong>
<span class="ml-2">
<span :class="{
'rounded-lg py-1 px-2': true,
'dark:bg-neutral-800 bg-neutral-400 text-slate-800 dark:text-slate-200': diagnosticLoading,
'dark:bg-green-700 bg-green-500 text-slate-800 dark:text-slate-200': !diagnosticLoading && isZMQActive,
'dark:bg-red-700 bg-red-700 text-slate-200 dark:text-slate-200': !diagnosticLoading && !isZMQActive,
}">
<span v-if="diagnosticLoading" class="loading loading-dots loading-sm h-4 inline-block align-middle"></span>
<span v-else class="font-bold">
{{ !isZMQActive ? 'No message received yet' : `ZMQ Active (${ZMQMessageCount} messages)` }}
</span>
</span>
</span>
</h4>
<template v-if="diagnosticLoading || isMISPOnline">
<h4 class="font-semibold ml-1"><strong>MISP Settings:</strong></h4>
<div class="ml-3">
<div v-if="diagnosticLoading" class="flex justify-center">
<span class="loading loading-dots loading-lg"></span>
</div>
<table v-else class="bg-white dark:bg-slate-700 rounded-lg shadow-xl w-full mt-2">
<thead>
<tr>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Setting</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Value</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">Expected Value</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-center">Action</th>
</tr>
</thead>
<tbody>
<tr
v-for="(settingValues, setting) in diagnostic['settings']"
:key="setting"
>
<td class="font-mono font-semibold text-base px-2">{{ setting }}</td>
<td
:class="`font-mono text-base tracking-tight px-2 ${settingValues.expected_value != settingValues.value ? 'text-red-600 dark:text-red-600' : ''}`"
>
<i v-if="settingValues.value === undefined || settingValues.value === null" class="text-nowrap">- none -</i>
{{ settingValues.value }}
</td>
<td class="font-mono text-base tracking-tight px-2">{{ settingValues.expected_value }}</td>
<td class="px-2 text-center">
<span v-if="settingValues.error === true"
class="text-red-600 dark:text-red-600"
>Error: {{ settingValues.errorMessage }}</span>
<button
v-else-if="settingValues.expected_value != settingValues.value"
@click="clickedButtons.push(setting) && settingHandler(setting)"
:disabled="clickedButtons.includes(setting)"
class="h-8 min-h-8 px-2 font-semibold bg-green-600 text-slate-200 hover:bg-green-700 btn gap-1"
>
<template v-if="!clickedButtons.includes(setting)">
<FontAwesomeIcon :icon="faHammer" size="sm" fixed-width></FontAwesomeIcon>
Remediate
</template>
<template v-else>
<span class="loading loading-dots loading-sm"></span>
</template>
</button>
<span v-else class="text-base font-bold text-green-600 dark:text-green-600">
<FontAwesomeIcon :icon="faCheck" class=""></FontAwesomeIcon>
OK
</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div> </div>
<h3 class="text-lg font-semibold">
<FontAwesomeIcon :icon="faGraduationCap" class="mr-1"></FontAwesomeIcon>
Selected Exercises
</h3>
<div v-for="exercise in exercises" :key="exercise.name" class="form-control pl-3">
<label class="label cursor-pointer justify-start">
<input
@change="changeSelectionState($event.target.checked, exercise.uuid)"
type="checkbox"
:checked="selected_exercises.includes(exercise.uuid)"
:value="exercise.uuid"
:class="`checkbox ${
selected_exercises.includes(exercise.uuid) ? 'checkbox-success' : ''
} [--fallback-bc:#94a3b8]`"
/>
<span class="font-mono font-semibold text-base ml-3">{{ exercise.name }}</span>
</label>
</div>
<h3 class="text-lg font-semibold mt-4">
<FontAwesomeIcon :icon="faSuitcaseMedical" class="mr-1"></FontAwesomeIcon>
Diagnostic
</h3>
<h4 class="font-semibold ml-1 my-3">
<strong>MISP Status:</strong>
<span class="ml-2">
<span
:class="{
'rounded-lg py-1 px-2 inline-flex': true,
'dark:bg-neutral-800 bg-neutral-400 text-slate-800 dark:text-slate-200':
diagnosticLoading,
'dark:bg-green-700 bg-green-500 text-slate-800 dark:text-slate-200':
!diagnosticLoading && isMISPOnline,
'dark:bg-red-700 bg-red-700 text-slate-200 dark:text-slate-200':
!diagnosticLoading && !isMISPOnline
}"
>
<Loading v-if="diagnosticLoading" class="inline-block text-xl"></Loading>
<span v-else class="font-bold">
{{ !isMISPOnline ? 'Unreachable' : `Online (${diagnostic['version']['version']})` }}
</span>
</span>
</span>
</h4>
<h4 class="font-semibold ml-1 my-3">
<strong>ZMQ Status:</strong>
<span class="ml-2">
<span
:class="{
'rounded-lg py-1 px-2 inline-flex': true,
'dark:bg-neutral-800 bg-neutral-400 text-slate-800 dark:text-slate-200':
diagnosticLoading,
'dark:bg-green-700 bg-green-500 text-slate-800 dark:text-slate-200':
!diagnosticLoading && isZMQActive,
'dark:bg-red-700 bg-red-700 text-slate-200 dark:text-slate-200':
!diagnosticLoading && !isZMQActive
}"
>
<Loading v-if="diagnosticLoading" class="inline-block text-xl"></Loading>
<span v-else class="font-bold">
{{
!isZMQActive
? 'No message received yet'
: `ZMQ Active (${ZMQMessageCount} messages)`
}}
</span>
</span>
</span>
</h4>
<template v-if="diagnosticLoading || isMISPOnline">
<h4 class="font-semibold ml-1"><strong>MISP Settings:</strong></h4>
<div class="ml-3">
<div v-if="diagnosticLoading" class="flex justify-center">
<Loading class="text-3xl"></Loading>
</div>
<table
v-else
class="bg-white dark:bg-slate-700 dark:text-slate-100 text-slate-700 rounded-lg shadow-xl w-full mt-2"
>
<thead>
<tr>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">
Setting
</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">
Value
</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-left">
Expected Value
</th>
<th class="border-b border-slate-200 dark:border-slate-600 p-2 text-center">
Action
</th>
</tr>
</thead>
<tbody>
<tr v-for="(settingValues, setting) in diagnostic['settings']" :key="setting">
<td class="font-mono font-semibold text-base px-2">{{ setting }}</td>
<td
:class="`font-mono text-base tracking-tight px-2 ${
settingValues.expected_value != settingValues.value
? 'text-red-600 dark:text-red-600'
: ''
}`"
>
<i
v-if="settingValues.value === undefined || settingValues.value === null"
class="text-nowrap"
>- none -</i
>
{{ settingValues.value }}
</td>
<td class="font-mono text-base tracking-tight px-2">
{{ settingValues.expected_value }}
</td>
<td class="px-2 text-center">
<span v-if="settingValues.error === true" class="text-red-600 dark:text-red-600"
>Error: {{ settingValues.errorMessage }}</span
>
<button
v-else-if="settingValues.expected_value != settingValues.value"
@click="clickedButtons.push(setting) && settingHandler(setting)"
:disabled="clickedButtons.includes(setting)"
class="h-8 min-h-8 px-2 font-semibold bg-green-600 text-slate-200 hover:bg-green-700 btn gap-1"
>
<template v-if="!clickedButtons.includes(setting)">
<FontAwesomeIcon :icon="faHammer" size="sm" fixed-width></FontAwesomeIcon>
Remediate
</template>
<template v-else>
<Loading class="text-xl"></Loading>
</template>
</button>
<span v-else class="text-base font-bold text-green-600 dark:text-green-600">
<FontAwesomeIcon :icon="faCheck" class=""></FontAwesomeIcon>
OK
</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div> </div>
<form method="dialog" class="modal-backdrop backdrop-blur"> </template>
<button>close</button> </Modal>
</form>
</dialog>
</template> </template>

View file

@ -1,81 +1,113 @@
<script setup> <script setup>
import { ref, watch, computed } from "vue" import { ref, watch, computed } from 'vue'
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } from "@/socket"; import {
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' notifications,
import { faSignal, faCloud, faCog, faUsers, faCircle } from '@fortawesome/free-solid-svg-icons' userCount,
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue"; notificationCounter,
notificationAPICounter,
toggleVerboseMode,
toggleApiQueryMode
} from '@/socket'
import { faSignal, faCloud, faCog, faUsers, faCircle } from '@fortawesome/free-solid-svg-icons'
import TheLiveLogsActivityGraphVue from './TheLiveLogsActivityGraph.vue'
import ToggleSwitch from '@/components/elements/ToggleSwitch.vue'
const verbose = ref(false)
const api_query = ref(false)
const verbose = ref(false) watch(verbose, (newValue) => {
const api_query = ref(false) toggleVerboseMode(newValue == true)
})
watch(verbose, (newValue) => { watch(api_query, (newValue) => {
toggleVerboseMode(newValue == true) toggleApiQueryMode(newValue == true)
}) })
watch(api_query, (newValue) => { function getClassFromResponseCode(response_code) {
toggleApiQueryMode(newValue == true) if (String(response_code).startsWith('2') || response_code == 302) {
}) return 'text-green-500'
} else if (String(response_code).startsWith('5')) {
function getClassFromResponseCode(response_code) { return 'text-red-600'
if (String(response_code).startsWith('2') || response_code == 302) { } else {
return 'text-green-500' return 'text-amber-600'
} else if (String(response_code).startsWith('5')) {
return 'text-red-600'
} else {
return 'text-amber-600'
}
} }
}
</script> </script>
<template> <template>
<div> <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 uppercase">
<FontAwesomeIcon :icon="faSignal"></FontAwesomeIcon> <FontAwesomeIcon :icon="faSignal"></FontAwesomeIcon>
Live logs Live logs
</h3> </h3>
<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
<span class="mr-1"> class="rounded-lg py-1 px-2 dark:bg-sky-700 bg-sky-400 text-slate-800 dark:text-slate-200"
<FontAwesomeIcon :icon="faUsers" size="sm"></FontAwesomeIcon> >
Players: <span class="mr-1">
<FontAwesomeIcon :icon="faUsers" size="sm"></FontAwesomeIcon>
Players:
</span>
<span class="font-bold">{{ userCount }}</span>
</span> </span>
<span class="font-bold">{{ userCount }}</span> <span
</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="faSignal" size="sm"></FontAwesomeIcon> <FontAwesomeIcon :icon="faSignal" size="sm"></FontAwesomeIcon>
Total Queries: Total Queries:
</span>
<span class="font-bold">{{ notificationCounter }}</span>
</span> </span>
<span class="font-bold">{{ notificationCounter }}</span> <span
</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="faCog" size="sm" :mask="faCloud" transform="shrink-7 left-1"></FontAwesomeIcon> <FontAwesomeIcon
Total API Queries: :icon="faCog"
size="sm"
:mask="faCloud"
transform="shrink-7 left-1"
></FontAwesomeIcon>
Total API Queries:
</span>
<span class="font-bold">{{ notificationAPICounter }}</span>
</span> </span>
<span class="font-bold">{{ notificationAPICounter }}</span> <span class="flex items-center">
</span> <label class="mr-1 flex items-center cursor-pointer text-slate-700 dark:text-slate-300">
<span class="flex items-center"> <input
<label class="mr-1 flex items-center cursor-pointer text-slate-700 dark:text-slate-300"> type="checkbox"
<input type="checkbox" class="toggle toggle-warning [--fallback-su:#22c55e] mr-1" :checked="verbose" @change="verbose = !verbose"/> class="toggle toggle-warning mr-1"
Verbose :checked="verbose"
</label> @change="verbose = !verbose"
</span> />
<span class="flex items-center"> Verbose
<label class="mr-1 flex items-center cursor-pointer text-slate-700 dark:text-slate-300"> </label>
<input type="checkbox" class="toggle toggle-success [--fallback-su:#22c55e] mr-1" :checked="api_query" @change="api_query = !api_query"/> </span>
<FontAwesomeIcon :icon="faCog" size="sm" :mask="faCloud" transform="shrink-7 left-1" class="mr-1"></FontAwesomeIcon> <span class="flex items-center">
API Queries <label class="mr-1 flex items-center cursor-pointer text-slate-700 dark:text-slate-300">
</label> <input
</span> type="checkbox"
</div> class="toggle toggle-success mr-1"
:checked="api_query"
@change="api_query = !api_query"
/>
<FontAwesomeIcon
:icon="faCog"
size="sm"
:mask="faCloud"
transform="shrink-7 left-1"
class="mr-1"
></FontAwesomeIcon>
API Queries
</label>
</span>
</div>
<TheLiveLogsActivityGraphVue></TheLiveLogsActivityGraphVue> <TheLiveLogsActivityGraphVue></TheLiveLogsActivityGraphVue>
<table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full"> <table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full">
<thead> <thead>
<tr class="font-medium dark:text-slate-200 text-slate-600"> <tr class="font-medium dark:text-slate-200 text-slate-600">
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left"></th> <th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left"></th>
@ -99,7 +131,9 @@
<td <td
class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-1 pl-2 w-12 whitespace-nowrap" class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-1 pl-2 w-12 whitespace-nowrap"
> >
<FontAwesomeIcon :icon="faCircle" size="xs" <FontAwesomeIcon
:icon="faCircle"
size="xs"
:class="getClassFromResponseCode(notification.response_code)" :class="getClassFromResponseCode(notification.response_code)"
></FontAwesomeIcon> ></FontAwesomeIcon>
<pre class="inline ml-1">{{ notification.response_code }}</pre> <pre class="inline ml-1">{{ notification.response_code }}</pre>
@ -111,45 +145,58 @@
<span class="text-lg font-bold font-mono">{{ notification.user.split('@')[0] }}</span> <span class="text-lg font-bold font-mono">{{ notification.user.split('@')[0] }}</span>
<span class="text-xs font-mono">@{{ notification.user.split('@')[1] }}</span> <span class="text-xs font-mono">@{{ notification.user.split('@')[1] }}</span>
</td> </td>
<td class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-1">{{ notification.time }}</td> <td
<td class="border-b border-slate-100 dark:border-slate-700 text-sky-600 dark:text-sky-400 p-1"> class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-1"
>
{{ notification.time }}
</td>
<td
class="border-b border-slate-100 dark:border-slate-700 text-sky-600 dark:text-sky-400 p-1"
>
<div class="flex items-center"> <div class="flex items-center">
<span v-if="notification.http_method == 'POST'" <span
class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center v-if="notification.http_method == 'POST'"
dark:bg-amber-600 dark:text-neutral-100 bg-amber-600 text-neutral-100" class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center dark:bg-amber-600 dark:text-neutral-100 bg-amber-600 text-neutral-100"
>POST</span> >POST</span
<span v-else-if="notification.http_method == 'PUT'" >
class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center <span
dark:bg-amber-600 dark:text-neutral-100 bg-amber-600 text-neutral-100" v-else-if="notification.http_method == 'PUT'"
>PUT</span> class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center dark:bg-amber-600 dark:text-neutral-100 bg-amber-600 text-neutral-100"
<span v-else-if="notification.http_method == 'DELETE'" >PUT</span
class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center >
dark:bg-red-600 dark:text-neutral-100 bg-red-600 text-neutral-100" <span
>DEL</span> v-else-if="notification.http_method == 'DELETE'"
<span v-else class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center dark:bg-red-600 dark:text-neutral-100 bg-red-600 text-neutral-100"
class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center >DEL</span
dark:bg-blue-600 dark:text-neutral-100 bg-blue-600 text-neutral-100" >
>{{ notification.http_method }}</span> <span
v-else
class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center dark:bg-blue-600 dark:text-neutral-100 bg-blue-600 text-neutral-100"
>{{ notification.http_method }}</span
>
<FontAwesomeIcon <FontAwesomeIcon
v-if="notification.is_api_request" v-if="notification.is_api_request"
class="text-slate-800 dark:text-slate-100 mr-1 inline-block" class="text-slate-800 dark:text-slate-100 mr-1 inline-block"
:icon="faCog" :mask="faCloud" transform="shrink-7 left-1" :icon="faCog"
:mask="faCloud"
transform="shrink-7 left-1"
></FontAwesomeIcon> ></FontAwesomeIcon>
<pre class="text-sm inline">{{ notification.url }}</pre> <pre class="text-sm inline">{{ notification.url }}</pre>
</div> </div>
</td> </td>
<td class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-300 p-1"> <td
<div v-if="notification.http_method == 'POST'" class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-300 p-1"
>
<div
v-if="notification.http_method == 'POST'"
class="border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 rounded-md" class="border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 rounded-md"
> >
<pre <pre class="p-1 text-xs">{{ JSON.stringify(notification.payload, null, 2) }}</pre>
class="p-1 text-xs"
>{{ JSON.stringify(notification.payload, null, 2) }}</pre>
</div> </div>
</td> </td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
</table> </table>
</div> </div>
</template> </template>

View file

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

View file

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

View file

@ -1,58 +1,54 @@
<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 } from '@/socket'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import {
import { faGraduationCap, faUpRightAndDownLeftFromCenter, faDownLeftAndUpRightToCenter, faWarning } from '@fortawesome/free-solid-svg-icons' faGraduationCap,
import TheScoreTable from "./scoreViews/TheScoreTable.vue" faUpRightAndDownLeftFromCenter,
import TheFullScreenScoreGrid from "./scoreViews/TheFullScreenScoreGrid.vue" faDownLeftAndUpRightToCenter,
import ThePlayerGrid from "./ThePlayerGrid.vue" faWarning
import { fullscreenModeOn } from "@/settings.js" } from '@fortawesome/free-solid-svg-icons'
import TheScoreTable from './scoreViews/TheScoreTable.vue'
import TheFullScreenScoreGrid from './scoreViews/TheFullScreenScoreGrid.vue'
import ThePlayerGrid from './ThePlayerGrid.vue'
import { fullscreenModeOn } from '@/settings.js'
const hasExercises = computed(() => exercises.value.length > 0) const hasExercises = computed(() => exercises.value.length > 0)
const fullscreen_panel = ref(false) const fullscreen_panel = ref(false)
function toggleFullScreen(exercise_index) { function toggleFullScreen(exercise_index) {
if (fullscreen_panel.value === exercise_index) { if (fullscreen_panel.value === exercise_index) {
fullscreen_panel.value = false fullscreen_panel.value = false
fullscreenModeOn.value = false fullscreenModeOn.value = false
} else { } else {
fullscreen_panel.value = exercise_index fullscreen_panel.value = exercise_index
fullscreenModeOn.value = true fullscreenModeOn.value = true
}
} }
}
</script> </script>
<template> <template>
<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 uppercase">
<FontAwesomeIcon :icon="faGraduationCap"></FontAwesomeIcon> <FontAwesomeIcon :icon="faGraduationCap"></FontAwesomeIcon>
Active Exercises Active Exercises
</h3> </h3>
<div <div v-if="!hasExercises" class="text-slate-600 dark:text-slate-400 p-3 pl-6">
v-if="!hasExercises" <Alert variant="warning">
class="text-slate-600 dark:text-slate-400 p-3 pl-6"
>
<div class="
p-2 border-l-4 text-left rounded
dark:bg-yellow-300 dark:text-slate-900 dark:border-yellow-700
bg-yellow-200 text-slate-900 border-yellow-700
">
<FontAwesomeIcon :icon="faWarning" class="text-yellow-700 text-lg mx-3"></FontAwesomeIcon>
<strong class="">No Exercise available.</strong> <strong class="">No Exercise available.</strong>
<span class="ml-1">Select an exercise in the <i class="underline">Admin panel</i>.</span> <span class="ml-1">Select an exercise in the <i class="underline">Admin panel</i>.</span>
</div> </Alert>
<ThePlayerGrid></ThePlayerGrid> <ThePlayerGrid></ThePlayerGrid>
</div> </div>
<template <template v-for="(exercise, exercise_index) in exercises" :key="exercise.name">
v-for="(exercise, exercise_index) in exercises"
:key="exercise.name"
>
<div :class="fullscreen_panel === false ? 'relative min-w-fit' : ''"> <div :class="fullscreen_panel === false ? 'relative min-w-fit' : ''">
<span <span
v-show="fullscreen_panel === false || fullscreen_panel === exercise_index" v-show="fullscreen_panel === false || fullscreen_panel === exercise_index"
:class="['inline-block absolute shadow-lg z-50', fullscreen_panel === false ? 'top-0 -right-7' : 'top-2 right-2']" :class="[
'inline-block absolute shadow-lg z-50',
fullscreen_panel === false ? 'top-0 -right-7' : 'top-2 right-2'
]"
> >
<button <button
@click="toggleFullScreen(exercise_index)" @click="toggleFullScreen(exercise_index)"
@ -63,7 +59,14 @@
${fullscreen_panel === false ? 'rounded-r-md' : 'rounded-bl-md'} ${fullscreen_panel === false ? 'rounded-r-md' : 'rounded-bl-md'}
`" `"
> >
<FontAwesomeIcon :icon="fullscreen_panel !== exercise_index ? faUpRightAndDownLeftFromCenter : faDownLeftAndUpRightToCenter" fixed-width></FontAwesomeIcon> <FontAwesomeIcon
:icon="
fullscreen_panel !== exercise_index
? faUpRightAndDownLeftFromCenter
: faDownLeftAndUpRightToCenter
"
fixed-width
></FontAwesomeIcon>
</button> </button>
</span> </span>
<KeepAlive> <KeepAlive>

View file

@ -1,46 +1,42 @@
<script setup> <script setup>
import { ref, onMounted } from "vue" import { ref, onMounted } from 'vue'
import { socketConnected, zmqLastTime } from "@/socket"; import { socketConnected, zmqLastTime } from '@/socket'
const zmqLastTimeSecond = ref('?') const zmqLastTimeSecond = ref('?')
function refreshLastTime() { function refreshLastTime() {
if (zmqLastTime.value !== false) { if (zmqLastTime.value !== false) {
zmqLastTimeSecond.value = parseInt(((new Date()).getTime() - zmqLastTime.value * 1000) / 1000) zmqLastTimeSecond.value = parseInt((new Date().getTime() - zmqLastTime.value * 1000) / 1000)
} else { } else {
zmqLastTimeSecond.value = '?' zmqLastTimeSecond.value = '?'
}
} }
}
onMounted(() => { onMounted(() => {
setInterval(() => { setInterval(() => {
refreshLastTime() refreshLastTime()
}, 1000) }, 1000)
}) })
</script> </script>
<template> <template>
<span class="flex flex-col justify-center mt-1"> <span class="flex flex-col justify-center mt-1">
<span :class="{ <span
'px-2 rounded-md inline-block w-48 leading-4': true, :class="{
'text-slate-900 dark:text-slate-400': socketConnected, 'px-2 rounded-md inline-block w-48 leading-4': true,
'text-slate-50 bg-red-600 px-2 py-1': !socketConnected, 'text-slate-900 dark:text-slate-400': socketConnected,
}"> 'text-slate-50 bg-red-600 px-2 py-1': !socketConnected
}"
>
<span class="mr-1">Socket.IO:</span> <span class="mr-1">Socket.IO:</span>
<span v-show="socketConnected" class="font-semibold text-green-600 dark:text-green-400">Connected</span> <span v-show="socketConnected" class="font-semibold text-green-600 dark:text-green-400"
>Connected</span
>
<span v-show="!socketConnected" class="font-semibold text-slate-50">Disconnected</span> <span v-show="!socketConnected" class="font-semibold text-slate-50">Disconnected</span>
</span> </span>
<span <span v-if="socketConnected" class="text-xs font-thin leading-3 inline-block text-center">
v-if="socketConnected" <template v-if="zmqLastTimeSecond == 0"> online </template>
class="text-xs font-thin leading-3 inline-block text-center" <template v-else> Last keep-alive: {{ zmqLastTimeSecond }}s ago </template>
>
<template v-if="zmqLastTimeSecond == 0">
online
</template>
<template v-else>
Last keep-alive: {{ zmqLastTimeSecond }}s ago
</template>
</span> </span>
</span> </span>
</template> </template>

View file

@ -1,19 +1,18 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons' import { darkModeOn } from '@/settings.js'
import { darkModeOn } from "@/settings.js"
const darkMode = ref(darkModeOn.value) const darkMode = ref(darkModeOn.value)
watch(darkMode, (newValue) => { watch(darkMode, (newValue) => {
darkModeOn.value = newValue darkModeOn.value = newValue
if (newValue) { if (newValue) {
document.getElementsByTagName('body')[0].classList.add('dark') document.getElementsByTagName('body')[0].classList.add('dark')
} else { } else {
document.getElementsByTagName('body')[0].classList.remove('dark') document.getElementsByTagName('body')[0].classList.remove('dark')
} }
}) })
</script> </script>
<template> <template>
@ -23,9 +22,10 @@
type="checkbox" type="checkbox"
@click="darkMode = !darkMode" @click="darkMode = !darkMode"
:checked="darkMode" :checked="darkMode"
class="toggle theme-controller bg-slate-400 col-span-2 col-start-1 row-start-1 [--tglbg:#e2e8f0]" /> class="toggle theme-controller bg-slate-500 col-span-2 col-start-1 row-start-1 [--tglbg:#e2e8f0] dark:[--tglbg:#1d232a]"
/>
<svg <svg
class="stroke-base-100 fill-base-100 col-start-1 row-start-1" class="stroke-slate-800 dark:stroke-slate-100 fill-slate-800 dark:fill-slate-100 col-start-1 row-start-1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="14"
height="14" height="14"
@ -34,13 +34,15 @@
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round"> stroke-linejoin="round"
>
<circle cx="12" cy="12" r="5" /> <circle cx="12" cy="12" r="5" />
<path <path
d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" /> d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"
/>
</svg> </svg>
<svg <svg
class="stroke-base-100 fill-base-100 col-start-2 row-start-1" class="dark:stroke-slate-800 stroke-slate-700 dark:fill-slate-800 fill-slate-700 col-start-2 row-start-1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="14"
height="14" height="14"
@ -49,9 +51,48 @@
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round"> stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg> </svg>
</label> </label>
</div> </div>
</template> </template>
<style scoped>
.toggle {
--animation-input: 0.2s;
--handleoffset: 1.5rem;
--handleoffsetcalculator: calc(var(--handleoffset) * -1);
--togglehandleborder: 0 0;
@apply h-6 w-12 rounded-3xl cursor-pointer appearance-none border border-current bg-current;
transition:
background,
box-shadow var(--animation-input, 0.2s) ease-out;
box-shadow:
var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,
0 0 0 2px var(--tglbg) inset,
var(--togglehandleborder);
&:focus-visible {
@apply outline outline-2 outline-offset-2;
}
&:hover {
@apply bg-current;
}
&:checked,
&[aria-checked='true'] {
background-image: none;
--handleoffsetcalculator: var(--handleoffset);
}
&:indeterminate {
box-shadow:
calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset,
calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset,
0 0 0 2px var(--tglbg) inset;
}
&:disabled {
@apply cursor-not-allowed bg-transparent opacity-30;
--togglehandleborder: 0 0 0 3px #000 inset, var(--handleoffsetcalculator) 0 0 3px #000 inset;
}
}
</style>

View file

@ -0,0 +1,63 @@
<script setup>
import {
faCircleCheck,
faCircleExclamation,
faCircleInfo,
faTriangleExclamation
} from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
const props = defineProps({
title: String,
message: [Array, String],
variant: {
type: String,
validator(value, props) {
return ['success', 'warning', 'danger', 'info'].includes(value)
},
default: 'info'
}
})
const icon = computed(() => {
if (props.variant == 'success') {
return faCircleCheck
} else if (props.variant == 'warning') {
return faCircleExclamation
} else if (props.variant == 'danger') {
return faTriangleExclamation
}
return faCircleInfo
})
const variantClass = computed(() => {
if (props.variant == 'success') {
return 'green'
} else if (props.variant == 'warning') {
return 'amber'
} else if (props.variant == 'danger') {
return 'red'
}
return 'blue'
})
const messages = computed(() => {
return Array.isArray(props.message)
? props.message
: props.message !== undefined
? [props.message]
: []
})
</script>
<template>
<div
:class="`mb-2 p-2 border-l-4 text-left rounded dark:bg-${variantClass}-300 dark:text-slate-900 dark:border-${variantClass}-700 bg-${variantClass}-200 text-slate-900 border-${variantClass}-700`"
>
<FontAwesomeIcon
:icon="icon"
:class="`text-${variantClass}-700 text-lg mx-3`"
></FontAwesomeIcon>
<slot></slot>
</div>
</template>

View file

@ -0,0 +1,7 @@
<script setup>
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
</script>
<template>
<FontAwesomeIcon :icon="faSpinner" class="fa-spin" fixed-width></FontAwesomeIcon>
</template>

View file

@ -0,0 +1,73 @@
<script setup>
import { ref, watch } from 'vue'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
const props = defineProps({
showModal: Boolean
})
const dialog = ref(null)
const emit = defineEmits(['modal-close'])
function closeModal() {
emit('modal-close')
}
</script>
<template>
<Teleport to="body">
<div>
<Transition>
<div
v-if="props.showModal"
class="fixed w-4/6 top-20 left-2/4 -translate-x-1/2 rounded-lg border border-slate-800 shadow-lg z-50"
>
<Teleport to="body">
<div
@click.stop="closeModal()"
class="bg-white/30 backdrop-blur-sm fixed top-0 bottom-0 left-0 right-0 z-40 cursor-pointer"
></div>
</Teleport>
<div class="flex px-4 py-3 bg-slate-700 rounded-t-lg border-b border-slate-800">
<h2 class="text-white font-semibold text-lg">
<slot name="header"></slot>
</h2>
<span class="ml-auto text-xl">
<button
@click.stop="closeModal()"
class="hover:text-slate-200 hover:dark:text-slate-50 hover:bg-slate-200/20 rounded-full p-1"
>
<FontAwesomeIcon :icon="faTimes" class="fa-fw"></FontAwesomeIcon>
</button>
</span>
</div>
<div class="px-4 py-3 bg-slate-100">
<slot name="body"></slot>
</div>
<div class="px-4 py-3 bg-slate-100 rounded-b-lg">
<slot name="footer">
<div class="flex flex-row-reverse">
<button class="btn btn-primary btn-lg" @click.stop="closeModal()">Ok</button>
</div>
</slot>
</div>
</div>
</Transition>
</div>
</Teleport>
</template>
<style scoped>
.v-enter-active {
@apply transition duration-150 ease-out;
}
.v-leave-active {
@apply transition duration-150 ease-in;
}
.v-enter-from,
.v-leave-to {
@apply scale-90;
@apply opacity-0;
}
</style>

View file

@ -0,0 +1,100 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { removeToast } from '@/utils.js'
import {
faCancel,
faCheck,
faCircleCheck,
faCircleExclamation,
faCircleInfo,
faClose,
faTriangleExclamation
} from '@fortawesome/free-solid-svg-icons'
const emit = defineEmits(['close'])
const props = defineProps({
id: Number,
title: String,
message: String,
variant: {
type: String,
validator(value, props) {
return ['success', 'warning', 'danger', 'info'].includes(value)
},
default: 'info'
},
confirm: Boolean,
confirmCb: Function
})
const duration = 7000
const icon = computed(() => {
if (props.variant == 'success') {
return faCircleCheck
} else if (props.variant == 'warning') {
return faCircleExclamation
} else if (props.variant == 'danger') {
return faTriangleExclamation
}
return faCircleInfo
})
const variantClass = computed(() => {
if (props.variant == 'success') {
return 'green'
} else if (props.variant == 'warning') {
return 'amber'
} else if (props.variant == 'danger') {
return 'red'
}
return 'blue'
})
function close() {
emit('close')
}
function callConfirmCb() {
props.confirmCb()
close()
}
onMounted(() => {
setTimeout(() => {
removeToast(props.id)
}, duration)
})
</script>
<template>
<div :class="`flex flex-col min-w-72 py-2 px-3 rounded bg-${variantClass}-100`">
<div :class="`text-${variantClass}-800 flex items-center`">
<FontAwesomeIcon :icon="icon" class="mr-2"></FontAwesomeIcon>
<span class="font-semibold">{{ props.title }}</span>
<button class="ml-auto w-6 btn btn-link !p-0">
<FontAwesomeIcon
:icon="faClose"
class="text-gray-500 hover:text-gray-700"
fixed-width
@click="close()"
></FontAwesomeIcon>
</button>
</div>
<div
class="text-slate-600 p-1 font-light"
v-if="props.message !== undefined && props.message.length > 0"
>
{{ props.message }}
</div>
<div v-if="props.confirm" class="flex gap-1">
<button class="btn btn-info" @click="callConfirmCb()">
<FontAwesomeIcon :icon="faCheck" class="fa-fw"></FontAwesomeIcon> Confirm
</button>
<button class="btn" @click="close()">
<FontAwesomeIcon :icon="faCancel" class="fa-fw"></FontAwesomeIcon> Cancel
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,47 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { allToasts, removeToast } from '@/utils.js'
import Toast from '@/components/elements/Toast.vue'
const container = ref()
const toasts = computed(() => allToasts.value)
function remove(toast_id) {
removeToast(toast_id)
}
</script>
<template>
<Teleport to="body">
<div ref="container" class="fixed m-3 top-0 right-0">
<transition-group name="list" tag="div" class="flex flex-col-reverse gap-2">
<Toast
v-for="toast of toasts"
:key="toast.id"
:id="toast.id"
:title="toast.title"
:message="toast.message"
:variant="toast.variant"
:confirm="toast.confirm"
:confirmCb="toast.confirmCb"
@close="remove(toast.id)"
/>
</transition-group>
</div>
</Teleport>
</template>
<style>
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease-out;
}
.list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.list-leave-to {
opacity: 0;
transform: translateX(10px);
}
</style>

View file

@ -0,0 +1,56 @@
<script setup></script>
<template>
<label class="grid cursor-pointer place-items-center">
<input
type="checkbox"
class="toggle theme-controller bg-slate-500 col-span-2 col-start-1 row-start-1 [--tglbg:#e2e8f0] dark:[--tglbg:#1d232a]"
/>
</label>
</template>
<style scoped>
.toggle {
--animation-input: 0.2s;
--handleoffset: 1.5rem;
--handleoffsetcalculator: calc(var(--handleoffset) * -1);
--togglehandleborder: 0 0;
@apply h-6 w-12 rounded-3xl cursor-pointer appearance-none border border-current bg-current;
transition:
background,
box-shadow var(--animation-input, 0.2s) ease-out;
box-shadow:
var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,
0 0 0 2px var(--tglbg) inset,
var(--togglehandleborder);
&:focus-visible {
@apply outline outline-2 outline-offset-2;
}
&:hover {
@apply bg-current;
}
&:checked,
&[aria-checked='true'] {
background-image: none;
--handleoffsetcalculator: var(--handleoffset);
}
&:indeterminate {
box-shadow:
calc(var(--handleoffset) / 2) 0 0 2px var(--tglbg) inset,
calc(var(--handleoffset) / -2) 0 0 2px var(--tglbg) inset,
0 0 0 2px var(--tglbg) inset;
}
&:disabled {
@apply cursor-not-allowed bg-transparent opacity-30;
--togglehandleborder: 0 0 0 3px #000 inset, var(--handleoffsetcalculator) 0 0 3px #000 inset;
}
&-success {
}
}
/* backward compatibility */
.toggle-mark {
@apply hidden;
}
</style>

View file

@ -1,251 +1,291 @@
<script setup> <script setup>
import { ref, computed } from "vue"; import { ref, computed } from 'vue'
import { active_exercises as exercises, progresses, userCount, setCompletedState } from "@/socket"; 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 { faCheck, faTimes, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons' import { faCircleCheck } from '@fortawesome/free-regular-svg-icons'
import { faCircleCheck } from '@fortawesome/free-regular-svg-icons' import { darkModeEnabled } from '@/settings.js'
import { darkModeEnabled } from "@/settings.js" import LiveLogsUserActivityGraph from '../LiveLogsUserActivityGraph.vue'
import LiveLogsUserActivityGraph from "../LiveLogsUserActivityGraph.vue"
const props = defineProps(['exercise', 'exercise_index']) const props = defineProps(['exercise', 'exercise_index'])
const collapsed_panels = ref([]) const collapsed_panels = ref([])
const chartOptions = computed(() => { const chartOptions = computed(() => {
return { return {
chart: { chart: {
type: 'radialBar', type: 'radialBar',
height: 120, height: 120,
sparkline: { sparkline: {
enabled: true enabled: true
},
animations: {
enabled: false,
easing: 'easeinout',
speed: 200,
},
}, },
colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'], animations: {
plotOptions: { enabled: false,
radialBar: { easing: 'easeinout',
startAngle: -110, speed: 200
endAngle: 110, }
hollow: { },
margin: 0, colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'],
size: '30%', plotOptions: {
background: '#64748b', radialBar: {
position: 'front', startAngle: -110,
dropShadow: { endAngle: 110,
enabled: true, hollow: {
top: 3, margin: 0,
left: 0, size: '30%',
blur: 4, background: '#64748b',
opacity: 0.24 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
}, },
track: { value: {
background: '#475569', formatter: function (val) {
strokeWidth: '97%', return parseInt((val * userCount.value) / 100)
margin: 0,
dropShadow: {
enabled: true,
top: 3,
left: 0,
blur: 3,
opacity: 0.35
}
}, },
dataLabels: { offsetY: 7,
show: true, color: darkModeEnabled.value ? '#cbd5e1' : '#f1f5f9',
name: { fontSize: '1.25rem',
show: false, show: true
},
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' stroke: {
}, lineCap: 'smooth'
colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'], },
labels: ['Progress'], colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'],
tooltip: { labels: ['Progress'],
enabled: false, 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 }) function toggleCompleted(completed, user_id, exec_uuid, task_uuid) {
const ultraCompactGrid = computed(() => { return userCount.value > 100 }) setCompletedState(completed, user_id, exec_uuid, task_uuid)
const hasProgress = computed(() => Object.keys(progresses.value).length > 0) }
const sortedProgress = computed(() => Object.values(progresses.value).sort((a, b) => {
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) { if (a.email < b.email) {
return -1; return -1
} }
if (a.email > b.email) { if (a.email > b.email) {
return 1; return 1
} }
return 0; return 0
})) })
)
const taskCompletionPercentages = computed(() => { const taskCompletionPercentages = computed(() => {
const completions = {} const completions = {}
Object.values(props.exercise.tasks).forEach(task => { Object.values(props.exercise.tasks).forEach((task) => {
completions[task.uuid] = 0 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
}) })
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> </script>
<template> <template>
<div class=" <div
fixed inset-2 z-40 h-100 overflow-x-hidden 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"
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"
<div >
class=" <!-- Modal header -->
rounded-t-lg text-md p-3 pl-6 text-center <div class="flex justify-between items-center">
dark:bg-blue-800 bg-blue-500 dark:text-slate-300 text-slate-100 <span class="text-lg font-semibold">{{ exercise.name }}</span>
" <span class="mr-8">
> Level:
<!-- Modal header --> <span
<div class="flex justify-between items-center"> :class="{
<span class="text-lg font-semibold">{{ exercise.name }}</span>
<span class="mr-8">
Level: <span :class="{
'rounded-lg px-1 ml-2': true, 'rounded-lg px-1 ml-2': true,
'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner', '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-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced',
'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert', 'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert'
}">{{ exercise.level }}</span> }"
</span> >{{ exercise.level }}</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>
<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>
</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> </template>

View file

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

View file

@ -1,10 +1,19 @@
import './assets/main.css' import './assets/main.css'
import VueApexCharts from "vue3-apexcharts"; import VueApexCharts from 'vue3-apexcharts'
import { createApp } from 'vue' import { createApp, ref } from 'vue'
import App from './App.vue' import App from './App.vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import Modal from '@/components/elements/Modal.vue'
import Loading from '@/components/elements/Loading.vue'
import Alert from '@/components/elements/Alert.vue'
const app = createApp(App) const app = createApp(App)
app.component('FontAwesomeIcon', FontAwesomeIcon)
app.component('Modal', Modal)
app.component('Loading', Loading)
app.component('Alert', Alert)
app.use(VueApexCharts) app.use(VueApexCharts)
app.mount('#app') app.mount('#app')

59
src/router.js Normal file
View file

@ -0,0 +1,59 @@
import { createWebHistory, createRouter } from 'vue-router'
const routes = [
{ path: '/', component: ScenarioList },
{
path: '/scenarios/index',
name: 'Scenario Index',
component: ScenarioList,
meta: { requiresScenarioSelection: false }
},
{
path: '/scenarios/add',
name: 'New Scenario',
component: ScenarioNew,
meta: { requiresScenarioSelection: false }
},
{
path: '/scenarios/overview/:uuid?',
name: 'Scenario Overview',
component: ScenarioOverview,
meta: { requiresScenarioSelection: true },
props: true
},
{
path: '/scenarios/designer/:uuid?',
name: 'Scenario Designer',
component: ScenarioDesigner,
meta: { requiresScenarioSelection: true },
props: true
},
{ path: '/injects/tester/:uuid?', name: 'Inject Tester', component: InjectTester, props: true }
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from) => {
if (to.path === '/') {
return { path: '/scenarios/index' }
}
if (!hasScenarios()) {
fetchScenarios()
}
if (
from.name == undefined &&
['Scenario Overview', 'Scenario Designer'].includes(to.name) &&
to?.params?.uuid !== undefined
) {
store.selected_scenario = to.params.uuid
}
if (to?.meta?.requiresScenarioSelection === true && store.selected_scenario === null) {
return { path: '/scenarios/index' }
}
if (to.matched.length == 0) {
return { path: '/scenarios/index' }
}
})

View file

@ -1,9 +1,9 @@
import { reactive, computed } from "vue"; import { reactive, computed } from 'vue'
import { io } from "socket.io-client"; 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:40001"; 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 = {
@ -17,27 +17,27 @@ const initial_state = {
exercises: [], exercises: [],
selected_exercises: [], selected_exercises: [],
progresses: {}, progresses: {},
diagnostic: {}, diagnostic: {}
} }
const state = reactive({ ...initial_state }); const state = reactive({ ...initial_state })
const connectionState = reactive({ const connectionState = reactive({
connected: false, connected: false,
zmq_last_time: false, zmq_last_time: false
}) })
const socket = io(URL, { const socket = io(URL, {
autoConnect: true autoConnect: true
}); })
/* Public */ /* Public */
/* ------ */ /* ------ */
export const exercises = computed(() => state.exercises) export const exercises = computed(() => state.exercises)
export const selected_exercises = computed(() => state.selected_exercises) export const selected_exercises = computed(() => state.selected_exercises)
export const active_exercises = computed(() => state.exercises.filter((exercise) => state.selected_exercises.includes(exercise.uuid))) export const active_exercises = computed(() =>
state.exercises.filter((exercise) => state.selected_exercises.includes(exercise.uuid))
)
export const progresses = computed(() => state.progresses) export const progresses = computed(() => state.progresses)
export const notifications = computed(() => state.notificationEvents) export const notifications = computed(() => state.notificationEvents)
export const notificationCounter = computed(() => state.notificationCounter) export const notificationCounter = computed(() => state.notificationCounter)
@ -52,7 +52,7 @@ export const socketConnected = computed(() => connectionState.connected)
export const zmqLastTime = computed(() => connectionState.zmq_last_time) export const zmqLastTime = computed(() => connectionState.zmq_last_time)
export function resetState() { export function resetState() {
Object.assign(state, initial_state); Object.assign(state, initial_state)
} }
export function fullReload() { export function fullReload() {
@ -67,7 +67,7 @@ export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
const payload = { const payload = {
user_id: user_id, user_id: user_id,
exercise_uuid: exec_uuid, exercise_uuid: exec_uuid,
task_uuid: task_uuid, task_uuid: task_uuid
} }
sendCompletedState(completed, payload) sendCompletedState(completed, payload)
} }
@ -87,7 +87,7 @@ export function resetLiveLogs() {
export function changeExerciseSelection(exec_uuid, state_enabled) { export function changeExerciseSelection(exec_uuid, state_enabled) {
const payload = { const payload = {
exercise_uuid: exec_uuid, exercise_uuid: exec_uuid,
selected: state_enabled, selected: state_enabled
} }
sendChangeExerciseSelection(payload) sendChangeExerciseSelection(payload)
} }
@ -103,7 +103,8 @@ export function toggleApiQueryMode(enabled) {
export function remediateSetting(setting) { export function remediateSetting(setting) {
sendRemediateSetting(setting, (result) => { sendRemediateSetting(setting, (result) => {
if (result.success) { if (result.success) {
state.diagnostic['settings'][setting].value = state.diagnostic['settings'][setting].expected_value state.diagnostic['settings'][setting].value =
state.diagnostic['settings'][setting].expected_value
} else { } else {
state.diagnostic['settings'][setting].error = true state.diagnostic['settings'][setting].error = true
state.diagnostic['settings'][setting].errorMessage = result.message state.diagnostic['settings'][setting].errorMessage = result.message
@ -111,79 +112,77 @@ export function remediateSetting(setting) {
}) })
} }
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 })
/* Private */ /* Private */
/* ------- */ /* ------- */
function getExercises() { function getExercises() {
socket.emit("get_exercises", (all_exercises) => { socket.emit('get_exercises', (all_exercises) => {
state.exercises = all_exercises state.exercises = all_exercises
}) })
} }
function getSelectedExercises() { function getSelectedExercises() {
socket.emit("get_selected_exercises", (all_selected_exercises) => { socket.emit('get_selected_exercises', (all_selected_exercises) => {
state.selected_exercises = all_selected_exercises state.selected_exercises = all_selected_exercises
}) })
} }
function getNotifications() { function getNotifications() {
socket.emit("get_notifications", (all_notifications) => { socket.emit('get_notifications', (all_notifications) => {
state.notificationEvents = all_notifications state.notificationEvents = all_notifications
}) })
} }
function getProgress() { function getProgress() {
socket.emit("get_progress", (all_progress) => { socket.emit('get_progress', (all_progress) => {
state.progresses = all_progress state.progresses = all_progress
}) })
} }
function getUsersActivity() { function getUsersActivity() {
socket.emit("get_users_activity", (user_activity_bundle) => { socket.emit('get_users_activity', (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
}); })
} }
function getDiangostic() { function getDiangostic() {
state.diagnostic = {} state.diagnostic = {}
socket.emit("get_diagnostic", (diagnostic) => { socket.emit('get_diagnostic', (diagnostic) => {
state.diagnostic = diagnostic state.diagnostic = diagnostic
}) })
} }
function sendCompletedState(completed, payload) { function sendCompletedState(completed, payload) {
const event_name = !completed ? "mark_task_completed": "mark_task_incomplete" const event_name = !completed ? 'mark_task_completed' : 'mark_task_incomplete'
socket.emit(event_name, payload, () => { socket.emit(event_name, payload, () => {
getProgress() getProgress()
}) })
} }
function sendResetAllExerciseProgress() { function sendResetAllExerciseProgress() {
socket.emit("reset_all_exercise_progress", () => { socket.emit('reset_all_exercise_progress', () => {
getProgress() getProgress()
}) })
} }
function sendResetAll() { function sendResetAll() {
socket.emit("reset_all", () => { socket.emit('reset_all', () => {
getProgress() getProgress()
}) })
} }
function sendResetLiveLogs() { function sendResetLiveLogs() {
socket.emit("reset_notifications", () => { socket.emit('reset_notifications', () => {
getNotifications() getNotifications()
}) })
} }
function sendChangeExerciseSelection(payload) { function sendChangeExerciseSelection(payload) {
socket.emit("change_exercise_selection", payload, () => { socket.emit('change_exercise_selection', payload, () => {
getSelectedExercises() getSelectedExercises()
}) })
} }
@ -192,64 +191,64 @@ function sendToggleVerboseMode(enabled) {
const payload = { const payload = {
verbose: enabled verbose: enabled
} }
socket.emit("toggle_verbose_mode", payload, () => {}) socket.emit('toggle_verbose_mode', payload, () => {})
} }
function sendToggleApiQueryMode(enabled) { function sendToggleApiQueryMode(enabled) {
const payload = { const payload = {
apiquery: enabled apiquery: enabled
} }
socket.emit("toggle_apiquery_mode", payload, () => {}) socket.emit('toggle_apiquery_mode', payload, () => {})
} }
function sendRemediateSetting(setting, cb) { function sendRemediateSetting(setting, cb) {
const payload = { const payload = {
name: setting name: setting
} }
socket.emit("remediate_setting", payload, (result) => { socket.emit('remediate_setting', payload, (result) => {
cb(result) cb(result)
}) })
} }
/* Event listener */ /* Event listener */
socket.on("connect", () => { socket.on('connect', () => {
connectionState.connected = true; connectionState.connected = true
}); })
socket.on("disconnect", () => { socket.on('disconnect', () => {
connectionState.connected = false; connectionState.connected = false
}); })
socket.on("notification", (message) => { socket.on('notification', (message) => {
state.notificationCounter += 1 state.notificationCounter += 1
if (message.is_api_request) { if (message.is_api_request) {
state.notificationAPICounter += 1 state.notificationAPICounter += 1
} }
addLimited(state.notificationEvents, message, MAX_LIVE_LOG) addLimited(state.notificationEvents, message, MAX_LIVE_LOG)
}); })
socket.on("new_user", (new_user) => { socket.on('new_user', (new_user) => {
debouncedGetProgress() debouncedGetProgress()
}); })
socket.on("refresh_score", (new_user) => { socket.on('refresh_score', (new_user) => {
debouncedGetProgress() debouncedGetProgress()
}); })
socket.on("keep_alive", (keep_alive) => { socket.on('keep_alive', (keep_alive) => {
connectionState.zmq_last_time = keep_alive['zmq_last_time'] connectionState.zmq_last_time = keep_alive['zmq_last_time']
}); })
socket.on("update_notification_history", (notification_history_bundle) => { socket.on('update_notification_history', (notification_history_bundle) => {
state.notificationHistory = notification_history_bundle.history state.notificationHistory = notification_history_bundle.history
state.notificationHistoryConfig = notification_history_bundle.config state.notificationHistoryConfig = notification_history_bundle.config
}); })
socket.on("update_users_activity", (user_activity_bundle) => { socket.on('update_users_activity', (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
}); })
function addLimited(target, message, maxCount) { function addLimited(target, message, maxCount) {
target.unshift(message) target.unshift(message)

20
src/utils.js Normal file
View file

@ -0,0 +1,20 @@
import { computed, ref } from 'vue'
let toastID = 0
export const toastBuffer = ref([])
export const allToasts = computed(() => toastBuffer.value)
export function toast(toast) {
toastID += 1
toast.id = toastID
toastBuffer.value.push(toast)
}
export function removeToast(id) {
toastBuffer.value = toastBuffer.value.filter((toast) => toast.id != id)
}
export function ajaxFeedback(response) {
toast({
variant: response.success ? 'success' : 'danger',
message: String(response.message),
title: response.title
})
}

View file

@ -8,6 +8,15 @@ export default {
{ {
pattern: /bg-blue+/, // Includes bg of all colors and shades pattern: /bg-blue+/, // Includes bg of all colors and shades
}, },
{
pattern: /bg-(red|green|blue|amber)-(100|200|300|800)/, // Includes bg of all colors and shades
},
{
pattern: /text-(red|green|blue|amber)-(100|700|800)/, // Includes bg of all colors and shades
},
{
pattern: /border-(red|green|blue|amber)-(700)/, // Includes bg of all colors and shades
},
], ],
theme: { theme: {
extend: { extend: {
@ -18,22 +27,16 @@ export default {
'3xl': '1800px', '3xl': '1800px',
}, },
fontSize: { fontSize: {
'xxs': '0.6rem', '2xs': '0.66rem',
}, },
maxWidth: {
'8xl': '88rem',
'9xl': '96rem',
'10xl': '104rem',
}
}, },
}, },
plugins: [ plugins: [
require('daisyui'),
], ],
darkMode: ['selector'], darkMode: ['selector'],
daisyui: {
themes: false, // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"]
darkTheme: "dark", // name of one of the included themes for dark mode
base: false, // applies background color and foreground color for root element by default
styled: true, // include daisyUI colors and design decisions for all components
utils: false, // adds responsive and modifier utility classes
prefix: "", // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors)
logs: false, // Shows info about daisyUI version and used config in the console when building your CSS
themeRoot: ":root", // The element that receives theme color CSS variables
},
} }