mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 07:48:37 +01:00
implementing snapshots
This commit is contained in:
@@ -29,6 +29,13 @@ const debugMode = process.dev;
|
||||
|
||||
const { isOpen, close } = useMenu();
|
||||
|
||||
const { snapshots, snapshot } = useSnapshot();
|
||||
|
||||
const snapshotsItems = computed(() => {
|
||||
if (!snapshots.data.value) return []
|
||||
return snapshots.data.value as any[];
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,7 +56,32 @@ const { isOpen, close } = useMenu();
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="px-4 w-full flex-col">
|
||||
|
||||
<USelectMenu 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> {{ 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> {{ option.name }} </div>
|
||||
</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
|
||||
<div v-if="snapshot">
|
||||
<div> {{ new Date(snapshot.from).toLocaleString('it-IT') }} </div>
|
||||
<div> {{ new Date(snapshot.to).toLocaleString('it-IT') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div v-for="section of sections" class="flex flex-col gap-1">
|
||||
|
||||
@@ -17,7 +17,7 @@ const props = defineProps<{
|
||||
<template>
|
||||
|
||||
<Card class="flex 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">
|
||||
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ const props = defineProps<{
|
||||
</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 || []"
|
||||
:color="props.color">
|
||||
</DashboardEmbedChartCard>
|
||||
|
||||
@@ -9,8 +9,22 @@ const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
|
||||
const { snapshot } = useSnapshot();
|
||||
|
||||
const snapshotFrom = computed(() => {
|
||||
return new Date(snapshot.value?.from || '0').toISOString();
|
||||
});
|
||||
|
||||
const snapshotTo = computed(() => {
|
||||
return new Date(snapshot.value?.to || Date.now()).toISOString();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimeline('sessions', props.slice);
|
||||
ready.value = false;
|
||||
const response = await useTimeline('sessions', props.slice,
|
||||
snapshotFrom.value.toString(),
|
||||
snapshotTo.value.toString()
|
||||
);
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
@@ -20,6 +34,7 @@ async function loadData() {
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
watch(snapshot, async () => { await loadData(); });
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -4,24 +4,50 @@ import DateService from '@services/DateService';
|
||||
|
||||
const { data: metricsInfo } = useMetricsData();
|
||||
|
||||
|
||||
|
||||
|
||||
type Data = {
|
||||
data: number[],
|
||||
labels: string[],
|
||||
trend: number,
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
|
||||
const { snapshot } = useSnapshot()
|
||||
|
||||
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 });
|
||||
|
||||
const snapshotFrom = computed(() => {
|
||||
return new Date(snapshot.value?.from || '0').getTime();
|
||||
});
|
||||
|
||||
const snapshotTo = computed(() => {
|
||||
return new Date(snapshot.value?.to || Date.now()).getTime();
|
||||
});
|
||||
|
||||
const avgVisitDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.visitsCount / Math.max(days, 1);
|
||||
const days = (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
|
||||
const counts = visitsData.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(days, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgEventsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.eventsCount / Math.max(days, 1);
|
||||
const days = (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
|
||||
const counts = eventsData.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(days, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgSessionsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1);
|
||||
const days = (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
|
||||
const counts = sessionsData.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(days, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
@@ -49,23 +75,19 @@ const avgSessionDuration = computed(() => {
|
||||
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');
|
||||
target.ready = false;
|
||||
|
||||
const response = await useTimeline(timelineEndpointName as any, 'day',
|
||||
snapshot.value?.from.toString() || "0",
|
||||
snapshot.value?.to.toString() || Date.now().toString()
|
||||
);
|
||||
|
||||
console.log(timelineEndpointName,response);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
target.data = response.map(e => e.count);
|
||||
target.labels = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, 'day'));
|
||||
|
||||
@@ -80,12 +102,23 @@ async function loadData(timelineEndpointName: string, target: Data) {
|
||||
target.ready = true;
|
||||
}
|
||||
|
||||
|
||||
async function loadAllData() {
|
||||
console.log('LOAD ALL DATA')
|
||||
await Promise.all([
|
||||
loadData('visits', visitsData),
|
||||
loadData('events', eventsData),
|
||||
loadData('sessions', sessionsData),
|
||||
loadData('sessions_duration', sessionsDurationData),
|
||||
])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
await loadData('visits', visitsData);
|
||||
await loadData('events', eventsData);
|
||||
await loadData('sessions', sessionsData);
|
||||
await loadData('sessions_duration', sessionsDurationData);
|
||||
await loadAllData();
|
||||
watch(snapshot, async () => {
|
||||
await loadAllData();
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
@@ -98,17 +131,18 @@ onMounted(async () => {
|
||||
<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">
|
||||
|
||||
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
|
||||
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'"
|
||||
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7">
|
||||
:value="formatNumberK(visitsData.data.reduce((a, e) => a + e, 0))"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.trend" :data="visitsData.data"
|
||||
:labels="visitsData.labels" color="#5655d7">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
|
||||
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'"
|
||||
:value="formatNumberK(eventsData.data.reduce((a, e) => a + e, 0))" :avg="formatNumberK(avgEventsDay) + '/day'"
|
||||
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
|
||||
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||
:value="formatNumberK(sessionsData.data.reduce((a, e) => a + e, 0))" :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
|
||||
</DashboardCountCard>
|
||||
|
||||
|
||||
@@ -8,8 +8,23 @@ const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
|
||||
const { snapshot } = useSnapshot();
|
||||
|
||||
const snapshotFrom = computed(() => {
|
||||
return new Date(snapshot.value?.from || '0').toISOString();
|
||||
});
|
||||
|
||||
const snapshotTo = computed(() => {
|
||||
return new Date(snapshot.value?.to || Date.now()).toISOString();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimeline('visits', props.slice);
|
||||
ready.value = false;
|
||||
const response = await useTimeline('visits', props.slice,
|
||||
snapshotFrom.value.toString(),
|
||||
snapshotTo.value.toString()
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
data.value = response.map(e => e.count);
|
||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
@@ -19,6 +34,7 @@ async function loadData() {
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
watch(snapshot, async () => { await loadData(); });
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -6,13 +6,24 @@ const { data: websites, pending, refresh } = useWebsitesData();
|
||||
|
||||
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value);
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = websites.value;
|
||||
})
|
||||
|
||||
const isPagesView = ref<boolean>(false);
|
||||
const isLoading = ref<boolean>(false);
|
||||
|
||||
|
||||
const { snapshot } = useSnapshot()
|
||||
|
||||
watch(pending, () => {
|
||||
isLoading.value = true;
|
||||
currentViewData.value = websites.value;
|
||||
isLoading.value = false;
|
||||
});
|
||||
|
||||
watch(snapshot, () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
|
||||
async function showDetails(website: string) {
|
||||
if (isPagesView.value == true) return;
|
||||
isLoading.value = true;
|
||||
@@ -21,6 +32,7 @@ async function showDetails(website: string) {
|
||||
const { data: pagesData, pending } = usePagesData(website, 10);
|
||||
|
||||
watch(pending, () => {
|
||||
isLoading.value = true;
|
||||
currentViewData.value = pagesData.value;
|
||||
isLoading.value = false;
|
||||
})
|
||||
|
||||
@@ -87,12 +87,18 @@ export function usePagesData(website: string, limit: number = 10) {
|
||||
|
||||
}
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
export function useWebsitesData(limit: number = 10) {
|
||||
const activeProject = useActiveProject();
|
||||
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`, {
|
||||
...signHeaders({ 'x-query-limit': limit.toString() }),
|
||||
key: `websites_data:${limit}`,
|
||||
lazy: true
|
||||
...signHeaders({
|
||||
'x-query-limit': limit.toString(),
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to
|
||||
}),
|
||||
key: `websites_data:${limit}:${safeSnapshotDates.value.from}:${safeSnapshotDates.value.to}`,
|
||||
lazy: true,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
26
dashboard/composables/useSnapshot.ts
Normal file
26
dashboard/composables/useSnapshot.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { TProjectSnapshot } from "@schema/ProjectSnapshot";
|
||||
|
||||
const snapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
|
||||
...signHeaders(),
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const snapshot = ref<TProjectSnapshot>();
|
||||
|
||||
watch(snapshots.data, () => {
|
||||
if (!snapshots.data.value) return;
|
||||
snapshot.value = snapshots.data.value[0];
|
||||
});
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
export function useSnapshot() {
|
||||
if (snapshots.status.value === 'idle') {
|
||||
snapshots.execute();
|
||||
}
|
||||
return { snapshot, snapshots, safeSnapshotDates }
|
||||
}
|
||||
@@ -91,7 +91,7 @@ const selectLabels = [
|
||||
<DashboardTopCards></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.">
|
||||
<template #header>
|
||||
@@ -117,7 +117,7 @@ const selectLabels = [
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
|
||||
@@ -22,12 +22,25 @@ export default defineEventHandler(async event => {
|
||||
const limit = getRequestHeader(event, 'x-query-limit');
|
||||
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({
|
||||
key: `websites:${project_id}:${numLimit}`,
|
||||
exp: DATA_EXPIRE_TIME
|
||||
}, async () => {
|
||||
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, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: numLimit }
|
||||
|
||||
19
dashboard/server/api/project/snapshots.ts
Normal file
19
dashboard/server/api/project/snapshots.ts
Normal 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[];
|
||||
|
||||
});
|
||||
20
shared/schema/ProjectSnapshot.ts
Normal file
20
shared/schema/ProjectSnapshot.ts
Normal 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);
|
||||
Reference in New Issue
Block a user