Merge branch 'snapshots'

This commit is contained in:
Emily
2024-08-07 15:06:44 +02:00
89 changed files with 2602 additions and 1082 deletions

View File

@@ -97,6 +97,11 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
const { pid, instant, flowHash } = data; const { pid, instant, flowHash } = data;
const existingSession = await SessionModel.findOne({ project_id: pid }, { _id: 1 });
if (!existingSession) {
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
}
if (instant == "true") { if (instant == "true") {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, { await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 0 }, $inc: { duration: 0 },

View File

@@ -6,15 +6,37 @@ Lit.init('6643cd08a1854e3b81722ab5');
const debugMode = process.dev; const debugMode = process.dev;
const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDialog(); const { alerts, closeAlert } = useAlert();
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
</script> </script>
<template> <template>
<div class="w-dvw h-dvh bg-[#151517] relative"> <div class="w-dvw h-dvh bg-lyx-background-light relative">
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
<div v-for="alert of alerts"
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
<div class="flex items-start gap-4">
<div> <i :class="alert.icon"></i> </div>
<div class="grow">
<div class="poppins font-semibold">{{ alert.title }}</div>
<div class="poppins">
{{ alert.text }}
</div>
</div>
<div>
<i @click="closeAlert(alert.id)" class="fas fa-close hover:text-[#CCCCCC] cursor-pointer"></i>
</div>
</div>
<div :style="`width: ${Math.floor(100 / alert.ms * alert.remaining)}%; ${alert.transitionStyle}`"
class="absolute bottom-0 left-0 h-1 bg-lyx-primary z-100 alert-bar"></div>
</div>
</div>
<div v-if="debugMode" <div v-if="debugMode"
class="absolute bottom-8 left-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]"> class="absolute bottom-8 right-4 bg-red-400 text-white text-[.9rem] font-bold px-4 py-[.2rem] rounded-lg z-[100]">
<div class="poppins flex sm:hidden"> XS </div> <div class="poppins flex sm:hidden"> XS </div>
<div class="poppins hidden sm:max-md:flex"> SM - MOBILE </div> <div class="poppins hidden sm:max-md:flex"> SM - MOBILE </div>
<div class="poppins hidden md:max-lg:flex"> MD - TABLET </div> <div class="poppins hidden md:max-lg:flex"> MD - TABLET </div>
@@ -24,9 +46,9 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
</div> </div>
<div v-if="showDialog" <div v-if="showDialog"
class="custom-dialog flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 w-full h-full z-[100] backdrop-blur-[2px] bg-black/50"> class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] bg-black/50">
<div class="bg-menu w-full h-full rounded-xl relative"> <div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
<div class="flex justify-end absolute z-[100] right-8 top-8"> <div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i> <i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
</div> </div>
<div class="flex items-center justify-center w-full h-full p-4"> <div class="flex items-center justify-center w-full h-full p-4">
@@ -41,3 +63,4 @@ const { showDialog, closeDialog, dialogComponent, dialogParams } = useCustomDial
</div> </div>
</template> </template>

View File

@@ -80,6 +80,7 @@ const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, op
onMounted(async () => { onMounted(async () => {
const c = document.createElement('canvas'); const c = document.createElement('canvas');
const ctx = c.getContext("2d"); const ctx = c.getContext("2d");
let gradient: any = `${props.color}22`; let gradient: any = `${props.color}22`;
@@ -95,7 +96,6 @@ onMounted(async () => {
chartData.value.datasets[0].backgroundColor = [gradient]; chartData.value.datasets[0].backgroundColor = [gradient];
watch(props, () => { watch(props, () => {
console.log('UPDATE')
chartData.value.labels = props.labels; chartData.value.labels = props.labels;
chartData.value.datasets[0].data = props.data; chartData.value.datasets[0].data = props.data;
}); });
@@ -106,5 +106,5 @@ onMounted(async () => {
<template> <template>
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart> <LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</template> </template>

View File

@@ -1,5 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { TProject } from '@schema/ProjectSchema';
import CreateSnapshot from './dialog/CreateSnapshot.vue';
export type Entry = { export type Entry = {
label: string, label: string,
@@ -8,7 +10,8 @@ export type Entry = {
icon?: string, icon?: string,
action?: () => any, action?: () => any,
adminOnly?: boolean, adminOnly?: boolean,
external?: boolean external?: boolean,
grow?: boolean
} }
export type Section = { export type Section = {
@@ -29,42 +32,238 @@ const debugMode = process.dev;
const { isOpen, close } = useMenu(); const { isOpen, close } = useMenu();
const { snapshots, snapshot, updateSnapshots } = useSnapshot();
const snapshotsItems = computed(() => {
if (!snapshots.value) return []
return snapshots.value as any[];
})
const { openDialogEx } = useCustomDialog();
function openSnapshotDialog() {
openDialogEx(CreateSnapshot, {
width: "24rem",
height: "16rem",
closable: false
});
}
const { createAlert } = useAlert()
async function deleteSnapshot(close: () => any) {
await $fetch("/api/snapshot/delete", {
method: 'DELETE',
...signHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
id: snapshot.value._id.toString(),
})
});
await updateSnapshots();
snapshot.value = snapshots.value[1];
createAlert('Snapshot deleted', 'Snapshot deleted successfully', 'far fa-circle-check', 5000);
close();
}
async function generatePDF() {
try {
const res = await $fetch<Blob>('/api/project/generate_pdf', {
...signHeaders(),
responseType: 'blob'
});
const url = URL.createObjectURL(res);
const a = document.createElement('a');
a.href = url;
a.download = `Report.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (ex: any) {
alert(ex.message);
}
}
const { setToken } = useAccessToken();
const router = useRouter();
function onLogout() {
console.log('LOGOUT')
setToken('');
setLoggedUser(undefined);
router.push('/login');
}
const { projects } = useProjectsList();
const activeProject = useActiveProject();
const { data: maxProjects } = useFetch("/api/user/max_projects", {
headers: computed(() => {
return {
Authorization: authorizationHeaderComputed.value
}
})
});
const selected = ref<TProject>(activeProject.value as TProject);
watch(selected, () => {
setActiveProject(selected.value._id.toString())
})
</script> </script>
<template> <template>
<div class="CVerticalNavigation h-full w-[20rem] bg-[#111111] flex shadow-[1px_0_10px_#000000] rounded-r-lg" :class="{ <div class="CVerticalNavigation h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen, :class="{
'hidden lg:flex': !isOpen 'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
}"> 'hidden lg:flex': !isOpen
<div class="p-4 gap-6 flex flex-col w-full"> }">
<div class="py-4 px-2 gap-6 flex flex-col w-full">
<div class="flex items-center gap-2 ml-2"> <div class="flex px-2 flex-col">
<div class="bg-black h-[2.4rem] aspect-[1/1] flex items-center justify-center rounded-lg">
<img class="h-[2rem]" :src="'/logo.png'"> <div class="flex items-center gap-2 w-full">
</div>
<div class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div> <USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
base: '!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter'
}
}" class="w-full" v-if="projects" v-model="selected" :options="projects">
<template #option="{ option, active, selected }">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
</div>
<div> {{ option.name }} </div>
</div>
</template>
<template #label>
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
</div>
<div> {{ activeProject?.name || '???' }} </div>
</div>
</template>
</USelectMenu>
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
<i @click="close()" class="fas fa-close"></i>
</div>
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
<i @click="close()" class="fas fa-close"></i>
</div> </div>
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
<div><i class="fas fa-plus"></i></div>
<div> Create new project </div>
</NuxtLink>
</div> </div>
<div class="flex flex-col gap-4">
<div class="w-full flex-col px-2">
<div class="flex mb-2 items-center justify-between">
<div class="poppins text-[.8rem]">
Snapshots
</div>
<div @click="openSnapshotDialog()"
class="poppins text-[.8rem] px-2 rounded-lg outline outline-[2px] outline-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget-lighter">
<i class="far fa-plus"></i>
Add
</div>
</div>
<USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
base: '!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter'
}
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
<template #label>
<div class="flex items-center gap-2">
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
</div>
<div class="poppins"> {{ snapshot?.name }} </div>
</div>
</template>
<template #option="{ option }">
<div class="flex items-center gap-2">
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
</div>
<div class="poppins"> {{ option.name }} </div>
</div>
</template>
</USelectMenu>
<div v-if="snapshot" class="flex flex-col text-[.8rem] mt-2">
<div class="flex">
<div class="grow poppins"> From:</div>
<div class="poppins"> {{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim() }}
</div>
</div>
<div class="flex">
<div class="grow poppins"> To:</div>
<div class="poppins"> {{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim() }}
</div>
</div>
<LyxUiButton @click="generatePDF()" type="secondary" class="w-full text-center mt-4">
Download report
</LyxUiButton>
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
<UPopover placement="bottom">
<LyxUiButton type="danger" class="w-full text-center">
Delete current snapshot
</LyxUiButton>
<template #panel="{ close }">
<div class="p-4 bg-lyx-widget">
<div class="poppins text-center font-medium">
Are you sure?
</div>
<div class="flex gap-2 mt-4">
<LyxUiButton @click="close()" type="secondary"> Cancel </LyxUiButton>
<LyxUiButton type="danger" @click="deleteSnapshot(close)"> Delete </LyxUiButton>
</div>
</div>
</template>
</UPopover>
</div>
</div>
</div>
<div class="bg-lyx-widget-lighter h-[2px] w-full"></div>
<div class="flex flex-col h-full">
<div v-for="section of sections" class="flex flex-col gap-1"> <div v-for="section of sections" class="flex flex-col gap-1">
<div v-for="entry of section.entries"> <div v-for="entry of section.entries">
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))" <div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
class="bg-[#111111] text-gray-300 hover:bg-[#1b1b1b] py-2 px-4 rounded-lg" :class="{ class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
'text-gray-700 pointer-events-none': entry.disabled, :class="{
'bg-[#1b1b1b]': route.path == (entry.to || '#') '!text-lyx-text-darker pointer-events-none': entry.disabled,
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
}"> }">
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''" <NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
tag="div" class="flex" :to="entry.to || '/'"> tag="div" class="w-full flex items-center" :to="entry.to || '/'">
<div class="flex items-center w-[1.8rem] justify-start"> <div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
<i :class="entry.icon"></i> <i :class="entry.icon"></i>
</div> </div>
<div class="manrope"> <div class="manrope">
@@ -78,6 +277,45 @@ const { isOpen, close } = useMenu();
</div> </div>
<div class="grow"></div>
<div class="text-lyx-text-dark poppins text-[.8rem] px-4 pb-3">
Litlyx is in Beta version.
</div>
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
<div class="flex justify-end px-2">
<div class="grow flex gap-3">
<NuxtLink to="https://github.com/litlyx/litlyx" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-github"></i>
</NuxtLink>
<NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-discord"></i>
</NuxtLink>
<NuxtLink to="https://x.com/litlyx" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-x-twitter"></i>
</NuxtLink>
<NuxtLink to="https://dev.to/litlyx-org" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-dev"></i>
</NuxtLink>
<NuxtLink to="/admin" v-if="isAdmin"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fas fa-cat"></i>
</NuxtLink>
</div>
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
</div>
</UTooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -85,10 +323,6 @@ const { isOpen, close } = useMenu();
<style lang="scss" scoped> <style lang="scss" scoped>
.CVerticalNavigation * {
font-family: 'Geist';
}
input:focus { input:focus {
outline: none; outline: none;
} }

View File

@@ -5,7 +5,7 @@ const props = defineProps<{ title: string, sub?: string }>();
</script> </script>
<template> <template>
<Card> <LyxUiCard>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="flex flex-col grow"> <div class="flex flex-col grow">
@@ -23,5 +23,5 @@ const props = defineProps<{ title: string, sub?: string }>();
<slot></slot> <slot></slot>
</div> </div>
</div> </div>
</Card> </LyxUiCard>
</template> </template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
type CItem = { label: string, slot: string }
const props = defineProps<{ items: CItem[] }>();
const activeTabIndex = ref<number>(0);
</script>
<template>
<div>
<div class="flex">
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
}">
{{ tab.label }}
</div>
<div class="border-b-[1px] border-lyx-text-darker w-full">
</div>
</div>
<div>
<slot :name="props.items[activeTabIndex].slot"></slot>
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
import type { DatePickerDate, DatePickerRangeObject } from 'v-calendar/dist/types/src/use/datePicker'
import 'v-calendar/dist/style.css'
const props = defineProps({
modelValue: {
type: [Date, Object] as PropType<DatePickerDate | DatePickerRangeObject | null>,
default: null
}
})
const emit = defineEmits(['update:model-value', 'close'])
const date = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:model-value', value)
emit('close')
}
})
const attrs = {
transparent: true,
borderless: true,
color: 'primary',
'is-dark': { selector: 'html', darkClass: 'dark' },
'first-day-of-week': 2,
}
</script>
<template>
<VCalendarDatePicker v-if="date && (typeof date === 'object')" v-model.range="date" :columns="2" v-bind="{ ...attrs, ...$attrs }" />
<VCalendarDatePicker v-else v-model="date" v-bind="{ ...attrs, ...$attrs }" />
</template>
<style>
:root {
--vc-gray-50: rgb(var(--color-gray-50));
--vc-gray-100: rgb(var(--color-gray-100));
--vc-gray-200: rgb(var(--color-gray-200));
--vc-gray-300: rgb(var(--color-gray-300));
--vc-gray-400: rgb(var(--color-gray-400));
--vc-gray-500: rgb(var(--color-gray-500));
--vc-gray-600: rgb(var(--color-gray-600));
--vc-gray-700: rgb(var(--color-gray-700));
--vc-gray-800: rgb(var(--color-gray-800));
--vc-gray-900: rgb(var(--color-gray-900));
}
.vc-primary {
--vc-accent-50: rgb(var(--color-primary-50));
--vc-accent-100: rgb(var(--color-primary-100));
--vc-accent-200: rgb(var(--color-primary-200));
--vc-accent-300: rgb(var(--color-primary-300));
--vc-accent-400: rgb(var(--color-primary-400));
--vc-accent-500: rgb(var(--color-primary-500));
--vc-accent-600: rgb(var(--color-primary-600));
--vc-accent-700: rgb(var(--color-primary-700));
--vc-accent-800: rgb(var(--color-primary-800));
--vc-accent-900: rgb(var(--color-primary-900));
}
</style>

View File

@@ -50,112 +50,99 @@ function openExternalLink(link: string) {
<template> <template>
<div class="flex h-full"> <LyxUiCard class="w-full h-full p-4 flex flex-col gap-8 relative">
<div class="flex justify-between mb-3">
<div class="text-text flex flex-col items-start gap-4 w-full relative"> <div class="flex flex-col gap-1">
<div class="flex gap-4 items-center">
<div class="w-full h-full p-4 flex flex-col bg-card rounded-xl gap-8 card-shadow"> <div class="poppins font-semibold text-[1.4rem] text-text">
{{ label }}
<div class="flex justify-between mb-3">
<div class="flex flex-col gap-1">
<div class="flex gap-4 items-center">
<div class="poppins font-semibold text-[1.4rem] text-text">
{{ label }}
</div>
<div class="flex items-center">
<i @click="reloadData()"
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
</div>
</div>
<div class="poppins text-[1rem] text-text-sub/90">
{{ desc }}
</div>
</div> </div>
<div v-if="rawButton" class="hidden lg:flex"> <div class="flex items-center">
<div @click="$emit('showRawData')" <i @click="reloadData()"
class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]"> class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
<div> Raw data </div>
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
</div>
</div> </div>
</div> </div>
<div class="poppins text-[1rem] text-text-sub/90">
<div> {{ desc }}
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center">
<i @click="$emit('showGeneral')"
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
</div>
<div> {{ subLabel }} </div>
</div>
<div> Count </div>
</div>
<div class="flex flex-col gap-1">
<div v-if="props.data.length > 0" class="flex justify-between items-center"
v-for="element of props.data">
<div class="flex items-center gap-2 w-10/12 relative">
<div v-if="showLink">
<i @click="openExternalLink(element._id)"
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
</div>
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
:class="{ 'cursor-pointer line-active': interactive }">
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4">
<div v-if="iconProvider && iconProvider(element._id) != undefined"
class="flex items-center h-[1.3rem]">
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
<i v-else :class="iconProvider(element._id)?.[1]"></i>
</div>
<span
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
{{ elementTextTransformer?.(element._id) || element._id }}
</span>
</div>
</div>
</div>
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
formatNumberK(element.count) }} </div>
</div>
<div v-if="props.data.length == 0"
class="flex justify-center text-text-sub font-bold text-[1.1rem]">
No visits yet
</div>
</div>
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
<div @click="$emit('showMore')"
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
Show more
</div>
</div>
</div> </div>
</div>
<div v-if="loading" <div v-if="rawButton" class="hidden lg:flex">
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute"> <div @click="$emit('showRawData')"
<i class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]">
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i> <div> Raw data </div>
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div>
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center">
<i @click="$emit('showGeneral')"
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
</div>
<div> {{ subLabel }} </div>
</div>
<div> Count </div>
</div>
<div class="flex flex-col gap-1">
<div v-if="props.data.length > 0" class="flex justify-between items-center"
v-for="element of props.data">
<div class="flex items-center gap-2 w-10/12 relative">
<div v-if="showLink">
<i @click="openExternalLink(element._id)"
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
</div>
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
:class="{ 'cursor-pointer line-active': interactive }">
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4">
<div v-if="iconProvider && iconProvider(element._id) != undefined"
class="flex items-center h-[1.3rem]">
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
<i v-else :class="iconProvider(element._id)?.[1]"></i>
</div>
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
{{ elementTextTransformer?.(element._id) || element._id }}
</span>
</div>
</div>
</div>
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
formatNumberK(element.count) }} </div>
</div>
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
No visits yet
</div>
</div>
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
<div @click="$emit('showMore')"
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
Show more
</div>
</div>
</div>
<div v-if="loading"
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
</LyxUiCard>
</template> </template>

View File

@@ -1,39 +1,46 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { BrowsersAggregated } from '~/server/api/metrics/[project_id]/data/browsers'; const activeProject = useActiveProject();
const activeProject = await useActiveProject(); const { safeSnapshotDates } = useSnapshot()
const { data: events, pending, refresh } = await useFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
...signHeaders(), const isShowMore = ref<boolean>(false);
lazy: true
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
}); });
const browsersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog(); const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() { function showMore() {
isShowMore.value = true;
showDialog.value = true; showDialog.value = true;
dialogBarData.value = []; dialogBarData.value = browsersData.data.value || [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
} }
onMounted(() => {
browsersData.execute();
});
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" <DashboardBarsCard @showMore="showMore()" @dataReload="browsersData.refresh()"
desc="The browsers most used to search your website." :dataIcons="false" :loading="pending" :data="browsersData.data.value || []" desc="The browsers most used to search your website."
label="Top Browsers" sub-label="Browsers"></DashboardBarsCard> :dataIcons="false" :loading="browsersData.pending.value" label="Top Browsers" sub-label="Browsers">
</DashboardBarsCard>
</div> </div>
</template> </template>

View File

@@ -16,8 +16,8 @@ const props = defineProps<{
<template> <template>
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full"> <LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
<div class="flex p-4 items-start"> <div v-if="ready" class="flex p-4 items-start">
<div class="flex items-center mt-2 mr-4"> <div class="flex items-center mt-2 mr-4">
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i> <i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
</div> </div>
@@ -40,32 +40,16 @@ const props = defineProps<{
</div> </div>
</div> </div>
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0"> <div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
v-if="((props.data?.length || 0) > 0) && ready">
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []" <DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
:color="props.color"> :color="props.color">
</DashboardEmbedChartCard> </DashboardEmbedChartCard>
</div> </div>
</Card> <div v-if="!ready" class="flex justify-center items-center w-full h-full">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
<!-- <div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
:style="`background: ${props.color}`">
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
</div>
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
{{ title }}
</div>
</div>
<div class="flex gap-2 items-center lg:items-end">
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
{{ text }}
</div>
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
</div> </div>
</LyxUiCard>
</div> -->
</template> </template>

View File

@@ -1,39 +1,46 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { DevicesAggregated } from '~/server/api/metrics/[project_id]/data/devices'; const activeProject = useActiveProject();
const activeProject = await useActiveProject(); const { safeSnapshotDates } = useSnapshot()
const { data: events, pending, refresh } = await useFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, {
...signHeaders(), const isShowMore = ref<boolean>(false);
lazy: true
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
}); });
const devicesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/devices`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog(); const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() { function showMore() {
isShowMore.value = true;
showDialog.value = true; showDialog.value = true;
dialogBarData.value = []; dialogBarData.value = devicesData.data.value || [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
} }
onMounted(() => {
devicesData.execute();
});
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" :dataIcons="false" <DashboardBarsCard @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []" :dataIcons="false"
desc="The devices most used to access your website." :loading="pending" label="Top Devices" desc="The devices most used to access your website." :loading="devicesData.pending.value" label="Top Devices"
sub-label="Devices"></DashboardBarsCard> sub-label="Devices"></DashboardBarsCard>
</div> </div>
</template> </template>

View File

@@ -1,37 +1,42 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/visits/events';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, {
...signHeaders(),
lazy: true
});
const router = useRouter(); const router = useRouter();
function goToView() { function goToView() {
router.push('/dashboard/events'); router.push('/dashboard/events');
} }
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog(); const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() { function showMore() {
isShowMore.value = true;
showDialog.value = true; showDialog.value = true;
dialogBarData.value = []; dialogBarData.value = eventsData.data.value || [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
} }
onMounted(async () => {
eventsData.execute();
});
</script> </script>
@@ -39,7 +44,8 @@ function showMore() {
<template> <template>
<div class="flex flex-col gap-2 h-full"> <div class="flex flex-col gap-2 h-full">
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()" <DashboardBarsCard @showMore="showMore()" @showRawData="goToView()"
desc="Most frequent user events triggered in this project" @dataReload="refresh" :data="events || []" desc="Most frequent user events triggered in this project" @dataReload="eventsData.refresh()"
:loading="pending" label="Top Events" sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard> :data="eventsData.data.value || []" :loading="eventsData.pending.value" label="Top Events"
sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
</div> </div>
</template> </template>

View File

@@ -2,7 +2,7 @@
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js'; import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3'; import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie'; import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/data/events';
definePageMeta({ layout: 'dashboard' }); definePageMeta({ layout: 'dashboard' });
@@ -20,15 +20,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
ticks: { display: false }, ticks: { display: false },
grid: { display: false, drawBorder: false }, grid: { display: false, drawBorder: false },
}, },
// r: {
// ticks: { display: false },
// grid: {
// display: true,
// drawBorder: false,
// color: '#CCCCCC22',
// borderDash: [20, 8]
// },
// }
}, },
plugins: { plugins: {
legend: { legend: {
@@ -55,7 +46,7 @@ const chartData = ref<ChartData<'doughnut'>>({
{ {
rotation: 1, rotation: 1,
data: [], data: [],
backgroundColor: ['#6bbbe3','#5655d0', '#a6d5cb', '#fae0b9'], backgroundColor: ['#6bbbe3', '#5655d0', '#a6d5cb', '#fae0b9'],
borderColor: ['#1d1d1f'], borderColor: ['#1d1d1f'],
borderWidth: 2 borderWidth: 2
}, },
@@ -65,15 +56,18 @@ const chartData = ref<ChartData<'doughnut'>>({
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions }); const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
onMounted(async () => { const activeProject = useActiveProject();
const activeProject = useActiveProject() const { safeSnapshotDates } = useSnapshot();
const eventsData = await $fetch<EventsPie[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
chartData.value.labels = eventsData.map(e => {
function transformResponse(input: CustomEventsAggregated[]) {
chartData.value.labels = input.map(e => {
return `${e._id}`; return `${e._id}`;
}); });
chartData.value.datasets[0].data = eventsData.map(e => e.count); chartData.value.datasets[0].data = input.map(e => e.count);
doughnutChartRef.value?.update(); doughnutChartRef.value?.update();
if (window.innerWidth < 800) { if (window.innerWidth < 800) {
@@ -81,11 +75,34 @@ onMounted(async () => {
chartOptions.value.plugins.legend.display = false; chartOptions.value.plugins.legend.display = false;
} }
} }
}) }
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: "10"
}
});
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
method: 'POST', headers, lazy: true, immediate: false,transform:transformResponse
});
onMounted(() => {
eventsData.execute();
});
</script> </script>
<template> <template>
<DoughnutChart v-bind="doughnutChartProps"> </DoughnutChart> <div>
<div v-if="eventsData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<DoughnutChart v-if="!eventsData.pending.value" v-bind="doughnutChartProps"> </DoughnutChart>
</div>
</template> </template>

View File

@@ -1,14 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { CountriesAggregated } from '~/server/api/metrics/[project_id]/data/countries';
import type { IconProvider } from './BarsCard.vue'; import type { IconProvider } from './BarsCard.vue';
const activeProject = await useActiveProject();
const { data: countries, pending, refresh } = await useFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, {
...signHeaders(),
lazy: true
});
function iconProvider(id: string): ReturnType<IconProvider> { function iconProvider(id: string): ReturnType<IconProvider> {
if (id === 'self') return ['icon', 'fas fa-link']; if (id === 'self') return ['icon', 'fas fa-link'];
return [ return [
@@ -19,31 +12,51 @@ function iconProvider(id: string): ReturnType<IconProvider> {
const customIconStyle = `width: 2rem; padding: 1px;` const customIconStyle = `width: 2rem; padding: 1px;`
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const geolocationData = useFetch(`/api/metrics/${activeProject.value?._id}/data/countries`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog(); const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() { function showMore() {
isShowMore.value = true;
showDialog.value = true; showDialog.value = true;
dialogBarData.value = [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders({ dialogBarData.value = geolocationData.data.value?.map(e => {
'x-query-limit': '200' return { ...e, icon: iconProvider(e._id) }
})).then(data => { }) || [];
dialogBarData.value = data; isDataLoading.value = false;
isDataLoading.value = false;
});
} }
onMounted(async () => {
geolocationData.execute();
});
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="countries || []" :dataIcons="false" <DashboardBarsCard @showMore="showMore()" @dataReload="geolocationData.refresh()" :data="geolocationData.data.value || []" :dataIcons="false"
:loading="pending" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider" :loading="geolocationData.pending.value" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website."> :customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
</DashboardBarsCard> </DashboardBarsCard>
</div> </div>

View File

@@ -1,39 +1,44 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { OssAggregated } from '~/server/api/metrics/[project_id]/data/oss'; const activeProject = useActiveProject();
const activeProject = await useActiveProject(); const { safeSnapshotDates } = useSnapshot()
const { data: events, pending, refresh } = await useFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, {
...signHeaders(), const isShowMore = ref<boolean>(false);
lazy: true
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
}); });
const ossData = useFetch(`/api/metrics/${activeProject.value?._id}/data/oss`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog(); const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
function showMore() { function showMore() {
isShowMore.value = true;
showDialog.value = true; showDialog.value = true;
dialogBarData.value = []; dialogBarData.value = ossData.data.value || [];
isDataLoading.value = true;
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data;
isDataLoading.value = false;
});
} }
onMounted(() => {
ossData.execute();
});
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 h-full">
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" <DashboardBarsCard @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false" desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
:loading="pending" label="Top OS" sub-label="OSs"></DashboardBarsCard> :loading="ossData.pending.value" label="Top OS" sub-label="OSs"></DashboardBarsCard>
</div> </div>
</template> </template>

View File

@@ -1,16 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
import type { IconProvider } from './BarsCard.vue'; import type { IconProvider } from './BarsCard.vue';
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue'; import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
const activeProject = await useActiveProject();
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
...signHeaders(),
lazy: true
});
function iconProvider(id: string): ReturnType<IconProvider> { function iconProvider(id: string): ReturnType<IconProvider> {
if (id === 'self') return ['icon', 'fas fa-link']; if (id === 'self') return ['icon', 'fas fa-link'];
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`] return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
@@ -21,45 +13,56 @@ function elementTextTransformer(element: string) {
return element; return element;
} }
const activeProject = useActiveProject();
const { safeSnapshotDates } = useSnapshot()
const isShowMore = ref<boolean>(false);
const headers = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const referrersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
method: 'POST', headers, lazy: true, immediate: false
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog(); const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
const customDialog = useCustomDialog(); // const customDialog = useCustomDialog();
function onShowDetails(referrer: string) {
customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
}
// function onShowDetails(referrer: string) {
// customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
// }
function showMore() { function showMore() {
isShowMore.value = true;
showDialog.value = true; showDialog.value = true;
dialogBarData.value = []; dialogBarData.value = referrersData.data.value?.map(e => {
isDataLoading.value = true; return { ...e, icon: iconProvider(e._id) }
}) || [];
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders({
'x-query-limit': '200'
})).then(data => {
dialogBarData.value = data.map(e => {
return { ...e, icon: iconProvider(e._id) }
});
isDataLoading.value = false;
});
} }
onMounted(async () => {
referrersData.execute();
});
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<DashboardBarsCard @showDetails="onShowDetails" @showMore="showMore()" <DashboardBarsCard @showMore="showMore()"
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider" @dataReload="refresh" :elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider"
:showLink=true :data="events || []" :interactive="true" desc="Where users find your website." @dataReload="referrersData.refresh()" :showLink=true :data="referrersData.data.value || []"
:dataIcons="true" :loading="pending" label="Top Referrers" sub-label="Referrers"></DashboardBarsCard> :interactive="false" desc="Where users find your website." :dataIcons="true" :loading="referrersData.pending.value"
label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
</div> </div>
</template> </template>

View File

@@ -1,31 +1,46 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService'; import DateService, { type Slice } from '@services/DateService';
const data = ref<number[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
const props = defineProps<{ slice: Slice }>(); const props = defineProps<{ slice: Slice }>();
async function loadData() { const activeProject = useActiveProject();
const response = await useTimeline('sessions', props.slice);
if (!response) return; const { safeSnapshotDates } = useSnapshot()
data.value = response.map(e => e.count);
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice)); function transformResponse(input: { _id: string, count: number }[]) {
ready.value = true; const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
return { data, labels }
} }
const body = computed(() => {
return {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: props.slice
}
});
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
});
onMounted(async () => { onMounted(async () => {
await loadData(); sessionsData.execute();
watch(props, async () => { await loadData(); }); });
})
</script> </script>
<template> <template>
<div> <div>
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#f56523"></AdvancedLineChart> <div v-if="sessionsData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<AdvancedLineChart v-if="!sessionsData.pending.value" :data="sessionsData.data.value?.data || []"
:labels="sessionsData.data.value?.labels || []" color="#f56523"></AdvancedLineChart>
</div> </div>
</template> </template>

View File

@@ -1,27 +1,37 @@
<script lang="ts" setup> <script lang="ts" setup>
import DateService from '@services/DateService'; import DateService from '@services/DateService';
import type { Slice } from '@services/DateService';
const { data: metricsInfo } = useMetricsData(); const { data: metricsInfo } = useMetricsData();
const { snapshot, safeSnapshotDates } = useSnapshot()
const snapshotFrom = computed(() => new Date(snapshot.value?.from || '0').getTime());
const snapshotTo = computed(() => new Date(snapshot.value?.to || Date.now()).getTime());
const snapshotDays = computed(() => {
return (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
});
const avgVisitDay = computed(() => { const avgVisitDay = computed(() => {
if (!metricsInfo.value) return '0.00'; if (!visitsData.data.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24; const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
const avg = metricsInfo.value.visitsCount / Math.max(days, 1); const avg = counts / Math.max(snapshotDays.value, 1);
return avg.toFixed(2); return avg.toFixed(2);
}); });
const avgEventsDay = computed(() => { const avgEventsDay = computed(() => {
if (!metricsInfo.value) return '0.00'; if (!eventsData.data.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24; const counts = eventsData.data.value.data.reduce((a, e) => e + a, 0);
const avg = metricsInfo.value.eventsCount / Math.max(days, 1); const avg = counts / Math.max(snapshotDays.value, 1);
return avg.toFixed(2); return avg.toFixed(2);
}); });
const avgSessionsDay = computed(() => { const avgSessionsDay = computed(() => {
if (!metricsInfo.value) return '0.00'; if (!sessionsData.data.value) return '0.00';
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24; const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1); const avg = counts / Math.max(snapshotDays.value, 1);
return avg.toFixed(2); return avg.toFixed(2);
}); });
@@ -29,92 +39,105 @@ const avgSessionsDay = computed(() => {
const avgSessionDuration = computed(() => { const avgSessionDuration = computed(() => {
if (!metricsInfo.value) return '0.00'; if (!metricsInfo.value) return '0.00';
const avg = metricsInfo.value.avgSessionDuration; const avg = metricsInfo.value.avgSessionDuration;
let hours = 0; let hours = 0;
let minutes = 0; let minutes = 0;
let seconds = 0; let seconds = 0;
seconds += avg * 60; seconds += avg * 60;
while (seconds > 60) { seconds -= 60; minutes += 1; }
while (seconds > 60) { while (minutes > 60) { minutes -= 60; hours += 1; }
seconds -= 60;
minutes += 1;
}
while (minutes > 60) {
minutes -= 60;
hours += 1;
}
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s` return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
}); });
type Data = {
data: number[],
labels: string[],
trend: number,
ready: boolean
}
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
async function loadData(timelineEndpointName: string, target: Data) {
const response = await useTimeline(timelineEndpointName as any, 'day');
if (!response) return;
target.data = response.map(e => e.count);
target.labels = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, 'day'));
const pool = [...response.map(e => e.count)];
pool.pop();
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
const diffPercent: number = (100 / avg * (response.at(-1)?.count || 0)) - 100;
target.trend = Math.max(Math.min(diffPercent, 99), -99);
target.ready = true;
}
onMounted(async () => {
await loadData('visits', visitsData);
await loadData('events', eventsData);
await loadData('sessions', sessionsData);
await loadData('sessions_duration', sessionsDurationData);
const chartSlice = computed(() => {
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 30) return 'day' as Slice;
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 90) return 'day' as Slice;
return 'month' as Slice;
}); });
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
const pool = [...input.map(e => e.count)];
pool.pop();
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
const diffPercent: number = (100 / avg * (input.at(-1)?.count || 0)) - 100;
const trend = Math.max(Math.min(diffPercent, 99), -99);
return { data, labels, trend }
}
const activeProject = useActiveProject();
function getBody() {
return JSON.stringify({
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: chartSlice.value
});
}
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
lazy: true, immediate: false
});
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
lazy: true, immediate: false
});
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
lazy: true, immediate: false
});
const sessionsDurationData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions_duration`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
lazy: true, immediate: false
});
onMounted(async () => {
visitsData.execute();
eventsData.execute();
sessionsData.execute();
sessionsDurationData.execute();
});
</script> </script>
<template> <template>
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4" v-if="metricsInfo"> <div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits" <DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7"> :avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events" <DashboardCountCard :ready="!eventsData.pending.value" icon="far fa-flag" text="Total custom events"
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'" :value="formatNumberK(eventsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86"> :avg="formatNumberK(avgEventsDay) + '/day'" :trend="eventsData.data.value?.trend"
:data="eventsData.data.value?.data" :labels="eventsData.data.value?.labels" color="#1e9b86">
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'" <DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visits sessions"
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8"> :value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :ready="sessionsDurationData.ready" icon="far fa-timer" text="Avg session time"
:value="avgSessionDuration" :trend="sessionsDurationData.trend" :data="sessionsDurationData.data" <DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Avg session time"
:labels="sessionsDurationData.labels" color="#f56523"> :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
color="#f56523">
</DashboardCountCard> </DashboardCountCard>
</div> </div>

View File

@@ -6,10 +6,12 @@ onMounted(() => startWatching());
onUnmounted(() => stopWatching()); onUnmounted(() => stopWatching());
const { createAlert } = useAlert();
function copyProjectId() { function copyProjectId() {
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP'); if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText((activeProject.value?._id || 0).toString()); navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
alert('Copiato !'); createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
} }
</script> </script>
@@ -20,23 +22,23 @@ function copyProjectId() {
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start"> <div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div> <div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
<div> {{ onlineUsers }} Online users</div> <div class="poppins font-medium text-[1.2rem]"> {{ onlineUsers }} Online users</div>
</div> </div>
<div class="grow"></div> <div class="grow"></div>
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row"> <div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
<div>Project:</div> <div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
<div class="text-text/90"> {{ activeProject?.name || 'Loading...' }} </div> <div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }} </div>
</div> </div>
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start"> <div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
<div>Project id:</div> <div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="text-text/90 text-[.9rem] lg:text-2xl"> <div class="text-lyx-text poppins font-medium text-[1.2rem]">
{{ activeProject?._id || 'Loading...' }} {{ activeProject?._id || 'Loading...' }}
</div> </div>
<div class="flex items-center ml-3"> <div class="flex items-center ml-3">
<i @click="copyProjectId()" class="far fa-copy hover:text-text cursor-pointer text-[1.2rem]"></i> <i @click="copyProjectId()" class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,29 +2,45 @@
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService'; import DateService, { type Slice } from '@services/DateService';
const data = ref<number[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
const props = defineProps<{ slice: Slice }>(); const props = defineProps<{ slice: Slice }>();
async function loadData() { const activeProject = useActiveProject();
const response = await useTimeline('visits', props.slice);
if (!response) return; const { safeSnapshotDates } = useSnapshot()
data.value = response.map(e => e.count);
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice)); function transformResponse(input: { _id: string, count: number }[]) {
ready.value = true; const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
return { data, labels }
} }
const body = computed(() => {
return {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: props.slice
}
});
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
lazy: true, immediate: false
});
onMounted(async () => { onMounted(async () => {
await loadData(); visitsData.execute();
watch(props, async () => { await loadData(); }); });
})
</script> </script>
<template> <template>
<div> <div>
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#5655d7"></AdvancedLineChart> <div v-if="visitsData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<AdvancedLineChart v-if="!visitsData.pending.value" :data="visitsData.data.value?.data || []"
:labels="visitsData.data.value?.labels || []" color="#5655d7">
</AdvancedLineChart>
</div> </div>
</template> </template>

View File

@@ -2,29 +2,57 @@
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites'; import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
const { data: websites, pending, refresh } = useWebsitesData(); const activeProject = useActiveProject();
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value); const { safeSnapshotDates } = useSnapshot()
watch(pending, () => { const isShowMore = ref<boolean>(false);
currentViewData.value = websites.value;
}) const currentWebsite = ref<string>("");
const websitesHeaders = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10'
}
});
const pagesHeaders = computed(() => {
return {
'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to,
Authorization: authorizationHeaderComputed.value,
limit: isShowMore.value === true ? '200' : '10',
'x-website-name': currentWebsite.value
}
});
const websitesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/websites`, {
method: 'POST', headers: websitesHeaders, lazy: true, immediate: false
});
const pagesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/pages`, {
method: 'POST', headers: pagesHeaders, lazy: true, immediate: false
});
const isPagesView = ref<boolean>(false); const isPagesView = ref<boolean>(false);
const isLoading = ref<boolean>(false);
const currentData = computed(() => {
return isPagesView.value ? pagesData : websitesData
})
async function showDetails(website: string) { async function showDetails(website: string) {
if (isPagesView.value == true) return; currentWebsite.value = website;
isLoading.value = true; pagesData.execute();
isPagesView.value = true; isPagesView.value = true;
}
const { data: pagesData, pending } = usePagesData(website, 10); async function showGeneral() {
websitesData.execute();
watch(pending, () => { isPagesView.value = false;
currentViewData.value = pagesData.value;
isLoading.value = false;
})
} }
const router = useRouter(); const router = useRouter();
@@ -33,26 +61,18 @@ function goToView() {
router.push('/dashboard/visits'); router.push('/dashboard/visits');
} }
function setDefaultData() { onMounted(()=>{
currentViewData.value = websites.value; websitesData.execute();
isPagesView.value = false; })
}
async function dataReload() {
await refresh();
setDefaultData();
}
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 h-full"> <div class="flex flex-col gap-2 h-full">
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()" <DashboardBarsCard :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []" @dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'" :loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Websites'"
:sub-label="isPagesView ? 'Page' : 'Website'" :sub-label="isPagesView ? 'Page' : 'Website'"
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'" :desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView"> :interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">

View File

@@ -0,0 +1,116 @@
<script lang="ts" setup>
const { closeDialog } = useCustomDialog();
import { sub, format, isSameDay, type Duration } from 'date-fns'
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const currentColor = ref<string>("#5680F8");
const colorpicker = ref<HTMLInputElement | null>(null);
function showColorPicker() {
colorpicker.value?.click();
}
function onColorChange() {
currentColor.value = colorpicker.value?.value || '#000000';
}
const snapshotName = ref<string>("");
const { updateSnapshots } = useSnapshot();
const { createAlert } = useAlert()
async function confirmSnapshot() {
await $fetch("/api/snapshot/create", {
method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
name: snapshotName.value,
color: currentColor.value,
from: selected.value.start.toISOString(),
to: selected.value.end.toISOString()
})
});
await updateSnapshots();
closeDialog();
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
}
</script>
<template>
<div class="w-full h-full flex flex-col">
<div class="poppins text-center">
Create a snapshot
</div>
<div class="mt-10 flex items-center gap-2">
<div :style="`background-color: ${currentColor};`" @click="showColorPicker"
class="w-6 h-6 rounded-full aspect-[1/1] relative cursor-pointer">
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
</div>
<div class="grow">
<LyxUiInput placeholder="Snapshot name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
</div>
</div>
<div class="mt-4 justify-center flex w-full">
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
<div class="grow"></div>
<div class="flex items-center justify-around gap-4">
<LyxUiButton @click="closeDialog()" type="secondary" class="w-full text-center">
Cancel
</LyxUiButton>
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
:disabled="snapshotName.length == 0">
Confirm
</LyxUiButton>
</div>
</div>
</template>

View File

@@ -26,12 +26,27 @@ async function getMetadataFields() {
currentSearchText.value = ""; currentSearchText.value = "";
} }
const { safeSnapshotDates } = useSnapshot();
async function getMetadataFieldGrouped() { async function getMetadataFieldGrouped() {
if (!selectedMetadataField.value) return; if (!selectedMetadataField.value) return;
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?name=${selectedEventName.value}&field=${selectedMetadataField.value}`, signHeaders());
const queryParams: Record<string, any> = {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
name: selectedEventName.value,
field: selectedMetadataField.value
}
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
metadataFieldGrouped.value = await $fetch<string[]>(`/api/metrics/${activeProject.value?._id.toString()}/events/metadata_field_group?${queryParamsString}`, signHeaders());
} }
const metadataFieldGroupedFiltered = computed(() => { const metadataFieldGroupedFiltered = computed(() => {
if (currentSearchText.value.length == 0) return metadataFieldGrouped.value; if (currentSearchText.value.length == 0) return metadataFieldGrouped.value;
return metadataFieldGrouped.value.filter(e => { return metadataFieldGrouped.value.filter(e => {

View File

@@ -1,17 +1,33 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Slice } from '@services/DateService';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
const datasets = ref<any[]>([]);
const labels = ref<string[]>([]);
const ready = ref<boolean>(false);
const props = defineProps<{ slice: SliceName }>(); const props = defineProps<{ slice: Slice }>();
const slice = computed(() => props.slice);
async function loadData() { const activeProject = useActiveProject();
const response = await useTimelineDataRaw('events_stacked', props.slice); const { safeSnapshotDates } = useSnapshot()
if (!response) return;
const fixed = fixMetrics(response, props.slice, { advanced: true, advancedGroupKey: 'name' }); const body = computed(() => {
return {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
slice: slice.value,
}
});
function transformResponse(input: { _id: string, name: string, count: number }[]) {
const fixed = fixMetrics({
data: input,
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to
}, slice.value, {
advanced: true,
advancedGroupKey: 'name'
});
const parsedDatasets: any[] = []; const parsedDatasets: any[] = [];
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9']; const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
@@ -28,25 +44,32 @@ async function loadData() {
if (!target) return; if (!target) return;
line.data.push(target.value); line.data.push(target.value);
}); });
} }
datasets.value = parsedDatasets; return {
labels.value = fixed.labels; datasets: parsedDatasets,
ready.value = true; labels: fixed.labels
}
} }
const eventsStackedData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events_stacked`, {
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse, ...signHeaders()
});
onMounted(async () => { onMounted(async () => {
await loadData(); eventsStackedData.execute();
watch(props, async () => { await loadData(); }); });
})
</script> </script>
<template> <template>
<div> <div>
<AdvancedStackedBarChart v-if="ready" :datasets="datasets" :labels="labels"> <div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value" :datasets="eventsStackedData.data.value?.datasets || []"
:labels="eventsStackedData.data.value?.labels || []">
</AdvancedStackedBarChart> </AdvancedStackedBarChart>
</div> </div>
</template> </template>

View File

@@ -12,13 +12,28 @@ onMounted(async () => {
const userFlowData = ref<any>(); const userFlowData = ref<any>();
const analyzing = ref<boolean>(false); const analyzing = ref<boolean>(false);
async function analyzeEvent() { const { safeSnapshotDates } = useSnapshot();
async function getUserFlowData() {
userFlowData.value = undefined; userFlowData.value = undefined;
analyzing.value = true; analyzing.value = true;
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?name=${selectedEventName.value}`, signHeaders());
const queryParams: Record<string, any> = {
from: safeSnapshotDates.value.from,
to: safeSnapshotDates.value.to,
name: selectedEventName.value
}
const queryParamsString = Object.keys(queryParams).map((key) => `${key}=${queryParams[key]}`).join('&');
userFlowData.value = await $fetch(`/api/metrics/${activeProject.value?._id.toString()}/events/flow_from_name?${queryParamsString}`, signHeaders());
analyzing.value = false; analyzing.value = false;
} }
async function analyzeEvent() {
getUserFlowData();
}
</script> </script>
<template> <template>
@@ -41,13 +56,15 @@ async function analyzeEvent() {
</div> </div>
<div class="flex flex-col gap-2" v-if="userFlowData"> <div class="flex flex-col gap-2" v-if="userFlowData">
<div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg" v-for="(count, referrer) in userFlowData"> <div class="flex gap-4 items-center bg-bg py-1 px-2 rounded-lg"
v-for="(count, referrer) in userFlowData">
<div class="w-5 h-5 flex items-center justify-center"> <div class="w-5 h-5 flex items-center justify-center">
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`" :alt="'referrer'"> <img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
:alt="'referrer'">
</div> </div>
<div> {{ referrer }} </div> <div> {{ referrer }} </div>
<div class="grow"></div> <div class="grow"></div>
<div> {{ count }} </div> <div> {{ count.toFixed(2).replace('.', ',') }} % </div>
</div> </div>
</div> </div>

View File

@@ -1,18 +0,0 @@
<script lang="ts" setup>
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'danger';
const props = defineProps<{ type: ButtonType, }>();
</script>
<template>
<div class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-text" :class="{
'bg-lyx-primary-dark outline-lyx-primary hover:bg-lyx-primary-hover': type === 'primary',
'bg-lyx-widget-lighter outline-lyx-widget-lighter hover:bg-lyx-widget-light': type === 'secondary',
'bg-lyx-transparent outline-lyx-widget-lighter hover:bg-lyx-widget-light': type === 'outline',
'bg-lyx-danger-dark outline-lyx-danger hover:bg-lyx-danger': type === 'danger',
}">
<slot></slot>
</div>
</template>

View File

@@ -1,10 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<div class="w-fit h-fit rounded-md bg-lyx-widget p-4 outline outline-[1px] outline-lyx-background-lighter">
<slot></slot>
</div>
</template>

View File

@@ -1,11 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{ icon: string }>();
</script>
<template>
<span class="material-symbols-outlined">
{{ props.icon }}
</span>
</template>

View File

@@ -1,26 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<LyxUiCard>
<div class="flex items-center">
<div class="grow">
PROJECT_NAME
</div>
<div>
Active
</div>
<LyxUiIcon icon="drag_indicator"></LyxUiIcon>
</div>
<div class="flex items-center">
<LyxUiButton type="primary">
CURRENT_SUBSCRIPTION
</LyxUiButton>
<div class="poppins font-light text-lyx-text-dark">
next billing: NEXT_BILLING_DATE
</div>
</div>
</LyxUiCard>
</template>

View File

@@ -2,30 +2,30 @@
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService'; import DateService, { type Slice } from '@services/DateService';
const data = ref<number[]>([]); // const data = ref<number[]>([]);
const labels = ref<string[]>([]); // const labels = ref<string[]>([]);
const ready = ref<boolean>(false); // const ready = ref<boolean>(false);
const props = defineProps<{ slice: Slice, referrer: string }>(); // const props = defineProps<{ slice: Slice, referrer: string }>();
async function loadData() { // async function loadData() {
const response = await useReferrersTimeline(props.referrer, props.slice); // const response = await useReferrersTimeline(props.referrer, props.slice);
if (!response) return; // if (!response) return;
data.value = response.map(e => e.count); // data.value = response.map(e => e.count);
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice)); // labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
ready.value = true; // ready.value = true;
} // }
onMounted(async () => { // onMounted(async () => {
await loadData(); // await loadData();
watch(props, async () => { await loadData(); }); // watch(props, async () => { await loadData(); });
}) // })
</script> </script>
<template> <template>
<div> <div>
<AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8"> <!-- <AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
</AdvancedBarChart> </AdvancedBarChart> -->
</div> </div>
</template> </template>

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
]
const { setToken } = useAccessToken();
async function deleteAccount() {
const sure = confirm("Are you sure you want to delete this account ?");
if (!sure) return;
await $fetch("/api/user/delete_account", {
...signHeaders(),
method: "DELETE"
})
setToken('');
location.href = "/login"
}
</script>
<template>
<SettingsTemplate :entries="entries">
<template #delete>
<div
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]">
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
<div @click="deleteAccount()"
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]">
Delete account
</div>
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,101 @@
<script lang="ts" setup>
import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [
{ id: 'pname', title: 'Name', text: 'Project name' },
{ id: 'pid', title: 'Id', text: 'Project id' },
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
]
const activeProject = useActiveProject();
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
watch(activeProject, () => {
projectNameInputVal.value = activeProject.value?.name || "";
})
const canChange = computed(() => {
if (activeProject.value?.name == projectNameInputVal.value) return false;
if (projectNameInputVal.value.length === 0) return false;
return true;
});
async function changeProjectName() {
await $fetch("/api/project/change_name", {
method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: projectNameInputVal.value })
});
location.reload();
}
async function deleteProject() {
if (!activeProject.value) return;
const sure = confirm(`Are you sure to delete the project ${activeProject.value.name} ?`);
if (!sure) return;
try {
await $fetch('/api/project/delete', {
method: 'DELETE',
...signHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ project_id: activeProject.value._id.toString() })
});
const projectsList = useProjectsList()
await projectsList.refresh();
const firstProjectId = projectsList.data.value?.[0]?._id.toString();
if (firstProjectId) {
await setActiveProject(firstProjectId);
}
} catch (ex: any) {
alert(ex.message);
}
}
</script>
<template>
<SettingsTemplate :entries="entries" :key="activeProject?.name || 'NONE'">
<template #pname>
<div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton>
</div>
</template>
<template #pid>
<LyxUiCard class="w-full flex items-center">
<div class="grow">{{ activeProject?._id.toString() }}</div>
<div><i class="far fa-copy"></i></div>
</LyxUiCard>
</template>
<template #pscript>
<LyxUiCard class="w-full flex items-center">
<div class="grow">
{{ `
<script defer data-project="${activeProject?._id}"
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
</div>
<div><i class="far fa-copy"></i></div>
</LyxUiCard>
</template>
<template #pdelete>
<div class="flex justify-end">
<LyxUiButton type="danger" @click="deleteProject()">
Delete project
</LyxUiButton>
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,37 @@
<script lang="ts" setup>
export type SettingsTemplateEntry = {
title: string,
text: string,
id: string
}
type SettingsTemplateProp = {
entries: SettingsTemplateEntry[]
}
const props = defineProps<SettingsTemplateProp>();
</script>
<template>
<div class="mt-10 px-4">
<div v-for="(entry, index) of props.entries" class="flex flex-col">
<div class="flex">
<div class="flex-[2]">
<div class="poppins font-medium text-lyx-text">
{{ entry.title }}
</div>
<div class="poppins font-regular text-lyx-text-dark">
{{ entry.text }}
</div>
</div>
<div class="flex-[3]">
<slot :name="entry.id"></slot>
</div>
</div>
<div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-widget-lighter w-full my-10"></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,231 @@
<script lang="ts" setup>
import dayjs from 'dayjs';
import type { SettingsTemplateEntry } from './Template.vue';
const activeProject = useActiveProject();
definePageMeta({ layout: 'dashboard' });
const { data: planData, refresh: planRefresh, pending: planPending } = useFetch('/api/project/plan', {
...signHeaders(),
lazy: true
});
const percent = computed(() => {
if (!planData.value) return '-';
return (100 / planData.value.limit * planData.value.count).toFixed(2) + '%';
});
const color = computed(() => {
if (!planData.value) return 'blue';
if (planData.value.count >= planData.value.limit) return 'red';
return 'blue';
});
const daysLeft = computed(() => {
if (!planData.value) return '-';
return (-dayjs().diff(planData.value.billing_expire_at, 'days')).toString();
});
const leftPercent = computed(() => {
if (!planData.value) return 0;
const left = dayjs().diff(planData.value.billing_expire_at, 'days');
const total = dayjs(planData.value.billing_start_at).diff(planData.value.billing_expire_at, 'days');
const percent = 100 - (100 / total * left);
return percent;
});
const prettyExpireDate = computed(() => {
if (!planData.value) return '';
return dayjs(planData.value.billing_expire_at).format('DD/MM/YYYY');
});
const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = useFetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, {
...signHeaders(),
lazy: true
})
const showPricingDrawer = ref<boolean>(false);
function onPlanUpgradeClick() {
showPricingDrawer.value = true;
}
function openInvoice(link: string) {
window.open(link, '_blank');
}
function getPremiumName(type: number) {
if (type === 0) return 'FREE';
if (type === 1) return 'ACCELERATION';
if (type === 2) return 'EXPANSION';
return 'CUSTOM';
}
watch(activeProject, () => {
invoicesRefresh();
planRefresh();
})
const entries: SettingsTemplateEntry[] = [
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
]
</script>
<template>
<div class="relative">
<Transition name="pdrawer">
<PricingDrawer @onCloseClick="showPricingDrawer = false" :currentSub="planData?.premium_type || 0"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]"
v-if=showPricingDrawer>
</PricingDrawer>
</Transition>
<div v-if="invoicesPending || planPending"
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<SettingsTemplate v-if="!invoicesPending && !planPending" :entries="entries">
<template #plan>
<LyxUiCard v-if="planData" class="flex flex-col w-full">
<div class="flex flex-col gap-6 px-8 grow">
<div class="flex justify-between flex-col sm:flex-row">
<div class="flex flex-col">
<div class="flex gap-3 items-center">
<div class="poppins font-semibold text-[1.1rem]">
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
</div>
<div
class="flex lato text-[.7rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
</div>
</div>
<div class="poppins text-text-sub text-[.9rem]">
Our free plan for testing the product.
</div>
</div>
<div class="flex items-center gap-1">
<div class="poppins font-semibold text-[2rem]"> $0 </div>
<div class="poppins text-text-sub mt-2"> per month </div>
</div>
</div>
<div class="flex flex-col">
<div class="poppins"> Billing period:</div>
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
<div class="grow w-full md:w-auto">
<UProgress color="green" :min="0" :max="100" :value="leftPercent"></UProgress>
</div>
<div class="poppins"> {{ daysLeft }} days left </div>
</div>
<div class="flex justify-center">
Subscription: {{ planData.subscription_status }}
</div>
</div>
</div>
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
</div>
<div class="flex justify-between px-8 flex-col sm:flex-row">
<div class="flex gap-2 text-text-sub text-[.9rem]">
<div class="poppins"> Expire date:</div>
<div> {{ prettyExpireDate }}</div>
</div>
<div v-if="!isGuest" @click="onPlanUpgradeClick()"
class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]">
<div class="poppins"> Upgrade plan </div>
<i class="fas fa-arrow-up-right"></i>
</div>
</div>
</LyxUiCard>
</template>
<template #usage>
<LyxUiCard v-if="planData" class="flex flex-col w-full">
<div class="flex flex-col gap-6 px-8">
<div class="flex justify-between">
<div class="flex flex-col">
<div class="poppins font-semibold text-[1.1rem]">
Usage
</div>
<div class="poppins text-text-sub text-[.9rem]">
Check the usage limits of your project.
</div>
</div>
</div>
<div class="flex flex-col">
<div class="poppins"> Usage:</div>
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
<div class="grow w-full md:w-auto">
<UProgress :color="color" :min="0" :max="planData.limit" :value="planData.count">
</UProgress>
</div>
<div class="poppins"> {{ percent }}</div>
</div>
<div class="flex justify-center">
{{ formatNumberK(planData.count) }} / {{ formatNumberK(planData.limit) }}
</div>
</div>
</div>
</LyxUiCard>
</template>
<template #invoices>
<CardTitled v-if="!isGuest" title="Invoices"
:sub="(invoices && invoices.length == 0) ? 'No invoices yet' : ''" class="p-4 mt-8 w-full">
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg"
v-for="invoice of invoices">
<div> <i class="fal fa-file-invoice"></i> </div>
<div class="flex flex-col md:flex-row md:justify-around md:grow items-center gap-2">
<div> {{ new Date(invoice.date).toLocaleString() }} </div>
<div> {{ invoice.cost / 100 }} </div>
<div> {{ invoice.id }} </div>
<div
class="flex items-center lato text-[.8rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
{{ invoice.status }}
</div>
</div>
<div>
<i @click="openInvoice(invoice.link)"
class="far fa-download cursor-pointer hover:text-white/80"></i>
</div>
</div>
</div>
</CardTitled>
</template>
</SettingsTemplate>
</div>
</template>
<style scoped lang="scss">
.pdrawer-enter-active,
.pdrawer-leave-active {
transition: all .5s ease-in-out;
}
.pdrawer-enter-from,
.pdrawer-leave-to {
transform: translateX(100%)
}
.pdrawer-enter-to,
.pdrawer-leave-from {
transform: translateX(0)
}
</style>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SettingsTemplateEntry } from './Template.vue';
const activeProject = useActiveProject();
definePageMeta({ layout: 'dashboard' }); definePageMeta({ layout: 'dashboard' });
const activeProject = useActiveProject();
const columns = [ const columns = [
{ key: 'me', label: '' }, { key: 'me', label: '' },
@@ -13,7 +15,7 @@ const columns = [
// { key: 'pending', label: 'Pending' }, // { key: 'pending', label: 'Pending' },
] ]
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', signHeaders()); const { data: members, refresh: refreshMembers, pending: pendingMembers } = useFetch('/api/project/members/list', signHeaders());
const showAddMember = ref<boolean>(false); const showAddMember = ref<boolean>(false);
@@ -67,32 +69,35 @@ async function addMember() {
} catch (ex: any) { } } catch (ex: any) { }
} }
watch(activeProject, () => {
refreshMembers();
})
const entries: SettingsTemplateEntry[] = [
{ id: 'add', title: 'Add member', text: 'Add new member to project' },
{ id: 'members', title: 'Members', text: 'Manage members of current project' },
]
</script> </script>
<template> <template>
<div class="home w-full h-full px-10 lg:px-6 overflow-y-auto pb-[12rem] md:pb-0 py-2"> <SettingsTemplate :entries="entries">
<template #add>
<div v-if="!isGuest" class="flex flex-col">
<div class="flex flex-col gap-4"> <div class="flex gap-4 items-center">
<LyxUiInput class="px-4 py-1 w-full" placeholder="User email" v-model="addMemberEmail"></LyxUiInput>
<div v-if="!isGuest" @click="showAddMember = !showAddMember;" <LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
class="flex items-center gap-2 bg-menu w-fit px-3 py-2 rounded-lg hover:bg-menu/80 cursor-pointer"> </div>
<i class="fas fa-plus"></i> <div class="poppins text-[.8rem] mt-2 text-lyx-text-darker">
<div> Add member </div> User should have been registered to Litlyx
</div>
<div v-if="showAddMember" class="flex gap-4 items-center">
<input v-model="addMemberEmail" class="focus:outline-none bg-menu px-4 py-1 rounded-lg" type="text"
placeholder="user email">
<div @click="addMember" class="bg-menu w-fit py-1 px-4 rounded-lg hover:bg-menu/80 cursor-pointer">
Add
</div> </div>
</div> </div>
</template>
<template #members>
<UTable :rows="members || []" :columns="columns"> <UTable :rows="members || []" :columns="columns">
<template #me-data="e"> <template #me-data="e">
@@ -108,9 +113,7 @@ async function addMember() {
</template> </template>
</UTable> </UTable>
</template>
</div> </SettingsTemplate>
</div>
</template> </template>

View File

@@ -49,6 +49,11 @@ export function useActiveProject() {
export async function setActiveProject(project_id: string) { export async function setActiveProject(project_id: string) {
changingProject.value = true;
await new Promise(e => setTimeout(e, 500));
await $fetch<string>(`/api/user/set_active_project?project_id=${project_id}`, signHeaders()); await $fetch<string>(`/api/user/set_active_project?project_id=${project_id}`, signHeaders());
await activeProjectId.refresh(); await activeProjectId.refresh();
changingProject.value = false;
} }
export const changingProject = ref<boolean>(false);

View File

@@ -9,6 +9,11 @@ export function signHeaders(headers?: Record<string, string>) {
return { headers: { ...(headers || {}), 'Authorization': 'Bearer ' + token.value } } return { headers: { ...(headers || {}), 'Authorization': 'Bearer ' + token.value } }
} }
export const authorizationHeaderComputed = computed(() => {
const { token } = useAccessToken()
return token.value ? 'Bearer ' + token.value : '';
});
export function useAccessToken() { export function useAccessToken() {
const tokenCookie = useCookie(ACCESS_TOKEN_COOKIE_KEY, { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) }) const tokenCookie = useCookie(ACCESS_TOKEN_COOKIE_KEY, { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) })

View File

@@ -0,0 +1,43 @@
export type Alert = {
title: string,
text: string,
icon: string,
ms: number,
id: number,
remaining: number,
transitionStyle: string
}
const alerts = ref<Alert[]>([]);
const idPool = {
id: 0,
getId() {
return idPool.id++;
}
}
function createAlert(title: string, text: string, icon: string, ms: number) {
const alert = reactive<Alert>({
title, text, icon, ms, id: idPool.getId(), remaining: ms,
transitionStyle: 'transition: all 250ms linear;'
});
alerts.value.push(alert);
const timeout = setInterval(() => {
alert.remaining -= 250;
if (alert.remaining <= 0) {
closeAlert(alert.id);
clearInterval(timeout);
}
}, 250)
}
function closeAlert(id: number) {
alerts.value = alerts.value.filter(e => e.id != id);
}
export function useAlert() {
return { alerts, createAlert, closeAlert }
}

View File

@@ -4,17 +4,42 @@ import type { Component } from "vue";
const showDialog = ref<boolean>(false); const showDialog = ref<boolean>(false);
const dialogParams = ref<any>({}); const dialogParams = ref<any>({});
const dialogComponent = ref<Component>(); const dialogComponent = ref<Component>();
const dialogWidth = ref<string>("100%");
const dialogHeight = ref<string>("100%");
const dialogClosable = ref<boolean>(true);
function closeDialog() { function closeDialog() {
showDialog.value = false; showDialog.value = false;
} }
export type CustomDialogOptions = {
params?: any,
width?: string,
height?: string,
closable?: boolean
}
function openDialogEx(component: Component, options?: CustomDialogOptions) {
dialogComponent.value = component;
dialogParams.value = options?.params || {};
showDialog.value = true;
dialogWidth.value = options?.width || '100%';
dialogHeight.value = options?.height || '100%';
dialogClosable.value = options?.closable ?? true;
}
function openDialog(component: Component, params: any) { function openDialog(component: Component, params: any) {
dialogComponent.value = component; dialogComponent.value = component;
dialogParams.value = params; dialogParams.value = params;
showDialog.value = true; showDialog.value = true;
dialogWidth.value = '100%';
dialogHeight.value = '100%';
} }
const dialogStyle = computed(() => {
return `width: ${dialogWidth.value}; height: ${dialogHeight.value}`;
});
export function useCustomDialog() { export function useCustomDialog() {
return { showDialog, closeDialog, openDialog, dialogParams, dialogComponent }; return { showDialog, openDialogEx, closeDialog, openDialog, dialogParams, dialogComponent, dialogStyle, dialogClosable };
} }

View File

@@ -0,0 +1,80 @@
import type { InternalApi } from 'nitropack';
import type { WatchSource, WatchStopHandle } from 'vue';
type NitroFetchRequest = Exclude<keyof InternalApi, `/_${string}` | `/api/_${string}`> | (string & {});
export type CustomFetchOptions = {
watchProps?: WatchSource[],
lazy?: boolean,
method?: string,
getBody?: () => Record<string, any>,
watchKey?: string
}
type OnResponseCallback<TData> = (data: Ref<TData | undefined>) => any
type OnRequestCallback = () => any
const watchStopHandles: Record<string, WatchStopHandle> = {}
export function useCustomFetch<T>(url: NitroFetchRequest, getHeaders: () => Record<string, string>, options?: CustomFetchOptions) {
const pending = ref<boolean>(false);
const data = ref<T | undefined>();
const error = ref<Error | undefined>();
let onResponseCallback: OnResponseCallback<T> = () => { }
let onRequestCallback: OnRequestCallback = () => { }
const onResponse = (callback: OnResponseCallback<T>) => {
onResponseCallback = callback;
}
const onRequest = (callback: OnRequestCallback) => {
onRequestCallback = callback;
}
const execute = async () => {
onRequestCallback();
pending.value = true;
error.value = undefined;
try {
data.value = await $fetch<T>(url, {
headers: getHeaders(),
method: (options?.method || 'GET') as any,
body: options?.getBody ? JSON.stringify(options.getBody()) : undefined
});
onResponseCallback(data);
} catch (err) {
error.value = err as Error;
} finally {
pending.value = false;
}
}
if (options?.lazy !== true) {
execute();
}
if (options?.watchProps) {
const watchStop = watch(options.watchProps, () => {
execute();
});
const key = options?.watchKey || `${url}`;
if (watchStopHandles[key]) watchStopHandles[key]();
watchStopHandles[key] = watchStop;
console.log('Watchers:', Object.keys(watchStopHandles).length);
}
const refresh = execute;
return { pending, execute, data, error, refresh, onResponse, onRequest };
}

View File

@@ -1,6 +1,12 @@
import type { Slice } from "@services/DateService"; import type { Slice } from "@services/DateService";
import DateService from "@services/DateService"; import DateService from "@services/DateService";
import type { MetricsCounts } from "~/server/api/metrics/[project_id]/counts"; import type { MetricsCounts } from "~/server/api/metrics/[project_id]/counts";
import type { BrowsersAggregated } from "~/server/api/metrics/[project_id]/data/browsers";
import type { CountriesAggregated } from "~/server/api/metrics/[project_id]/data/countries";
import type { DevicesAggregated } from "~/server/api/metrics/[project_id]/data/devices";
import type { CustomEventsAggregated } from "~/server/api/metrics/[project_id]/data/events";
import type { OssAggregated } from "~/server/api/metrics/[project_id]/data/oss";
import type { ReferrersAggregated } from "~/server/api/metrics/[project_id]/data/referrers";
import type { VisitsWebsiteAggregated } from "~/server/api/metrics/[project_id]/data/websites"; import type { VisitsWebsiteAggregated } from "~/server/api/metrics/[project_id]/data/websites";
import type { MetricsTimeline } from "~/server/api/metrics/[project_id]/timeline/generic"; import type { MetricsTimeline } from "~/server/api/metrics/[project_id]/timeline/generic";
@@ -13,86 +19,151 @@ export function useMetricsData() {
return metricsInfo; return metricsInfo;
} }
export function useFirstInteractionData() {
const activeProject = useActiveProject();
const metricsInfo = useFetch<boolean>(`/api/metrics/${activeProject.value?._id}/first_interaction`, signHeaders());
return metricsInfo;
}
export async function useTimelineAdvanced(endpoint: string, slice: Slice, fromDate?: string, toDate?: string, customBody: Object = {}) {
const { from, to } = DateService.prepareDateRange(
fromDate || DateService.getDefaultRange(slice).from,
toDate || DateService.getDefaultRange(slice).to,
slice
);
const activeProject = useActiveProject();
const response = await $fetch(
`/api/metrics/${activeProject.value?._id}/timeline/${endpoint}`, {
method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ slice, from, to, ...customBody })
});
return response as { _id: string, count: number }[];
}
export async function useTimeline(endpoint: 'visits' | 'sessions' | 'referrers', slice: Slice, fromDate?: string, toDate?: string) {
return await useTimelineAdvanced(endpoint, slice, fromDate, toDate, {});
}
export async function useReferrersTimeline(referrer: string, slice: Slice, fromDate?: string, toDate?: string) {
return await useTimelineAdvanced('referrers', slice, fromDate, toDate, { referrer });
}
export async function useTimelineDataRaw(timelineEndpointName: string, slice: SliceName) { // const { safeSnapshotDates, snapshot } = useSnapshot()
const activeProject = useActiveProject(); // const activeProject = useActiveProject();
const response = await $fetch<{ data: MetricsTimeline[], from: string, to: string }>( // const createFromToHeaders = (headers: Record<string, string> = {}) => ({
`/api/metrics/${activeProject.value?._id}/timeline/${timelineEndpointName}`, { // 'x-from': safeSnapshotDates.value.from,
method: 'POST', // 'x-to': safeSnapshotDates.value.to,
...signHeaders({ 'Content-Type': 'application/json' }), // ...headers
body: JSON.stringify({ slice }), // });
});
return response; // const createFromToBody = (body: Record<string, any> = {}) => ({
} // from: safeSnapshotDates.value.from,
// to: safeSnapshotDates.value.to,
// ...body
// });
export async function useTimelineData(timelineEndpointName: string, slice: SliceName) {
const response = await useTimelineDataRaw(timelineEndpointName, slice);
if (!response) return;
const fixed = fixMetrics(response, slice);
return fixed;
}
export function usePagesData(website: string, limit: number = 10) {
const activeProject = useActiveProject();
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/pages`, { // export function useFirstInteractionData() {
...signHeaders({ // const activeProject = useActiveProject();
'x-query-limit': limit.toString(), // const metricsInfo = useFetch<boolean>(`/api/metrics/${activeProject.value?._id}/first_interaction`, signHeaders());
'x-website-name': website // return metricsInfo;
}), // }
key: `pages_data:${website}:${limit}`,
lazy: true
});
return res;
} // export function useTimelineAdvanced<T>(endpoint: string, slice: Ref<Slice>, customBody: Object = {}) {
// const response = useCustomFetch<T>(
// `/api/metrics/${activeProject.value?._id}/timeline/${endpoint}`,
// () => signHeaders({ 'Content-Type': 'application/json' }).headers, {
// method: 'POST',
// getBody: () => createFromToBody({ slice: slice.value, ...customBody }),
// lazy: true,
// watchProps: [snapshot, slice]
// });
// return response;
// }
export function useWebsitesData(limit: number = 10) {
const activeProject = useActiveProject(); // export function useTimeline(endpoint: 'visits' | 'sessions' | 'referrers' | 'events_stacked', slice: Ref<Slice>) {
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`, { // return useTimelineAdvanced<{ _id: string, count: number }[]>(endpoint, slice);
...signHeaders({ 'x-query-limit': limit.toString() }), // }
key: `websites_data:${limit}`,
lazy: true // export async function useReferrersTimeline(referrer: string, slice: Ref<Slice>) {
}); // return await useTimelineAdvanced<{ _id: string, count: number }[]>('referrers', slice, { referrer });
return res; // }
}
// export function useEventsStackedTimeline(slice: Ref<Slice>) {
// return useTimelineAdvanced<{ _id: string, name: string, count: number }[]>('events_stacked', slice);
// }
// export async function useTimelineDataRaw(timelineEndpointName: string, slice: SliceName) {
// const activeProject = useActiveProject();
// const response = await $fetch<{ data: MetricsTimeline[], from: string, to: string }>(
// `/api/metrics/${activeProject.value?._id}/timeline/${timelineEndpointName}`, {
// method: 'POST',
// ...signHeaders({ 'Content-Type': 'application/json' }),
// body: JSON.stringify({ slice }),
// });
// return response;
// }
// export async function useTimelineData(timelineEndpointName: string, slice: SliceName) {
// const response = await useTimelineDataRaw(timelineEndpointName, slice);
// if (!response) return;
// const fixed = fixMetrics(response, slice);
// return fixed;
// }
// export function usePagesData(website: string, limit: number = 10) {
// const activeProject = useActiveProject();
// const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/pages`, {
// ...signHeaders({
// 'x-query-limit': limit.toString(),
// 'x-website-name': website
// }),
// key: `pages_data:${website}:${limit}`,
// lazy: true
// });
// return res;
// }
// export function useWebsitesData(limit: number = 10) {
// const res = useCustomFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`,
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
// { lazy: false, watchProps: [snapshot] }
// );
// return res;
// }
// export function useEventsData(limit: number = 10) {
// const res = useCustomFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/events`,
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
// { lazy: false, watchProps: [snapshot] }
// );
// return res;
// }
// export function useReferrersData(limit: number = 10) {
// const res = useCustomFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`,
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
// { lazy: false, watchProps: [snapshot] }
// );
// return res;
// }
// export function useBrowsersData(limit: number = 10) {
// const res = useCustomFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`,
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
// { lazy: false, watchProps: [snapshot] }
// );
// return res;
// }
// export function useOssData(limit: number = 10) {
// const res = useCustomFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`,
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
// { lazy: false, watchProps: [snapshot] }
// );
// return res;
// }
// export function useGeolocationData(limit: number = 10) {
// const res = useCustomFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`,
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
// { lazy: false, watchProps: [snapshot] }
// );
// return res;
// }
// export function useDevicesData(limit: number = 10) {
// const res = useCustomFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`,
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
// { lazy: false, watchProps: [snapshot] }
// );
// return res;
// }

View File

@@ -0,0 +1,76 @@
import type { TProjectSnapshot } from "@schema/ProjectSnapshot";
const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
...signHeaders(),
immediate: false
});
const activeProject = useActiveProject();
watch(activeProject, async () => {
await remoteSnapshots.refresh();
snapshot.value = isLiveDemo() ? snapshots.value[0] : snapshots.value[1];
});
const snapshots = computed(() => {
const activeProject = useActiveProject();
const getDefaultSnapshots: () => TProjectSnapshot[] = () => [
{
project_id: activeProject.value?._id as any,
_id: 'default0' as any,
name: 'All',
from: new Date(activeProject.value?.created_at || 0),
to: new Date(Date.now()),
color: '#CCCCCC'
},
{
project_id: activeProject.value?._id as any,
_id: 'default1' as any,
name: 'Last month',
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
to: new Date(Date.now()),
color: '#00CC00'
},
{
project_id: activeProject.value?._id as any,
_id: 'default2' as any,
name: 'Last week',
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
to: new Date(Date.now()),
color: '#0F02D2'
},
{
project_id: activeProject.value?._id as any,
_id: 'default3' as any,
name: 'Last day',
from: new Date(Date.now() - 1000 * 60 * 60 * 24),
to: new Date(Date.now()),
color: '#CC11CC'
}
]
return [
...getDefaultSnapshots(),
...(remoteSnapshots.data.value || [])
];
})
const snapshot = ref<TProjectSnapshot>(isLiveDemo() ? snapshots.value[0] : snapshots.value[1]);
const safeSnapshotDates = computed(() => {
const from = new Date(snapshot.value?.from || 0).toISOString();
const to = new Date(snapshot.value?.to || Date.now()).toISOString();
return { from, to }
})
async function updateSnapshots() {
await remoteSnapshots.refresh();
}
export function useSnapshot() {
if (remoteSnapshots.status.value === 'idle') {
remoteSnapshots.execute();
}
return { snapshot, snapshots, safeSnapshotDates, updateSnapshots }
}

View File

@@ -2,60 +2,21 @@
import type { Section } from '~/components/CVerticalNavigation.vue'; import type { Section } from '~/components/CVerticalNavigation.vue';
const router = useRouter();
const { setToken } = useAccessToken();
import { Lit } from 'litlyx-js'; import { Lit } from 'litlyx-js';
const sections: Section[] = [ const sections: Section[] = [
{
title: 'General',
entries: [
{ label: 'Projects', icon: 'far fa-table-layout', to: '/project_selector' },
{ label: 'Members', icon: 'far fa-users', to: '/members' },
{ label: 'Admin', icon: 'fas fa-cat', adminOnly: true, to: '/admin' },
]
},
{ {
title: 'Project', title: 'Project',
entries: [ entries: [
{ label: 'Dashboard', to: '/', icon: 'far fa-home' }, { label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
{ label: 'Events', to: '/events', icon: 'far fa-bolt' }, { label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
{ label: 'Analyst', to: '/analyst', icon: 'far fa-microchip-ai' }, { label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' },
{ label: 'Report', to: '/report', icon: 'far fa-notes' }, { label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
// { label: 'AI', to: '/dashboard/settings', icon: 'far fa-robot brightness-[.4]' },
// { label: 'Visits', to: '/dashboard/visits', icon: 'far fa-eye' },
// { label: 'Events', to: '/dashboard/events', icon: 'far fa-line-chart' },
]
},
{
title: 'Non si vede',
entries: [
{ {
label: 'Docs', to: 'https://docs.litlyx.com', icon: 'far fa-book-open', external: true, label: 'Docs', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
action() { Lit.event('docs_clicked') }, action() { Lit.event('docs_clicked') },
}, },
{ { label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
label: 'Github', to: 'https://github.com/litlyx/litlyx', icon: 'fab fa-github', external: true,
action() { Lit.event('git_clicked') },
},
{ label: 'Billing', to: '/plans', icon: 'far fa-wallet' },
{ label: 'Book a demo', to: '/book_demo', icon: 'far fa-calendar' },
]
},
{
title: 'Actions',
entries: [
{
label: 'Logout',
icon: 'far fa-arrow-right-from-bracket',
action: () => {
console.log('LOGOUT')
setToken('');
setLoggedUser(undefined);
router.push('/login');
}
},
] ]
} }
]; ];
@@ -92,7 +53,7 @@ const { isOpen, close, open } = useMenu();
</CVerticalNavigation> </CVerticalNavigation>
<div class="overflow-hidden w-full bg-bg relative h-full"> <div class="overflow-hidden w-full bg-lyx-background-light relative h-full">
<div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]"> <div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]">
<i <i

View File

@@ -64,4 +64,6 @@ export default defineNuxtConfig({
devServer: { devServer: {
host: '0.0.0.0', host: '0.0.0.0',
}, },
components: true,
extends: ['../lyx-ui']
}) })

View File

@@ -10,11 +10,12 @@
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"test": "vitest", "test": "vitest",
"docker-build": "docker build -t litlyx-dashboard -f Dockerfile ../", "docker-build": "docker build -t litlyx-dashboard -f Dockerfile ../",
"docker-inspect": "docker run -it litlyx-dashboard sh" "docker-inspect": "docker run -it litlyx-dashboard sh"
}, },
"dependencies": { "dependencies": {
"@nuxtjs/tailwindcss": "^6.12.0", "@nuxtjs/tailwindcss": "^6.12.0",
"chart.js": "^3.9.1", "chart.js": "^3.9.1",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"google-auth-library": "^9.9.0", "google-auth-library": "^9.9.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -29,6 +30,7 @@
"redis": "^4.6.13", "redis": "^4.6.13",
"sass": "^1.75.0", "sass": "^1.75.0",
"stripe": "^15.8.0", "stripe": "^15.8.0",
"v-calendar": "^3.1.2",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-chart-3": "^3.1.8", "vue-chart-3": "^3.1.8",
"vue-router": "^4.3.0" "vue-router": "^4.3.0"

View File

@@ -22,6 +22,7 @@ type TProjectsGrouped = {
project_name: string, project_name: string,
total_visits: number, total_visits: number,
total_events: number, total_events: number,
total_sessions: number
}[] }[]
} }
@@ -47,7 +48,8 @@ const projectsGrouped = computed(() => {
premium: project.premium, premium: project.premium,
project_name: project.project_name, project_name: project.project_name,
total_events: project.total_events, total_events: project.total_events,
total_visits: project.total_visits total_visits: project.total_visits,
total_sessions: project.total_sessions
}); });
} else { } else {
@@ -61,7 +63,8 @@ const projectsGrouped = computed(() => {
premium_type: project.premium_type, premium_type: project.premium_type,
project_name: project.project_name, project_name: project.project_name,
total_events: project.total_events, total_events: project.total_events,
total_visits: project.total_visits total_visits: project.total_visits,
total_sessions: project.total_sessions
}] }]
} }
@@ -71,6 +74,12 @@ const projectsGrouped = computed(() => {
} }
result.sort((sa, sb) => {
const ca = sa.projects.reduce((a, e) => a + (e.total_visits + e.total_events), 0);
const cb = sb.projects.reduce((a, e) => a + (e.total_visits + e.total_events), 0);
return cb - ca;
})
return result; return result;
}); });
@@ -107,7 +116,6 @@ const totalEvents = computed(() => {
return projects.value?.reduce((a, e) => a + e.total_events, 0) || 0; return projects.value?.reduce((a, e) => a + e.total_events, 0) || 0;
}); });
const details = ref<any>(); const details = ref<any>();
const showDetails = ref<boolean>(false); const showDetails = ref<boolean>(false);
async function getProjectDetails(project_id: string) { async function getProjectDetails(project_id: string) {
@@ -188,17 +196,17 @@ async function resetCount(project_id: string) {
<div> {{ project.total_visits }} </div> <div> {{ project.total_visits }} </div>
<div> Events: </div> <div> Events: </div>
<div> {{ project.total_events }} </div> <div> {{ project.total_events }} </div>
<div> Sessions: </div>
<div> {{ project.total_sessions }} </div>
</div> </div>
<div class="flex gap-4"> <div class="flex gap-4 items-center mt-4">
<div class="bg-[#272727] hover:bg-[#313131] cursor-pointer px-8 py-2 mt-3 rounded-lg" <LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
@click="getProjectDetails(project._id)"> Payment details
Get details </LyxUiButton>
</div> <LyxUiButton type="danger" @click="resetCount(project._id)">
<div class="bg-[#272727] hover:bg-[#313131] cursor-pointer px-8 py-2 mt-3 rounded-lg" Refresh counts
@click="resetCount(project._id)"> </LyxUiButton>
Reset counts
</div>
</div> </div>
</div> </div>

View File

@@ -8,6 +8,11 @@ const selectLabelsEvents = [
]; ];
const eventsStackedSelectIndex = ref<number>(0); const eventsStackedSelectIndex = ref<number>(0);
const activeProject = useActiveProject();
const { snapshot } = useSnapshot();
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
</script> </script>
@@ -15,9 +20,9 @@ const eventsStackedSelectIndex = ref<number>(0);
<template> <template>
<div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col"> <div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
<div class="flex gap-6 flex-col xl:flex-row"> <div class="flex gap-6 flex-col xl:flex-row">
<CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
<CardTitled :key="refreshKey" class="p-4 flex-[4] w-full" title="Events" sub="Events stacked bar chart.">
<template #header> <template #header>
<SelectButton @changeIndex="eventsStackedSelectIndex = $event" <SelectButton @changeIndex="eventsStackedSelectIndex = $event"
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents"> :currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
@@ -29,27 +34,19 @@ const eventsStackedSelectIndex = ref<number>(0);
</div> </div>
</CardTitled> </CardTitled>
<div class="bg-card p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full"> <CardTitled :key="refreshKey" class="p-4 flex-[2] w-full h-full" title="Top events"
<div class="flex flex-col gap-1"> sub="Displays key events.">
<div class="poppins font-semibold text-[1.4rem] text-text">
Top events
</div>
<div class="poppins text-[1rem] text-text-sub/90">
Displays key events.
</div>
</div>
<DashboardEventsChart class="w-full"> </DashboardEventsChart> <DashboardEventsChart class="w-full"> </DashboardEventsChart>
</CardTitled>
</div>
</div> </div>
<div class="flex"> <div class="flex">
<EventsUserFlow></EventsUserFlow> <EventsUserFlow :key="refreshKey"></EventsUserFlow>
</div> </div>
<div class="flex"> <div class="flex">
<EventsMetadataAnalyzer></EventsMetadataAnalyzer> <EventsMetadataAnalyzer :key="refreshKey"></EventsMetadataAnalyzer>
</div> </div>

View File

@@ -27,15 +27,18 @@ onMounted(async () => {
}); });
const { createAlert } = useAlert();
function copyProjectId() { function copyProjectId() {
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP'); if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText(activeProject.value?._id?.toString() || ''); navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
alert('Copiato !'); createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
} }
function copyScript() { function copyScript() {
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP'); if (!navigator.clipboard) alert('You can\'t copy in HTTP');
const createScriptText = () => { const createScriptText = () => {
@@ -48,18 +51,17 @@ function copyScript() {
} }
navigator.clipboard.writeText(createScriptText()); navigator.clipboard.writeText(createScriptText());
alert('Copiato !'); createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
} }
const { data: firstInteraction, pending, refresh } = useFirstInteractionData(); const firstInteractionUrl = computed(() => {
return `/api/metrics/${activeProject.value?._id}/first_interaction`
});
const firstInteraction = useFetch<boolean>(firstInteractionUrl, {
watch(pending, () => { ...signHeaders(),
if (pending.value === true) return; lazy: true
if (firstInteraction.value === false) { });
setTimeout(() => { refresh(); }, 2000);
}
})
const selectLabels = [ const selectLabels = [
{ label: 'Hour', value: 'hour' }, { label: 'Hour', value: 'hour' },
@@ -68,32 +70,49 @@ const selectLabels = [
]; ];
</script>
const { snapshot } = useSnapshot();
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
</script>
<template> <template>
<div class="dashboard w-full h-full overflow-y-auto pb-20 md:pt-4 lg:pt-0"> <div class="dashboard w-full h-full overflow-y-auto pb-20 md:pt-4 lg:pt-0">
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction"> <div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value">
<div class="w-full px-4 py-2"> <div class="w-full px-4 py-2">
<div v-if="limitsInfo && limitsInfo.limited" <div v-if="limitsInfo && limitsInfo.limited"
class="bg-orange-600 justify-center flex gap-2 py-2 px-4 font-semibold text-[1.2rem] rounded-lg"> class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
<div class="poppins text-text"> Limit reached </div> <div class="flex flex-col grow">
<NuxtLink to="/plans" class="poppins text-[#393972] underline cursor-pointer"> <div class="poppins font-semibold text-[#fbbf24]">
Upgrade project Limit reached
</NuxtLink> </div>
<div class="poppins text-[#fbbf24]">
Litlyx has stopped to collect yur data. Please upgrade the plan for a minimal data loss.
</div>
</div>
<div>
<LyxUiButton type="outline"> Upgrade </LyxUiButton>
</div>
</div> </div>
</div> </div>
<DashboardTopSection></DashboardTopSection> <DashboardTopSection></DashboardTopSection>
<DashboardTopCards></DashboardTopCards> <DashboardTopCards :key="refreshKey"></DashboardTopCards>
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row"> <div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits."> <CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Visits trends"
sub="Shows trends in page visits.">
<template #header> <template #header>
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex" <SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
:options="selectLabels"> :options="selectLabels">
@@ -105,7 +124,8 @@ const selectLabels = [
</div> </div>
</CardTitled> </CardTitled>
<CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions."> <CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions"
sub="Shows trends in sessions.">
<template #header> <template #header>
<SelectButton @changeIndex="sessionsChartSelectIndex = $event" <SelectButton @changeIndex="sessionsChartSelectIndex = $event"
:currentIndex="sessionsChartSelectIndex" :options="selectLabels"> :currentIndex="sessionsChartSelectIndex" :options="selectLabels">
@@ -119,26 +139,25 @@ const selectLabels = [
</div> </div>
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">
<div class="flex w-full gap-6 flex-col xl:flex-row"> <div class="flex w-full gap-6 flex-col xl:flex-row">
<div class="flex-1"> <div class="flex-1">
<DashboardWebsitesBarCard></DashboardWebsitesBarCard> <DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<DashboardEventsBarCard></DashboardEventsBarCard> <DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
</div> </div>
</div> </div>
</div> </div>
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">
<div class="flex w-full gap-6 flex-col xl:flex-row"> <div class="flex w-full gap-6 flex-col xl:flex-row">
<div class="flex-1"> <div class="flex-1">
<DashboardReferrersBarCard></DashboardReferrersBarCard> <DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<DashboardBrowsersBarCard></DashboardBrowsersBarCard> <DashboardBrowsersBarCard :key="refreshKey"></DashboardBrowsersBarCard>
</div> </div>
</div> </div>
</div> </div>
@@ -146,10 +165,10 @@ const selectLabels = [
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">
<div class="flex w-full gap-6 flex-col xl:flex-row"> <div class="flex w-full gap-6 flex-col xl:flex-row">
<div class="flex-1"> <div class="flex-1">
<DashboardOssBarCard></DashboardOssBarCard> <DashboardOssBarCard :key="refreshKey"></DashboardOssBarCard>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<DashboardGeolocationBarCard></DashboardGeolocationBarCard> <DashboardGeolocationBarCard :key="refreshKey"></DashboardGeolocationBarCard>
</div> </div>
</div> </div>
</div> </div>
@@ -157,7 +176,7 @@ const selectLabels = [
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">
<div class="flex w-full gap-6 flex-col xl:flex-row"> <div class="flex w-full gap-6 flex-col xl:flex-row">
<div class="flex-1"> <div class="flex-1">
<DashboardDevicesBarCard></DashboardDevicesBarCard> <DashboardDevicesBarCard :key="refreshKey"></DashboardDevicesBarCard>
</div> </div>
<div class="flex-1"> <div class="flex-1">
</div> </div>
@@ -166,10 +185,10 @@ const selectLabels = [
</div> </div>
<div v-if="!firstInteraction && activeProject" class="mt-[36vh] flex flex-col gap-6"> <div v-if="!firstInteraction.data.value && activeProject" class="mt-[20vh] lg:mt-[36vh] flex flex-col gap-6">
<div class="flex gap-4 items-center justify-center"> <div class="flex gap-4 items-center justify-center">
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div> <div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
<div class="text-text/90 poppins text-[1.4rem] font-bold"> <div class="text-text/90 poppins text-[1.3rem] font-semibold">
Waiting for your first Visit or Event Waiting for your first Visit or Event
</div> </div>
</div> </div>
@@ -200,7 +219,11 @@ const selectLabels = [
</div> </div>
<div></div> <NuxtLink to="https://docs.litlyx.com" target="_blank"
class="flex justify-center poppins text-[1.2rem] text-accent gap-2 items-center">
<div> <i class="far fa-book"></i> </div>
<div class="poppins"> Go to docs </div>
</NuxtLink>
</div> </div>

View File

@@ -46,7 +46,7 @@ const selectLabelsEvents = [
{ label: 'Month', value: 'month' }, { label: 'Month', value: 'month' },
]; ];
const { snapshot } = useSnapshot();
</script> </script>
@@ -118,7 +118,7 @@ const selectLabelsEvents = [
<div class="flex gap-6 flex-col xl:flex-row p-6"> <div class="flex gap-6 flex-col xl:flex-row p-6">
<CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart."> <!-- <CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
<template #header> <template #header>
<SelectButton @changeIndex="eventsStackedSelectIndex = $event" <SelectButton @changeIndex="eventsStackedSelectIndex = $event"
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents"> :currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
@@ -128,7 +128,7 @@ const selectLabelsEvents = [
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)"> <EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
</EventsStackedBarChart> </EventsStackedBarChart>
</div> </div>
</CardTitled> </CardTitled> -->
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full"> <div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">

View File

@@ -1,219 +0,0 @@
<script lang="ts" setup>
import dayjs from 'dayjs';
const activeProject = useActiveProject();
definePageMeta({ layout: 'dashboard' });
const { data: planData } = useFetch('/api/project/plan', signHeaders());
const percent = computed(() => {
if (!planData.value) return '-';
return (100 / planData.value.limit * planData.value.count).toFixed(2) + '%';
});
const color = computed(() => {
if (!planData.value) return 'blue';
if (planData.value.count >= planData.value.limit) return 'red';
return 'blue';
});
const daysLeft = computed(() => {
if (!planData.value) return '-';
return (-dayjs().diff(planData.value.billing_expire_at, 'days')).toString();
});
const leftPercent = computed(() => {
if (!planData.value) return 0;
const left = dayjs().diff(planData.value.billing_expire_at, 'days');
const total = dayjs(planData.value.billing_start_at).diff(planData.value.billing_expire_at, 'days');
const percent = 100 - (100 / total * left);
return percent;
});
const prettyExpireDate = computed(() => {
if (!planData.value) return '';
return dayjs(planData.value.billing_expire_at).format('DD/MM/YYYY');
});
const { data: invoices } = await useFetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, signHeaders())
const showPricingDrawer = ref<boolean>(false);
function onPlanUpgradeClick() {
showPricingDrawer.value = true;
}
function openInvoice(link: string) {
window.open(link, '_blank');
}
function getPremiumName(type: number) {
if (type === 0) return 'FREE';
if (type === 1) return 'ACCELERATION';
if (type === 2) return 'EXPANSION';
return 'CUSTOM';
}
</script>
<template>
<div class="w-full h-full p-8 overflow-y-auto pb-40 lg:pb-0 relative overflow-x-hidden">
<Transition name="pdrawer">
<PricingDrawer @onCloseClick="showPricingDrawer = false" :currentSub="planData?.premium_type || 0"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]"
v-if=showPricingDrawer>
</PricingDrawer>
</Transition>
<div @click="showPricingDrawer = false" v-if="showPricingDrawer"
class="barrier absolute left-0 top-0 w-full h-full z-[19] bg-black/40 backdrop-blur-[1px]">
</div>
<div class="poppins font-semibold text-[1.8rem]">
Billing
</div>
<div class="poppins text-[1.3rem] text-text-sub">
Manage your billing cycle for the project
<span class="font-bold">
{{ activeProject?.name || '' }}
</span>
</div>
<div class="my-4 mb-10 w-full bg-gray-400/30 h-[1px]">
</div>
<div class="flex flex-wrap justify-start gap-8">
<Card v-if="planData" class="px-0 pt-6 pb-4 w-[35rem] flex flex-col">
<div class="flex flex-col gap-6 px-8 grow">
<div class="flex justify-between flex-col sm:flex-row">
<div class="flex flex-col">
<div class="flex gap-3 items-center">
<div class="poppins font-semibold text-[1.1rem]">
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
</div>
<div
class="flex lato text-[.7rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
</div>
</div>
<div class="poppins text-text-sub text-[.9rem]">
Our free plan for testing the product.
</div>
</div>
<div class="flex items-center gap-1">
<div class="poppins font-semibold text-[2rem]"> $0 </div>
<div class="poppins text-text-sub mt-2"> per month </div>
</div>
</div>
<div class="flex flex-col">
<div class="poppins"> Billing period:</div>
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
<div class="grow w-full md:w-auto">
<UProgress color="green" :min="0" :max="100" :value="leftPercent"></UProgress>
</div>
<div class="poppins"> {{ daysLeft }} days left </div>
</div>
<div class="flex justify-center">
Subscription: {{ planData.subscription_status }}
</div>
</div>
</div>
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
</div>
<div class="flex justify-between px-8 flex-col sm:flex-row">
<div class="flex gap-2 text-text-sub text-[.9rem]">
<div class="poppins"> Expire date:</div>
<div> {{ prettyExpireDate }}</div>
</div>
<div v-if="!isGuest" @click="onPlanUpgradeClick()"
class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]">
<div class="poppins"> Upgrade plan </div>
<i class="fas fa-arrow-up-right"></i>
</div>
</div>
</Card>
<Card v-if="planData" class="px-0 pt-6 pb-4 w-[35rem] flex flex-col">
<div class="flex flex-col gap-6 px-8">
<div class="flex justify-between">
<div class="flex flex-col">
<div class="poppins font-semibold text-[1.1rem]">
Usage
</div>
<div class="poppins text-text-sub text-[.9rem]">
Check the usage limits of your project.
</div>
</div>
</div>
<div class="flex flex-col">
<div class="poppins"> Usage:</div>
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
<div class="grow w-full md:w-auto">
<UProgress :color="color" :min="0" :max="planData.limit" :value="planData.count">
</UProgress>
</div>
<div class="poppins"> {{ percent }}</div>
</div>
<div class="flex justify-center">
{{ formatNumberK(planData.count) }} / {{ formatNumberK(planData.limit) }}
</div>
</div>
</div>
</Card>
</div>
<CardTitled v-if="!isGuest" title="Invoices" :sub="(invoices && invoices.length == 0) ? 'No invoices yet' : ''"
class="p-4 mt-8 max-w-[72rem]">
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg" v-for="invoice of invoices">
<div> <i class="fal fa-file-invoice"></i> </div>
<div class="flex flex-col md:flex-row md:justify-around md:grow items-center gap-2">
<div> {{ new Date(invoice.date).toLocaleString() }} </div>
<div> {{ invoice.cost / 100 }} </div>
<div> {{ invoice.id }} </div>
<div
class="flex items-center lato text-[.8rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
{{ invoice.status }}
</div>
</div>
<div>
<i @click="openInvoice(invoice.link)"
class="far fa-download cursor-pointer hover:text-white/80"></i>
</div>
</div>
</div>
</CardTitled>
</div>
</template>
<style scoped lang="scss">
.pdrawer-enter-active,
.pdrawer-leave-active {
transition: all .5s ease-in-out;
}
.pdrawer-enter-from,
.pdrawer-leave-to {
transform: translateX(100%)
}
.pdrawer-enter-to,
.pdrawer-leave-from {
transform: translateX(0)
}
</style>

View File

@@ -31,7 +31,9 @@ async function generatePDF() {
<div class="home w-full h-full px-10 lg:px-0 overflow-y-auto pb-[12rem] md:pb-0"> <div class="home w-full h-full px-10 lg:px-0 overflow-y-auto pb-[12rem] md:pb-0">
<div class="flex flex-col items-center justify-center mt-20 gap-20"> <DialogCreateSnapshot></DialogCreateSnapshot>
<!-- <div class="flex flex-col items-center justify-center mt-20 gap-20">
<div class="flex flex-col items-center justify-center gap-10"> <div class="flex flex-col items-center justify-center gap-10">
<div class="poppins text-[2.4rem] font-bold text-text"> <div class="poppins text-[2.4rem] font-bold text-text">
@@ -84,7 +86,7 @@ async function generatePDF() {
</div> </div> -->
</div> </div>

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
definePageMeta({ layout: 'dashboard' });
const items = [
{ label: 'General', slot: 'general' },
{ label: 'Members', slot: 'members' },
{ label: 'Billing', slot: 'billing' },
{ label: 'Account', slot: 'account' }
]
</script>
<template>
<div class="px-10 py-8 h-dvh overflow-y-auto hide-scrollbars">
<div class="poppins font-semibold text-[1.3rem]"> Settings </div>
<CustomTab :items="items" class="mt-8">
<template #general>
<SettingsGeneral></SettingsGeneral>
</template>
<template #members>
<SettingsMembers></SettingsMembers>
</template>
<template #billing>
<SettingsBilling></SettingsBilling>
</template>
<template #account>
<SettingsAccount></SettingsAccount>
</template>
</CustomTab>
<!-- <UTabs :items="items" class="mt-8">
<template #general>
<SettingsGeneral></SettingsGeneral>
</template>
<template #members>
<SettingsMembers></SettingsMembers>
</template>
<template #billing>
<SettingsBilling></SettingsBilling>
</template>
<template #account>
<SettingsAccount></SettingsAccount>
</template>
</UTabs> -->
</div>
</template>

106
dashboard/pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
chart.js: chart.js:
specifier: ^3.9.1 specifier: ^3.9.1
version: 3.9.1 version: 3.9.1
date-fns:
specifier: ^3.6.0
version: 3.6.0
dayjs: dayjs:
specifier: ^1.11.11 specifier: ^1.11.11
version: 1.11.11 version: 1.11.11
@@ -56,6 +59,9 @@ importers:
stripe: stripe:
specifier: ^15.8.0 specifier: ^15.8.0
version: 15.8.0 version: 15.8.0
v-calendar:
specifier: ^3.1.2
version: 3.1.2(@popperjs/core@2.11.8)(vue@3.4.27(typescript@5.4.2))
vue: vue:
specifier: ^3.4.21 specifier: ^3.4.21
version: 3.4.27(typescript@5.4.2) version: 3.4.27(typescript@5.4.2)
@@ -1125,6 +1131,9 @@ packages:
'@types/jsonwebtoken@9.0.6': '@types/jsonwebtoken@9.0.6':
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
'@types/lodash@4.17.7':
resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==}
'@types/node-fetch@2.6.11': '@types/node-fetch@2.6.11':
resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==} resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==}
@@ -1140,6 +1149,9 @@ packages:
'@types/pdfkit@0.13.4': '@types/pdfkit@0.13.4':
resolution: {integrity: sha512-ixGNDHYJCCKuamY305wbfYSphZ2WPe8FPkjn8oF4fHV+PgPV4V+hecPh2VOS2h4RNtpSB3zQcR4sCpNvvrEb1A==} resolution: {integrity: sha512-ixGNDHYJCCKuamY305wbfYSphZ2WPe8FPkjn8oF4fHV+PgPV4V+hecPh2VOS2h4RNtpSB3zQcR4sCpNvvrEb1A==}
'@types/resize-observer-browser@0.1.11':
resolution: {integrity: sha512-cNw5iH8JkMkb3QkCoe7DaZiawbDQEUX8t7iuQaRTyLOyQCR2h+ibBD4GJt7p5yhUHrlOeL7ZtbxNHeipqNsBzQ==}
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -1373,20 +1385,20 @@ packages:
'@vue/reactivity@3.4.27': '@vue/reactivity@3.4.27':
resolution: {integrity: sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==} resolution: {integrity: sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==}
'@vue/reactivity@3.4.28': '@vue/reactivity@3.4.34':
resolution: {integrity: sha512-B5uvZK0ArgBMkjK8RA9l5XP+PuQ/x99oqrcHRc78wa0pWyDje5X/isGihuiuSr0nFZTA5guoy78sJ6J8XxZv1A==} resolution: {integrity: sha512-ua+Lo+wBRlBEX9TtgPOShE2JwIO7p6BTZ7t1KZVPoaBRfqbC7N3c8Mpzicx173fXxx5VXeU6ykiHo7WgLzJQDA==}
'@vue/runtime-core@3.4.27': '@vue/runtime-core@3.4.27':
resolution: {integrity: sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==} resolution: {integrity: sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==}
'@vue/runtime-core@3.4.28': '@vue/runtime-core@3.4.34':
resolution: {integrity: sha512-Corp5aAn5cm9h2cse6w5vRlnlfpy8hBRrsgCzHSoUohStlbqBXvI/uopPVkCivPCgY4fJZhXOufYYJ3DXzpN/w==} resolution: {integrity: sha512-PXhkiRPwcPGJ1BnyBZFI96GfInCVskd0HPNIAZn7i3YOmLbtbTZpB7/kDTwC1W7IqdGPkTVC63IS7J2nZs4Ebg==}
'@vue/runtime-dom@3.4.27': '@vue/runtime-dom@3.4.27':
resolution: {integrity: sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==} resolution: {integrity: sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==}
'@vue/runtime-dom@3.4.28': '@vue/runtime-dom@3.4.34':
resolution: {integrity: sha512-y9lDMMFf2Y5GpYdE8+IuavVl95D1GY1Zp8jU1vZhQ3Z4ga3f0Ym+XxRhcFtqaQAm9u82GwB7zDpBxafWDRq4pw==} resolution: {integrity: sha512-dXqIe+RqFAK2Euak4UsvbIupalrhc67OuQKpD7HJ3W2fv8jlqvI7szfBCsAEcE8o/wyNpkloxB6J8viuF/E3gw==}
'@vue/server-renderer@3.4.27': '@vue/server-renderer@3.4.27':
resolution: {integrity: sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==} resolution: {integrity: sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==}
@@ -1396,8 +1408,8 @@ packages:
'@vue/shared@3.4.27': '@vue/shared@3.4.27':
resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==} resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==}
'@vue/shared@3.4.28': '@vue/shared@3.4.34':
resolution: {integrity: sha512-2b+Vuv5ichZQZPmRJfniHQkBSNigmRsRkr17bkYqBFy3J88T4lB7dRbAX/rx8qr9v0cr8Adg6yP872xhxGmh0w==} resolution: {integrity: sha512-x5LmiRLpRsd9KTjAB8MPKf0CDPMcuItjP0gbNqFCIgL1I8iYp4zglhj9w9FPCdIbHG2M91RVeIbArFfFTz9I3A==}
'@vueuse/components@10.10.0': '@vueuse/components@10.10.0':
resolution: {integrity: sha512-HiA10NQ9HJAGnju+8ZK4TyA8LIc0a6BnJmVWDa/k+TRhaYCVacSDU04k0BQ2otV+gghUDdwu98upf6TDRXpoeg==} resolution: {integrity: sha512-HiA10NQ9HJAGnju+8ZK4TyA8LIc0a6BnJmVWDa/k+TRhaYCVacSDU04k0BQ2otV+gghUDdwu98upf6TDRXpoeg==}
@@ -1965,6 +1977,18 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
date-fns-tz@2.0.1:
resolution: {integrity: sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==}
peerDependencies:
date-fns: 2.x
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
dayjs@1.11.11: dayjs@1.11.11:
resolution: {integrity: sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==} resolution: {integrity: sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==}
@@ -4533,6 +4557,12 @@ packages:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true hasBin: true
v-calendar@3.1.2:
resolution: {integrity: sha512-QDWrnp4PWCpzUblctgo4T558PrHgHzDtQnTeUNzKxfNf29FkCeFpwGd9bKjAqktaa2aJLcyRl45T5ln1ku34kg==}
peerDependencies:
'@popperjs/core': ^2.0.0
vue: ^3.2.0
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
@@ -4729,6 +4759,11 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.2.0 vue: ^3.2.0
vue-screen-utils@1.0.0-beta.13:
resolution: {integrity: sha512-EJ/8TANKhFj+LefDuOvZykwMr3rrLFPLNb++lNBqPOpVigT2ActRg6icH9RFQVm4nHwlHIHSGm5OY/Clar9yIg==}
peerDependencies:
vue: ^3.2.0
vue-template-compiler@2.7.16: vue-template-compiler@2.7.16:
resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==}
@@ -6168,6 +6203,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.12.12 '@types/node': 20.12.12
'@types/lodash@4.17.7': {}
'@types/node-fetch@2.6.11': '@types/node-fetch@2.6.11':
dependencies: dependencies:
'@types/node': 18.19.33 '@types/node': 18.19.33
@@ -6189,6 +6226,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.12.12 '@types/node': 20.12.12
'@types/resize-observer-browser@0.1.11': {}
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.20': {}
@@ -6614,7 +6653,7 @@ snapshots:
'@volar/language-core': 1.11.1 '@volar/language-core': 1.11.1
'@volar/source-map': 1.11.1 '@volar/source-map': 1.11.1
'@vue/compiler-dom': 3.4.27 '@vue/compiler-dom': 3.4.27
'@vue/shared': 3.4.28 '@vue/shared': 3.4.34
computeds: 0.0.1 computeds: 0.0.1
minimatch: 9.0.4 minimatch: 9.0.4
muggle-string: 0.3.1 muggle-string: 0.3.1
@@ -6627,19 +6666,19 @@ snapshots:
dependencies: dependencies:
'@vue/shared': 3.4.27 '@vue/shared': 3.4.27
'@vue/reactivity@3.4.28': '@vue/reactivity@3.4.34':
dependencies: dependencies:
'@vue/shared': 3.4.28 '@vue/shared': 3.4.34
'@vue/runtime-core@3.4.27': '@vue/runtime-core@3.4.27':
dependencies: dependencies:
'@vue/reactivity': 3.4.27 '@vue/reactivity': 3.4.27
'@vue/shared': 3.4.27 '@vue/shared': 3.4.27
'@vue/runtime-core@3.4.28': '@vue/runtime-core@3.4.34':
dependencies: dependencies:
'@vue/reactivity': 3.4.28 '@vue/reactivity': 3.4.34
'@vue/shared': 3.4.28 '@vue/shared': 3.4.34
'@vue/runtime-dom@3.4.27': '@vue/runtime-dom@3.4.27':
dependencies: dependencies:
@@ -6647,11 +6686,11 @@ snapshots:
'@vue/shared': 3.4.27 '@vue/shared': 3.4.27
csstype: 3.1.3 csstype: 3.1.3
'@vue/runtime-dom@3.4.28': '@vue/runtime-dom@3.4.34':
dependencies: dependencies:
'@vue/reactivity': 3.4.28 '@vue/reactivity': 3.4.34
'@vue/runtime-core': 3.4.28 '@vue/runtime-core': 3.4.34
'@vue/shared': 3.4.28 '@vue/shared': 3.4.34
csstype: 3.1.3 csstype: 3.1.3
'@vue/server-renderer@3.4.27(vue@3.4.27(typescript@5.4.2))': '@vue/server-renderer@3.4.27(vue@3.4.27(typescript@5.4.2))':
@@ -6662,7 +6701,7 @@ snapshots:
'@vue/shared@3.4.27': {} '@vue/shared@3.4.27': {}
'@vue/shared@3.4.28': {} '@vue/shared@3.4.34': {}
'@vueuse/components@10.10.0(vue@3.4.27(typescript@5.4.2))': '@vueuse/components@10.10.0(vue@3.4.27(typescript@5.4.2))':
dependencies: dependencies:
@@ -7244,6 +7283,16 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
date-fns-tz@2.0.1(date-fns@2.30.0):
dependencies:
date-fns: 2.30.0
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.24.6
date-fns@3.6.0: {}
dayjs@1.11.11: {} dayjs@1.11.11: {}
db0@0.1.4: {} db0@0.1.4: {}
@@ -10163,6 +10212,17 @@ snapshots:
uuid@9.0.1: {} uuid@9.0.1: {}
v-calendar@3.1.2(@popperjs/core@2.11.8)(vue@3.4.27(typescript@5.4.2)):
dependencies:
'@popperjs/core': 2.11.8
'@types/lodash': 4.17.7
'@types/resize-observer-browser': 0.1.11
date-fns: 2.30.0
date-fns-tz: 2.0.1(date-fns@2.30.0)
lodash: 4.17.21
vue: 3.4.27(typescript@5.4.2)
vue-screen-utils: 1.0.0-beta.13(vue@3.4.27(typescript@5.4.2))
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
dependencies: dependencies:
spdx-correct: 3.2.0 spdx-correct: 3.2.0
@@ -10340,8 +10400,8 @@ snapshots:
vue-chart-3@3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2)): vue-chart-3@3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2)):
dependencies: dependencies:
'@vue/runtime-core': 3.4.28 '@vue/runtime-core': 3.4.34
'@vue/runtime-dom': 3.4.28 '@vue/runtime-dom': 3.4.34
chart.js: 3.9.1 chart.js: 3.9.1
csstype: 3.1.3 csstype: 3.1.3
lodash-es: 4.17.21 lodash-es: 4.17.21
@@ -10366,6 +10426,10 @@ snapshots:
'@vue/devtools-api': 6.6.1 '@vue/devtools-api': 6.6.1
vue: 3.4.27(typescript@5.4.2) vue: 3.4.27(typescript@5.4.2)
vue-screen-utils@1.0.0-beta.13(vue@3.4.27(typescript@5.4.2)):
dependencies:
vue: 3.4.27(typescript@5.4.2)
vue-template-compiler@2.7.16: vue-template-compiler@2.7.16:
dependencies: dependencies:
de-indent: 1.0.2 de-indent: 1.0.2

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

View File

@@ -14,7 +14,8 @@ export type AdminProjectsList = {
created_at: Date created_at: Date
}, },
total_visits: number, total_visits: number,
total_events: number total_events: number,
total_sessions: number
} }
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
@@ -54,6 +55,9 @@ export default defineEventHandler(async event => {
}, },
total_events: { total_events: {
$arrayElemAt: ["$counts.events", 0] $arrayElemAt: ["$counts.events", 0]
},
total_sessions: {
$arrayElemAt: ["$counts.sessions", 0]
} }
} }
} }

View File

@@ -1,6 +1,7 @@
import { ProjectCountModel } from "@schema/ProjectsCounts"; import { ProjectCountModel } from "@schema/ProjectsCounts";
import { EventModel } from "@schema/metrics/EventSchema"; import { EventModel } from "@schema/metrics/EventSchema";
import { SessionModel } from "@schema/metrics/SessionSchema";
import { VisitModel } from "@schema/metrics/VisitSchema"; import { VisitModel } from "@schema/metrics/VisitSchema";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
@@ -13,8 +14,9 @@ export default defineEventHandler(async event => {
const events = await EventModel.countDocuments({ project_id }); const events = await EventModel.countDocuments({ project_id });
const visits = await VisitModel.countDocuments({ project_id }); const visits = await VisitModel.countDocuments({ project_id });
const sessions = await SessionModel.countDocuments({ project_id });
await ProjectCountModel.updateOne({ project_id, events, visits }, {}, { upsert: true }); await ProjectCountModel.updateOne({ project_id, events, visits, sessions }, {}, { upsert: true });
return { ok: true }; return { ok: true };
}); });

View File

@@ -36,12 +36,12 @@ export default defineEventHandler(async event => {
$group: { $group: {
_id: "$project_id", _id: "$project_id",
events: { $sum: "$events" }, events: { $sum: "$events" },
visits: { $sum: "$visits" } visits: { $sum: "$visits" },
sessions: { $sum: "$sessions" },
} }
} }
]); ]);
const sessionsVisitsCount: any[] = await Redis.useCache({ const sessionsVisitsCount: any[] = await Redis.useCache({
key: `counts:${project_id}:sessions_count`, key: `counts:${project_id}:sessions_count`,
exp: COUNTS_SESSIONS_EXPIRE_TIME exp: COUNTS_SESSIONS_EXPIRE_TIME

View File

@@ -22,12 +22,26 @@ export default defineEventHandler(async event => {
const limit = getRequestHeader(event, 'x-query-limit'); const limit = getRequestHeader(event, 'x-query-limit');
const numLimit = parseInt(limit || '10'); const numLimit = parseInt(limit || '10');
const from = getRequestHeader(event, 'x-from');
const to = getRequestHeader(event, 'x-to');
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
return await Redis.useCache({ return await Redis.useCache({
key: `browsers:${project_id}:${numLimit}`, key: `browsers:${project_id}:${numLimit}:${from}:${to}`,
exp: DATA_EXPIRE_TIME exp: DATA_EXPIRE_TIME
}, async () => { }, async () => {
const browsers: BrowsersAggregated[] = await VisitModel.aggregate([ const browsers: BrowsersAggregated[] = await VisitModel.aggregate([
{ $match: { project_id: project._id }, }, {
$match: {
project_id: project._id,
created_at: {
$gte: new Date(from),
$lte: new Date(to)
}
},
},
{ $group: { _id: "$browser", count: { $sum: 1, } } }, { $group: { _id: "$browser", count: { $sum: 1, } } },
{ $sort: { count: -1 } }, { $sort: { count: -1 } },
{ $limit: numLimit } { $limit: numLimit }

View File

@@ -21,13 +21,26 @@ export default defineEventHandler(async event => {
const limit = getRequestHeader(event, 'x-query-limit'); const limit = getRequestHeader(event, 'x-query-limit');
const numLimit = parseInt(limit || '10'); const numLimit = parseInt(limit || '10');
const from = getRequestHeader(event, 'x-from');
const to = getRequestHeader(event, 'x-to');
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
return await Redis.useCache({ return await Redis.useCache({
key: `countries:${project_id}:${numLimit}`, key: `countries:${project_id}:${numLimit}:${from}:${to}`,
exp: DATA_EXPIRE_TIME exp: DATA_EXPIRE_TIME
}, async () => { }, async () => {
const countries: CountriesAggregated[] = await VisitModel.aggregate([ const countries: CountriesAggregated[] = await VisitModel.aggregate([
{ $match: { project_id: project._id, country: { $ne: null } }, }, {
$match: {
project_id: project._id,
country: { $ne: null },
created_at: {
$gte: new Date(from),
$lte: new Date(to)
}
},
},
{ $group: { _id: "$country", count: { $sum: 1, } } }, { $group: { _id: "$country", count: { $sum: 1, } } },
{ $sort: { count: -1 } }, { $sort: { count: -1 } },
{ $limit: numLimit } { $limit: numLimit }

View File

@@ -20,13 +20,26 @@ export default defineEventHandler(async event => {
const limit = getRequestHeader(event, 'x-query-limit'); const limit = getRequestHeader(event, 'x-query-limit');
const numLimit = parseInt(limit || '10'); const numLimit = parseInt(limit || '10');
const from = getRequestHeader(event, 'x-from');
const to = getRequestHeader(event, 'x-to');
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
return await Redis.useCache({ return await Redis.useCache({
key: `devices:${project_id}:${numLimit}`, key: `devices:${project_id}:${numLimit}:${from}:${to}`,
exp: DATA_EXPIRE_TIME exp: DATA_EXPIRE_TIME
}, async () => { }, async () => {
const devices: DevicesAggregated[] = await VisitModel.aggregate([ const devices: DevicesAggregated[] = await VisitModel.aggregate([
{ $match: { project_id: project._id, device: { $ne: null } }, }, {
$match: {
project_id: project._id,
device: { $ne: null },
created_at: {
$gte: new Date(from),
$lte: new Date(to)
}
},
},
{ $group: { _id: "$device", count: { $sum: 1, } } }, { $group: { _id: "$device", count: { $sum: 1, } } },
{ $sort: { count: -1 } }, { $sort: { count: -1 } },
{ $limit: numLimit } { $limit: numLimit }

View File

@@ -0,0 +1,49 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { EventModel } from "@schema/metrics/EventSchema";
import { DATA_EXPIRE_TIME, Redis } from "~/server/services/CacheService";
export type CustomEventsAggregated = {
_id: string,
count: number
}
export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event);
if (!project_id) return;
const user = getRequestUser(event);
const project = await getUserProjectFromId(project_id, user);
if (!project) return;
const from = getRequestHeader(event, 'x-from');
const to = getRequestHeader(event, 'x-to');
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
return await Redis.useCache({
key: `events:${project_id}:${from}:${to}`,
exp: DATA_EXPIRE_TIME
}, async () => {
const events: CustomEventsAggregated[] = await EventModel.aggregate([
{
$match: {
project_id: project._id, created_at: {
$gte: new Date(from),
$lte: new Date(to)
}
},
},
{ $group: { _id: "$name", count: { $sum: 1, } } },
{ $sort: { count: -1 } }
]);
return events;
});
});

View File

@@ -22,13 +22,25 @@ export default defineEventHandler(async event => {
const limit = getRequestHeader(event, 'x-query-limit'); const limit = getRequestHeader(event, 'x-query-limit');
const numLimit = parseInt(limit || '10'); const numLimit = parseInt(limit || '10');
const from = getRequestHeader(event, 'x-from');
const to = getRequestHeader(event, 'x-to');
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
return await Redis.useCache({ return await Redis.useCache({
key: `oss:${project_id}:${numLimit}`, key: `oss:${project_id}:${numLimit}:${from}:${to}`,
exp: DATA_EXPIRE_TIME exp: DATA_EXPIRE_TIME
}, async () => { }, async () => {
const oss: OssAggregated[] = await VisitModel.aggregate([ const oss: OssAggregated[] = await VisitModel.aggregate([
{ $match: { project_id: project._id }, }, {
$match: {
project_id: project._id,
created_at: {
$gte: new Date(from),
$lte: new Date(to)
}
},
},
{ $group: { _id: "$os", count: { $sum: 1, } } }, { $group: { _id: "$os", count: { $sum: 1, } } },
{ $sort: { count: -1 } }, { $sort: { count: -1 } },
{ $limit: numLimit } { $limit: numLimit }

View File

@@ -22,13 +22,25 @@ export default defineEventHandler(async event => {
const limit = getRequestHeader(event, 'x-query-limit'); const limit = getRequestHeader(event, 'x-query-limit');
const numLimit = parseInt(limit || '10'); const numLimit = parseInt(limit || '10');
const from = getRequestHeader(event, 'x-from');
const to = getRequestHeader(event, 'x-to');
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
return await Redis.useCache({ return await Redis.useCache({
key: `referrers:${project_id}:${numLimit}`, key: `referrers:${project_id}:${numLimit}:${from}:${to}`,
exp: DATA_EXPIRE_TIME exp: DATA_EXPIRE_TIME
}, async () => { }, async () => {
const referrers: ReferrersAggregated[] = await VisitModel.aggregate([ const referrers: ReferrersAggregated[] = await VisitModel.aggregate([
{ $match: { project_id: project._id }, }, {
$match: {
project_id: project._id,
created_at: {
$gte: new Date(from),
$lte: new Date(to)
}
},
},
{ $group: { _id: "$referrer", count: { $sum: 1, } } }, { $group: { _id: "$referrer", count: { $sum: 1, } } },
{ $sort: { count: -1 } }, { $sort: { count: -1 } },
{ $limit: numLimit } { $limit: numLimit }

View File

@@ -22,12 +22,25 @@ export default defineEventHandler(async event => {
const limit = getRequestHeader(event, 'x-query-limit'); const limit = getRequestHeader(event, 'x-query-limit');
const numLimit = parseInt(limit || '10'); const numLimit = parseInt(limit || '10');
const from = getRequestHeader(event, 'x-from');
const to = getRequestHeader(event, 'x-to');
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
return await Redis.useCache({ return await Redis.useCache({
key: `websites:${project_id}:${numLimit}`, key: `websites:${project_id}:${numLimit}`,
exp: DATA_EXPIRE_TIME exp: DATA_EXPIRE_TIME
}, async () => { }, async () => {
const websites: VisitsWebsiteAggregated[] = await VisitModel.aggregate([ const websites: VisitsWebsiteAggregated[] = await VisitModel.aggregate([
{ $match: { project_id: project._id }, }, {
$match: {
project_id: project._id,
created_at: {
$gte: new Date(from),
$lte: new Date(to)
}
},
},
{ $group: { _id: "$website", count: { $sum: 1, } } }, { $group: { _id: "$website", count: { $sum: 1, } } },
{ $sort: { count: -1 } }, { $sort: { count: -1 } },
{ $limit: numLimit } { $limit: numLimit }

View File

@@ -15,12 +15,24 @@ export default defineEventHandler(async event => {
const project = await getUserProjectFromId(project_id, user); const project = await getUserProjectFromId(project_id, user);
if (!project) return; if (!project) return;
const { name: eventName } = getQuery(event); const { name: eventName, from, to } = getQuery(event);
if (!eventName) return [];
if (!from) return setResponseStatus(event, 400, 'from is required');
if (!to) return setResponseStatus(event, 400, 'to is required');
if (!eventName) return setResponseStatus(event, 400, 'name is required');
const allEvents = await EventModel.find({ project_id: project_id, name: eventName }, { flowHash: 1 }); const allEvents = await EventModel.find({
project_id: project_id,
name: eventName,
created_at: {
$gte: new Date(from.toString()),
$lte: new Date(to.toString()),
}
}, { flowHash: 1 });
const allFlowHashes = new Map<string, number>(); const allFlowHashes = new Map<string, number>();
allEvents.forEach(e => { allEvents.forEach(e => {
@@ -71,6 +83,17 @@ export default defineEventHandler(async event => {
grouped[referrer]++; grouped[referrer]++;
} }
const eventsCount = allEvents.length;
const allGroupedValue = Object.keys(grouped)
.map(key => grouped[key])
.reduce((a, e) => a + e, 0);
for (const key in grouped) {
grouped[key] = 100 / allGroupedValue * grouped[key];
}
return grouped; return grouped;
}); });

View File

@@ -15,11 +15,24 @@ export default defineEventHandler(async event => {
const project = await getUserProjectFromId(project_id, user); const project = await getUserProjectFromId(project_id, user);
if (!project) return; if (!project) return;
const { name: eventName, field } = getQuery(event); const { name: eventName, field, from, to } = getQuery(event);
if (!eventName || !field) return [];
if (!from) return setResponseStatus(event, 400, 'from is required');
if (!to) return setResponseStatus(event, 400, 'to is required');
if (!eventName) return setResponseStatus(event, 400, 'name is required');
if (!field) return setResponseStatus(event, 400, 'field is required');
const aggregation: PipelineStage[] = [ const aggregation: PipelineStage[] = [
{ $match: { project_id: project._id, name: eventName } }, {
$match: {
project_id: project._id, name: eventName,
created_at: {
$gte: new Date(from.toString()),
$lte: new Date(to.toString()),
}
}
},
{ $group: { _id: `$metadata.${field}`, count: { $sum: 1 } } }, { $group: { _id: `$metadata.${field}`, count: { $sum: 1 } } },
{ $sort: { count: -1 } } { $sort: { count: -1 } }
] ]

View File

@@ -15,8 +15,8 @@ export default defineEventHandler(async event => {
const { slice, from, to } = await readBody(event); const { slice, from, to } = await readBody(event);
if (!from) return setResponseStatus(event, 400, 'from is required'); if (!from) return setResponseStatus(event, 400, 'from is required');
if (!from) return setResponseStatus(event, 400, 'to is required'); if (!to) return setResponseStatus(event, 400, 'to is required');
if (!from) return setResponseStatus(event, 400, 'slice is required'); if (!slice) return setResponseStatus(event, 400, 'slice is required');
return await Redis.useCache({ return await Redis.useCache({
key: `timeline:events:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, key: `timeline:events:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,

View File

@@ -2,6 +2,7 @@ import { EventModel } from "@schema/metrics/EventSchema";
import { getTimeline } from "./generic"; import { getTimeline } from "./generic";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService"; import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { executeAdvancedTimelineAggregation } from "~/server/services/TimelineService";
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event); const project_id = getRequestProjectId(event);
@@ -11,16 +12,23 @@ export default defineEventHandler(async event => {
if (!project) return; if (!project) return;
const { slice, duration } = await readBody(event); const { slice, from, to } = await readBody(event);
if (!from) return setResponseStatus(event, 400, 'from is required');
if (!to) return setResponseStatus(event, 400, 'to is required');
if (!slice) return setResponseStatus(event, 400, 'slice is required');
return await Redis.useCache({ key: `timeline:events_stacked:${project_id}:${slice}`, exp: TIMELINE_EXPIRE_TIME }, async () => { return await Redis.useCache({ key: `timeline:events_stacked:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, exp: TIMELINE_EXPIRE_TIME }, async () => {
const timelineStackedEvents = await getTimeline(EventModel, project_id, slice, duration,
{}, const timelineStackedEvents = await executeAdvancedTimelineAggregation<{ name: String }>({
{}, model: EventModel,
{ name: "$_id.name" }, projectId: project._id,
{ name: '$name' } from, to, slice,
); customProjection: { name: "$_id.name" },
customIdGroup: { name: '$name' },
})
return timelineStackedEvents; return timelineStackedEvents;
}); });

View File

@@ -16,8 +16,8 @@ export default defineEventHandler(async event => {
const { slice, from, to, referrer } = await readBody(event); const { slice, from, to, referrer } = await readBody(event);
if (!from) return setResponseStatus(event, 400, 'from is required'); if (!from) return setResponseStatus(event, 400, 'from is required');
if (!from) return setResponseStatus(event, 400, 'to is required'); if (!to) return setResponseStatus(event, 400, 'to is required');
if (!from) return setResponseStatus(event, 400, 'slice is required'); if (!slice) return setResponseStatus(event, 400, 'slice is required');
return await Redis.useCache({ return await Redis.useCache({
key: `timeline:referrers:${referrer}:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, key: `timeline:referrers:${referrer}:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,

View File

@@ -16,8 +16,8 @@ export default defineEventHandler(async event => {
const { slice, from, to } = await readBody(event); const { slice, from, to } = await readBody(event);
if (!from) return setResponseStatus(event, 400, 'from is required'); if (!from) return setResponseStatus(event, 400, 'from is required');
if (!from) return setResponseStatus(event, 400, 'to is required'); if (!to) return setResponseStatus(event, 400, 'to is required');
if (!from) return setResponseStatus(event, 400, 'slice is required'); if (!slice) return setResponseStatus(event, 400, 'slice is required');
return await Redis.useCache({ return await Redis.useCache({
key: `timeline:sessions:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, key: `timeline:sessions:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,

View File

@@ -27,8 +27,8 @@ export default defineEventHandler(async event => {
const { slice, from, to } = await readBody(event); const { slice, from, to } = await readBody(event);
if (!from) return setResponseStatus(event, 400, 'from is required'); if (!from) return setResponseStatus(event, 400, 'from is required');
if (!from) return setResponseStatus(event, 400, 'to is required'); if (!to) return setResponseStatus(event, 400, 'to is required');
if (!from) return setResponseStatus(event, 400, 'slice is required'); if (!slice) return setResponseStatus(event, 400, 'slice is required');
return await Redis.useCache({ return await Redis.useCache({
key: `timeline:sessions_duration:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, key: `timeline:sessions_duration:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,

View File

@@ -17,8 +17,8 @@ export default defineEventHandler(async event => {
const { slice, from, to } = await readBody(event); const { slice, from, to } = await readBody(event);
if (!from) return setResponseStatus(event, 400, 'from is required'); if (!from) return setResponseStatus(event, 400, 'from is required');
if (!from) return setResponseStatus(event, 400, 'to is required'); if (!to) return setResponseStatus(event, 400, 'to is required');
if (!from) return setResponseStatus(event, 400, 'slice is required'); if (!slice) return setResponseStatus(event, 400, 'slice is required');
return await Redis.useCache({ return await Redis.useCache({
key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`, key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,

View File

@@ -1,29 +0,0 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { ProjectModel } from "@schema/ProjectSchema";
import { EventModel } from "@schema/metrics/EventSchema";
export type CustomEventsAggregated = {
_id: string,
count: number
}
export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event);
if (!project_id) return;
const user = getRequestUser(event);
const project = await getUserProjectFromId(project_id, user);
if (!project) return;
const websites: CustomEventsAggregated[] = await EventModel.aggregate([
{ $match: { project_id: project._id }, },
{ $group: { _id: "$name", count: { $sum: 1, } } },
{ $sort: { count: -1 } }
]);
return websites;
});

View File

@@ -3,9 +3,7 @@ import StripeService from '~/server/services/StripeService';
import type Event from 'stripe'; import type Event from 'stripe';
import { ProjectModel } from '@schema/ProjectSchema'; import { ProjectModel } from '@schema/ProjectSchema';
import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM'; import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM';
import { ProjectCountModel } from '@schema/ProjectsCounts';
import { ProjectLimitModel } from '@schema/ProjectsLimits'; import { ProjectLimitModel } from '@schema/ProjectsLimits';
import { UserModel } from '@schema/UserSchema';

View File

@@ -0,0 +1,30 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { TeamMemberModel } from "@schema/TeamMemberSchema";
import { UserModel } from "@schema/UserSchema";
import { UserSettingsModel } from "@schema/UserSettings";
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
const project_id = currentActiveProject.active_project_id;
const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not found');
if (project.owner.toString() != userData.id) {
return setResponseStatus(event, 400, 'You are not the owner');
}
const { name } = await readBody(event);
project.name = name;
await project.save();
return { ok: true };
});

View File

@@ -39,7 +39,8 @@ export default defineEventHandler(async event => {
await ProjectCountModel.create({ await ProjectCountModel.create({
project_id: project._id, project_id: project._id,
events: 0, events: 0,
visits: 0 visits: 0,
sessions: 0
}); });
await ProjectLimitModel.updateOne({ project_id: project._id }, { await ProjectLimitModel.updateOne({ project_id: project._id }, {
@@ -76,7 +77,8 @@ export default defineEventHandler(async event => {
await ProjectCountModel.create({ await ProjectCountModel.create({
project_id: project._id, project_id: project._id,
events: 0, events: 0,
visits: 0 visits: 0,
sessions: 0
}); });
return project.toJSON() as TProject; return project.toJSON() as TProject;

View File

@@ -0,0 +1,19 @@
import { ProjectSnapshotModel, TProjectSnapshot } from "@schema/ProjectSnapshot";
import { UserSettingsModel } from "@schema/UserSettings";
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
const project_id = currentActiveProject.active_project_id;
const snapshots = await ProjectSnapshotModel.find({ project_id });
return snapshots.map(e => e.toJSON()) as TProjectSnapshot[];
});

View File

@@ -0,0 +1,43 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
import { UserSettingsModel } from "@schema/UserSettings";
export default defineEventHandler(async event => {
const body = await readBody(event);
const { name: newSnapshotName, from, to, color: snapshotColor } = body;
if (!newSnapshotName) return setResponseStatus(event, 400, 'SnapshotName too short');
if (newSnapshotName.length == 0) return setResponseStatus(event, 400, 'SnapshotName too short');
if (!from) return setResponseStatus(event, 400, 'from is required');
if (!to) return setResponseStatus(event, 400, 'to is required');
if (!snapshotColor) return setResponseStatus(event, 400, 'color is required');
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { active_project_id: 1 });
if (!userSettings) return setResponseStatus(event, 500, 'Unkwnown error');
const currentProjectId = userSettings.active_project_id;
const project = await ProjectModel.findById(currentProjectId);
if (!project) return setResponseStatus(event, 400, 'Project not found');
const newSnapshot = await ProjectSnapshotModel.create({
name: newSnapshotName,
from: new Date(from),
to: new Date(to),
color: snapshotColor,
project_id: currentProjectId
});
return newSnapshot.id;
});

View File

@@ -0,0 +1,35 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
import { UserSettingsModel } from "@schema/UserSettings";
export default defineEventHandler(async event => {
const body = await readBody(event);
const { id: snapshotId } = body;
if (!snapshotId) return setResponseStatus(event, 400, 'id is required');
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const userSettings = await UserSettingsModel.findOne({ user_id: userData.id }, { active_project_id: 1 });
if (!userSettings) return setResponseStatus(event, 500, 'Unkwnown error');
const currentProjectId = userSettings.active_project_id;
const project = await ProjectModel.findById(currentProjectId);
if (!project) return setResponseStatus(event, 400, 'Project not found');
const deletation = await ProjectSnapshotModel.deleteOne({
project_id: currentProjectId,
_id: snapshotId
});
return { ok: deletation.acknowledged };
});

View File

@@ -21,11 +21,14 @@ export class Redis {
url: runtimeConfig.REDIS_URL, url: runtimeConfig.REDIS_URL,
username: runtimeConfig.REDIS_USERNAME, username: runtimeConfig.REDIS_USERNAME,
password: runtimeConfig.REDIS_PASSWORD, password: runtimeConfig.REDIS_PASSWORD,
database: process.dev ? 1 : 0 database: process.dev ? 1 : 0,
}); });
static async init() { static async init() {
await this.client.connect(); await this.client.connect();
this.client.on('error', function (err) {
console.error('Redis error:', err);
});
} }
static async setString(key: string, value: string, exp: number) { static async setString(key: string, value: string, exp: number) {

View File

@@ -16,14 +16,16 @@ export type TimelineAggregationOptions = {
export type AdvancedTimelineAggregationOptions = TimelineAggregationOptions & { export type AdvancedTimelineAggregationOptions = TimelineAggregationOptions & {
customMatch?: Record<string, any>, customMatch?: Record<string, any>,
customGroup?: Record<string, any>, customGroup?: Record<string, any>,
customProjection?: Record<string, any> customProjection?: Record<string, any>,
customIdGroup?: Record<string, any>
} }
export async function executeAdvancedTimelineAggregation(options: AdvancedTimelineAggregationOptions) { export async function executeAdvancedTimelineAggregation<T = {}>(options: AdvancedTimelineAggregationOptions) {
options.customMatch = options.customMatch || {}; options.customMatch = options.customMatch || {};
options.customGroup = options.customGroup || {}; options.customGroup = options.customGroup || {};
options.customProjection = options.customProjection || {}; options.customProjection = options.customProjection || {};
options.customIdGroup = options.customIdGroup || {};
const { group, sort, fromParts } = DateService.getQueryDateRange(options.slice); const { group, sort, fromParts } = DateService.getQueryDateRange(options.slice);
@@ -35,7 +37,7 @@ export async function executeAdvancedTimelineAggregation(options: AdvancedTimeli
...options.customMatch ...options.customMatch
} }
}, },
{ $group: { _id: group, count: { $sum: 1 }, ...options.customGroup } }, { $group: { _id: { ...group, ...options.customIdGroup }, count: { $sum: 1 }, ...options.customGroup } },
{ $sort: sort }, { $sort: sort },
{ $project: { _id: { $dateFromParts: fromParts }, count: "$count", ...options.customProjection } } { $project: { _id: { $dateFromParts: fromParts }, count: "$count", ...options.customProjection } }
] ]
@@ -44,7 +46,7 @@ export async function executeAdvancedTimelineAggregation(options: AdvancedTimeli
console.log(JSON.stringify(aggregation, null, 2)); console.log(JSON.stringify(aggregation, null, 2));
} }
const timeline: { _id: string, count: number }[] = await options.model.aggregate(aggregation); const timeline: { _id: string, count: number & T }[] = await options.model.aggregate(aggregation);
return timeline; return timeline;

View File

@@ -29,6 +29,48 @@ module.exports = {
light: '#2c91ed', light: '#2c91ed',
sub: '#99A7F1', sub: '#99A7F1',
}, },
"lyx-primary": {
DEFAULT: '#5680F8',
dark: '#222A42',
hover: '#2A3450'
},
"lyx-text": {
DEFAULT: '#FFFFFF',
dark: '#D4D4D4',
darker: '#6A6A6A'
},
"lyx-widget": {
DEFAULT: '#151515',
light: '#1E1E1E',
lighter: '#262626'
},
"lyx-background": {
DEFAULT: '#0A0A0A',
light: '#121212',
lighter: '#212121'
},
"lyx-danger": {
DEFAULT: '#F86956',
dark: '#4A2D29'
},
"lyx-chart": {
purple: {
DEFAULT: '#5655D7',
dark: '#282844'
},
green: {
DEFAULT: '#1D9B86',
dark: '#213734'
},
cyan: {
DEFAULT: '#4ABDE8',
dark: '#273D48'
},
orange: {
DEFAULT: '#F56524',
dark: '#492C22'
}
}
} }
}, },
}, },

View File

@@ -60,9 +60,10 @@ export function fixMetrics(result: { data: MetricsTimeline[], from: string, to:
} }
} }
const allKeys = !options.advanced ? [] : Array.from(new Set(result.data.map((e: any) => e[options.advancedGroupKey])).values()); const allKeys = !options.advanced ? [] : Array.from(new Set(result.data.map((e: any) => e[options.advancedGroupKey])).values());
console.log({allKeys})
const fixed: any[] = allDates.map(matchDate => { const fixed: any[] = allDates.map(matchDate => {
if (!options.advanced) { if (!options.advanced) {
@@ -85,6 +86,7 @@ export function fixMetrics(result: { data: MetricsTimeline[], from: string, to:
return returnObject; return returnObject;
}); });
if (slice === 'day' || slice == 'hour') fixed.pop(); if (slice === 'day' || slice == 'hour') fixed.pop();
const data = fixed.map(e => e.count); const data = fixed.map(e => e.count);

View File

@@ -1,5 +1,8 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0");
.poppins { .poppins {
font-family: 'Poppins' !important; font-family: 'Poppins' !important;
} }

View File

@@ -1,18 +1,19 @@
<script lang="ts" setup> <script lang="ts" setup>
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'danger'; export type ButtonType = 'primary' | 'secondary' | 'outline' | 'outlined' | 'danger';
const props = defineProps<{ type: ButtonType, link?: string, target?: string }>(); const props = defineProps<{ type: ButtonType, link?: string, target?: string, disabled?: boolean }>();
</script> </script>
<template> <template>
<NuxtLink tag="div" :to="link" :target="target" <NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-text" :class="{ class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-text" :class="{
'bg-lyx-primary-dark outline-lyx-primary hover:bg-lyx-primary-hover': type === 'primary', 'bg-lyx-primary-dark outline-lyx-primary hover:bg-lyx-primary-hover': type === 'primary',
'bg-lyx-widget-lighter outline-lyx-widget-lighter hover:bg-lyx-widget-light': type === 'secondary', 'bg-lyx-widget-lighter outline-lyx-widget-lighter hover:bg-lyx-widget-light': type === 'secondary',
'bg-lyx-transparent outline-lyx-widget-lighter hover:bg-lyx-widget-light': type === 'outline', 'bg-lyx-transparent outline-lyx-widget-lighter hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
'bg-lyx-danger-dark outline-lyx-danger hover:bg-lyx-danger': type === 'danger', 'bg-lyx-danger-dark outline-lyx-danger hover:bg-lyx-danger': type === 'danger',
'!bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
}"> }">
<slot></slot> <slot></slot>
</NuxtLink> </NuxtLink>

View File

@@ -0,0 +1,20 @@
import { model, Schema, Types } from 'mongoose';
export type TProjectSnapshot = {
_id: Schema.Types.ObjectId,
project_id: Schema.Types.ObjectId,
name: string,
from: Date,
to: Date,
color: string
}
const ProjectSnapshotSchema = new Schema<TProjectSnapshot>({
project_id: { type: Types.ObjectId, index: true },
name: { type: String, required: true },
from: { type: Date, required: true },
to: { type: Date, required: true },
color: { type: String, required: true },
});
export const ProjectSnapshotModel = model<TProjectSnapshot>('project_snapshots', ProjectSnapshotSchema);

View File

@@ -5,12 +5,14 @@ export type TProjectCount = {
project_id: Schema.Types.ObjectId, project_id: Schema.Types.ObjectId,
events: number, events: number,
visits: number, visits: number,
sessions: number,
} }
const ProjectCountSchema = new Schema<TProjectCount>({ const ProjectCountSchema = new Schema<TProjectCount>({
project_id: { type: Types.ObjectId, index: true, unique: true }, project_id: { type: Types.ObjectId, index: true, unique: true },
events: { type: Number, required: true, default: 0 }, events: { type: Number, required: true, default: 0 },
visits: { type: Number, required: true, default: 0 }, visits: { type: Number, required: true, default: 0 },
sessions: { type: Number, required: true, default: 0 },
}); });
export const ProjectCountModel = model<TProjectCount>('project_counts', ProjectCountSchema); export const ProjectCountModel = model<TProjectCount>('project_counts', ProjectCountSchema);

View File

@@ -36,7 +36,7 @@ const VisitSchema = new Schema<TVisit>({
website: { type: String, required: true }, website: { type: String, required: true },
page: { type: String, required: true }, page: { type: String, required: true },
referrer: { type: String, required: true }, referrer: { type: String, required: true },
created_at: { type: Date, default: () => Date.now() }, created_at: { type: Date, default: () => Date.now(), index: true },
}) })
export const VisitModel = model<TVisit>('visits', VisitSchema); export const VisitModel = model<TVisit>('visits', VisitSchema);

View File

@@ -108,6 +108,8 @@ class DateService {
const lastDate = dayjs(dates.at(-1)); const lastDate = dayjs(dates.at(-1));
let currentDate = firstDate.clone(); let currentDate = firstDate.clone();
allDates.push(currentDate);
while (currentDate.isBefore(lastDate, slice)) { while (currentDate.isBefore(lastDate, slice)) {
currentDate = currentDate.add(1, slice); currentDate = currentDate.add(1, slice);
allDates.push(currentDate); allDates.push(currentDate);