12 Commits

Author SHA1 Message Date
Emily
4c1d10f8b7 fix dockerfiles 2025-02-14 17:15:38 +01:00
Emily
50d275e0ff fix dashboard dockerfile 2025-02-14 17:10:47 +01:00
Emily
98238fa180 fix dashboard dockerfile 2025-02-14 17:06:34 +01:00
Emily
c07bccd0dd fix dockerfile dashboard 2025-02-14 17:03:44 +01:00
Emily
7192c31136 fix dockerfiles 2025-02-14 16:55:34 +01:00
Emily
56d7e71d90 add username+pass login on NO_AUTH mode 2025-02-14 16:33:33 +01:00
Emily
30229d4b97 Merge branch 'refactoring' 2025-02-14 16:13:46 +01:00
Emily
b2303468a4 update ui 2025-02-14 16:13:06 +01:00
Emily
af6dff57ed update admin panel 2025-02-12 16:49:19 +01:00
antonio
dd8b089c46 updated readme 2025-02-12 11:00:47 +01:00
antonio
a5750d556a update readme 2025-02-12 10:58:56 +01:00
Emily
f5882bff9f admin panel 2025-02-12 03:20:54 +01:00
55 changed files with 2309 additions and 605 deletions

3
.gitignore vendored
View File

@@ -7,4 +7,5 @@ docker-compose.admin.yml
full_reload.sh full_reload.sh
build-all.sh build-all.sh
tmp tmp
ecosystem.config.js ecosystem.config.js
todo

View File

@@ -4,13 +4,14 @@
</p> </p>
<h4 align="center"> <h4 align="center">
🌐 <a href="https://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Try Litlyx Cloud. It's Free.</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🌐 <a href="https://litlyx.com">Website</a> 🔥 <a href="https://dashboard.litlyx.com">Try Litlyx Cloud. It's Free forever.</a>
</h4> </h4>
# #
<p align="center"> <p align="center">
The freshest, developer-friendly analytics tool.<br> Litlys is a modern, developer-friendly, cookie-free analytics tool.<br>
Litlyx is an open-source, self-hostable analytics solution for modern frameworks. Setup takes less than 30 seconds! Setup takes less than 30 seconds! Completely self-hostable with docker.<br>
Alternative to Google Analytics, Matomo, Umami, Plausible & Simple Analytics.
</p> </p>
# #
@@ -25,7 +26,7 @@
## Get Started on our Cloud Version ## Get Started on our Cloud Version
Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker. Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then simply use your `project_id` to connect Litlyx to your website.
## Universal Installation ## Universal Installation
@@ -33,25 +34,25 @@ Sign-up on [Litlyx.com](https://dashboard.litlyx.com) and create a project. Then
<script defer data-project="your_project_id" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script> <script defer data-project="your_project_id" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
``` ```
Importing Litlyx with a direct script instantly starts tracking `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Bouncing Rate`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`. Importing Litlyx with a direct script instantly starts tracking `Visits`, `Top Pages`, `Bouncing Rate`, `Real-Time Online Users`, `Unique Visitors`, `Countries`, and `Average Session Duration`.
# All Javascript Runtimes # All Javascript Runtimes
You can install Litlyx using `npm`, `pnpm`, `yarn` or any modern package managers: You can install Litlyx using `npm`, `pnpm` or any modern package managers:
```sh ```sh
npm i litlyx-js npm i litlyx-js
``` ```
Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx also works in serverless environments with Cloud (or Edge) Functions. Litlyx natively works with all JavaScript / TypeScript frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a third party plug-in.
<p align="center"> <p align="center">
<img src="assets/tech.png" /> <img src="assets/tech.png" />
</p> </p>
# Import # Import using a package manager
Import litlyx-js library into your code: First, Import litlyx-js library into your code:
```js ```js
import { Lit } from 'litlyx-js'; import { Lit } from 'litlyx-js';
@@ -63,9 +64,9 @@ Once imported, you need to initialize Litlyx:
Lit.init('your_project_id'); Lit.init('your_project_id');
``` ```
After initialization, Litlyx will automatically track analytics such as `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`. After initialization, Litlyx will automatically track web analytics such as `Page visits`, `Real-Time Online Users`, `Unique Vistors`, and many more.
# Track Custom Events # Track Custom Events (Actions)
You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project. You aren't just limited to the built-in KPIs. With Litlyx, you can create your own events to track in your project.
@@ -104,11 +105,9 @@ curl -X POST "https://broker.litlyx.com/event" \
}' }'
``` ```
# Self-Hosting with Docker # Self-hosting with docker
To self-host the Litlyx dashboard, first **fork** this repository. To self-host the Litlyx dashboard, first **clone** this repository. (Litlyx's Docker images are hosted on DockerHub).
You can find our Docker images on DockerHub for more.
Then run the following command: Then run the following command:
```bash ```bash
@@ -117,9 +116,9 @@ docker-compose up
at localhost:3000 you will see your own instance of the Litlyx Dashboard. at localhost:3000 you will see your own instance of the Litlyx Dashboard.
## Forward data to your local instance with script tag ## Forward data to your self-hosted instance with script tag
To forward your data on your self-hosted instance, you need to set up the following variables: add your `data-host`, add your `data-port`, and add your `data-secure`, setting it to true if it is HTTPS, and false if it is HTTP. To forward your data on your self-hosted instance, you need to set up the following variables: `data-host`, `data-port`, `data-secure`(`true` if it is HTTPS or `false` if it is HTTP).
```html ```html
<script defer data-project="your_project_id" <script defer data-project="your_project_id"
@@ -130,17 +129,23 @@ To forward your data on your self-hosted instance, you need to set up the follow
</script> </script>
``` ```
# Official Docs # Read our docs
For more info read our [documentation](https://docs.litlyx.com). (will be improved in the near future using Mintlify!) For more info on how to use litlyx read our [documentation](https://docs.litlyx.com).
# Join Discord
If you need more information, interact with us or the community, help, or want to provide feedbacks, feel free to join us on the Litlyx [Discord](https://discord.gg/9cQykjsmWX) # Stay updated with our roadmap
# Contributors To keep track on what we are cooking behind the scene we have a public [Roadmap](https://litlyx.com/roadmap) for you to check.
Every kind of contribution is accepted in this stage of the project. In the future we will improve the contributor onboarding process.
# Join discord
If you need more information, want to interact with us or the community, need help, or have feedback to share, feel free to join us on Litlyx's [Discord](https://discord.gg/9cQykjsmWX) channel.
# Contribution
If you want to contribute to Litlyx's development, reach out to us on [Discord](https://discord.gg/9cQykjsmWX) in our `#contribution` channel.
### Thank you! ### Thank you!
<a href="https://github.com/litlyx/litlyx/graphs/contributors"> <a href="https://github.com/litlyx/litlyx/graphs/contributors">

BIN
assets/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -5,24 +5,13 @@ RUN npm i -g pnpm
WORKDIR /home/app WORKDIR /home/app
COPY --link ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
COPY --link ./scripts/package.json ./scripts/pnpm-lock.yaml ./scripts/
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/ COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
RUN pnpm install
RUN pnpm install --filter consumer
WORKDIR /home/app/scripts
RUN pnpm install
WORKDIR /home/app
COPY --link ../scripts ./scripts
COPY --link ../shared ./shared
COPY --link ../consumer ./consumer
WORKDIR /home/app/consumer WORKDIR /home/app/consumer
RUN pnpm install
COPY --link ../consumer ./
RUN pnpm run build RUN pnpm run build
CMD ["node", "/home/app/consumer/dist/consumer/src/index.js"] CMD ["node", "/home/app/consumer/dist/index.js"]

View File

@@ -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);

View File

@@ -7,20 +7,14 @@ RUN npm i -g pnpm
WORKDIR /home/app WORKDIR /home/app
COPY --link ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/ COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
RUN pnpm install
RUN pnpm install --filter dashboard
WORKDIR /home/app
COPY --link ./dashboard ./dashboard
COPY --link ./shared ./shared
WORKDIR /home/app/dashboard WORKDIR /home/app/dashboard
RUN pnpm install
RUN pnpm run build COPY --link ./dashboard ./
RUN pnpm run build:compose
FROM node:21-alpine AS production FROM node:21-alpine AS production

View File

@@ -1,20 +1,62 @@
<script lang="ts" setup> <script lang="ts" setup>
type CItem = { label: string, slot: string }
const props = defineProps<{ items: CItem[] }>();
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> <div class="h-full flex flex-col">
<div class="flex overflow-y-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 }}
@@ -24,7 +66,7 @@ const activeTabIndex = ref<number>(0);
</div> </div>
</div> </div>
<div> <div :class="{ 'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
<slot :name="props.items[activeTabIndex].slot"></slot> <slot :name="props.items[activeTabIndex].slot"></slot>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
const props = defineProps<{ size?: string }>();
const widgetStyle = computed(() => {
return `height: ${props.size ?? '1px'}`;
})
</script>
<template>
<div :style="widgetStyle" class="bg-lyx-widget-light"></div>
</template>

View File

@@ -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>

View 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>

View 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>

View File

@@ -0,0 +1,271 @@
<script lang="ts" setup>
import DateService, { type Slice } from '@services/DateService';
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as fns from 'date-fns';
const props = defineProps<{ pid: string }>();
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
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;
}
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: (false as any),
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
beginAtZero: true,
},
x: {
ticks: { display: true },
stacked: false,
offset: false,
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: { enabled: false }
},
});
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
labels: [],
datasets: [
{
label: 'Visits',
data: [],
backgroundColor: ['#5655d7'],
borderColor: '#5655d7',
borderWidth: 4,
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: '#5655d7',
hoverBorderColor: 'white',
hoverBorderWidth: 2,
segment: {
borderColor(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return '#5655d7';
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
return '#5655d7'
},
borderDash(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return undefined;
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
return undefined;
},
backgroundColor(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
return createGradient('#5655d7');
},
},
},
{
label: 'Unique visitors',
data: [],
backgroundColor: ['#4abde8'],
borderColor: '#4abde8',
borderWidth: 2,
hoverBackgroundColor: '#4abde8',
hoverBorderColor: '#4abde8',
hoverBorderWidth: 2,
type: 'bar',
// barThickness: 20,
borderSkipped: ['bottom'],
},
{
label: 'Events',
data: [],
backgroundColor: ['#fbbf24'],
borderWidth: 2,
hoverBackgroundColor: '#fbbf24',
hoverBorderColor: '#fbbf24',
hoverBorderWidth: 2,
type: 'bubble',
stack: 'combined',
borderColor: ["#fbbf24"]
},
],
});
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
const selectedSlice: Slice = 'day'
const allDatesFull = ref<string[]>([]);
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
return { data, labels, todayIndex }
}
function onResponseError(e: any) {
let message = e.response._data.message ?? 'Generic error';
if (message == 'internal server error') message = 'Please change slice';
errorData.value = { errored: true, text: message }
}
function onResponse(e: any) {
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
}
const headers = computed(() => {
return {
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
'x-pid': props.pid
}
});
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}),
lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
watch(readyToDisplay, () => {
if (readyToDisplay.value === true) onDataReady();
})
function onDataReady() {
if (!visitsData.data.value) return;
if (!eventsData.data.value) return;
if (!sessionsData.data.value) return;
chartData.value.labels = visitsData.data.value.labels;
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
const maxEventSize = Math.max(...eventsData.data.value.data)
chartData.value.datasets[0].data = visitsData.data.value.data;
chartData.value.datasets[1].data = sessionsData.data.value.data;
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
const rValue = 20 / maxEventSize * e;
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
});
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return true;
return 'bottom';
});
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return '#fbbf2400';
return '#fbbf24';
});
updateChart();
}
</script>
<template>
<div class="h-[10rem] w-full flex">
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
</div>
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
{{ errorData.text }}
</div>
</div>
</template>
<style lang="scss" scoped>
#external-tooltip {
border-radius: 3px;
color: white;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translate(-50%, 0);
transition: all .1s ease;
}
</style>

View 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>

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const page = ref<number>(1);
const ordersList = [
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
{ label: 'active -->', id: '{ "last_log_at": 1 }' },
{ label: 'active <--', id: '{ "last_log_at": -1 }' },
{ label: 'visits -->', id: '{ "visits": 1 }' },
{ label: 'visits <--', id: '{ "visits": -1 }' },
{ label: 'events -->', id: '{ "events": 1 }' },
{ label: 'events <--', id: '{ "events": -1 }' },
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const limitList = [
{ label: '10', id: 10 },
{ label: '20', id: 20 },
{ label: '50', id: 50 },
{ label: '100', id: 100 },
]
const limit = ref<number>(20);
const filterList = [
{ label: 'ALL', id: '{}' },
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
]
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(() => {
for (const key in PREMIUM_PLAN) {
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
}
})
const filter = ref<string>('{}');
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}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<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>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Limit:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
value-attribute="id" option-attribute="label" v-model="limit">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Filter:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
value-attribute="id" option-attribute="label" v-model="filter">
</USelectMenu>
</div>
</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> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
}}</div>
</div>
<div>
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
</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
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
class="w-[26rem]" v-for="project of projectsInfo?.projects" />
<div v-if="pendingProjects"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import type { TAdminUser } from '~/server/api/admin/users';
const filterText = ref<string>('');
watch(filterText,()=>{
page.value = 1;
})
const filter = computed(() => {
return JSON.stringify({
$or: [
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
]
})
})
const page = ref<number>(1);
const ordersList = [
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const limitList = [
{ label: '10', id: 10 },
{ label: '20', id: 20 },
{ label: '50', id: 50 },
{ label: '100', id: 100 },
]
const limit = ref<number>(20);
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Limit:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
value-attribute="id" option-attribute="label" v-model="limit">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
</div>
<div class="flex gap-2 items-center">
<div>Page {{ page }} </div>
<div>
{{ Math.min(limit, usersInfo?.count || 0) }}
of
{{ usersInfo?.count || 0 }}
</div>
</div>
<div>
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
</div>
</div>
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
v-for="user of usersInfo?.users" />
<div v-if="pendingUsers"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View 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>

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
const props = defineProps<{ pid: string }>();
const { data: projectInfo, refresh, pending } = useFetch<{ domains: { _id: string }[], project: TAdminProject }>(
() => `/api/admin/project_info?pid=${props.pid}`,
signHeaders(),
);
</script>
<template>
<div class="mt-6 h-full flex flex-col gap-10 w-full" v-if="!pending">
<div>
<LyxUiButton type="secondary" @click="refresh"> Refresh </LyxUiButton>
</div>
<div class="flex justify-center gap-10" v-if="projectInfo">
<AdminOverviewProjectCard :project="projectInfo.project" class="w-[30rem] shrink-0" />
<AdminMiniChart class="max-w-[40rem]" :pid="pid"></AdminMiniChart>
</div>
<div v-if="projectInfo" class="flex flex-col">
<div>Domains:</div>
<div class="flex flex-wrap gap-8 mt-8">
<div v-for="domain of projectInfo.domains">
{{ domain._id }}
</div>
</div>
</div>
</div>
<div v-if="pending">
Loading...
</div>
</template>
<style scoped lang="scss"></style>

View 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>

View File

@@ -0,0 +1,134 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { getPlanFromId } from '~/shared/data/PREMIUM';
import { AdminDialogProjectDetails } from '#components';
const { openDialogEx } = useCustomDialog();
function showProjectDetails(pid: string) {
openDialogEx(AdminDialogProjectDetails, {
params: { pid }
})
}
const props = defineProps<{ project: TAdminProject }>();
const logBg = computed(() => {
const day = 1000 * 60 * 60 * 24;
const week = 1000 * 60 * 60 * 24 * 7;
const lastLoggedAtDate = new Date(props.project.last_log_at || 0);
if (lastLoggedAtDate.getTime() > Date.now() - day) {
return 'bg-green-500'
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
return 'bg-yellow-500'
} else {
return 'bg-red-500'
}
});
const dateDiffDays = computed(() => {
const res = (Date.now() - new Date(props.project.last_log_at || 0).getTime()) / (1000 * 60 * 60 * 24)
if (res > -1 && res < 1) return 0;
return res;
});
const usageLabel = computed(() => {
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
});
const usagePercentLabel = computed(() => {
const percent = 100 / props.project.limit_max * props.project.limit_total;
return `~ ${percent.toFixed(1)}%`;
});
const usageAiLabel = computed(() => {
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
}
); const usageAiPercentLabel = computed(() => {
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
return `~ ${percent.toFixed(1)}%`
});
</script>
<template>
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative h-fit">
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
</div>
<div class="flex gap-4 justify-center text-[.9rem]">
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
<div class="font-medium text-lyx-text-dark">
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? '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 @click="showProjectDetails(project._id.toString())" class="font-medium hover:text-lyx-primary cursor-pointer">
{{ project.name }}
</div>
</div>
<div class="flex flex-col items-center mt-2">
<div class="flex gap-4">
<div class="flex gap-2">
<div class="text-right"> Visits:</div>
<div>{{ formatNumberK(project.visits || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Events:</div>
<div>{{ formatNumberK(project.events || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Sessions:</div>
<div>{{ formatNumberK(project.sessions || 0) }}</div>
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="mb-2">
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
</div>
<div class="flex gap-6 justify-around">
<div class="flex gap-1">
<div>
{{ usageLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usagePercentLabel }}
</div>
</div>
<div class="flex gap-2">
<div>
{{ usageAiLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usageAiPercentLabel }}
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,135 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import type { TAdminUser } from '~/server/api/admin/users';
import { getPlanFromId } from '~/shared/data/PREMIUM';
import { AdminDialogProjectDetails } from '#components';
const { openDialogEx } = useCustomDialog();
function showProjectDetails(pid: string) {
openDialogEx(AdminDialogProjectDetails, {
params: { pid }
})
}
const props = defineProps<{ user: TAdminUser }>();
</script>
<template>
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative max-h-[15rem]">
<div class="flex gap-4 justify-center text-[.9rem]">
<div class="font-medium text-lyx-text-dark">
{{ user.name ?? user.given_name }}
</div>
<div class="text-lyx-text-darker">
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex gap-5 justify-center">
<div class="font-medium">
{{ user.email }}
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="flex flex-col text-[.9rem]">
<div class="flex gap-2" v-for="project of user.projects">
<div class="text-lyx-text-darker">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
<div class="font-medium text-lyx-text-dark">
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
</div>
</UTooltip>
<div @click="showProjectDetails(project._id.toString())"
class="ml-1 hover:text-lyx-primary cursor-pointer">
{{ project.name }}
</div>
</div>
</div>
</div>
<!-- <div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative">
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
</div>
<div class="flex gap-4 justify-center text-[.9rem]">
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
<div class="font-medium text-lyx-text-dark">
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? '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-2">
<div class="flex gap-4">
<div class="flex gap-2">
<div class="text-right"> Visits:</div>
<div>{{ formatNumberK(project.visits || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Events:</div>
<div>{{ formatNumberK(project.events || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Sessions:</div>
<div>{{ formatNumberK(project.sessions || 0) }}</div>
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="mb-2">
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
</div>
<div class="flex gap-6 justify-around">
<div class="flex gap-1">
<div>
{{ usageLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usagePercentLabel }}
</div>
</div>
<div class="flex gap-2">
<div>
{{ usageAiLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usageAiPercentLabel }}
</div>
</div>
</div>
</div> -->
</template>
<style scoped lang="scss"></style>

View File

@@ -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>

View File

@@ -96,7 +96,7 @@ const todayIndex = computed(() => {
<template> <template>
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4"> <div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 m-cards-wrap:grid-cols-4">
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth" <DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')" text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"

View File

@@ -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

View File

@@ -40,9 +40,12 @@ function onChange(e: string) {
</div> </div>
</template> </template>
</USelectMenu> </USelectMenu>
<div @click="refreshDomains" v-if="!refreshingDomains"
class="flex items-center hover:rotate-[60deg] transition-all duration-200 ease-in-out cursor-pointer"> <UTooltip text="Manage domains">
<i class="far fa-refresh"></i> <NuxtLink to="/settings?tab=domains"
</div> class="flex items-center hover:rotate-[60deg] transition-all duration-200 ease-in-out cursor-pointer">
<i class="far fa-gear"></i>
</NuxtLink>
</UTooltip>
</div> </div>
</template> </template>

View File

@@ -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>

View File

@@ -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
} }

View File

@@ -0,0 +1,13 @@
export function useSelectMenuStyle() {
return {
uiMenu: {
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
}
}
}
}

View File

@@ -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) || [])
] ]

View File

@@ -83,7 +83,7 @@ const { isOpen, close, open } = useMenu();
</div> --> </div> -->
</div> </div>
<div class="flex h-full"> <div class="flex h-full overflow-y-hidden">
<div v-if="isOpen" @click="close()" <div v-if="isOpen" @click="close()"
@@ -95,7 +95,7 @@ const { isOpen, close, open } = useMenu();
</LayoutVerticalNavigation> </LayoutVerticalNavigation>
<div class="overflow-hidden w-full bg-lyx-lightmode-background dark:bg-lyx-background relative h-full"> <div class="flex flex-col overflow-hidden w-full bg-lyx-lightmode-background dark:bg-lyx-background relative h-full">
<div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]"> <div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]">
<i <i
@@ -107,9 +107,9 @@ const { isOpen, close, open } = useMenu();
<DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard> <DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard>
</div> </div>
<LayoutTopNavigation class="flex"></LayoutTopNavigation> <LayoutTopNavigation class="flex shrink-0"></LayoutTopNavigation>
<div class="h-full pb-[3rem]"> <div class="flex-1 overflow-auto">
<slot></slot> <slot></slot>
</div> </div>

View File

@@ -1,135 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const entries = [
{
label: 'Home',
icon: 'i-heroicons-home',
to: '/',
},
{
label: 'Pricing',
icon: 'i-heroicons-currency-dollar',
to: '/pricing'
},
{
label: 'FAQ',
icon: 'i-heroicons-question-mark-circle',
to: '/faq'
}
]
const loggedUser = useLoggedUser();
const { setToken } = useAccessToken();
</script>
<template>
<div class="layout h-full flex flex-col pt-1 px-1">
<div class="text-white flex items-center py-4 pl-10 gap-2 mx-20">
<div class="flex gap-4 items-center">
<div class="bg-[#2969f1] h-[2.8rem] aspect-[1/1] flex items-center justify-center rounded-lg">
<img class="h-[1.8rem]" :src="'/logo.png'">
</div>
<div class="font-bold text-[1.6rem] text-gray-300 poppins"> Litlyx </div>
</div>
<!-- <div class="flex items-center gap-4">
<div class="w-8 h-8 bg-blue-400"></div>
<div class="font-bold text-[1.2rem] poppins"> Litlyx </div>
</div> -->
<div class="grow"></div>
<div class="flex gap-8 text-[1rem] text-white font-[500] poppins">
<div> Open metrics </div>
<div> Docs </div>
<div> Pricing </div>
<div> GitHub </div>
<div> FAQ </div>
</div>
<div class="px-10">
<div class="poppins font-[500] px-4 py-[.3rem] bg-accent rounded-xl"> Open App </div>
</div>
</div>
<div class="overflow-y-auto shrink h-full">
<div>
<slot></slot>
</div>
<div class="flex justify-center text-[1.3rem] items-center poppins py-16">
Made with in Italy
</div>
<div class="border-t-[1px] border-accent/40 flex h-fit py-12 w-full justify-between px-[8rem] footer">
<div class="flex flex-col gap-7">
<div class="flex items-center gap-2">
<!-- <div class="flex items-center justify-center">
<img :src="'logo.png'" class="h-[1.5rem]">
</div> -->
<div class="poppins font-bold text-[1.6rem] text-text/90">
Litlyx
</div>
</div>
<div class="flex gap-6 text-[1.5rem] text-text-sub/80">
<div> <i class="fab fa-x-twitter"></i> </div>
<div> <i class="fab fa-linkedin"></i> </div>
</div>
<div>
<div class="text-[.9rem] text-text-sub/80"> © 2024 Epictech Development S.r.l. All right
reserved.
</div>
</div>
</div>
<div class="flex gap-20">
<div class="flex flex-col gap-4">
<div class="text-text-sub/60 font-semibold"> Product </div>
<div class="hover:text-accent cursor-pointer"> Pricing </div>
<div class="hover:text-accent cursor-pointer"> Docs </div>
<div class="hover:text-accent cursor-pointer"> Github </div>
</div>
<div class="flex flex-col gap-4">
<div class="text-text-sub/60 font-semibold"> Company </div>
<div class="hover:text-accent cursor-pointer"> About </div>
<div class="hover:text-accent cursor-pointer"> Contact us </div>
</div>
<div class="flex flex-col gap-4">
<div class="text-text-sub/60 font-semibold"> Legal </div>
<div class="hover:text-accent cursor-pointer"> Privacy policy </div>
<div class="hover:text-accent cursor-pointer"> Terms and conditions </div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.banner * {
font-family: "Nunito";
}
.layout * {
font-family: "Inter";
}
</style>

View File

@@ -55,7 +55,7 @@ export default defineNuxtConfig({
STRIPE_SECRET_TEST: process.env.STRIPE_SECRET_TEST, STRIPE_SECRET_TEST: process.env.STRIPE_SECRET_TEST,
STRIPE_WH_SECRET_TEST: process.env.STRIPE_WH_SECRET_TEST, STRIPE_WH_SECRET_TEST: process.env.STRIPE_WH_SECRET_TEST,
NOAUTH_USER_EMAIL: process.env.NOAUTH_USER_EMAIL, NOAUTH_USER_EMAIL: process.env.NOAUTH_USER_EMAIL,
NOAUTH_USER_NAME: process.env.NOAUTH_USER_NAME, NOAUTH_USER_PASS: process.env.NOAUTH_USER_PASS,
MODE: process.env.MODE || 'NONE', MODE: process.env.MODE || 'NONE',
SELFHOSTED: process.env.SELFHOSTED || 'FALSE', SELFHOSTED: process.env.SELFHOSTED || 'FALSE',
public: { public: {

View File

@@ -5,8 +5,10 @@
"scripts": { "scripts": {
"build": "npm run workspace:shared && nuxt build --dotenv .env.testmode", "build": "npm run workspace:shared && nuxt build --dotenv .env.testmode",
"build:prod": "npm run workspace:shared && nuxt build --dotenv .env.prod", "build:prod": "npm run workspace:shared && nuxt build --dotenv .env.prod",
"build:compose": "nuxt build",
"dev": "npm run workspace:shared && nuxt dev --dotenv .env.testmode", "dev": "npm run workspace:shared && nuxt dev --dotenv .env.testmode",
"dev:prod": "npm run workspace:shared && nuxi dev --dotenv .env.prod", "dev:prod": "npm run workspace:shared && nuxi dev --dotenv .env.prod",
"dev:docker": "npm run workspace:shared && nuxi dev --dotenv .env.docker",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AdminProjectsList } from '~/server/api/admin/projects'; import type { CItem } from '~/components/CustomTab.vue';
definePageMeta({ layout: 'dashboard' }); definePageMeta({ layout: 'dashboard' });
@@ -24,110 +24,110 @@ const timeRangeTimestamp = computed(() => {
}) })
const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders()); // const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders()); // const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
function onHideClicked() { // function onHideClicked() {
isAdminHidden.value = true; // isAdminHidden.value = true;
} // }
function isAppsumoType(type: number) { // function isAppsumoType(type: number) {
return type > 6000 && type < 6004 // return type > 6000 && type < 6004
} // }
const projectsAggregated = computed(() => { // const projectsAggregated = computed(() => {
let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : []; // let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : [];
let shownPool: AdminProjectsList[] = []; // let shownPool: AdminProjectsList[] = [];
for (const element of pool) { // for (const element of pool) {
shownPool.push({ ...element, projects: [...element.projects] }); // shownPool.push({ ...element, projects: [...element.projects] });
if (filterAppsumo.value === true) { // if (filterAppsumo.value === true) {
shownPool.forEach(e => { // shownPool.forEach(e => {
e.projects = e.projects.filter(project => { // e.projects = e.projects.filter(project => {
return isAppsumoType(project.premium_type) // return isAppsumoType(project.premium_type)
}) // })
}) // })
shownPool = shownPool.filter(e => { // shownPool = shownPool.filter(e => {
return e.projects.length > 0; // return e.projects.length > 0;
}) // })
} else if (filterPremium.value === true) { // } else if (filterPremium.value === true) {
shownPool.forEach(e => { // shownPool.forEach(e => {
e.projects = e.projects.filter(project => { // e.projects = e.projects.filter(project => {
return project.premium === true; // return project.premium === true;
}) // })
}) // })
shownPool = shownPool.filter(e => { // shownPool = shownPool.filter(e => {
return e.projects.length > 0; // return e.projects.length > 0;
}) // })
} else { // } else {
console.log('NO DATA') // console.log('NO DATA')
} // }
} // }
return shownPool.sort((a, b) => { // return shownPool.sort((a, b) => {
const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0); // const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0); // const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
return sumVisitsB - sumVisitsA; // return sumVisitsB - sumVisitsA;
}).filter(e => { // }).filter(e => {
return new Date(e.created_at).getTime() >= timeRangeTimestamp.value // return new Date(e.created_at).getTime() >= timeRangeTimestamp.value
}); // });
}) // })
const premiumCount = computed(() => { // const premiumCount = computed(() => {
let premiums = 0; // let premiums = 0;
projectsAggregated.value?.forEach(e => { // projectsAggregated.value?.forEach(e => {
e.projects.forEach(p => { // e.projects.forEach(p => {
if (p.premium) premiums++; // if (p.premium) premiums++;
}); // });
}) // })
return premiums; // return premiums;
}) // })
const activeProjects = computed(() => { // const activeProjects = computed(() => {
let actives = 0; // let actives = 0;
projectsAggregated.value?.forEach(e => { // projectsAggregated.value?.forEach(e => {
e.projects.forEach(p => { // e.projects.forEach(p => {
if (!p.counts) return; // if (!p.counts) return;
if (!p.counts.updated_at) return; // if (!p.counts.updated_at) return;
const updated_at = new Date(p.counts.updated_at).getTime(); // const updated_at = new Date(p.counts.updated_at).getTime();
if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return; // if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return;
actives++; // actives++;
}); // });
}) // })
return actives; // return actives;
}); // });
const totalVisits = computed(() => { // const totalVisits = computed(() => {
return projectsAggregated.value?.reduce((a, e) => { // return projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0); // return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
}, 0) || 0; // }, 0) || 0;
}); // });
const totalEvents = computed(() => { // const totalEvents = computed(() => {
return projectsAggregated.value?.reduce((a, e) => { // return projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0); // return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0);
}, 0) || 0; // }, 0) || 0;
}); // });
@@ -165,121 +165,37 @@ function getLogBg(last_logged_at?: string) {
} }
const tabs: CItem[] = [
{ label: 'Overview', slot: 'overview' },
{ label: 'Users', slot: 'users' },
{ label: 'Feedbacks', slot: 'feedbacks' },
{ label: 'OnBoarding', slot: 'onboarding' },
{ label: 'Backend', slot: 'backend' }
]
</script> </script>
<template> <template>
<div class="bg-bg overflow-y-auto w-full h-dvh p-6 gap-6 flex flex-col"> <div class="bg-bg overflow-y-hidden w-full p-6 gap-6 flex flex-col h-full">
<div v-if="showDetails" <CustomTab :items="tabs" :manualScroll="true">
class="w-full md:px-40 h-full fixed top-0 left-0 bg-black/90 backdrop-blur-[2px] z-[20] overflow-y-auto"> <template #overview>
<div class="cursor-pointer bg-red-400 w-fit px-10 py-2 rounded-lg font-semibold my-3" <AdminOverview></AdminOverview>
@click="showDetails = false"> </template>
Close <template #users>
</div> <AdminUsers></AdminUsers>
<div class="whitespace-pre-wrap poppins"> </template>
{{ JSON.stringify(details, null, 3) }} <template #feedbacks>
</div> <AdminFeedbacks></AdminFeedbacks>
</div> </template>
<template #onboarding>
<div @click="onHideClicked()" v-if="!isAdminHidden" <AdminOnboardings></AdminOnboardings>
class="bg-menu hover:bg-menu/70 cursor-pointer flex gap-2 rounded-lg w-fit px-6 py-4 text-text-sub"> </template>
<div class="text-text-sub/90"> <i class="far fa-eye"></i> </div> <template #backend>
<div> Hide from the bar </div> <AdminBackend></AdminBackend>
</div> </template>
</CustomTab>
<Card class="p-2 flex gap-10 items-center justify-center">
<div :class="{ 'text-red-200': timeRange == 1 }" @click="setTimeRange(1)"> Last day </div>
<div :class="{ 'text-red-200': timeRange == 2 }" @click="setTimeRange(2)"> Last week </div>
<div :class="{ 'text-red-200': timeRange == 3 }" @click="setTimeRange(3)"> Last month </div>
<div :class="{ 'text-red-200': timeRange == 9 }" @click="setTimeRange(9)"> All </div>
</Card>
<Card class="p-2 flex gap-10 items-center justify-center">
<UCheckbox v-model="filterPremium" label="Filter Premium"></UCheckbox>
<UCheckbox v-model="filterAppsumo" label="Filter Appsumo"></UCheckbox>
</Card>
<Card class="p-4">
<div class="grid grid-cols-2 gap-1">
<div>
Users: {{ counts?.users }}
</div>
<div>
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
</div>
<div>
Total visits: {{ formatNumberK(totalVisits) }}
</div>
<div>
Active: {{ activeProjects }} |
Dead: {{ (counts?.projects || 0) - activeProjects }}
</div>
<div>
Total events: {{ formatNumberK(totalEvents) }}
</div>
</div>
</Card>
<div v-for="item of projectsAggregated || []"
class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full relative">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-1">
<div> {{ item.email }} </div>
<div> {{ item.name }} </div>
</div>
<div class="flex justify-evenly flex-col lg:grid lg:grid-cols-3 gap-2 lg:gap-4">
<div v-for="project of item.projects" :class="{
'outline outline-[2px] outline-yellow-400': isAppsumoType(project.premium_type)
}" class="flex relative flex-col items-center bg-bg p-6 rounded-xl">
<div class="absolute left-2 top-2 flex items-center gap-2">
<div :class="getLogBg(project?.counts?.updated_at)" class="h-3 w-3 rounded-full"> </div>
<div> {{ dateDiffDays(project?.counts?.updated_at || '0').toFixed(0) }} days </div>
</div>
<div class="flex gap-4">
<div class="font-bold">
{{ project.premium ? 'PREMIUM' : 'FREE' }}
</div>
<div class="text-text-sub/90">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="text-ellipsis line-clamp-1"> {{ project.name }} </div>
<div class="flex gap-2">
<div> Visits: </div>
<div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
<div> Events: </div>
<div> {{ formatNumberK(project.counts?.events || 0) }} </div>
<div> Sessions: </div>
<div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
</div>
<div class="flex gap-4 items-center mt-4">
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
Payment details
</LyxUiButton>
<LyxUiButton type="danger" @click="resetCount(project._id)">
Refresh counts
</LyxUiButton>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
import type { AdminProjectsList } from '~/server/api/admin/projects';
definePageMeta({ layout: 'dashboard' });
const filterPremium = ref<boolean>(false);
const filterAppsumo = ref<boolean>(false);
const timeRange = ref<number>(9);
function setTimeRange(n: number) {
timeRange.value = n;
}
const timeRangeTimestamp = computed(() => {
if (timeRange.value == 1) return Date.now() - 1000 * 60 * 60 * 24;
if (timeRange.value == 2) return Date.now() - 1000 * 60 * 60 * 24 * 7;
if (timeRange.value == 3) return Date.now() - 1000 * 60 * 60 * 24 * 30;
return 0;
})
const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
function onHideClicked() {
isAdminHidden.value = true;
}
function isAppsumoType(type: number) {
return type > 6000 && type < 6004
}
const projectsAggregated = computed(() => {
let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : [];
let shownPool: AdminProjectsList[] = [];
for (const element of pool) {
shownPool.push({ ...element, projects: [...element.projects] });
if (filterAppsumo.value === true) {
shownPool.forEach(e => {
e.projects = e.projects.filter(project => {
return isAppsumoType(project.premium_type)
})
})
shownPool = shownPool.filter(e => {
return e.projects.length > 0;
})
} else if (filterPremium.value === true) {
shownPool.forEach(e => {
e.projects = e.projects.filter(project => {
return project.premium === true;
})
})
shownPool = shownPool.filter(e => {
return e.projects.length > 0;
})
} else {
console.log('NO DATA')
}
}
return shownPool.sort((a, b) => {
const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
return sumVisitsB - sumVisitsA;
}).filter(e => {
return new Date(e.created_at).getTime() >= timeRangeTimestamp.value
});
})
const premiumCount = computed(() => {
let premiums = 0;
projectsAggregated.value?.forEach(e => {
e.projects.forEach(p => {
if (p.premium) premiums++;
});
})
return premiums;
})
const activeProjects = computed(() => {
let actives = 0;
projectsAggregated.value?.forEach(e => {
e.projects.forEach(p => {
if (!p.counts) return;
if (!p.counts.updated_at) return;
const updated_at = new Date(p.counts.updated_at).getTime();
if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return;
actives++;
});
})
return actives;
});
const totalVisits = computed(() => {
return projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
}, 0) || 0;
});
const totalEvents = computed(() => {
return projectsAggregated.value?.reduce((a, e) => {
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0);
}, 0) || 0;
});
const details = ref<any>();
const showDetails = ref<boolean>(false);
async function getProjectDetails(project_id: string) {
details.value = await $fetch(`/api/admin/details?project_id=${project_id}`, signHeaders());
showDetails.value = true;
}
async function resetCount(project_id: string) {
await $fetch(`/api/admin/reset_count?project_id=${project_id}`, signHeaders());
}
function dateDiffDays(a: string) {
return (Date.now() - new Date(a).getTime()) / (1000 * 60 * 60 * 24)
}
function getLogBg(last_logged_at?: string) {
const day = 1000 * 60 * 60 * 24;
const week = 1000 * 60 * 60 * 24 * 7;
const lastLoggedAtDate = new Date(last_logged_at || 0);
if (lastLoggedAtDate.getTime() > Date.now() - day) {
return 'bg-green-500'
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
return 'bg-yellow-500'
} else {
return 'bg-red-500'
}
}
</script>
<template>
<div class="bg-bg overflow-y-auto w-full h-dvh p-6 gap-6 flex flex-col">
<div v-if="showDetails"
class="w-full md:px-40 h-full fixed top-0 left-0 bg-black/90 backdrop-blur-[2px] z-[20] overflow-y-auto">
<div class="cursor-pointer bg-red-400 w-fit px-10 py-2 rounded-lg font-semibold my-3"
@click="showDetails = false">
Close
</div>
<div class="whitespace-pre-wrap poppins">
{{ JSON.stringify(details, null, 3) }}
</div>
</div>
<div @click="onHideClicked()" v-if="!isAdminHidden"
class="bg-menu hover:bg-menu/70 cursor-pointer flex gap-2 rounded-lg w-fit px-6 py-4 text-text-sub">
<div class="text-text-sub/90"> <i class="far fa-eye"></i> </div>
<div> Hide from the bar </div>
</div>
<Card class="p-2 flex gap-10 items-center justify-center">
<div :class="{ 'text-red-200': timeRange == 1 }" @click="setTimeRange(1)"> Last day </div>
<div :class="{ 'text-red-200': timeRange == 2 }" @click="setTimeRange(2)"> Last week </div>
<div :class="{ 'text-red-200': timeRange == 3 }" @click="setTimeRange(3)"> Last month </div>
<div :class="{ 'text-red-200': timeRange == 9 }" @click="setTimeRange(9)"> All </div>
</Card>
<Card class="p-2 flex gap-10 items-center justify-center">
<UCheckbox v-model="filterPremium" label="Filter Premium"></UCheckbox>
<UCheckbox v-model="filterAppsumo" label="Filter Appsumo"></UCheckbox>
</Card>
<Card class="p-4">
<div class="grid grid-cols-2 gap-1">
<div>
Users: {{ counts?.users }}
</div>
<div>
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
</div>
<div>
Total visits: {{ formatNumberK(totalVisits) }}
</div>
<div>
Active: {{ activeProjects }} |
Dead: {{ (counts?.projects || 0) - activeProjects }}
</div>
<div>
Total events: {{ formatNumberK(totalEvents) }}
</div>
</div>
</Card>
<div v-for="item of projectsAggregated || []"
class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full relative">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-1">
<div> {{ item.email }} </div>
<div> {{ item.name }} </div>
</div>
<div class="flex justify-evenly flex-col lg:grid lg:grid-cols-3 gap-2 lg:gap-4">
<div v-for="project of item.projects" :class="{
'outline outline-[2px] outline-yellow-400': isAppsumoType(project.premium_type)
}" class="flex relative flex-col items-center bg-bg p-6 rounded-xl">
<div class="absolute left-2 top-2 flex items-center gap-2">
<div :class="getLogBg(project?.counts?.updated_at)" class="h-3 w-3 rounded-full"> </div>
<div> {{ dateDiffDays(project?.counts?.updated_at || '0').toFixed(0) }} days </div>
</div>
<div class="flex gap-4">
<div class="font-bold">
{{ project.premium ? 'PREMIUM' : 'FREE' }}
</div>
<div class="text-text-sub/90">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="text-ellipsis line-clamp-1"> {{ project.name }} </div>
<div class="flex gap-2">
<div> Visits: </div>
<div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
<div> Events: </div>
<div> {{ formatNumberK(project.counts?.events || 0) }} </div>
<div> Sessions: </div>
<div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
</div>
<div class="flex gap-4 items-center mt-4">
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
Payment details
</LyxUiButton>
<LyxUiButton type="danger" @click="resetCount(project._id)">
Refresh counts
</LyxUiButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -13,32 +13,6 @@ const useCodeClientWrapper = isNoAuth.value === false ?
return { isReady: false, login: () => { } } return { isReady: false, login: () => { } }
} }
async function loginWithoutAuth() {
try {
const result = await $fetch('/api/auth/no_auth');
if (result.error) return alert('Error during login, please try again');
setToken(result.access_token);
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
const loggedUser = useLoggedUser();
loggedUser.user = user;
console.log('LOGIN DONE - USER', loggedUser.user);
const isFirstTime = await $fetch<boolean>('/api/user/is_first_time', { headers: { 'Authorization': 'Bearer ' + token.value } })
if (isFirstTime === true) {
router.push('/project_creation?just_logged=true');
} else {
router.push('/?just_logged=true');
}
} catch (ex: any) {
alert('Error during login.' + ex.message);
}
}
const { isReady, login } = useCodeClientWrapper({ onSuccess: handleOnSuccess, onError: handleOnError, }); const { isReady, login } = useCodeClientWrapper({ onSuccess: handleOnSuccess, onError: handleOnError, });
const router = useRouter(); const router = useRouter();
@@ -121,6 +95,39 @@ function goBackToEmailLogin() {
password.value = ''; password.value = '';
} }
async function signInSelfhosted() {
try {
const result = await $fetch(`/api/auth/no_auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value, password: password.value })
});
if (result.error) {
if (result.errorMessage) return alert(result.errorMessage);
return alert('Error during login, please try again');
}
setToken(result.access_token);
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
const loggedUser = useLoggedUser();
loggedUser.user = user;
console.log('LOGIN DONE - USER', loggedUser.user);
const isFirstTime = await $fetch<boolean>('/api/user/is_first_time', { headers: { 'Authorization': 'Bearer ' + token.value } })
if (isFirstTime === true) {
router.push('/project_creation?just_logged=true');
} else {
router.push('/?just_logged=true');
}
} catch (ex: any) {
alert('Error during login.' + ex.message);
}
}
async function signInWithCredentials() { async function signInWithCredentials() {
try { try {
@@ -176,7 +183,8 @@ async function signInWithCredentials() {
Sign in Sign in
</div> </div>
<div class="text-lyx-lightmode-text/80 dark:text-lyx-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2"> <div
class="text-lyx-lightmode-text/80 dark:text-lyx-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2">
Track web analytics and custom events Track web analytics and custom events
with extreme simplicity in under 30 sec. with extreme simplicity in under 30 sec.
<br> <br>
@@ -217,7 +225,8 @@ async function signInWithCredentials() {
</div> </div>
<div v-if="!isNoAuth && !isEmailLogin" class="flex flex-col text-lyx-lightmode-text dark:text-lyx-text gap-2"> <div v-if="!isNoAuth && !isEmailLogin"
class="flex flex-col text-lyx-lightmode-text dark:text-lyx-text gap-2">
<div @click="login" <div @click="login"
class="hover:bg-lyx-primary bg-white dark:bg-transparent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]"> class="hover:bg-lyx-primary bg-white dark:bg-transparent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
@@ -247,17 +256,30 @@ async function signInWithCredentials() {
</div> </div>
<div v-if="isNoAuth" @click="loginWithoutAuth" <div v-if="isNoAuth" @click="loginWithoutAuth"
class="hover:bg-accent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]"> class="flex text-[1.3rem] flex-col gap-4 items-center px-8 py-3 relative z-[2]">
<div class="flex items-center"> <div class="flex flex-col gap-4 z-[100] w-[20vw] min-w-[20rem]">
<i class="far fa-crown"></i> <LyxUiInput class="px-3 py-2" placeholder="Email" v-model="email"></LyxUiInput>
<LyxUiInput class="px-3 py-2" placeholder="Password" v-model="password" type="password">
</LyxUiInput>
</div>
<div class="flex justify-center mt-4 z-[100]">
<LyxUiButton @click="signInSelfhosted()" class="text-center" type="primary">
Sign in
</LyxUiButton>
</div> </div>
Continue as Admin
</div> </div>
</div> </div>
<div class="text-[.9rem] poppins mt-20 text-lyx-lightmode-text-dark dark:text-lyx-text-dark text-center relative z-[2]"> <div
class="text-[.9rem] poppins mt-20 text-lyx-lightmode-text-dark dark:text-lyx-text-dark text-center relative z-[2]">
By continuing you are accepting By continuing you are accepting
<br> <br>
our our

View File

@@ -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>

View 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 }
});

View 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;
});

View 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 };
});

View File

@@ -0,0 +1,89 @@
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
import { TProjectLimit } from "~/shared/schema/project/ProjectsLimits";
import { TAdminProject } from "./projects";
import { Types } from "mongoose";
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
function addFieldsFromArray(data: { fieldName: string, projectedName: string, arrayName: string }[]) {
const content: Record<string, any> = {};
data.forEach(e => {
content[e.projectedName] = {
"$ifNull": [{ "$getField": { "field": e.fieldName, "input": { "$arrayElemAt": [`$${e.arrayName}`, 0] } } }, 0]
}
});
return content;
}
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return;
const { pid } = getQuery(event);
const projects = await ProjectModel.aggregate([
{
$match: { _id: new Types.ObjectId(pid as string) }
},
{
$lookup: {
from: "project_limits",
localField: "_id",
foreignField: "project_id",
as: "limits"
}
},
{
$lookup: {
from: "project_counts",
localField: "_id",
foreignField: "project_id",
as: "counts"
}
},
{
$addFields: addFieldsFromArray([
{ arrayName: 'counts', fieldName: 'visits', projectedName: 'visits' },
{ arrayName: 'counts', fieldName: 'events', projectedName: 'events' },
{ arrayName: 'counts', fieldName: 'session', projectedName: 'session' },
{ arrayName: 'counts', fieldName: 'updated_at', projectedName: 'last_log_at' },
]),
},
{
$addFields: addFieldsFromArray([
{ arrayName: 'limits', fieldName: 'visits', projectedName: 'limit_visits' },
{ arrayName: 'limits', fieldName: 'events', projectedName: 'limit_events' },
{ arrayName: 'limits', fieldName: 'limit', projectedName: 'limit_max' },
{ arrayName: 'limits', fieldName: 'ai_messages', projectedName: 'limit_ai_messages' },
{ arrayName: 'limits', fieldName: 'ai_limit', projectedName: 'limit_ai_max' },
]),
},
{
$addFields: {
limit_total: {
$add: [
{ $ifNull: ["$limit_visits", 0] },
{ $ifNull: ["$limit_events", 0] }
]
},
}
},
{ $unset: 'counts' },
{ $unset: 'limits' },
]);
const domains = await VisitModel.aggregate([
{
$match: { project_id: new Types.ObjectId(pid as string) }
},
{
$group: {
_id: '$website',
}
}
])
return { domains, project: (projects[0] as TAdminProject) };
});

View File

@@ -1,24 +1,35 @@
import { UserModel } from "@schema/UserSchema"; import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
import { TProjectLimit } from "~/shared/schema/project/ProjectsLimits";
export type AdminProjectsList = { type ExtendedProject = {
_id: string, limits: TProjectLimit[],
name: string, counts: [{
given_name: string, events: number,
created_at: string, visits: number,
email: string, sessions: number
projects: { }],
_id: string, visits: number,
owner: string, events: number,
name: string, sessions: number,
premium: boolean, limit_visits: number,
premium_type: number, limit_events: number,
customer_id: string, limit_max: number,
subscription_id: string, limit_ai_messages: number,
premium_expire_at: string, limit_ai_max: number,
created_at: string, limit_total: number,
__v: number, last_log_at: string
counts: { _id: string, project_id: string, events: number, visits: number, sessions: number, updated_at?: string } }
}[],
export type TAdminProject = TProject & ExtendedProject;
function addFieldsFromArray(data: { fieldName: string, projectedName: string, arrayName: string }[]) {
const content: Record<string, any> = {};
data.forEach(e => {
content[e.projectedName] = {
"$ifNull": [{ "$getField": { "field": e.fieldName, "input": { "$arrayElemAt": [`$${e.arrayName}`, 0] } } }, 0]
}
});
return content;
} }
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
@@ -27,58 +38,78 @@ 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 data: AdminProjectsList[] = await UserModel.aggregate([ const { page, limit, sortQuery, filterQuery, filterFrom, filterTo } = getQuery(event);
const pageNumber = parseInt(page as string);
const limitNumber = parseInt(limit 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([
{
$match: matchQuery
},
{ {
$lookup: { $lookup: {
from: "projects", from: "project_limits",
localField: "_id", localField: "_id",
foreignField: "owner", foreignField: "project_id",
as: "projects" as: "limits"
}
},
{
$unwind: {
path: "$projects",
preserveNullAndEmptyArrays: true
} }
}, },
{ {
$lookup: { $lookup: {
from: "project_counts", from: "project_counts",
localField: "projects._id", localField: "_id",
foreignField: "project_id", foreignField: "project_id",
as: "projects.counts" as: "counts"
} }
}, },
{
$addFields: addFieldsFromArray([
{ arrayName: 'counts', fieldName: 'visits', projectedName: 'visits' },
{ arrayName: 'counts', fieldName: 'events', projectedName: 'events' },
{ arrayName: 'counts', fieldName: 'session', projectedName: 'session' },
{ arrayName: 'counts', fieldName: 'updated_at', projectedName: 'last_log_at' },
]),
},
{
$addFields: addFieldsFromArray([
{ arrayName: 'limits', fieldName: 'visits', projectedName: 'limit_visits' },
{ arrayName: 'limits', fieldName: 'events', projectedName: 'limit_events' },
{ arrayName: 'limits', fieldName: 'limit', projectedName: 'limit_max' },
{ arrayName: 'limits', fieldName: 'ai_messages', projectedName: 'limit_ai_messages' },
{ arrayName: 'limits', fieldName: 'ai_limit', projectedName: 'limit_ai_max' },
]),
},
{ {
$addFields: { $addFields: {
"projects.counts": { limit_total: {
$arrayElemAt: ["$projects.counts", 0] $add: [
} { $ifNull: ["$limit_visits", 0] },
{ $ifNull: ["$limit_events", 0] }
]
},
} }
}, },
{ { $unset: 'counts' },
$group: { { $unset: 'limits' },
_id: "$_id", { $sort: JSON.parse(sortQuery as string) },
name: { { $skip: pageNumber * limitNumber },
$first: "$name" { $limit: limitNumber }
},
given_name: {
$first: "$given_name"
},
created_at: {
$first: "$created_at"
},
email: {
$first: "$email"
},
projects: {
$push: "$projects"
}
}
}
]); ]);
return data; return {
count,
projects: projects as TAdminProject[]
};
}); });

View File

@@ -0,0 +1,37 @@
import { TProject } from "@schema/project/ProjectSchema";
import { TUser, UserModel } from "@schema/UserSchema";
export type TAdminUser = TUser & { _id: string, projects: TProject[] };
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return;
const { page, limit, sortQuery, filterQuery } = getQuery(event);
const pageNumber = parseInt(page as string);
const limitNumber = parseInt(limit as string);
const count = await UserModel.countDocuments(JSON.parse(filterQuery as string));
const users = await UserModel.aggregate([
{
$match: JSON.parse(filterQuery as string)
},
{
$lookup: {
from: "projects",
localField: "_id",
foreignField: "owner",
as: "projects"
}
},
{ $sort: JSON.parse(sortQuery as string) },
{ $skip: pageNumber * limitNumber },
{ $limit: limitNumber }
]);
return { count, users: users as TAdminUser[] }
});

View File

@@ -0,0 +1,97 @@
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
import { TUser, UserModel } from "@schema/UserSchema";
import { TProjectLimit } from "~/shared/schema/project/ProjectsLimits";
export type TAdminUserProjectInfo = TUser & {
projects: (TProject & {
limits: TProjectLimit[],
visits: number,
events: number,
sessions: number
})[],
}
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return;
const { page, limit, sortQuery } = getQuery(event);
const pageNumber = parseInt(page as string);
const limitNumber = parseInt(limit as string);
const users = await UserModel.aggregate([
{
$lookup: {
from: "projects",
localField: "_id",
foreignField: "owner",
pipeline: [
{
$lookup: {
from: "project_limits",
localField: "_id",
foreignField: "project_id",
as: "limits"
}
},
{
$lookup: {
from: "visits",
localField: "_id",
foreignField: "project_id",
pipeline: [
{
$count: "total_visits"
}
],
as: "visit_data"
}
},
{
$lookup: {
from: "events",
localField: "_id",
foreignField: "project_id",
pipeline: [
{
$count: "total_events"
}
],
as: "event_data"
}
},
{
$lookup: {
from: "sessions",
localField: "_id",
foreignField: "project_id",
pipeline: [
{
$count: "total_sessions"
}
],
as: "session_data"
}
},
{ $addFields: { visits: { $ifNull: [{ $arrayElemAt: ["$visit_data.total_visits", 0] }, 0] } } },
{ $addFields: { events: { $ifNull: [{ $arrayElemAt: ["$event_data.total_events", 0] }, 0] } } },
{ $addFields: { sessions: { $ifNull: [{ $arrayElemAt: ["$session_data.total_sessions", 0] }, 0] } }, },
{ $unset: "visit_data" },
{ $unset: "event_data" },
{ $unset: "session_data" }
],
as: "projects"
},
},
{ $sort: JSON.parse(sortQuery as string) },
{ $skip: pageNumber * limitNumber },
{ $limit: limitNumber }
]);
return users as TAdminUserProjectInfo[];
});

View File

@@ -2,7 +2,7 @@
import { createUserJwt } from '~/server/AuthManager'; import { createUserJwt } from '~/server/AuthManager';
import { UserModel } from '@schema/UserSchema'; import { UserModel } from '@schema/UserSchema';
const { NOAUTH_USER_EMAIL, NOAUTH_USER_NAME, public: publicRuntime } = useRuntimeConfig(); const { NOAUTH_USER_EMAIL, NOAUTH_USER_PASS, public: publicRuntime } = useRuntimeConfig();
const noAuthMode = publicRuntime.AUTH_MODE == 'NO_AUTH'; const noAuthMode = publicRuntime.AUTH_MODE == 'NO_AUTH';
@@ -18,11 +18,15 @@ export default defineEventHandler(async event => {
return { error: true, access_token: '' } return { error: true, access_token: '' }
} }
if (!NOAUTH_USER_NAME) { if (!NOAUTH_USER_PASS) {
console.error('NOAUTH_USER_NAME is required in NO_AUTH mode'); console.error('NOAUTH_USER_PASS is required in NO_AUTH mode');
return { error: true, access_token: '' } return { error: true, access_token: '' }
} }
const body = await readBody(event);
if (body.email != NOAUTH_USER_EMAIL || body.password != NOAUTH_USER_PASS) return { error: true, access_token: '', errorMessage: 'Username or password invalid' }
const user = await UserModel.findOne({ email: NOAUTH_USER_EMAIL }); const user = await UserModel.findOne({ email: NOAUTH_USER_EMAIL });
if (user) return { if (user) return {
@@ -35,8 +39,8 @@ export default defineEventHandler(async event => {
const newUser = new UserModel({ const newUser = new UserModel({
email: NOAUTH_USER_EMAIL, email: NOAUTH_USER_EMAIL,
given_name: NOAUTH_USER_NAME, given_name: NOAUTH_USER_EMAIL.split('@')[0] || 'NONAME',
name: NOAUTH_USER_NAME, name: NOAUTH_USER_EMAIL.split('@')[0] || 'NONAME',
locale: 'no-auth', locale: 'no-auth',
picture: '', picture: '',
created_at: Date.now() created_at: Date.now()

View File

@@ -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 '';
} }

View File

@@ -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) {

View File

@@ -21,7 +21,7 @@ export default defineEventHandler(async event => {
domain domain
}) })
return timelineStackedEvents; return timelineStackedEvents.filter(e => e.name != undefined);
}); });
}); });

View File

@@ -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;
}); });

View File

@@ -5,6 +5,7 @@ import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
import { Model, Types } from "mongoose"; import { Model, Types } from "mongoose";
import { TeamMemberModel } from "@schema/TeamMemberSchema"; import { TeamMemberModel } from "@schema/TeamMemberSchema";
import { Slice } from "@services/DateService"; import { Slice } from "@services/DateService";
import { ADMIN_EMAILS } from "~/shared/data/ADMINS";
export function getRequestUser(event: H3Event<EventHandlerRequest>) { export function getRequestUser(event: H3Event<EventHandlerRequest>) {
if (!event.context.auth) return; if (!event.context.auth) return;
@@ -40,6 +41,10 @@ async function hasAccessToProject(user_id: string, project: TProject) {
if (owner === user_id) return [true, 'OWNER']; if (owner === user_id) return [true, 'OWNER'];
const isGuest = await TeamMemberModel.exists({ project_id, user_id }); const isGuest = await TeamMemberModel.exists({ project_id, user_id });
if (isGuest) return [true, 'GUEST']; if (isGuest) return [true, 'GUEST'];
//TODO: Create table with admins
if (user_id === '66520c90f381ec1e9284938b') return [true, 'ADMIN'];
return [false, 'NONE']; return [false, 'NONE'];
} }
@@ -60,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_' }
} }

View File

@@ -1,4 +1,10 @@
{ {
// https://nuxt.com/docs/guide/concepts/typescript // https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json" "extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"skipLibCheck": true,
},
"exclude": [
"node_modules"
]
} }

View File

@@ -90,9 +90,9 @@ services:
# NO_AUTH or GOOGLE # NO_AUTH or GOOGLE
NUXT_PUBLIC_AUTH_MODE: 'NO_AUTH' NUXT_PUBLIC_AUTH_MODE: 'NO_AUTH'
# Default user created in NO_AUTH mode # Credentials to login in NO_AUTH mode
NUXT_NOAUTH_USER_EMAIL: 'default@user.com' NUXT_NOAUTH_USER_EMAIL: 'default@user.com'
NUXT_NOAUTH_USER_NAME: "defaultuser" NUXT_NOAUTH_USER_PASS: "litlyx123"
NUXT_SELFHOSTED: 'true' NUXT_SELFHOSTED: 'true'
NUXT_PUBLIC_SELFHOSTED: 'true' NUXT_PUBLIC_SELFHOSTED: 'true'

View File

@@ -5,24 +5,13 @@ RUN npm i -g pnpm
WORKDIR /home/app WORKDIR /home/app
COPY --link ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
COPY --link ./scripts/package.json ./scripts/pnpm-lock.yaml ./scripts/
COPY --link ./producer/package.json ./producer/pnpm-lock.yaml ./producer/ COPY --link ./producer/package.json ./producer/pnpm-lock.yaml ./producer/
RUN pnpm install
RUN pnpm install --filter producer
WORKDIR /home/app/scripts
RUN pnpm install
WORKDIR /home/app
COPY --link ../scripts ./scripts
COPY --link ../shared ./shared
COPY --link ../producer ./producer
WORKDIR /home/app/producer WORKDIR /home/app/producer
RUN pnpm install
COPY --link ../producer ./
RUN pnpm run build RUN pnpm run build
CMD ["node", "/home/app/producer/dist/producer/src/index.js"] CMD ["node", "/home/app/producer/dist/index.js"]

View File

@@ -26,7 +26,8 @@ export type PREMIUM_DATA = {
PRICE: string, PRICE: string,
PRICE_TEST: string, PRICE_TEST: string,
ID: number, ID: number,
COST: number COST: number,
TAG: PREMIUM_TAG
} }
export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = { export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
@@ -36,7 +37,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 10, AI_MESSAGE_LIMIT: 10,
PRICE: 'price_1POKCMB2lPUiVs9VLe3QjIHl', PRICE: 'price_1POKCMB2lPUiVs9VLe3QjIHl',
PRICE_TEST: 'price_1PNbHYB2lPUiVs9VZP32xglF', PRICE_TEST: 'price_1PNbHYB2lPUiVs9VZP32xglF',
COST: 0 COST: 0,
TAG: 'FREE'
}, },
PLAN_1: { PLAN_1: {
ID: 1, ID: 1,
@@ -44,7 +46,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 100, AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1POKCOB2lPUiVs9VC13s2rQw', PRICE: 'price_1POKCOB2lPUiVs9VC13s2rQw',
PRICE_TEST: 'price_1PNZjVB2lPUiVs9VrsTbJL04', PRICE_TEST: 'price_1PNZjVB2lPUiVs9VrsTbJL04',
COST: 0 COST: 0,
TAG: 'PLAN_1'
}, },
PLAN_2: { PLAN_2: {
ID: 2, ID: 2,
@@ -52,7 +55,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 5_000, AI_MESSAGE_LIMIT: 5_000,
PRICE: 'price_1POKCKB2lPUiVs9Vol8XOmhW', PRICE: 'price_1POKCKB2lPUiVs9Vol8XOmhW',
PRICE_TEST: 'price_1POK34B2lPUiVs9VIROb0IIV', PRICE_TEST: 'price_1POK34B2lPUiVs9VIROb0IIV',
COST: 0 COST: 0,
TAG: 'PLAN_2'
}, },
CUSTOM_1: { CUSTOM_1: {
ID: 1001, ID: 1001,
@@ -60,7 +64,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 100_000, AI_MESSAGE_LIMIT: 100_000,
PRICE: 'price_1POKZyB2lPUiVs9VMAY6jXTV', PRICE: 'price_1POKZyB2lPUiVs9VMAY6jXTV',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'CUSTOM_1'
}, },
INCUBATION: { INCUBATION: {
ID: 101, ID: 101,
@@ -68,7 +73,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 30, AI_MESSAGE_LIMIT: 30,
PRICE: 'price_1PdsyzB2lPUiVs9V4J246Jw0', PRICE: 'price_1PdsyzB2lPUiVs9V4J246Jw0',
PRICE_TEST: '', PRICE_TEST: '',
COST: 499 COST: 499,
TAG: 'INCUBATION'
}, },
ACCELERATION: { ACCELERATION: {
ID: 102, ID: 102,
@@ -76,7 +82,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 100, AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1Pdt5bB2lPUiVs9VhkuCouEt', PRICE: 'price_1Pdt5bB2lPUiVs9VhkuCouEt',
PRICE_TEST: '', PRICE_TEST: '',
COST: 999 COST: 999,
TAG: 'ACCELERATION'
}, },
GROWTH: { GROWTH: {
ID: 103, ID: 103,
@@ -84,7 +91,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PdszrB2lPUiVs9VIdkT3thv', PRICE: 'price_1PdszrB2lPUiVs9VIdkT3thv',
PRICE_TEST: '', PRICE_TEST: '',
COST: 2999 COST: 2999,
TAG: 'GROWTH'
}, },
EXPANSION: { EXPANSION: {
ID: 104, ID: 104,
@@ -92,7 +100,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 5_000, AI_MESSAGE_LIMIT: 5_000,
PRICE: 'price_1Pdt0xB2lPUiVs9V0Rdt80Fe', PRICE: 'price_1Pdt0xB2lPUiVs9V0Rdt80Fe',
PRICE_TEST: '', PRICE_TEST: '',
COST: 5999 COST: 5999,
TAG: 'EXPANSION'
}, },
SCALING: { SCALING: {
ID: 105, ID: 105,
@@ -100,7 +109,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 10_000, AI_MESSAGE_LIMIT: 10_000,
PRICE: 'price_1Pdt1UB2lPUiVs9VUmxntSwZ', PRICE: 'price_1Pdt1UB2lPUiVs9VUmxntSwZ',
PRICE_TEST: '', PRICE_TEST: '',
COST: 9999 COST: 9999,
TAG: 'SCALING'
}, },
UNICORN: { UNICORN: {
ID: 106, ID: 106,
@@ -108,7 +118,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 20_000, AI_MESSAGE_LIMIT: 20_000,
PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G', PRICE: 'price_1Pdt2LB2lPUiVs9VGBFAIG9G',
PRICE_TEST: '', PRICE_TEST: '',
COST: 14999 COST: 14999,
TAG: 'UNICORN'
}, },
LIFETIME_GROWTH_ONETIME: { LIFETIME_GROWTH_ONETIME: {
ID: 2001, ID: 2001,
@@ -116,7 +127,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PvewGB2lPUiVs9VLheJC8s1', PRICE: 'price_1PvewGB2lPUiVs9VLheJC8s1',
PRICE_TEST: 'price_1Pvf7LB2lPUiVs9VMFNyzpim', PRICE_TEST: 'price_1Pvf7LB2lPUiVs9VMFNyzpim',
COST: 239900 COST: 239900,
TAG: 'LIFETIME_GROWTH_ONETIME'
}, },
GROWTH_DUMMY: { GROWTH_DUMMY: {
ID: 5001, ID: 5001,
@@ -124,7 +136,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1PvgoRB2lPUiVs9VC51YBT7J', PRICE: 'price_1PvgoRB2lPUiVs9VC51YBT7J',
PRICE_TEST: 'price_1PvgRTB2lPUiVs9V3kFSNC3G', PRICE_TEST: 'price_1PvgRTB2lPUiVs9V3kFSNC3G',
COST: 0 COST: 0,
TAG: 'GROWTH_DUMMY'
}, },
APPSUMO_INCUBATION: { APPSUMO_INCUBATION: {
ID: 6001, ID: 6001,
@@ -132,7 +145,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 30, AI_MESSAGE_LIMIT: 30,
PRICE: 'price_1QIXwbB2lPUiVs9VKSsoksaU', PRICE: 'price_1QIXwbB2lPUiVs9VKSsoksaU',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'APPSUMO_INCUBATION'
}, },
APPSUMO_ACCELERATION: { APPSUMO_ACCELERATION: {
ID: 6002, ID: 6002,
@@ -140,7 +154,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 100, AI_MESSAGE_LIMIT: 100,
PRICE: 'price_1QIXxRB2lPUiVs9VrjaVRoOl', PRICE: 'price_1QIXxRB2lPUiVs9VrjaVRoOl',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'APPSUMO_ACCELERATION'
}, },
APPSUMO_GROWTH: { APPSUMO_GROWTH: {
ID: 6003, ID: 6003,
@@ -148,7 +163,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 3_000, AI_MESSAGE_LIMIT: 3_000,
PRICE: 'price_1QIXy8B2lPUiVs9VQBOUPAoE', PRICE: 'price_1QIXy8B2lPUiVs9VQBOUPAoE',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'APPSUMO_GROWTH'
}, },
APPSUMO_UNICORN: { APPSUMO_UNICORN: {
ID: 6006, ID: 6006,
@@ -156,7 +172,8 @@ export const PREMIUM_PLAN: Record<PREMIUM_TAG, PREMIUM_DATA> = {
AI_MESSAGE_LIMIT: 20_000, AI_MESSAGE_LIMIT: 20_000,
PRICE: 'price_1Qls1lB2lPUiVs9VI6ej8hwE', PRICE: 'price_1Qls1lB2lPUiVs9VI6ej8hwE',
PRICE_TEST: '', PRICE_TEST: '',
COST: 0 COST: 0,
TAG: 'APPSUMO_UNICORN'
} }
} }

View File

@@ -11,9 +11,6 @@ importers:
'@types/node': '@types/node':
specifier: ^22.10.10 specifier: ^22.10.10
version: 22.10.10 version: 22.10.10
'@types/redis':
specifier: ^4.0.11
version: 4.0.11
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -64,10 +61,6 @@ packages:
'@types/node@22.10.10': '@types/node@22.10.10':
resolution: {integrity: sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==} resolution: {integrity: sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==}
'@types/redis@4.0.11':
resolution: {integrity: sha512-bI+gth8La8Wg/QCR1+V1fhrL9+LZUSWfcqpOj2Kc80ZQ4ffbdL173vQd5wovmoV9i071FU9oP2g6etLuEwb6Rg==}
deprecated: This is a stub types definition. redis provides its own type definitions, so you do not need this installed.
'@types/webidl-conversions@7.0.3': '@types/webidl-conversions@7.0.3':
resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
@@ -220,10 +213,6 @@ snapshots:
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
'@types/redis@4.0.11':
dependencies:
redis: 4.7.0
'@types/webidl-conversions@7.0.3': {} '@types/webidl-conversions@7.0.3': {}
'@types/whatwg-url@11.0.5': '@types/whatwg-url@11.0.5':