mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 07:48:37 +01:00
change in progress
This commit is contained in:
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"files.exclude": {
|
||||||
|
"**/node_modules": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,8 +27,7 @@ type Props = {
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
const { isAdmin } = useUserRoles();
|
const { user, userRoles, setLoggedUser } = useLoggedUser()
|
||||||
const loggedUser = useLoggedUser()
|
|
||||||
|
|
||||||
const debugMode = process.dev;
|
const debugMode = process.dev;
|
||||||
|
|
||||||
@@ -102,16 +101,8 @@ function onLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { projects } = useProjectsList();
|
const { projects } = useProjectsList();
|
||||||
const { data: guestProjects } = useGuestProjectsList()
|
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
const selectorProjects = computed(() => {
|
|
||||||
const result: TProject[] = [];
|
|
||||||
if (projects.value) result.push(...projects.value);
|
|
||||||
if (guestProjects.value) result.push(...guestProjects.value);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||||
headers: computed(() => {
|
headers: computed(() => {
|
||||||
return {
|
return {
|
||||||
@@ -120,21 +111,10 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const selected = ref<TProject>(activeProject.value as TProject);
|
|
||||||
watch(selected, () => {
|
|
||||||
setActiveProject(selected.value._id.toString())
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPremium = computed(() => {
|
const isPremium = computed(() => {
|
||||||
return activeProject.value?.premium;
|
return activeProject.value?.premium;
|
||||||
})
|
})
|
||||||
|
|
||||||
function isProjectMine(owner?: string) {
|
|
||||||
if (!owner) return false;
|
|
||||||
if (!loggedUser.value?.logged) return;
|
|
||||||
return loggedUser.value.id == owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pricingDrawer = usePricingDrawer();
|
const pricingDrawer = usePricingDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -158,36 +138,7 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
|
|
||||||
<div class="flex items-center gap-2 w-full">
|
<div class="flex items-center gap-2 w-full">
|
||||||
|
|
||||||
<USelectMenu :uiMenu="{
|
<ProjectSelector></ProjectSelector>
|
||||||
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="selectorProjects" v-model="selected" :options="selectorProjects">
|
|
||||||
|
|
||||||
<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 }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</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 || '-' }}
|
|
||||||
{{ !isProjectMine(activeProject?.owner?.toString()) ? '(Guest)' : '' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</USelectMenu>
|
|
||||||
|
|
||||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
||||||
<i @click="close()" class="fas fa-close"></i>
|
<i @click="close()" class="fas fa-close"></i>
|
||||||
@@ -289,7 +240,7 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
|
|
||||||
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
|
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
|
||||||
|
|
||||||
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
<div v-if="(!entry.adminOnly || (userRoles.isAdmin && !isAdminHidden))"
|
||||||
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||||
:class="{
|
:class="{
|
||||||
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
||||||
@@ -337,7 +288,7 @@ const pricingDrawer = usePricingDrawer();
|
|||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||||
<i class="fab fa-dev"></i>
|
<i class="fab fa-dev"></i>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/admin" v-if="isAdmin"
|
<NuxtLink to="/admin" v-if="userRoles.isAdmin"
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||||
<i class="fas fa-cat"></i>
|
<i class="fas fa-cat"></i>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
62
dashboard/components/ProjectSelector.vue
Normal file
62
dashboard/components/ProjectSelector.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { TProject } from '@schema/ProjectSchema';
|
||||||
|
|
||||||
|
const { user } = useLoggedUser()
|
||||||
|
|
||||||
|
const { projectList, actions, project } = useProject();
|
||||||
|
const { data: guestProjects } = useGuestProjectsList()
|
||||||
|
|
||||||
|
const selectorProjects = computed(() => {
|
||||||
|
const result: TProject[] = [];
|
||||||
|
if (projectList.value) result.push(...projectList.value);
|
||||||
|
if (guestProjects.value) result.push(...guestProjects.value);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
function isProjectMine(owner?: string) {
|
||||||
|
if (!owner) return false;
|
||||||
|
if (!user.value) return false;
|
||||||
|
if (!user.value.logged) return;
|
||||||
|
return user.value.id == owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(e: TProject) {
|
||||||
|
actions.setActiveProject(e._id.toString());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<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="selectorProjects" @change="onChange" :value="project" :options="selectorProjects">
|
||||||
|
|
||||||
|
<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 }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</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>
|
||||||
|
{{ project?.name || '-' }}
|
||||||
|
{{ !isProjectMine(project?.owner?.toString()) ? '(Guest)' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
|
|
||||||
|
</template>
|
||||||
@@ -3,15 +3,45 @@
|
|||||||
import DateService from '@services/DateService';
|
import DateService from '@services/DateService';
|
||||||
import type { Slice } from '@services/DateService';
|
import type { Slice } from '@services/DateService';
|
||||||
|
|
||||||
const { data: metricsInfo } = useMetricsData();
|
|
||||||
|
|
||||||
const { snapshot, safeSnapshotDates } = useSnapshot()
|
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(() => {
|
const snapshotDays = computed(() => {
|
||||||
return (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
|
const to = new Date(safeSnapshotDates.value.to).getTime();
|
||||||
|
const from = new Date(safeSnapshotDates.value.from).getTime();
|
||||||
|
return (to - from) / 1000 / 60 / 60 / 24;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 || 0);
|
||||||
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
|
||||||
|
const pool = [...input.map(e => e.count || 0)];
|
||||||
|
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 visitsData = useFetch('/api/timeline/visits', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgVisitDay = computed(() => {
|
const avgVisitDay = computed(() => {
|
||||||
@@ -21,13 +51,6 @@ const avgVisitDay = computed(() => {
|
|||||||
return avg.toFixed(2);
|
return avg.toFixed(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// const avgEventsDay = computed(() => {
|
|
||||||
// if (!eventsData.data.value) return '0.00';
|
|
||||||
// const counts = eventsData.data.value.data.reduce((a, e) => e + a, 0);
|
|
||||||
// const avg = counts / Math.max(snapshotDays.value, 1);
|
|
||||||
// return avg.toFixed(2);
|
|
||||||
// });
|
|
||||||
|
|
||||||
const avgSessionsDay = computed(() => {
|
const avgSessionsDay = computed(() => {
|
||||||
if (!sessionsData.data.value) return '0.00';
|
if (!sessionsData.data.value) return '0.00';
|
||||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||||
@@ -47,8 +70,14 @@ const avgBouncingRate = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const avgSessionDuration = computed(() => {
|
const avgSessionDuration = computed(() => {
|
||||||
if (!metricsInfo.value) return '0.00';
|
if (!sessionsDurationData.data.value) return '0.00 %'
|
||||||
const avg = metricsInfo.value.avgSessionDuration;
|
|
||||||
|
const counts = sessionsDurationData.data.value.data
|
||||||
|
.filter(e => e > 0)
|
||||||
|
.reduce((a, e) => e + a, 0);
|
||||||
|
|
||||||
|
const avg = counts / Math.max(sessionsDurationData.data.value.data.filter(e => e > 0).length, 1);
|
||||||
|
|
||||||
let hours = 0;
|
let hours = 0;
|
||||||
let minutes = 0;
|
let minutes = 0;
|
||||||
let seconds = 0;
|
let seconds = 0;
|
||||||
@@ -59,91 +88,6 @@ const avgSessionDuration = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 computedHeaders = computed(() => {
|
|
||||||
return {
|
|
||||||
...signHeaders().headers,
|
|
||||||
'x-pid': activeProject.value?._id.toString() || '',
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
'x-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
|
|
||||||
});
|
|
||||||
|
|
||||||
const bouncingRateData = useFetch(`/api/data/bouncing_rate`, {
|
|
||||||
headers: computedHeaders, lazy: true, immediate: false,
|
|
||||||
transform: (input: { data: string, value: number | null }[]) => {
|
|
||||||
const data = input.map(e => e.value || 0);
|
|
||||||
const labels = input.map(e => DateService.getChartLabelFromISO(e.data, navigator.language, chartSlice.value));
|
|
||||||
const pool = [...input.map(e => e.value || 0)];
|
|
||||||
pool.pop();
|
|
||||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
|
||||||
const diffPercent: number = (100 / avg * (input.at(-1)?.value || 0)) - 100;
|
|
||||||
const trend = Math.max(Math.min(diffPercent, 99), -99);
|
|
||||||
return { data, labels, trend }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
visitsData.execute();
|
|
||||||
bouncingRateData.execute();
|
|
||||||
sessionsData.execute();
|
|
||||||
sessionsDurationData.execute()
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -157,8 +101,7 @@ onMounted(async () => {
|
|||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
<DashboardCountCard :ready="!bouncingRateData.pending.value" icon="far fa-chart-user" text="Bouncing rate"
|
<DashboardCountCard :ready="!bouncingRateData.pending.value" icon="far fa-chart-user" text="Bouncing rate"
|
||||||
:value="avgBouncingRate" :trend="bouncingRateData.data.value?.trend"
|
:value="avgBouncingRate" :trend="bouncingRateData.data.value?.trend" :slow="true"
|
||||||
:slow="true"
|
|
||||||
:data="bouncingRateData.data.value?.data" :labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
:data="bouncingRateData.data.value?.data" :labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
@@ -170,8 +113,8 @@ onMounted(async () => {
|
|||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
|
|
||||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Total avg session time"
|
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||||
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
text="Total avg session time" :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
||||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
||||||
color="#f56523">
|
color="#f56523">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
const { project } = useProject();
|
||||||
|
|
||||||
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
||||||
onMounted(() => startWatching());
|
onMounted(() => startWatching());
|
||||||
onUnmounted(() => stopWatching());
|
onUnmounted(() => stopWatching());
|
||||||
@@ -9,8 +11,9 @@ onUnmounted(() => stopWatching());
|
|||||||
const { createAlert } = useAlert();
|
const { createAlert } = useAlert();
|
||||||
|
|
||||||
function copyProjectId() {
|
function copyProjectId() {
|
||||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
if (!navigator.clipboard) return alert('You can\'t copy in HTTP');
|
||||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
if (!project.value) return alert('Project not loaded');
|
||||||
|
navigator.clipboard.writeText((project.value._id).toString());
|
||||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ function showAnomalyInfoAlert() {
|
|||||||
|
|
||||||
<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 class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
|
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
|
||||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }}
|
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ project?.name || 'Loading...' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,7 +53,7 @@ function showAnomalyInfoAlert() {
|
|||||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">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-lyx-text poppins font-medium text-[1.2rem]">
|
<div class="text-lyx-text poppins font-medium text-[1.2rem]">
|
||||||
{{ activeProject?._id || 'Loading...' }}
|
{{ project?._id || 'Loading...' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center ml-3">
|
<div class="flex items-center ml-3">
|
||||||
<i @click="copyProjectId()"
|
<i @click="copyProjectId()"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const ACCESS_TOKEN_STATE_KEY = 'access_token';
|
|
||||||
const ACCESS_TOKEN_COOKIE_KEY = 'access_token';
|
const ACCESS_TOKEN_COOKIE_KEY = 'access_token';
|
||||||
|
|
||||||
|
const tokenCookie = useCookie(ACCESS_TOKEN_COOKIE_KEY, { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) });
|
||||||
|
const token = ref<string | undefined>();
|
||||||
|
|
||||||
export function signHeaders(headers?: Record<string, string>) {
|
export function signHeaders(headers?: Record<string, string>) {
|
||||||
const { token } = useAccessToken()
|
const { token } = useAccessToken()
|
||||||
@@ -15,26 +14,12 @@ export const authorizationHeaderComputed = computed(() => {
|
|||||||
return token.value ? 'Bearer ' + token.value : '';
|
return token.value ? 'Bearer ' + token.value : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useAccessToken() {
|
function setToken(value: string) {
|
||||||
|
tokenCookie.value = value;
|
||||||
const tokenCookie = useCookie(ACCESS_TOKEN_COOKIE_KEY, { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) })
|
token.value = value;
|
||||||
|
}
|
||||||
const token = useState<string | undefined | null>(ACCESS_TOKEN_STATE_KEY);
|
|
||||||
const needLoad = useState<boolean>('needAccessTokenLoad', () => true);
|
export function useAccessToken() {
|
||||||
|
if (!token.value) token.value = tokenCookie.value as any;
|
||||||
|
return { setToken, token }
|
||||||
const readToken = () => {
|
|
||||||
token.value = tokenCookie.value;
|
|
||||||
needLoad.value = false;
|
|
||||||
}
|
|
||||||
const setToken = (newToken: string) => {
|
|
||||||
tokenCookie.value = newToken;
|
|
||||||
token.value = tokenCookie.value;
|
|
||||||
needLoad.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needLoad.value == true) readToken();
|
|
||||||
|
|
||||||
|
|
||||||
return { token, readToken, setToken, needLoad }
|
|
||||||
}
|
}
|
||||||
@@ -1,80 +1,27 @@
|
|||||||
import type { InternalApi } from 'nitropack';
|
|
||||||
import type { WatchSource, WatchStopHandle } from 'vue';
|
|
||||||
|
|
||||||
|
export type CustomOptions = {
|
||||||
type NitroFetchRequest = Exclude<keyof InternalApi, `/_${string}` | `/api/_${string}`> | (string & {});
|
useSnapshotDates?: boolean,
|
||||||
|
useActivePid?: boolean,
|
||||||
export type CustomFetchOptions = {
|
slice?: string,
|
||||||
watchProps?: WatchSource[],
|
|
||||||
lazy?: boolean,
|
|
||||||
method?: string,
|
|
||||||
getBody?: () => Record<string, any>,
|
|
||||||
watchKey?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OnResponseCallback<TData> = (data: Ref<TData | undefined>) => any
|
const { token } = useAccessToken();
|
||||||
type OnRequestCallback = () => any
|
const { projectId } = useProject();
|
||||||
|
const { safeSnapshotDates } = useSnapshot()
|
||||||
|
|
||||||
|
export function useComputedHeaders(customOptions?: CustomOptions) {
|
||||||
|
const useSnapshotDates = customOptions?.useSnapshotDates || true;
|
||||||
|
const useActivePid = customOptions?.useActivePid || true;
|
||||||
|
|
||||||
const watchStopHandles: Record<string, WatchStopHandle> = {}
|
const headers = computed<Record<string, string>>(() => {
|
||||||
|
return {
|
||||||
export function useCustomFetch<T>(url: NitroFetchRequest, getHeaders: () => Record<string, string>, options?: CustomFetchOptions) {
|
'Authorization': `Bearer ${token.value}`,
|
||||||
|
'x-pid': useActivePid ? (projectId.value ?? '') : '',
|
||||||
const pending = ref<boolean>(false);
|
'x-from': useSnapshotDates ? (safeSnapshotDates.value.from ?? '') : '',
|
||||||
const data = ref<T | undefined>();
|
'x-to': useSnapshotDates ? (safeSnapshotDates.value.to ?? '') : '',
|
||||||
const error = ref<Error | undefined>();
|
'x-slice': customOptions?.slice ?? ''
|
||||||
|
|
||||||
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) {
|
return headers;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,34 @@
|
|||||||
import type { AuthContext } from "~/server/middleware/01-authorization";
|
import type { AuthContext } from "~/server/middleware/01-authorization";
|
||||||
|
|
||||||
const LOGGED_USER_STATE_KEY = 'logged_user';
|
|
||||||
|
const loggedUser = ref<AuthContext | undefined>();
|
||||||
|
|
||||||
|
const setLoggedUser = (authContext?: AuthContext) => {
|
||||||
|
loggedUser.value = authContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export function useLoggedUser() {
|
function getUserRoles() {
|
||||||
const loggedUserState = useState<AuthContext | undefined>(LOGGED_USER_STATE_KEY);
|
|
||||||
return loggedUserState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLoggedUser(authContext?: AuthContext) {
|
|
||||||
useLoggedUser().value = authContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isAdminHidden = ref<boolean>(false);
|
|
||||||
|
|
||||||
export function useUserRoles() {
|
|
||||||
|
|
||||||
const isPremium = computed(() => {
|
const isPremium = computed(() => {
|
||||||
const loggedUser = useLoggedUser();
|
|
||||||
if (!loggedUser.value?.logged) return false;
|
if (!loggedUser.value?.logged) return false;
|
||||||
return loggedUser.value.user.roles.includes('PREMIUM');
|
return loggedUser.value.user.roles.includes('PREMIUM');
|
||||||
});
|
});
|
||||||
const isAdmin = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
const loggedUser = useLoggedUser();
|
|
||||||
if (!loggedUser.value?.logged) return false;
|
if (!loggedUser.value?.logged) return false;
|
||||||
return loggedUser.value.user.roles.includes('ADMIN');
|
return loggedUser.value.user.roles.includes('ADMIN');
|
||||||
});
|
});
|
||||||
return { isPremium, isAdmin }
|
return { isPremium, isAdmin }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isAdminHidden = ref<boolean>(false);
|
||||||
|
|
||||||
|
export function useLoggedUser() {
|
||||||
|
return {
|
||||||
|
user: loggedUser,
|
||||||
|
userRoles: getUserRoles(),
|
||||||
|
setLoggedUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
53
dashboard/composables/useProject.ts
Normal file
53
dashboard/composables/useProject.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import type { TProject } from "@schema/ProjectSchema";
|
||||||
|
import { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
|
||||||
|
|
||||||
|
const { token } = useAccessToken();
|
||||||
|
|
||||||
|
const projectsRequest = useFetch<TProject[]>('/api/project/list', {
|
||||||
|
headers: computed(() => {
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${token.value}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectList = computed(() => projectsRequest.data.value);
|
||||||
|
const refreshProjectsList = () => projectsRequest.refresh();
|
||||||
|
|
||||||
|
const activeProjectId = ref<string | undefined>();
|
||||||
|
|
||||||
|
const setActiveProject = (project_id: string) => {
|
||||||
|
activeProjectId.value = project_id;
|
||||||
|
localStorage.setItem('active_pid', project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = computed(() => {
|
||||||
|
if (!projectList.value) return;
|
||||||
|
if (projectList.value.length == 0) return;
|
||||||
|
if (activeProjectId.value) {
|
||||||
|
const target = projectList.value.find(e => e._id.toString() == activeProjectId.value);
|
||||||
|
if (target) return target;
|
||||||
|
}
|
||||||
|
const savedActive = localStorage.getItem('active_pid');
|
||||||
|
if (savedActive) {
|
||||||
|
const target = projectList.value.find(e => e._id.toString() == savedActive);
|
||||||
|
if (target) {
|
||||||
|
activeProjectId.value = savedActive;
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeProjectId.value = projectList.value[0]._id.toString();
|
||||||
|
return projectList.value[0];
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useProject() {
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
refreshProjectsList,
|
||||||
|
setActiveProject
|
||||||
|
}
|
||||||
|
|
||||||
|
return { project, projectList, actions, projectId: activeProjectId }
|
||||||
|
}
|
||||||
8
dashboard/composables/useRefreshKey.ts
Normal file
8
dashboard/composables/useRefreshKey.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
|
||||||
|
const { project } = useProject()
|
||||||
|
|
||||||
|
export const refreshKey = computed(() => {
|
||||||
|
if (!project.value) return 'null';
|
||||||
|
return project.value._id.toString();
|
||||||
|
})
|
||||||
@@ -2,7 +2,6 @@ import type { AuthContext } from "~/server/middleware/01-authorization";
|
|||||||
|
|
||||||
async function executeUserLogin(token: string): Promise<[true, AuthContext] | [false, null]> {
|
async function executeUserLogin(token: string): Promise<[true, AuthContext] | [false, null]> {
|
||||||
const user = await $fetch<AuthContext>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token } });
|
const user = await $fetch<AuthContext>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||||
console.log('USER RESPSONSE', user);
|
|
||||||
if (!user) return [false, null];
|
if (!user) return [false, null];
|
||||||
if (user.logged == false) return [false, null];
|
if (user.logged == false) return [false, null];
|
||||||
return [true, user];
|
return [true, user];
|
||||||
@@ -26,6 +25,7 @@ async function handleUserLogin(authContext?: AuthContext) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { setLoggedUser } = useLoggedUser();
|
||||||
setLoggedUser(newContext);
|
setLoggedUser(newContext);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -34,10 +34,11 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
|||||||
|
|
||||||
if (!to.name) return;
|
if (!to.name) return;
|
||||||
|
|
||||||
const loggedUser = useLoggedUser();
|
const { user } = useLoggedUser();
|
||||||
await handleUserLogin(loggedUser.value);
|
|
||||||
|
|
||||||
if (loggedUser.value?.logged) {
|
await handleUserLogin(user.value);
|
||||||
|
|
||||||
|
if (user.value?.logged) {
|
||||||
if (to.path == '/login') return '/';
|
if (to.path == '/login') return '/';
|
||||||
} else {
|
} else {
|
||||||
if (to.path != '/login' && to.path != '/live_demo') return '/login';
|
if (to.path != '/login' && to.path != '/live_demo') return '/login';
|
||||||
|
|||||||
@@ -49,12 +49,8 @@ const selectLabels = [
|
|||||||
// { label: 'Month', value: 'month' },
|
// { label: 'Month', value: 'month' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { snapshot } = useSnapshot();
|
const { snapshot } = useSnapshot();
|
||||||
|
|
||||||
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
|
||||||
|
|
||||||
const isPremium = computed(() => {
|
const isPremium = computed(() => {
|
||||||
return activeProject.value?.premium;
|
return activeProject.value?.premium;
|
||||||
})
|
})
|
||||||
@@ -72,7 +68,12 @@ function goToUpgrade() {
|
|||||||
|
|
||||||
<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()"
|
<div>
|
||||||
|
<DashboardTopSection :key="refreshKey"></DashboardTopSection>
|
||||||
|
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div :key="'home-' + isLiveDemo()"
|
||||||
v-if="projects && activeProject && (firstInteraction.data.value === true) && !justLogged">
|
v-if="projects && activeProject && (firstInteraction.data.value === true) && !justLogged">
|
||||||
|
|
||||||
<div class="w-full px-4 py-2 gap-2 flex flex-col">
|
<div class="w-full px-4 py-2 gap-2 flex flex-col">
|
||||||
@@ -118,35 +119,6 @@ function goToUpgrade() {
|
|||||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full">
|
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full">
|
||||||
<DashboardActionableChart :key="refreshKey"></DashboardActionableChart>
|
<DashboardActionableChart :key="refreshKey"></DashboardActionableChart>
|
||||||
</div>
|
</div>
|
||||||
<!--
|
|
||||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
|
||||||
|
|
||||||
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Visits trends"
|
|
||||||
sub="Shows trends in page visits.">
|
|
||||||
<template #header>
|
|
||||||
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
|
|
||||||
:options="selectLabels">
|
|
||||||
</SelectButton>
|
|
||||||
</template>
|
|
||||||
<div>
|
|
||||||
<DashboardVisitsLineChart :slice="(selectLabels[mainChartSelectIndex].value as any)">
|
|
||||||
</DashboardVisitsLineChart>
|
|
||||||
</div>
|
|
||||||
</CardTitled>
|
|
||||||
|
|
||||||
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions" sub="Shows trends in sessions.">
|
|
||||||
<template #header>
|
|
||||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
|
||||||
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
|
||||||
</SelectButton>
|
|
||||||
</template>
|
|
||||||
<div>
|
|
||||||
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
|
||||||
</DashboardSessionsLineChart>
|
|
||||||
</div>
|
|
||||||
</CardTitled>
|
|
||||||
|
|
||||||
</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">
|
||||||
@@ -202,7 +174,7 @@ function goToUpgrade() {
|
|||||||
|
|
||||||
<div v-if="justLogged" class="text-[2rem]">
|
<div v-if="justLogged" class="text-[2rem]">
|
||||||
The page will refresh soon
|
The page will refresh soon
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
34
dashboard/server/api/data/browsers.ts
Normal file
34
dashboard/server/api/data/browsers.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
|
||||||
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
|
import { Redis } from "~/server/services/CacheService";
|
||||||
|
import { getRequestData } from "~/server/utils/getRequestData";
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const data = await getRequestData(event, { requireSchema: false });
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const { pid, from, to, project_id, limit } = data;
|
||||||
|
|
||||||
|
const cacheKey = `browsers:${pid}:${from}:${to}`;
|
||||||
|
const cacheExp = 60;
|
||||||
|
|
||||||
|
return await Redis.useCacheV2(cacheKey, cacheExp, async () => {
|
||||||
|
|
||||||
|
const result = await VisitModel.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
project_id,
|
||||||
|
created_at: { $gte: new Date(from), $lte: new Date(to) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $group: { _id: "$browser", count: { $sum: 1, } } },
|
||||||
|
{ $sort: { count: -1 } },
|
||||||
|
{ $limit: limit }
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result as { _id: string, count: number }[];
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,57 +1,28 @@
|
|||||||
import { EventModel } from "@schema/metrics/EventSchema";
|
|
||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
|
||||||
import { Redis } from "~/server/services/CacheService";
|
import { Redis } from "~/server/services/CacheService";
|
||||||
|
import { getRequestData } from "~/server/utils/getRequestData";
|
||||||
import type { Model } from "mongoose";
|
|
||||||
|
|
||||||
|
|
||||||
const allowedModels: Record<string, { model: Model<any>, field: string }> = {
|
|
||||||
'events': {
|
|
||||||
model: EventModel,
|
|
||||||
field: 'name'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type TModelName = keyof typeof allowedModels;
|
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
const project_id = getHeader(event, 'x-pid');
|
|
||||||
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 data = await getRequestData(event);
|
||||||
const to = getRequestHeader(event, 'x-to');
|
if (!data) return;
|
||||||
|
|
||||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to are required');
|
const { schemaName, pid, from, to, model, project_id } = data;
|
||||||
|
|
||||||
const schemaName = getRequestHeader(event, 'x-schema');
|
const cacheKey = `count:${schemaName}:${pid}:${from}:${to}`;
|
||||||
if (!schemaName) return setResponseStatus(event, 400, 'x-schema is required');
|
|
||||||
|
|
||||||
if (!Object.keys(allowedModels).includes(schemaName)) return setResponseStatus(event, 400, 'x-schema value is not valid');
|
|
||||||
|
|
||||||
const cacheKey = `count:${schemaName}:${project_id}:${from}:${to}`;
|
|
||||||
const cacheExp = 60;
|
const cacheExp = 60;
|
||||||
|
|
||||||
return await Redis.useCacheV2(cacheKey, cacheExp, async (noStore, updateExp) => {
|
return await Redis.useCacheV2(cacheKey, cacheExp, async () => {
|
||||||
|
|
||||||
const { model } = allowedModels[schemaName as TModelName];
|
|
||||||
|
|
||||||
const result = await model.aggregate([
|
const result = await model.aggregate([
|
||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
project_id: project._id,
|
project_id,
|
||||||
created_at: {
|
created_at: { $gte: new Date(from), $lte: new Date(to) }
|
||||||
$gte: new Date(from),
|
|
||||||
$lte: new Date(to)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{ $count: 'count' },
|
||||||
$count: 'total'
|
|
||||||
}
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { EventModel } from "@schema/metrics/EventSchema";
|
|
||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
|
||||||
import { Redis } from "~/server/services/CacheService";
|
|
||||||
|
|
||||||
import type { Model } from "mongoose";
|
|
||||||
|
|
||||||
|
|
||||||
const allowedModels: Record<string, { model: Model<any>, field: string }> = {
|
|
||||||
'events': {
|
|
||||||
model: EventModel,
|
|
||||||
field: 'name'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type TModelName = keyof typeof allowedModels;
|
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
|
||||||
const project_id = getHeader(event, 'x-pid');
|
|
||||||
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 are required');
|
|
||||||
|
|
||||||
const schemaName = getRequestHeader(event, 'x-schema');
|
|
||||||
if (!schemaName) return setResponseStatus(event, 400, 'x-schema is required');
|
|
||||||
|
|
||||||
if (!Object.keys(allowedModels).includes(schemaName)) return setResponseStatus(event, 400, 'x-schema value is not valid');
|
|
||||||
|
|
||||||
const limitHeader = getRequestHeader(event, 'x-query-limit');
|
|
||||||
const limitNumber = parseInt(limitHeader || '10');
|
|
||||||
const limit = isNaN(limitNumber) ? 10 : limitNumber;
|
|
||||||
|
|
||||||
const cacheKey = `${schemaName}:${project_id}:${from}:${to}`;
|
|
||||||
const cacheExp = 60;
|
|
||||||
|
|
||||||
return await Redis.useCacheV2(cacheKey, cacheExp, async (noStore, updateExp) => {
|
|
||||||
|
|
||||||
const { model } = allowedModels[schemaName as TModelName];
|
|
||||||
|
|
||||||
const result = await model.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 } },
|
|
||||||
{ $limit: limit }
|
|
||||||
]);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -7,21 +7,14 @@ import mongoose from "mongoose";
|
|||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
const project_id = getHeader(event, 'x-pid');
|
|
||||||
if (!project_id) return;
|
|
||||||
|
|
||||||
const user = getRequestUser(event);
|
const data = await getRequestData(event, { requireSchema: false, requireSlice: true });
|
||||||
const project = await getUserProjectFromId(project_id, user);
|
if (!data) return;
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
const from = getRequestHeader(event, 'x-from');
|
const { pid, from, to, slice, project_id } = data;
|
||||||
const to = getRequestHeader(event, 'x-to');
|
|
||||||
|
|
||||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to are required');
|
|
||||||
|
|
||||||
const slice = getRequestHeader(event, 'x-slice');
|
const cacheKey = `timeline:bouncing_rate:${pid}:${from}:${to}`;
|
||||||
|
|
||||||
const cacheKey = `bouncing_rate:${project_id}:${from}:${to}`;
|
|
||||||
const cacheExp = 60 * 60; //1 hour
|
const cacheExp = 60 * 60; //1 hour
|
||||||
|
|
||||||
return await Redis.useCacheV2(cacheKey, cacheExp, async (noStore, updateExp) => {
|
return await Redis.useCacheV2(cacheKey, cacheExp, async (noStore, updateExp) => {
|
||||||
@@ -37,14 +30,14 @@ export default defineEventHandler(async event => {
|
|||||||
|
|
||||||
const allDates = DateService.createBetweenDates(from, to, slice as any);
|
const allDates = DateService.createBetweenDates(from, to, slice as any);
|
||||||
|
|
||||||
const result: { date: string, value: number }[] = [];
|
const result: { _id: string, count: number }[] = [];
|
||||||
|
|
||||||
for (const date of allDates.dates) {
|
for (const date of allDates.dates) {
|
||||||
|
|
||||||
const visits = await VisitModel.aggregate([
|
const visits = await VisitModel.aggregate([
|
||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
project_id: project._id,
|
project_id: project_id,
|
||||||
created_at: {
|
created_at: {
|
||||||
$gte: date.startOf(slice as any).toDate(),
|
$gte: date.startOf(slice as any).toDate(),
|
||||||
$lte: date.endOf(slice as any).toDate()
|
$lte: date.endOf(slice as any).toDate()
|
||||||
@@ -57,20 +50,28 @@ export default defineEventHandler(async event => {
|
|||||||
const sessions = await SessionModel.aggregate([
|
const sessions = await SessionModel.aggregate([
|
||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
project_id: project._id,
|
project_id: project_id,
|
||||||
created_at: {
|
created_at: {
|
||||||
$gte: date.startOf(slice as any).toDate(),
|
$gte: date.startOf(slice as any).toDate(),
|
||||||
$lte: date.endOf(slice as any).toDate()
|
$lte: date.endOf(slice as any).toDate()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ $group: { _id: "$session", count: { $sum: 1, }, duration: { $sum: '$duration' } } },
|
{
|
||||||
|
$group: {
|
||||||
|
_id: "$session", count: { $sum: 1, },
|
||||||
|
duration: { $sum: '$duration' }
|
||||||
|
}
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const total = visits.length;
|
const total = visits.length;
|
||||||
const bounced = sessions.filter(e => (e.duration / e.count) < 1).length;
|
const bounced = sessions.filter(e => (e.duration / e.count) < 1).length;
|
||||||
const bouncing_rate = 100 / total * bounced;
|
const bouncing_rate = 100 / total * bounced;
|
||||||
result.push({ date: date.toISOString(), value: bouncing_rate });
|
result.push({
|
||||||
|
_id: date.toISOString(),
|
||||||
|
count: bouncing_rate
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
29
dashboard/server/api/timeline/sessions.ts
Normal file
29
dashboard/server/api/timeline/sessions.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||||
|
import { Redis } from "~/server/services/CacheService";
|
||||||
|
import { executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const data = await getRequestData(event, { requireSchema: false, requireSlice: true });
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const { pid, from, to, slice, project_id } = data;
|
||||||
|
|
||||||
|
const cacheKey = `timeline:sessions:${pid}:${from}:${to}`;
|
||||||
|
const cacheExp = 60;
|
||||||
|
|
||||||
|
return await Redis.useCacheV2(cacheKey, cacheExp, async () => {
|
||||||
|
const timelineData = await executeTimelineAggregation({
|
||||||
|
projectId: project_id,
|
||||||
|
model: SessionModel,
|
||||||
|
from, to, slice,
|
||||||
|
});
|
||||||
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
|
||||||
|
return timelineFilledMerged;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
35
dashboard/server/api/timeline/sessions_duration.ts
Normal file
35
dashboard/server/api/timeline/sessions_duration.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||||
|
import { Redis } from "~/server/services/CacheService";
|
||||||
|
import { executeAdvancedTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const data = await getRequestData(event, { requireSchema: false, requireSlice: true });
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const { pid, from, to, slice, project_id } = data;
|
||||||
|
|
||||||
|
const cacheKey = `timeline:sessions_duration:${pid}:${from}:${to}`;
|
||||||
|
const cacheExp = 60;
|
||||||
|
|
||||||
|
return await Redis.useCacheV2(cacheKey, cacheExp, async () => {
|
||||||
|
const timelineData = await executeAdvancedTimelineAggregation({
|
||||||
|
projectId: project_id,
|
||||||
|
model: SessionModel,
|
||||||
|
from, to, slice,
|
||||||
|
customGroup: {
|
||||||
|
duration: { $sum: '$duration' }
|
||||||
|
},
|
||||||
|
customProjection: {
|
||||||
|
count: { $divide: ["$duration", "$count"] }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
|
||||||
|
return timelineFilledMerged;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
29
dashboard/server/api/timeline/visits.ts
Normal file
29
dashboard/server/api/timeline/visits.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
|
import { Redis } from "~/server/services/CacheService";
|
||||||
|
import { executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const data = await getRequestData(event, { requireSchema: false, requireSlice: true });
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const { pid, from, to, slice, project_id } = data;
|
||||||
|
|
||||||
|
const cacheKey = `timeline:visits:${pid}:${from}:${to}`;
|
||||||
|
const cacheExp = 60;
|
||||||
|
|
||||||
|
return await Redis.useCacheV2(cacheKey, cacheExp, async () => {
|
||||||
|
const timelineData = await executeTimelineAggregation({
|
||||||
|
projectId: project_id,
|
||||||
|
model: VisitModel,
|
||||||
|
from, to, slice,
|
||||||
|
});
|
||||||
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
|
||||||
|
return timelineFilledMerged;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
16
dashboard/server/services/DataService.ts
Normal file
16
dashboard/server/services/DataService.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
|
import { EventModel } from "@schema/metrics/EventSchema";
|
||||||
|
|
||||||
|
import type { Model } from "mongoose";
|
||||||
|
|
||||||
|
|
||||||
|
export type TModelName = keyof typeof allowedModels;
|
||||||
|
|
||||||
|
export const allowedModels: Record<string, { model: Model<any> }> = {
|
||||||
|
'events': {
|
||||||
|
model: EventModel,
|
||||||
|
},
|
||||||
|
'visits': {
|
||||||
|
model: VisitModel,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import type mongoose from "mongoose";
|
|||||||
|
|
||||||
|
|
||||||
export type TimelineAggregationOptions = {
|
export type TimelineAggregationOptions = {
|
||||||
projectId: mongoose.Schema.Types.ObjectId,
|
projectId: mongoose.Schema.Types.ObjectId | mongoose.Types.ObjectId,
|
||||||
model: mongoose.Model<any>,
|
model: mongoose.Model<any>,
|
||||||
from: string | number,
|
from: string | number,
|
||||||
to: string | number,
|
to: string | number,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { AuthContext } from "../middleware/01-authorization";
|
import type { AuthContext } from "../middleware/01-authorization";
|
||||||
import type { EventHandlerRequest, H3Event } from 'h3'
|
import type { EventHandlerRequest, H3Event } from 'h3'
|
||||||
|
import { allowedModels, TModelName } from "../services/DataService";
|
||||||
|
import { LITLYX_PROJECT_ID } from "@data/LITLYX";
|
||||||
|
import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
||||||
|
import { Model, Types } from "mongoose";
|
||||||
|
import { TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||||
|
import { Slice } from "@services/DateService";
|
||||||
|
|
||||||
export function getRequestUser(event: H3Event<EventHandlerRequest>) {
|
export function getRequestUser(event: H3Event<EventHandlerRequest>) {
|
||||||
if (!event.context.auth) return;
|
if (!event.context.auth) return;
|
||||||
@@ -15,3 +21,78 @@ export function getRequestAddress(event: H3Event<EventHandlerRequest>) {
|
|||||||
if (process.dev) return '127.0.0.1';
|
if (process.dev) return '127.0.0.1';
|
||||||
return event.headers.get('x-real-ip') || event.headers.get('X-Forwarded-For') || '0.0.0.0';
|
return event.headers.get('x-real-ip') || event.headers.get('X-Forwarded-For') || '0.0.0.0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type GetRequestDataOptions = {
|
||||||
|
allowGuests?: boolean,
|
||||||
|
requireSchema?: boolean,
|
||||||
|
allowLitlyx?: boolean,
|
||||||
|
requireSlice?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasAccessToProject(user_id: string, project: TProject) {
|
||||||
|
if (!project) return [false, 'NONE'];
|
||||||
|
const owner = project.owner.toString();
|
||||||
|
const project_id = project._id;
|
||||||
|
if (owner === user_id) return [true, 'OWNER'];
|
||||||
|
const isGuest = await TeamMemberModel.exists({ project_id, user_id });
|
||||||
|
if (isGuest) return [true, 'GUEST'];
|
||||||
|
return [false, 'NONE'];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getRequestData(event: H3Event<EventHandlerRequest>, options?: GetRequestDataOptions) {
|
||||||
|
|
||||||
|
const requireSchema = options?.requireSchema || false;
|
||||||
|
const allowGuests = options?.allowGuests || true;
|
||||||
|
const allowLitlyx = options?.allowLitlyx || true;
|
||||||
|
const requireSlice = options?.requireSlice || false;
|
||||||
|
|
||||||
|
const pid = getHeader(event, 'x-pid');
|
||||||
|
if (!pid) return setResponseStatus(event, 400, 'x-pid is required');
|
||||||
|
|
||||||
|
const slice = getHeader(event, 'x-slice') as Slice;
|
||||||
|
if (!slice && requireSlice) return setResponseStatus(event, 400, 'x-slice is required');
|
||||||
|
|
||||||
|
const from = getRequestHeader(event, 'x-from');
|
||||||
|
const to = getRequestHeader(event, 'x-to');
|
||||||
|
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to are required');
|
||||||
|
|
||||||
|
|
||||||
|
let model: (Model<any> | undefined) = undefined;
|
||||||
|
|
||||||
|
const schemaName = getRequestHeader(event, 'x-schema');
|
||||||
|
if (requireSchema) {
|
||||||
|
if (!schemaName) return setResponseStatus(event, 400, 'x-schema is required');
|
||||||
|
if (!Object.keys(allowedModels).includes(schemaName)) return setResponseStatus(event, 400, 'x-schema value is not valid');
|
||||||
|
const allowedModel = allowedModels[schemaName as TModelName];
|
||||||
|
model = allowedModel.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const limitHeader = getRequestHeader(event, 'x-limit');
|
||||||
|
const limitNumber = parseInt(limitHeader as string);
|
||||||
|
const limit = isNaN(limitNumber) ? 10 : limitNumber;
|
||||||
|
|
||||||
|
const user = getRequestUser(event);
|
||||||
|
|
||||||
|
if (!user || !user.logged) return setResponseStatus(event, 403, 'you must be logged');
|
||||||
|
|
||||||
|
const project_id = new Types.ObjectId(pid);
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return setResponseStatus(event, 400, 'project not found');
|
||||||
|
|
||||||
|
|
||||||
|
if (pid !== LITLYX_PROJECT_ID) {
|
||||||
|
const [hasAccess, role] = await hasAccessToProject(user.id, project);
|
||||||
|
if (!hasAccess) return setResponseStatus(event, 400, 'no access to project');
|
||||||
|
if (role === 'GUEST' && !allowGuests) return setResponseStatus(event, 403, 'only owner can access this');
|
||||||
|
} else {
|
||||||
|
if (!allowLitlyx) return setResponseStatus(event, 400, 'no access to project');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { from, to, pid, project_id, project, user, limit, slice, schemaName, model }
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user