mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
update ui
This commit is contained in:
@@ -19,7 +19,7 @@ metricsRouter.get('/queue', async (req, res) => {
|
|||||||
|
|
||||||
metricsRouter.get('/durations', async (req, res) => {
|
metricsRouter.get('/durations', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const durations = RedisStreamService.METRICS_get()
|
const durations = await RedisStreamService.METRICS_get()
|
||||||
res.json({ durations });
|
res.json({ durations });
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
console.error(ex);
|
console.error(ex);
|
||||||
|
|||||||
@@ -1,20 +1,62 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
export type CItem = { label: string, slot: string }
|
|
||||||
const props = defineProps<{ items: CItem[], manualScroll?:boolean }>();
|
|
||||||
|
export type CItem = { label: string, slot: string, tab?: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: CItem[],
|
||||||
|
manualScroll?: boolean,
|
||||||
|
route?: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const activeTabIndex = ref<number>(0);
|
const activeTabIndex = ref<number>(0);
|
||||||
|
|
||||||
|
|
||||||
|
function updateTab() {
|
||||||
|
const target = props.items.findIndex(e => e.tab == route.query.tab);
|
||||||
|
if (target == -1) {
|
||||||
|
activeTabIndex.value = 0;
|
||||||
|
} else {
|
||||||
|
activeTabIndex.value = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeTab(newIndex: number) {
|
||||||
|
activeTabIndex.value = newIndex;
|
||||||
|
const target = props.items[newIndex];
|
||||||
|
if (!target) return;
|
||||||
|
router.push({ query: { tab: target.tab } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
|
||||||
|
if (props.route !== true) return;
|
||||||
|
|
||||||
|
updateTab();
|
||||||
|
|
||||||
|
watch(route, () => {
|
||||||
|
updateTab();
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
<div class="flex overflow-x-auto hide-scrollbars">
|
<div class="flex overflow-x-auto hide-scrollbars">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
<div v-for="(tab, index) of items" @click="onChangeTab(index)"
|
||||||
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
|
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
|
||||||
:class="{
|
:class="{
|
||||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
|
||||||
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||||
}">
|
}">
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ async function saveJobTitle() {
|
|||||||
const showOnboarding = computed(() => {
|
const showOnboarding = computed(() => {
|
||||||
if (route.path === '/login') return false;
|
if (route.path === '/login') return false;
|
||||||
if (route.path === '/register') return false;
|
if (route.path === '/register') return false;
|
||||||
if (needsOnboarding.value?.exist === false) return true;
|
if ((needsOnboarding.value as any)?.exist === false) return true;
|
||||||
|
if ((needsOnboarding.value as any)?.exists === false) return true;
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
72
dashboard/components/admin/Backend.vue
Normal file
72
dashboard/components/admin/Backend.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
|
||||||
|
|
||||||
|
const avgDuration = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return -1;
|
||||||
|
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = new Array(650).fill('-');
|
||||||
|
|
||||||
|
const durationsDatasets = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return [];
|
||||||
|
|
||||||
|
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
|
||||||
|
|
||||||
|
const datasets = [];
|
||||||
|
|
||||||
|
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueConsumers.length; i++) {
|
||||||
|
|
||||||
|
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
points: consumerDurations.map((e: any) => {
|
||||||
|
return 1000 / parseInt(e[1])
|
||||||
|
}),
|
||||||
|
color: colors[i],
|
||||||
|
chartType: 'line',
|
||||||
|
name: uniqueConsumers[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return datasets;
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex justify-center w-full">
|
||||||
|
|
||||||
|
<div v-if="backendData" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
|
||||||
|
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
|
||||||
|
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
|
||||||
|
</AdminBackendLineChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @click="refreshBackend()"> Refresh </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="backendPending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
31
dashboard/components/admin/Feedbacks.vue
Normal file
31
dashboard/components/admin/Feedbacks.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: feedbacks, pending: pendingFeedbacks } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
|
||||||
|
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
|
||||||
|
v-for="feedback of feedbacks">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
|
||||||
|
<div class="text-lyx-text-dark"> {{ feedback.project[0]?.name || 'DELETED PROJECT' }} </div>
|
||||||
|
</div>
|
||||||
|
{{ feedback.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingFeedbacks"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
45
dashboard/components/admin/Onboardings.vue
Normal file
45
dashboard/components/admin/Onboardings.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex gap-40 px-20">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Anaytics </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Jobs </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex flex-col gap-8">
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingOnboardings"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -4,6 +4,8 @@ import type { TAdminProject } from '~/server/api/admin/projects';
|
|||||||
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
||||||
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||||
|
|
||||||
|
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
|
||||||
const page = ref<number>(1);
|
const page = ref<number>(1);
|
||||||
|
|
||||||
@@ -56,9 +58,28 @@ const filterList = [
|
|||||||
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
|
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
|
||||||
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
|
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
|
||||||
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
|
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
|
||||||
{ label: 'FREE', id: '{ "premium_type": 0' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 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() })
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
for (const key in PREMIUM_PLAN) {
|
for (const key in PREMIUM_PLAN) {
|
||||||
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
|
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
|
||||||
@@ -67,9 +88,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
const filter = ref<string>('{}');
|
const filter = ref<string>('{}');
|
||||||
|
|
||||||
|
|
||||||
const { data: projectsInfo, pending: pendingProjects } = await useFetch<{ count: number, projects: TAdminProject[] }>(
|
const { data: projectsInfo, pending: pendingProjects } = await useFetch<{ count: number, projects: TAdminProject[] }>(
|
||||||
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}`,
|
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||||
signHeaders()
|
signHeaders()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,14 +97,17 @@ const { data: projectsInfo, pending: pendingProjects } = await useFetch<{ count:
|
|||||||
|
|
||||||
const { uiMenu } = useSelectMenuStyle();
|
const { uiMenu } = useSelectMenuStyle();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-6 h-full">
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
|
||||||
<div class="flex items-center gap-10 px-10">
|
<div class="flex flex-col items-center gap-8">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 px-10">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<div>Order:</div>
|
<div>Order:</div>
|
||||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||||
@@ -106,7 +129,11 @@ const { uiMenu } = useSelectMenuStyle();
|
|||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 items-center">
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-10 justify-center px-10 w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center shrink-0">
|
||||||
<div>Page {{ page }} </div>
|
<div>Page {{ page }} </div>
|
||||||
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
|
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
|
||||||
}}</div>
|
}}</div>
|
||||||
@@ -116,6 +143,28 @@ const { uiMenu } = useSelectMenuStyle();
|
|||||||
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
|
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UPopover class="w-[20rem]" :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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import type { TAdminUserProjectInfo } from '~/server/api/admin/users_projects';
|
|
||||||
|
|
||||||
|
|
||||||
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
|
||||||
|
|
||||||
const page = ref<number>(0);
|
|
||||||
const limit = ref<number>(10);
|
|
||||||
|
|
||||||
const sortQuery = computed(() => {
|
|
||||||
return JSON.stringify({ created_at: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: usersWithProjects } = await useFetch<TAdminUserProjectInfo[]>(
|
|
||||||
() => `/api/admin/users_projects?page=${page.value}&limit=${limit.value}&sortQuery=${sortQuery.value}`,
|
|
||||||
signHeaders()
|
|
||||||
);
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="px-2 cursor-default">
|
|
||||||
<div v-for="user of usersWithProjects" class="py-6">
|
|
||||||
|
|
||||||
{{ user.email }}
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
<div class="w-[22rem] outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md"
|
|
||||||
v-for="project of user.projects">
|
|
||||||
|
|
||||||
<div class="flex gap-4 justify-center">
|
|
||||||
<UTooltip :text="`PRICE_ID: ${project.premium_type.toString()}`">
|
|
||||||
<div class="font-medium">
|
|
||||||
{{ getPlanFromId(project.premium_type)?.TAG ?? 'ERROR' }}
|
|
||||||
</div>
|
|
||||||
</UTooltip>
|
|
||||||
<div class="text-lyx-text-darker">
|
|
||||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-5 justify-center">
|
|
||||||
<div class="font-medium">
|
|
||||||
{{ project.name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center mt-4">
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="text-right"> Visits:</div>
|
|
||||||
<div>{{ formatNumberK(project.visits) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="text-right"> Events:</div>
|
|
||||||
<div>{{ formatNumberK(project.events) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="text-right"> Sessions:</div>
|
|
||||||
<div>{{ formatNumberK(project.sessions) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LyxUiSeparator class="my-2" />
|
|
||||||
|
|
||||||
<AdminMiniChart :pid="project._id.toString()"></AdminMiniChart>
|
|
||||||
|
|
||||||
<LyxUiSeparator class="my-2" />
|
|
||||||
|
|
||||||
|
|
||||||
<div class="flex gap-6 justify-around">
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<div>
|
|
||||||
{{ formatNumberK(project.limits[0].visits + project.limits[0].events) }}
|
|
||||||
/ {{ formatNumberK(project.limits[0].limit) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-lyx-text-dark">
|
|
||||||
~ {{
|
|
||||||
(100 / project.limits[0].limit * (project.limits[0].visits +
|
|
||||||
project.limits[0].events)).toFixed(1)
|
|
||||||
}}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div>
|
|
||||||
{{ formatNumberK(project.limits[0].ai_messages) }}
|
|
||||||
/ {{ formatNumberK(project.limits[0].ai_limit) }}
|
|
||||||
</div>
|
|
||||||
<div class="text-lyx-text-dark">
|
|
||||||
~ {{(100 / project.limits[0].ai_limit * project.limits[0].ai_messages).toFixed(1) }}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
|
||||||
132
dashboard/components/admin/backend/LineChart.vue
Normal file
132
dashboard/components/admin/backend/LineChart.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as datefns from 'date-fns';
|
||||||
|
|
||||||
|
const errored = ref<boolean>(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[],
|
||||||
|
title: string,
|
||||||
|
datasets: {
|
||||||
|
points: number[],
|
||||||
|
color: string,
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleFont: { size: 16, weight: 'bold' },
|
||||||
|
bodyFont: { size: 14 },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 4,
|
||||||
|
boxPadding: 10,
|
||||||
|
caretPadding: 20,
|
||||||
|
yAlign: 'bottom',
|
||||||
|
xAlign: 'center',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line'>>({
|
||||||
|
labels: props.labels.map(e => {
|
||||||
|
try {
|
||||||
|
return datefns.format(new Date(e), 'dd/MM');
|
||||||
|
} catch (ex) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
datasets: props.datasets.map(e => ({
|
||||||
|
data: e.points,
|
||||||
|
label: e.name,
|
||||||
|
backgroundColor: [e.color + '00'],
|
||||||
|
borderColor: e.color,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
type: 'line'
|
||||||
|
} as any))
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// chartData.value.datasets.forEach(dataset => {
|
||||||
|
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
|
||||||
|
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
|
||||||
|
// } else {
|
||||||
|
// dataset.backgroundColor = [createGradient('#3d59a4')]
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
} catch (ex) {
|
||||||
|
errored.value = true;
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="errored"> ERROR CREATING CHART </div>
|
||||||
|
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { type ChartData, type ChartOptions } from 'chart.js';
|
||||||
|
import { PieChart, usePieChart } from 'vue-chart-3';
|
||||||
|
|
||||||
|
const props = defineProps<{ data: { _id: string, count: number }[], title:string }>();
|
||||||
|
|
||||||
|
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title,
|
||||||
|
color: '#EEECF6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
|
||||||
|
labels: props.data.map(e => e._id),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.data.map(e => e.count),
|
||||||
|
backgroundColor: [
|
||||||
|
"#295270",
|
||||||
|
"#304F71",
|
||||||
|
"#374C72",
|
||||||
|
"#3E4A73",
|
||||||
|
"#444773",
|
||||||
|
"#4B4474",
|
||||||
|
"#524175",
|
||||||
|
],
|
||||||
|
borderColor: '#222222'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="graph">
|
||||||
|
<PieChart v-bind="pieChartProps">
|
||||||
|
</PieChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
@@ -52,7 +52,7 @@ const { showDrawer } = useDrawer();
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
||||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||||
<div v-if="props.slow"> Can be very slow on large timeframes </div>
|
<!-- <div v-if="props.slow"> Can be very slow on large timeframes </div> -->
|
||||||
</div>
|
</div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const isDark = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {safeSnapshotDates} = useSnapshot();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,13 +28,23 @@ const isDark = computed({
|
|||||||
<SelectorDomainSelector></SelectorDomainSelector>
|
<SelectorDomainSelector></SelectorDomainSelector>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden lg:flex pl-[12rem] items-center popping text-[.9rem] dark:text-lyx-text-dark">
|
||||||
|
Timeframe:
|
||||||
|
{{new Date(safeSnapshotDates.from).toLocaleDateString()}}
|
||||||
|
to
|
||||||
|
{{new Date(safeSnapshotDates.to).toLocaleDateString()}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="grow"></div>
|
<div class="grow"></div>
|
||||||
<div class="flex items-center gap-6 mr-10">
|
<div class="flex items-center gap-6 mr-10">
|
||||||
|
|
||||||
<div v-if="!selfhosted" @click="modal.open(DialogFeedback, {});"
|
<div v-if="!selfhosted" @click="modal.open(DialogFeedback, {});"
|
||||||
class="flex gap-2 items-center cursor-pointer">
|
class="flex gap-2 items-center cursor-pointer outline-[1px] outline-lyx-widget-lighter p-1 px-3 rounded-md outline">
|
||||||
<i class="far fa-message"></i>
|
<i class="far fa-message"></i>
|
||||||
Feedback
|
Feedback
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div @click="modal.open(DialogHelp, {});" class="cursor-pointer"> Help </div>
|
<div @click="modal.open(DialogHelp, {});" class="cursor-pointer"> Help </div>
|
||||||
<NuxtLink to="https://docs.litlyx.com" target="_blank" class="cursor-pointer">
|
<NuxtLink to="https://docs.litlyx.com" target="_blank" class="cursor-pointer">
|
||||||
Docs
|
Docs
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ function onChange(e: string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
<div @click="refreshDomains" v-if="!refreshingDomains"
|
|
||||||
|
<UTooltip text="Manage domains">
|
||||||
|
<NuxtLink to="/settings?tab=domains"
|
||||||
class="flex items-center hover:rotate-[60deg] transition-all duration-200 ease-in-out cursor-pointer">
|
class="flex items-center hover:rotate-[60deg] transition-all duration-200 ease-in-out cursor-pointer">
|
||||||
<i class="far fa-refresh"></i>
|
<i class="far fa-gear"></i>
|
||||||
</div>
|
</NuxtLink>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,7 +16,7 @@ function isProjectMine(owner?: string) {
|
|||||||
|
|
||||||
function onChange(e: TProject) {
|
function onChange(e: TProject) {
|
||||||
actions.setActiveProject(e._id.toString());
|
actions.setActiveProject(e._id.toString());
|
||||||
setActiveDomain('ALL DOMAINS');
|
setActiveDomain('All domains');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'],
|
|||||||
project_id,
|
project_id,
|
||||||
_id: '___allTime' as any,
|
_id: '___allTime' as any,
|
||||||
name: 'All Time',
|
name: 'All Time',
|
||||||
from: fns.addMinutes(fns.startOfMonth(new Date(project_created_at.toString())), 0),
|
from: fns.addMinutes(fns.startOfMonth(new Date(project_created_at.toString())), -new Date().getTimezoneOffset()),
|
||||||
to: new Date(Date.now()),
|
to: fns.addMilliseconds(fns.endOfDay(Date.now()), 1),
|
||||||
color: '#9362FF',
|
color: '#9362FF',
|
||||||
default: true
|
default: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const refreshingDomains = computed(() => domainsRequest.pending.value);
|
|||||||
const domainList = computed(() => {
|
const domainList = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
_id: 'ALL DOMAINS', visits: domainsRequest.data.value?.reduce((a, e) => a + e.visits, 0)
|
_id: 'All domains', visits: domainsRequest.data.value?.reduce((a, e) => a + e.visits, 0)
|
||||||
},
|
},
|
||||||
...(domainsRequest.data.value?.sort((a, b) => b.visits - a.visits) || [])
|
...(domainsRequest.data.value?.sort((a, b) => b.visits - a.visits) || [])
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -186,6 +186,15 @@ const tabs: CItem[] = [
|
|||||||
<template #users>
|
<template #users>
|
||||||
<AdminUsers></AdminUsers>
|
<AdminUsers></AdminUsers>
|
||||||
</template>
|
</template>
|
||||||
|
<template #feedbacks>
|
||||||
|
<AdminFeedbacks></AdminFeedbacks>
|
||||||
|
</template>
|
||||||
|
<template #onboarding>
|
||||||
|
<AdminOnboardings></AdminOnboardings>
|
||||||
|
</template>
|
||||||
|
<template #backend>
|
||||||
|
<AdminBackend></AdminBackend>
|
||||||
|
</template>
|
||||||
</CustomTab>
|
</CustomTab>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ definePageMeta({ layout: 'dashboard' });
|
|||||||
const selfhosted = useSelfhosted();
|
const selfhosted = useSelfhosted();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: 'General', slot: 'general' },
|
{ label: 'General', slot: 'general', tab: 'general' },
|
||||||
{ label: 'Data', slot: 'data' },
|
{ label: 'Domains', slot: 'domains', tab: 'domains' },
|
||||||
{ label: 'Members', slot: 'members' },
|
{ label: 'Members', slot: 'members', tab: 'members' },
|
||||||
{ label: 'Billing', slot: 'billing' },
|
{ label: 'Billing', slot: 'billing', tab: 'billing' },
|
||||||
{ label: 'Codes', slot: 'codes' },
|
{ label: 'Codes', slot: 'codes', tab: 'codes' },
|
||||||
{ label: 'Account', slot: 'account' }
|
{ label: 'Account', slot: 'account', tab: 'account' }
|
||||||
]
|
]
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -20,11 +20,11 @@ const items = [
|
|||||||
|
|
||||||
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Settings </div>
|
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Settings </div>
|
||||||
|
|
||||||
<CustomTab :items="items" class="mt-8">
|
<CustomTab :items="items" :route="true" class="mt-8">
|
||||||
<template #general>
|
<template #general>
|
||||||
<SettingsGeneral :key="refreshKey"></SettingsGeneral>
|
<SettingsGeneral :key="refreshKey"></SettingsGeneral>
|
||||||
</template>
|
</template>
|
||||||
<template #data>
|
<template #domains>
|
||||||
<SettingsData :key="refreshKey"></SettingsData>
|
<SettingsData :key="refreshKey"></SettingsData>
|
||||||
</template>
|
</template>
|
||||||
<template #members>
|
<template #members>
|
||||||
|
|||||||
18
dashboard/server/api/admin/backend.ts
Normal file
18
dashboard/server/api/admin/backend.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
|
||||||
|
const queueRes = await fetch("http://94.130.182.52:3031/metrics/queue");
|
||||||
|
const queue = await queueRes.json();
|
||||||
|
const durationsRes = await fetch("http://94.130.182.52:3031/metrics/durations");
|
||||||
|
const durations = await durationsRes.json();
|
||||||
|
|
||||||
|
return { queue, durations: durations }
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
31
dashboard/server/api/admin/feedbacks.ts
Normal file
31
dashboard/server/api/admin/feedbacks.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import { FeedbackModel } from '@schema/FeedbackSchema';
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
const feedbacks = await FeedbackModel.aggregate([
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: 'user_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'user'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'projects',
|
||||||
|
localField: 'project_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'project'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
return feedbacks;
|
||||||
|
|
||||||
|
});
|
||||||
30
dashboard/server/api/admin/onboardings.ts
Normal file
30
dashboard/server/api/admin/onboardings.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
import { OnboardingModel } from '~/shared/schema/OnboardingSchema';
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
const analytics = await OnboardingModel.aggregate([
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$analytics',
|
||||||
|
count: { $sum: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const jobs = await OnboardingModel.aggregate([
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$job',
|
||||||
|
count: { $sum: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
return { analytics, jobs };
|
||||||
|
|
||||||
|
});
|
||||||
@@ -38,16 +38,24 @@ export default defineEventHandler(async event => {
|
|||||||
if (!userData?.logged) return;
|
if (!userData?.logged) return;
|
||||||
if (!userData.user.roles.includes('ADMIN')) return;
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
const { page, limit, sortQuery, filterQuery } = getQuery(event);
|
const { page, limit, sortQuery, filterQuery, filterFrom, filterTo } = getQuery(event);
|
||||||
|
|
||||||
const pageNumber = parseInt(page as string);
|
const pageNumber = parseInt(page as string);
|
||||||
const limitNumber = parseInt(limit as string);
|
const limitNumber = parseInt(limit as string);
|
||||||
|
|
||||||
const count = await ProjectModel.countDocuments(JSON.parse(filterQuery as string));
|
const matchQuery = {
|
||||||
|
...JSON.parse(filterQuery as string),
|
||||||
|
created_at: {
|
||||||
|
$gte: new Date(filterFrom as string),
|
||||||
|
$lte: new Date(filterTo as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await ProjectModel.countDocuments(matchQuery);
|
||||||
|
|
||||||
const projects = await ProjectModel.aggregate([
|
const projects = await ProjectModel.aggregate([
|
||||||
{
|
{
|
||||||
$match: JSON.parse(filterQuery as string)
|
$match: matchQuery
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$lookup: {
|
$lookup: {
|
||||||
|
|||||||
@@ -2,59 +2,7 @@
|
|||||||
|
|
||||||
import { UserModel } from "@schema/UserSchema";
|
import { UserModel } from "@schema/UserSchema";
|
||||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
|
import { EventModel } from "~/shared/schema/metrics/EventSchema";
|
||||||
import { google } from 'googleapis';
|
|
||||||
|
|
||||||
const { GOOGLE_AUTH_CLIENT_SECRET, GOOGLE_AUTH_CLIENT_ID } = useRuntimeConfig()
|
|
||||||
|
|
||||||
async function exportToGoogle(data: string, user_id: string) {
|
|
||||||
|
|
||||||
const user = await UserModel.findOne({ _id: user_id }, { google_tokens: true });
|
|
||||||
|
|
||||||
const authClient = new google.auth.OAuth2({
|
|
||||||
clientId: GOOGLE_AUTH_CLIENT_ID,
|
|
||||||
clientSecret: GOOGLE_AUTH_CLIENT_SECRET
|
|
||||||
})
|
|
||||||
|
|
||||||
authClient.setCredentials({ access_token: user?.google_tokens?.access_token, refresh_token: user?.google_tokens?.refresh_token });
|
|
||||||
|
|
||||||
const sheets = google.sheets({ version: 'v4', auth: authClient });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const createSheetResponse = await sheets.spreadsheets.create({
|
|
||||||
requestBody: {
|
|
||||||
properties: {
|
|
||||||
title: 'Text export'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const spreadsheetId = createSheetResponse.data.spreadsheetId;
|
|
||||||
|
|
||||||
await sheets.spreadsheets.values.update({
|
|
||||||
spreadsheetId: spreadsheetId as string,
|
|
||||||
range: 'Sheet1!A1',
|
|
||||||
requestBody: {
|
|
||||||
values: data.split('\n').map(e => {
|
|
||||||
return e.split(',')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true }
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
|
|
||||||
console.error('Error creating Google Sheet from CSV:', error);
|
|
||||||
|
|
||||||
if (error.response && error.response.status === 401) {
|
|
||||||
return { error: 'Auth error, try to logout and login again' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: error.message.toString() }
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { SELFHOSTED } = useRuntimeConfig();
|
const { SELFHOSTED } = useRuntimeConfig();
|
||||||
|
|
||||||
@@ -120,15 +68,42 @@ export default defineEventHandler(async event => {
|
|||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} else if (mode === 'events') {
|
||||||
|
|
||||||
const isGoogle = getHeader(event, 'x-google-export');
|
|
||||||
|
|
||||||
if (isGoogle === 'true') {
|
const eventsReportData = await EventModel.find({
|
||||||
const data = await exportToGoogle(result, user.id);
|
project_id,
|
||||||
return data;
|
created_at: {
|
||||||
|
$gt: Date.now() - timeSub
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const csvHeader = [
|
||||||
|
"name",
|
||||||
|
"session",
|
||||||
|
"metadata",
|
||||||
|
"website",
|
||||||
|
"created_at",
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const lines: any[] = [];
|
||||||
|
eventsReportData.forEach(line => lines.push(line.toJSON()));
|
||||||
|
|
||||||
|
const result = csvHeader.join(',') + '\n' + lines.map(element => {
|
||||||
|
const content: string[] = [];
|
||||||
|
for (const key of csvHeader) {
|
||||||
|
content.push(element[key]);
|
||||||
|
}
|
||||||
|
return content.join(',');
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
|
||||||
import pdfkit from 'pdfkit';
|
import pdfkit from 'pdfkit';
|
||||||
|
|
||||||
import { PassThrough } from 'node:stream';
|
import { PassThrough } from 'node:stream';
|
||||||
|
|
||||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||||
@@ -33,7 +32,7 @@ function formatNumberK(value: string | number, decimals: number = 1) {
|
|||||||
|
|
||||||
const LINE_SPACING = 0.5;
|
const LINE_SPACING = 0.5;
|
||||||
|
|
||||||
const resourcePath = process.env.MODE === 'TEST' ? './public/pdf/' : '../public/pdf/';
|
const resourcePath = process.env.MODE === 'TEST' ? './public/pdf/' : './.output/public/pdf/';
|
||||||
|
|
||||||
function createPdf(data: PDFGenerationData) {
|
function createPdf(data: PDFGenerationData) {
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default defineEventHandler(async event => {
|
|||||||
domain
|
domain
|
||||||
})
|
})
|
||||||
|
|
||||||
return timelineStackedEvents;
|
return timelineStackedEvents.filter(e => e.name != undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -18,7 +18,9 @@ export default defineEventHandler(async event => {
|
|||||||
model: VisitModel,
|
model: VisitModel,
|
||||||
from, to, slice, timeOffset, domain
|
from, to, slice, timeOffset, domain
|
||||||
});
|
});
|
||||||
|
|
||||||
return timelineData;
|
return timelineData;
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export async function getRequestData(event: H3Event<EventHandlerRequest>, requir
|
|||||||
if (requireDomain) {
|
if (requireDomain) {
|
||||||
if (domain == null || domain == undefined || domain.length == 0) return setResponseStatus(event, 400, 'x-domain is required');
|
if (domain == null || domain == undefined || domain.length == 0) return setResponseStatus(event, 400, 'x-domain is required');
|
||||||
}
|
}
|
||||||
if (domain === 'ALL DOMAINS') {
|
if (domain === 'All domains') {
|
||||||
domain = { $ne: '_NODOMAIN_' }
|
domain = { $ne: '_NODOMAIN_' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user