Compare commits
231 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7658dbe85c | ||
|
|
1f9ef5d18c | ||
|
|
94a28b31d3 | ||
|
|
87c9aca5c4 | ||
|
|
afda29997d | ||
|
|
d1b3e997c1 | ||
|
|
be82f7046f | ||
|
|
45e9a9c6a7 | ||
|
|
942d074f99 | ||
|
|
63fa3995c5 | ||
|
|
76e5e07f79 | ||
|
|
b8f9e598a7 | ||
|
|
0ee4895e1a | ||
|
|
72d6b97383 | ||
|
|
3f22c655a5 | ||
|
|
4fea549a5a | ||
|
|
4c1d10f8b7 | ||
|
|
50d275e0ff | ||
|
|
98238fa180 | ||
|
|
c07bccd0dd | ||
|
|
7192c31136 | ||
|
|
56d7e71d90 | ||
|
|
30229d4b97 | ||
|
|
b2303468a4 | ||
|
|
af6dff57ed | ||
|
|
dd8b089c46 | ||
|
|
a5750d556a | ||
|
|
f5882bff9f | ||
|
|
f18cdc8278 | ||
|
|
a7ebbc22c0 | ||
|
|
346eecc928 | ||
|
|
abc485a9ef | ||
|
|
0292829805 | ||
|
|
4e2c8468f8 | ||
|
|
38cfd4315d | ||
|
|
b592695a49 | ||
|
|
0963201a32 | ||
|
|
4da840f2ec | ||
|
|
a1718875d9 | ||
|
|
e931235533 | ||
|
|
881a7800ce | ||
|
|
487c3ac7b4 | ||
|
|
0dd94be6e6 | ||
|
|
29a220b21e | ||
|
|
8cc2f07b95 | ||
|
|
88cec21df1 | ||
|
|
8183ae1e68 | ||
|
|
0f39cab26a | ||
|
|
a2e4ed9ee0 | ||
|
|
30b5db4200 | ||
|
|
bfeee8673c | ||
|
|
39b8dd84f1 | ||
|
|
19b7c7664a | ||
|
|
a3e74adf9c | ||
|
|
ad9aabcbf6 | ||
|
|
510bc2545a | ||
|
|
65c682c75d | ||
|
|
04acc0b18e | ||
|
|
852fea45a5 | ||
|
|
6f3e59e72e | ||
|
|
3960eaa8ad | ||
|
|
e4bdf7e4c3 | ||
|
|
afeaac1b0d | ||
|
|
8922507a64 | ||
|
|
13e94cb0f0 | ||
|
|
3923a06e9b | ||
|
|
6b5d23566c | ||
|
|
dbcda95823 | ||
|
|
fb89c87489 | ||
|
|
b59eea47e9 | ||
|
|
473331047d | ||
|
|
5af77ff63e | ||
|
|
e6e2340432 | ||
|
|
0b90c2fe3c | ||
|
|
a6d1797a4f | ||
|
|
d1abe1a91f | ||
|
|
b733cd2a68 | ||
|
|
88ebfc188c | ||
|
|
ab95772dd4 | ||
|
|
0d5dbc69ad | ||
|
|
8a359936d1 | ||
|
|
ffd2e96138 | ||
|
|
b8e434be9a | ||
|
|
835ab6208e | ||
|
|
617de36fec | ||
|
|
fb31fdcfff | ||
|
|
745a332e56 | ||
|
|
a10755f998 | ||
|
|
46bca2f787 | ||
|
|
cb928977c3 | ||
|
|
3b5a46a64a | ||
|
|
7d05a9d157 | ||
|
|
edc897d62a | ||
|
|
39c42e7bd5 | ||
|
|
7009a0ad02 | ||
|
|
3f26f1ab68 | ||
|
|
7082b88523 | ||
|
|
29bae329b4 | ||
|
|
f908b0b4a9 | ||
|
|
b38363ddf5 | ||
|
|
68d362d1b3 | ||
|
|
0a9474d00c | ||
|
|
6307e09dc3 | ||
|
|
f358bb9bb6 | ||
|
|
23b8f7229a | ||
|
|
78f979d23a | ||
|
|
ad8e9e1ead | ||
|
|
06768b6cdc | ||
|
|
91f69baacd | ||
|
|
0964ec4250 | ||
|
|
9ce2c89575 | ||
|
|
b630bddef0 | ||
|
|
30e428a8dc | ||
|
|
b700b96191 | ||
|
|
606eb0b035 | ||
|
|
ec974c3599 | ||
|
|
4c811c160b | ||
|
|
e140585362 | ||
|
|
7d56b7a6a2 | ||
|
|
070560c1e2 | ||
|
|
41037a01a1 | ||
|
|
caef67a0e1 | ||
|
|
5ac43dec6b | ||
|
|
9de299d841 | ||
|
|
2929b229c4 | ||
|
|
f06d7d78fc | ||
|
|
4d7cfbb7b9 | ||
|
|
b4c0620f17 | ||
|
|
b8c2e40f7a | ||
|
|
e866a1c22b | ||
|
|
f86a399840 | ||
|
|
36c4406af2 | ||
|
|
b2afd585bb | ||
|
|
24ae9d0e0d | ||
|
|
b479ca1bbf | ||
|
|
0a748346c5 | ||
|
|
fa7880552a | ||
|
|
06fb8bfab0 | ||
|
|
a876d77d42 | ||
|
|
e6bb58693f | ||
|
|
00e63cc80b | ||
|
|
e43f138945 | ||
|
|
73309e7021 | ||
|
|
80e3b0caa9 | ||
|
|
0a7f2b58d0 | ||
|
|
e953af2c1b | ||
|
|
126296d28f | ||
|
|
8dd10deecc | ||
|
|
f22e65ccc5 | ||
|
|
dfbc64fe33 | ||
|
|
9568566361 | ||
|
|
634cb641f1 | ||
|
|
204e1348b4 | ||
|
|
b73155a176 | ||
|
|
62c72b3ff9 | ||
|
|
79e956e930 | ||
|
|
b27cacf4e6 | ||
|
|
c2846ca595 | ||
|
|
e1953f2f9f | ||
|
|
314660d8a3 | ||
|
|
f516c53b7b | ||
|
|
dad8c521ee | ||
|
|
089d1a418e | ||
|
|
a08624b69b | ||
|
|
3ba6cd171b | ||
|
|
1828edf98b | ||
|
|
96c39dbba1 | ||
|
|
9403aebbb9 | ||
|
|
69bb6fb03c | ||
|
|
33b730e66b | ||
|
|
0ba44a406d | ||
|
|
3c77a727cd | ||
|
|
8e3ad2920f | ||
|
|
f4401d74a2 | ||
|
|
375330bac4 | ||
|
|
3b1ee0fd13 | ||
|
|
f5edf187fd | ||
|
|
5b7e93bcbb | ||
|
|
3b6a202538 | ||
|
|
cf1aa103e4 | ||
|
|
4eeebaa0c3 | ||
|
|
f285e92132 | ||
|
|
ac7ba7abd3 | ||
|
|
3c59551f88 | ||
|
|
628e471cec | ||
|
|
0be3dbecbf | ||
|
|
fa5a37ece2 | ||
|
|
db32afe741 | ||
|
|
e813b3246d | ||
|
|
86011c38ce | ||
|
|
fd5eca29cc | ||
|
|
a591b43600 | ||
|
|
cebb45484c | ||
|
|
e4e2c2a42a | ||
|
|
dfa1407102 | ||
|
|
e6adbf9c7b | ||
|
|
c3904ebd55 | ||
|
|
4c46a36c75 | ||
|
|
c253846b86 | ||
|
|
e7c2dbf237 | ||
|
|
525a371a6e | ||
|
|
6a9a698b7a | ||
|
|
4134d33dc4 | ||
|
|
5172ad4f4d | ||
|
|
be45448288 | ||
|
|
73739dde9d | ||
|
|
30b3ed80e2 | ||
|
|
8e56069b1a | ||
|
|
3ecdec9ca9 | ||
|
|
7b41a3ed0d | ||
|
|
5804d7a73b | ||
|
|
8b026099de | ||
|
|
d7e18d570f | ||
|
|
023f2b5f4a | ||
|
|
c003b655ec | ||
|
|
d499aa2f39 | ||
|
|
944996eb15 | ||
|
|
87b1f9caf9 | ||
|
|
748894b946 | ||
|
|
01e8a9ab1d | ||
|
|
a2034551ec | ||
|
|
6d26c3c8af | ||
|
|
518b4ce6c1 | ||
|
|
71bd4d0e58 | ||
|
|
0563a833eb | ||
|
|
ab07ffb108 | ||
|
|
79309cc537 | ||
|
|
9b9ed3e9ad | ||
|
|
1cb6b92d5c | ||
|
|
c1bdc30933 | ||
|
|
887ed45b4d |
@@ -1,51 +1,26 @@
|
|||||||
|
shared/node_modules
|
||||||
|
shared/.output
|
||||||
|
|
||||||
# Broker
|
scripts/node_modules
|
||||||
broker/node_modules
|
|
||||||
broker/scripts/start_dev.js
|
lyx-ui/node_modules
|
||||||
broker/ecosystem.config.cjs
|
lyx-ui/.nuxt
|
||||||
broker/ecosystem.config.example.cjs
|
lyx-ui/.output
|
||||||
broker/Dockerfile
|
|
||||||
broker/.gitignore
|
|
||||||
broker/dist
|
|
||||||
|
|
||||||
# Producer
|
|
||||||
producer/node_modules
|
producer/node_modules
|
||||||
producer/scripts/start_dev.js
|
producer/scripts/start_dev.js
|
||||||
producer/ecosystem.config.cjs
|
producer/ecosystem.config.cjs
|
||||||
producer/ecosystem.config.example.cjs
|
|
||||||
producer/Dockerfile
|
|
||||||
producer/.gitignore
|
|
||||||
producer/dist
|
|
||||||
|
|
||||||
# Dashboard
|
consumer/node_modules
|
||||||
|
consumer/scripts/start_dev.js
|
||||||
|
consumer/ecosystem.config.cjs
|
||||||
|
|
||||||
dashboard/node_modules
|
dashboard/node_modules
|
||||||
dashboard/.nuxt
|
dashboard/.nuxt
|
||||||
dashboard/.output
|
dashboard/.output
|
||||||
|
dashboard/explains
|
||||||
dashboard/tests
|
dashboard/tests
|
||||||
dashboard/.env.example
|
|
||||||
dashboard/.env
|
dashboard/.env
|
||||||
dashboard/.gitignore
|
dashboard/winston-*.ndjson
|
||||||
dashboard/ecosystem.config.cjs
|
dashboard/ecosystem.config.cjs
|
||||||
dashboard/out.pdf
|
|
||||||
dashboard/timeline.report.txt
|
|
||||||
dashboard/Dockerfile
|
|
||||||
dashboard/vitest.config.ts
|
|
||||||
dashboard/vitest.setup.ts
|
|
||||||
|
|
||||||
# Shared
|
|
||||||
shared/node_modules
|
|
||||||
shared/.gitignore
|
|
||||||
|
|
||||||
# Others
|
|
||||||
docs/*
|
|
||||||
landing/*
|
|
||||||
docker/*
|
|
||||||
dev/*
|
|
||||||
assets/*
|
|
||||||
CODE_OF_CONDUCT.md
|
|
||||||
LICENSE
|
|
||||||
readme.md
|
|
||||||
SECURITY.md
|
|
||||||
steps
|
|
||||||
docker-compose.yml
|
|
||||||
docker-compose.admin.yml
|
|
||||||
6
.gitignore
vendored
@@ -1,5 +1,11 @@
|
|||||||
steps
|
steps
|
||||||
PROCESS_EVENT
|
PROCESS_EVENT
|
||||||
|
**/node_modules/
|
||||||
docker
|
docker
|
||||||
dev
|
dev
|
||||||
docker-compose.admin.yml
|
docker-compose.admin.yml
|
||||||
|
full_reload.sh
|
||||||
|
build-all.sh
|
||||||
|
tmp
|
||||||
|
ecosystem.config.js
|
||||||
|
todo
|
||||||
109
README.md
@@ -1,18 +1,17 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/claim-t.png"/>
|
<img src="assets/claim.png"/>
|
||||||
</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://dashboard.litlyx.com">Start for 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>
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
The easiest Dev-Centric Analytics tool.<br>Litlyx is , Open-Source, Plug-In everywhere Javascript is Supported. Setup in less then 30 seconds, with just One-Line of code.
|
Litlys is a modern, developer-friendly, cookie-free analytics tool.<br>
|
||||||
|
Setup takes less than 30 seconds! Completely self-hostable with docker.<br>
|
||||||
|
Alternative to Google Analytics, Matomo, Umami, Plausible & Simple Analytics.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -20,44 +19,40 @@
|
|||||||
<br />
|
<br />
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/screen.png"/>
|
<img src="assets/dashboard-clip.png"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||

|
## Get Started on our Cloud Version
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
## Pre-Requisites
|
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.
|
||||||
|
|
||||||
Sign-up on [Litlyx cloud](https://dashboard.litlyx.com) using OAuth & name your project to get your project_id to connect Litlyx to your website OR Self-Host Litlyx with Docker.
|
|
||||||
|
|
||||||
## Universal Installation
|
## Universal Installation
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script defer data-project="project_id_here" 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 already tracks 10 KPIs such as `Page visits`, `Browsers`, `Devices`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `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`, `yarn`, or `pnpm`:
|
You can install Litlyx using `npm`, `pnpm` or any modern package managers:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm i litlyx-js
|
npm i litlyx-js
|
||||||
```
|
```
|
||||||
|
|
||||||
Litlyx natively supports all JS/TS frameworks. You can use Litlyx in all WordPress Websites by injecting JS code using a plug-in. Litlyx work in serverless enviroments 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/techs.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';
|
||||||
@@ -69,66 +64,88 @@ 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`, `OS`, `Real-Time Online Users`, `Unique Session`, `Countries`, `Average Session Time`.
|
After initialization, Litlyx will automatically track web analytics such as `Page visits`, `Real-Time Online Users`, `Unique Vistors`, and many more.
|
||||||
|
|
||||||
# Custom Events
|
# Track Custom Events (Actions)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
Lit.event('click_on_buy_item');
|
Lit.event('click_on_buy_item');
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want more dept tracking, you can use the `metadata` field, like this:
|
If you want more specific tracking, you can use the `metadata` field, like this:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
Lit.event('click_on_buy_item', {
|
Lit.event('click_on_buy_item', {
|
||||||
metadata: {
|
metadata: {
|
||||||
'product-name': 'Coca-Cola',
|
'product-name': 'Coca-Cola',
|
||||||
'price': 1.50,
|
'price': 1.50,
|
||||||
|
'currency': 'EUR'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
You can create your Tailor-Made Experience at ease.
|
Litlyx makes it easy for you to tailor your analytics to your project's needs.
|
||||||
|
|
||||||
# AI Data-Analyst
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/agent.png" width="180px"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
Lit can compare data, query specific metadata, visualize charts, and much more just by having a simple `conversation` with him.
|
|
||||||
|
|
||||||
|
|
||||||
# Self-Hosting with Docker
|
# Fire Your First Event with cURL
|
||||||
|
|
||||||
First thing first **Fork** this repository.
|
Want to quickly see how Litlyx works with events? Use the cURL command below to send a test event. Just replace the `project_id` with your actual project ID in your terminal.
|
||||||
|
|
||||||
Then run the following command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose build
|
curl -X POST "https://broker.litlyx.com/event" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"pid": "project_id",
|
||||||
|
"name": "testEvent1",
|
||||||
|
"metadata": "{\"test\": \"something\"}",
|
||||||
|
"website": "something",
|
||||||
|
"userAgent": "something"
|
||||||
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
then, after the build finish, run:
|
# Self-hosting with docker
|
||||||
|
|
||||||
|
To self-host the Litlyx dashboard, first **clone** this repository. (Litlyx's Docker images are hosted on DockerHub).
|
||||||
|
|
||||||
|
Then run the following command:
|
||||||
```bash
|
```bash
|
||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
on your localhost you will see your own instance of the Litlyx Dashboard.
|
at localhost:3000 you will see your own instance of the Litlyx Dashboard.
|
||||||
|
|
||||||
# Official Docs
|
## Forward data to your self-hosted instance with script tag
|
||||||
|
|
||||||
For more info read our [documentation](https://docs.litlyx.com). (will be improved in the near future using Mintlify!)
|
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).
|
||||||
|
|
||||||
# Join Discord
|
```html
|
||||||
|
<script defer data-project="your_project_id"
|
||||||
|
data-host="your-host-name"
|
||||||
|
data-port="your-port"
|
||||||
|
data-secure="true/false"
|
||||||
|
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
If you need more information, help, or want to provide general feedback, feel free to join us on[Discord](https://discord.gg/9cQykjsmWX)
|
# Read our docs
|
||||||
|
|
||||||
# Contributors
|
For more info on how to use litlyx read our [documentation](https://docs.litlyx.com).
|
||||||
|
|
||||||
Every kind of contribution is accepted in this stage of the project. In the future we will onboard you better.
|
|
||||||
|
# Stay updated with our roadmap
|
||||||
|
|
||||||
|
To keep track on what we are cooking behind the scene we have a public [Roadmap](https://litlyx.com/roadmap) for you to check.
|
||||||
|
|
||||||
|
|
||||||
|
# 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
BIN
assets/agent.png
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 42 KiB |
BIN
assets/bg.png
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
assets/claim.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
assets/dashboard-clip.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 144 KiB |
BIN
assets/tech.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
assets/techs.png
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,43 +0,0 @@
|
|||||||
ARG NODE_VERSION=21
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine as base
|
|
||||||
|
|
||||||
ENV NODE_ENV=development
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
|
|
||||||
FROM base as build
|
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
|
||||||
|
|
||||||
COPY --link broker/package.json broker/pnpm-lock.yaml home/app/
|
|
||||||
|
|
||||||
COPY --link shared/package.json shared/pnpm-lock.yaml /home/shared/
|
|
||||||
|
|
||||||
WORKDIR /home/app
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
WORKDIR /home/shared
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY --link ../broker /home/app
|
|
||||||
|
|
||||||
COPY --link ../shared /home/shared
|
|
||||||
|
|
||||||
WORKDIR /home/app
|
|
||||||
|
|
||||||
RUN pnpm run build_all
|
|
||||||
|
|
||||||
RUN pnpm prune
|
|
||||||
|
|
||||||
# Final stage
|
|
||||||
|
|
||||||
FROM base
|
|
||||||
|
|
||||||
COPY --from=build /home/app /home/app
|
|
||||||
|
|
||||||
WORKDIR /home/app
|
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
|
||||||
|
|
||||||
CMD ["node", "dist/app/src/index.js"]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'QueueBroker',
|
|
||||||
port: '3999',
|
|
||||||
exec_mode: 'fork',
|
|
||||||
script: './dist/producer/src/index.js',
|
|
||||||
env: {
|
|
||||||
EMAIL_SERVICE: "",
|
|
||||||
EMAIL_HOST: "",
|
|
||||||
EMAIL_USER: "",
|
|
||||||
EMAIL_PASS: "",
|
|
||||||
PORT: "",
|
|
||||||
MONGO_CONNECTION_STRING: "",
|
|
||||||
REDIS_URL: "",
|
|
||||||
REDIS_USERNAME: "",
|
|
||||||
REDIS_PASSWORD: "",
|
|
||||||
STREAM_NAME: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"express": "^4.19.2",
|
|
||||||
"mongoose": "^8.3.2",
|
|
||||||
"nodemailer": "^6.9.13",
|
|
||||||
"redis": "^4.6.14",
|
|
||||||
"ua-parser-js": "^1.0.37"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/cors": "^2.8.17",
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/node": "^20.12.13",
|
|
||||||
"@types/nodemailer": "^6.4.15",
|
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
|
||||||
"glob": "^10.4.1",
|
|
||||||
"node-ssh": "^13.2.0",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.4.5"
|
|
||||||
},
|
|
||||||
"name": "litlyx-queue-broker",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "node scripts/start_dev.js",
|
|
||||||
"compile": "tsc",
|
|
||||||
"build": "ts-node scripts/build.ts",
|
|
||||||
"create_db": "cd scripts && ts-node create_database.ts",
|
|
||||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
|
||||||
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
|
|
||||||
"docker-inspect": "docker run -it litlyx-broker sh"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "Emily",
|
|
||||||
"license": "MIT",
|
|
||||||
"description": "Queue broker for Litlyx - Saves events to database."
|
|
||||||
}
|
|
||||||
1467
broker/pnpm-lock.yaml
generated
@@ -1,17 +0,0 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
import { globSync } from 'glob';
|
|
||||||
const tsconfigContent = fs.readFileSync('tsconfig.json', 'utf8');
|
|
||||||
const tsconfigObject = JSON.parse(tsconfigContent);
|
|
||||||
const paths = tsconfigObject.compilerOptions.paths;
|
|
||||||
const filesList = globSync('dist/**/*.js');
|
|
||||||
filesList.forEach(file => {
|
|
||||||
let raw = fs.readFileSync(file, 'utf8');
|
|
||||||
for (const path in paths) {
|
|
||||||
const deep = (file.match(/\\|\//g) || []).length;
|
|
||||||
const pathText = path.replace('*', '');
|
|
||||||
const toReplaceText = new RegExp(`"${pathText}(.*?)"`, 'g');
|
|
||||||
raw = raw.replace(toReplaceText, `"${new Array(deep - 2).fill('../').join('')}${paths[path][0].replace('*', '')}${'$1'}"`);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(file, raw);
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { ProjectModel } from "@schema/ProjectSchema";
|
|
||||||
import { UserModel } from "@schema/UserSchema";
|
|
||||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
|
||||||
import EmailService from '@services/EmailService';
|
|
||||||
import { requireEnv } from "../../shared/utilts/requireEnv";
|
|
||||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
|
||||||
|
|
||||||
if (process.env.EMAIL_SERVICE) {
|
|
||||||
EmailService.createTransport(
|
|
||||||
requireEnv('EMAIL_SERVICE'),
|
|
||||||
requireEnv('EMAIL_HOST'),
|
|
||||||
requireEnv('EMAIL_USER'),
|
|
||||||
requireEnv('EMAIL_PASS'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
|
||||||
|
|
||||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) {
|
|
||||||
const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id });
|
|
||||||
if (notify && notify.limit1 === true) return;
|
|
||||||
const project = await ProjectModel.findById(projectCounts.project_id);
|
|
||||||
if (!project) return;
|
|
||||||
const owner = await UserModel.findById(project.owner);
|
|
||||||
if (!owner) return;
|
|
||||||
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email);
|
|
||||||
await LimitNotifyModel.updateOne({ project_id: projectCounts._id }, { limit1: true, limit2: false, limit3: false }, { upsert: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
export function getDeviceFromScreenSize(width: number, height: number) {
|
|
||||||
const totalArea = width * height;
|
|
||||||
|
|
||||||
const mobileArea = 375 * 667;
|
|
||||||
const tabletMinArea = 768 * 1366
|
|
||||||
const tabletMaxArea = 1024 * 1366
|
|
||||||
|
|
||||||
const isMobile = totalArea <= mobileArea;
|
|
||||||
const isTablet = totalArea >= tabletMinArea && totalArea <= tabletMaxArea;
|
|
||||||
|
|
||||||
if (isMobile) return 'mobile';
|
|
||||||
if (isTablet) return 'tablet'
|
|
||||||
return 'desktop';
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
|
|
||||||
import { RedisStreamService } from '@services/RedisStreamService';
|
|
||||||
import { requireEnv } from '../../shared/utilts/requireEnv';
|
|
||||||
import { EventModel } from '@schema/metrics/EventSchema';
|
|
||||||
import { SessionModel } from '@schema/metrics/SessionSchema';
|
|
||||||
import { ProjectModel } from '@schema/ProjectSchema';
|
|
||||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
|
||||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
|
||||||
import { EVENT_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
|
||||||
import { checkLimitsForEmail } from './Controller';
|
|
||||||
import { lookup } from './lookup';
|
|
||||||
import { UAParser } from 'ua-parser-js';
|
|
||||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
|
||||||
|
|
||||||
|
|
||||||
export async function startStreamLoop() {
|
|
||||||
|
|
||||||
await RedisStreamService.connect();
|
|
||||||
|
|
||||||
await RedisStreamService.startReadingLoop({
|
|
||||||
streamName: requireEnv('STREAM_NAME'),
|
|
||||||
delay: { base: 100, empty: 5000 },
|
|
||||||
readBlock: 2500
|
|
||||||
}, processStreamEvent);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function processStreamEvent(data: Record<string, string>) {
|
|
||||||
try {
|
|
||||||
const eventType = data._type;
|
|
||||||
if (!eventType) return;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { pid, sessionHash } = data;
|
|
||||||
|
|
||||||
const project = await ProjectModel.exists({ _id: pid });
|
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
if (eventType === 'event') return await process_event(data, sessionHash);
|
|
||||||
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
|
|
||||||
if (eventType === 'visit') return await process_visit(data, sessionHash);
|
|
||||||
|
|
||||||
} catch (ex: any) {
|
|
||||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
|
||||||
|
|
||||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
|
||||||
|
|
||||||
const projectLimits = await ProjectLimitModel.findOne({ project_id: pid });
|
|
||||||
if (!projectLimits) return;
|
|
||||||
|
|
||||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
|
||||||
const COUNT_LIMIT = projectLimits.limit;
|
|
||||||
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return;
|
|
||||||
await checkLimitsForEmail(projectLimits);
|
|
||||||
|
|
||||||
let referrerParsed;
|
|
||||||
try {
|
|
||||||
referrerParsed = new URL(referrer);
|
|
||||||
} catch (ex) {
|
|
||||||
referrerParsed = { hostname: referrer };
|
|
||||||
}
|
|
||||||
|
|
||||||
const geoLocation = lookup(ip);
|
|
||||||
|
|
||||||
const userAgentParsed = UAParser(userAgent);
|
|
||||||
|
|
||||||
const visit = new VisitModel({
|
|
||||||
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
|
||||||
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
|
||||||
os: userAgentParsed.os.name || 'NO_OS',
|
|
||||||
device: userAgentParsed.device.type,
|
|
||||||
session: sessionHash,
|
|
||||||
flowHash,
|
|
||||||
continent: geoLocation[0],
|
|
||||||
country: geoLocation[1],
|
|
||||||
});
|
|
||||||
|
|
||||||
await visit.save();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true });
|
|
||||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } });
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
|
||||||
|
|
||||||
const { pid, instant, flowHash } = data;
|
|
||||||
|
|
||||||
const existingSession = await SessionModel.findOne({ project_id: pid }, { _id: 1 });
|
|
||||||
if (!existingSession) {
|
|
||||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instant == "true") {
|
|
||||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
|
||||||
$inc: { duration: 0 },
|
|
||||||
flowHash,
|
|
||||||
updated_at: Date.now()
|
|
||||||
}, { upsert: true });
|
|
||||||
} else {
|
|
||||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
|
||||||
$inc: { duration: 1 },
|
|
||||||
flowHash,
|
|
||||||
updated_at: Date.now()
|
|
||||||
}, { upsert: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function process_event(data: Record<string, string>, sessionHash: string) {
|
|
||||||
|
|
||||||
const { name, metadata, pid, flowHash } = data;
|
|
||||||
|
|
||||||
let metadataObject;
|
|
||||||
try {
|
|
||||||
if (metadata) metadataObject = JSON.parse(metadata);
|
|
||||||
} catch (ex) {
|
|
||||||
metadataObject = { error: 'Error parsing metadata' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = new EventModel({ project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash });
|
|
||||||
await event.save();
|
|
||||||
|
|
||||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true });
|
|
||||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } });
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
|
|
||||||
import { requireEnv } from '../../shared/utilts/requireEnv';
|
|
||||||
import { connectDatabase } from '@services/DatabaseService';
|
|
||||||
import { startStreamLoop } from './StreamLoopController';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(cors());
|
|
||||||
|
|
||||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
|
||||||
|
|
||||||
import HealthRouter from './routes/HealthRouter';
|
|
||||||
app.use('/health', HealthRouter);
|
|
||||||
|
|
||||||
app.listen(requireEnv('PORT'), () => console.log(`Listening on port ${requireEnv('PORT')}`));
|
|
||||||
|
|
||||||
startStreamLoop();
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
|
|
||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
return res.json({ alive: true });
|
|
||||||
} catch (ex) {
|
|
||||||
console.error(ex);
|
|
||||||
return res.status(500).json({ error: 'ERROR' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Request } from "express";
|
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
export function getIPFromRequest(req: Request) {
|
|
||||||
const ip = req.header('X-Real-IP') || req.header('X-Forwarded-For') || '0.0.0.0';
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function createSessionHash(website: string, ip: string, userAgent: string) {
|
|
||||||
const dailySalt = new Date().toLocaleDateString('it-IT');
|
|
||||||
const sessionClean = dailySalt + website + ip + userAgent;
|
|
||||||
const sessionHash = crypto.createHash('md5').update(sessionClean).digest("hex");
|
|
||||||
return sessionHash;
|
|
||||||
}
|
|
||||||
10
consumer/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
ecosystem.config.cjs
|
||||||
|
ecosystem.config.js
|
||||||
|
|
||||||
|
scripts/start_dev.js
|
||||||
|
scripts/start_dev_prod.js
|
||||||
|
dist
|
||||||
|
src/shared
|
||||||
17
consumer/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
FROM node:21-alpine as base
|
||||||
|
|
||||||
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
|
WORKDIR /home/app
|
||||||
|
|
||||||
|
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
|
||||||
|
|
||||||
|
WORKDIR /home/app/consumer
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
COPY --link ../consumer ./
|
||||||
|
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
CMD ["node", "/home/app/consumer/dist/index.js"]
|
||||||
34
consumer/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"mongoose": "^8.9.5",
|
||||||
|
"redis": "^4.7.0",
|
||||||
|
"ua-parser-js": "^1.0.37"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^20.12.13",
|
||||||
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
},
|
||||||
|
"name": "consumer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/start_dev.js",
|
||||||
|
"dev_prod": "node scripts/start_dev_prod.js",
|
||||||
|
"compile": "tsc",
|
||||||
|
"build": "npm run compile && npm run create_db",
|
||||||
|
"create_db": "cd scripts && ts-node create_database.ts",
|
||||||
|
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
|
||||||
|
"docker-inspect": "docker run -it litlyx-consumer sh",
|
||||||
|
"workspace:shared": "ts-node ../scripts/consumer/shared.ts",
|
||||||
|
"workspace:deploy": "ts-node ../scripts/consumer/deploy.ts"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Emily",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Database Consumer - Saves events to database."
|
||||||
|
}
|
||||||
1151
consumer/pnpm-lock.yaml
generated
Normal file
|
Can't render this file because it is too large.
|
82
consumer/src/EmailController.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
|
||||||
|
import { UserModel } from "./shared/schema/UserSchema";
|
||||||
|
import { LimitNotifyModel } from "./shared/schema/broker/LimitNotifySchema";
|
||||||
|
import { EmailService } from './shared/services/EmailService';
|
||||||
|
import { TProjectLimit } from "./shared/schema/project/ProjectsLimits";
|
||||||
|
import { EmailServiceHelper } from "./EmailServiceHelper";
|
||||||
|
|
||||||
|
|
||||||
|
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||||
|
|
||||||
|
const project_id = projectCounts.project_id;
|
||||||
|
const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
|
||||||
|
if (!hasNotifyEntry) {
|
||||||
|
await LimitNotifyModel.create({ project_id, limit1: false, limit2: false, limit3: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
|
||||||
|
|
||||||
|
if (hasNotifyEntry.limit3 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const owner = await UserModel.findById(project.owner);
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_max', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
|
||||||
|
|
||||||
|
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
||||||
|
|
||||||
|
if (hasNotifyEntry.limit2 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const owner = await UserModel.findById(project.owner);
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_90', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
|
||||||
|
|
||||||
|
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
||||||
|
|
||||||
|
if (hasNotifyEntry.limit1 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const owner = await UserModel.findById(project.owner);
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
const emailData = EmailService.getEmailServerInfo('limit_50', {
|
||||||
|
target: owner.email,
|
||||||
|
projectName: project.name
|
||||||
|
});
|
||||||
|
EmailServiceHelper.sendEmail(emailData);
|
||||||
|
});
|
||||||
|
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
19
consumer/src/EmailServiceHelper.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
import { EmailServerInfo } from './shared/services/EmailService'
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const EMAIL_SECRET = process.env.EMAIL_SECRET;
|
||||||
|
|
||||||
|
export class EmailServiceHelper {
|
||||||
|
static async sendEmail(data: EmailServerInfo) {
|
||||||
|
try {
|
||||||
|
await axios(data.url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: data.body,
|
||||||
|
headers: { ...data.headers, 'x-litlyx-token': EMAIL_SECRET }
|
||||||
|
})
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
consumer/src/LimitChecker.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
|
||||||
|
import { MAX_LOG_LIMIT_PERCENT } from './shared/data/broker/Limits';
|
||||||
|
import { checkLimitsForEmail } from './EmailController';
|
||||||
|
|
||||||
|
export async function checkLimits(project_id: string) {
|
||||||
|
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||||
|
if (!projectLimits) return false;
|
||||||
|
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||||
|
const COUNT_LIMIT = projectLimits.limit;
|
||||||
|
if ((TOTAL_COUNT) > COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT) return false;
|
||||||
|
await checkLimitsForEmail(projectLimits);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
28
consumer/src/Metrics.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { RedisStreamService } from './shared/services/RedisStreamService';
|
||||||
|
import { requireEnv } from './shared/utils/requireEnv';
|
||||||
|
|
||||||
|
const stream_name = requireEnv('STREAM_NAME');
|
||||||
|
|
||||||
|
export const metricsRouter = Router();
|
||||||
|
|
||||||
|
metricsRouter.get('/queue', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const size = await RedisStreamService.getQueueInfo(stream_name);
|
||||||
|
res.json({ size });
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
res.status(500).json({ error: ex.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
metricsRouter.get('/durations', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const durations = await RedisStreamService.METRICS_get()
|
||||||
|
res.json({ durations });
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
res.status(500).json({ error: ex.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
162
consumer/src/index.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
|
||||||
|
import { requireEnv } from './shared/utils/requireEnv';
|
||||||
|
import { connectDatabase } from './shared/services/DatabaseService';
|
||||||
|
import { RedisStreamService } from './shared/services/RedisStreamService';
|
||||||
|
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
|
||||||
|
import { VisitModel } from "./shared/schema/metrics/VisitSchema";
|
||||||
|
import { SessionModel } from "./shared/schema/metrics/SessionSchema";
|
||||||
|
import { EventModel } from "./shared/schema/metrics/EventSchema";
|
||||||
|
import { lookup } from './lookup';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
import { checkLimits } from './LimitChecker';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
|
||||||
|
import { ProjectCountModel } from './shared/schema/project/ProjectsCounts';
|
||||||
|
import { metricsRouter } from './Metrics';
|
||||||
|
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use('/metrics', metricsRouter);
|
||||||
|
|
||||||
|
app.listen(process.env.PORT, () => console.log(`Listening on port ${process.env.PORT}`));
|
||||||
|
|
||||||
|
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||||
|
main();
|
||||||
|
|
||||||
|
const CONSUMER_NAME = `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
|
||||||
|
await RedisStreamService.connect();
|
||||||
|
|
||||||
|
const stream_name = requireEnv('STREAM_NAME');
|
||||||
|
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop"
|
||||||
|
|
||||||
|
await RedisStreamService.startReadingLoop({
|
||||||
|
stream_name, group_name, consumer_name: CONSUMER_NAME
|
||||||
|
}, processStreamEntry);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processStreamEntry(data: Record<string, string>) {
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const eventType = data._type;
|
||||||
|
if (!eventType) return console.log('No type');
|
||||||
|
|
||||||
|
const { pid, sessionHash } = data;
|
||||||
|
|
||||||
|
const project = await ProjectModel.exists({ _id: pid });
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const canLog = await checkLimits(pid);
|
||||||
|
if (!canLog) return;
|
||||||
|
|
||||||
|
if (eventType === 'event') {
|
||||||
|
await process_event(data, sessionHash);
|
||||||
|
} else if (eventType === 'keep_alive') {
|
||||||
|
await process_keep_alive(data, sessionHash);
|
||||||
|
} else if (eventType === 'visit') {
|
||||||
|
await process_visit(data, sessionHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (ex: any) {
|
||||||
|
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
RedisStreamService.METRICS_onProcess(CONSUMER_NAME, duration);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
|
const { pid, ip, website, page, referrer, userAgent, flowHash, timestamp } = data;
|
||||||
|
|
||||||
|
let referrerParsed;
|
||||||
|
try {
|
||||||
|
referrerParsed = new URL(referrer);
|
||||||
|
} catch (ex) {
|
||||||
|
referrerParsed = { hostname: referrer };
|
||||||
|
}
|
||||||
|
|
||||||
|
const geoLocation = lookup(ip);
|
||||||
|
|
||||||
|
const userAgentParsed = UAParser(userAgent);
|
||||||
|
|
||||||
|
const device = userAgentParsed.device.type;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
VisitModel.create({
|
||||||
|
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
||||||
|
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
||||||
|
os: userAgentParsed.os.name || 'NO_OS',
|
||||||
|
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||||
|
session: sessionHash,
|
||||||
|
flowHash,
|
||||||
|
continent: geoLocation[0],
|
||||||
|
country: geoLocation[1],
|
||||||
|
created_at: new Date(parseInt(timestamp))
|
||||||
|
}),
|
||||||
|
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true }),
|
||||||
|
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
|
const { pid, instant, flowHash, timestamp, website } = data;
|
||||||
|
|
||||||
|
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||||
|
if (!existingSession) {
|
||||||
|
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instant == "true") {
|
||||||
|
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||||
|
$inc: { duration: 0 },
|
||||||
|
flowHash,
|
||||||
|
website,
|
||||||
|
updated_at: new Date(parseInt(timestamp))
|
||||||
|
}, { upsert: true });
|
||||||
|
} else {
|
||||||
|
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||||
|
$inc: { duration: 1 },
|
||||||
|
flowHash,
|
||||||
|
website,
|
||||||
|
updated_at: new Date(parseInt(timestamp))
|
||||||
|
}, { upsert: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function process_event(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
|
const { name, metadata, pid, flowHash, timestamp, website } = data;
|
||||||
|
|
||||||
|
let metadataObject;
|
||||||
|
try {
|
||||||
|
if (metadata) metadataObject = JSON.parse(metadata);
|
||||||
|
} catch (ex) {
|
||||||
|
metadataObject = { error: 'Error parsing metadata' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
EventModel.create({
|
||||||
|
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
|
||||||
|
website,
|
||||||
|
created_at: new Date(parseInt(timestamp))
|
||||||
|
}),
|
||||||
|
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
||||||
|
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
13
consumer/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,9 +10,7 @@ AI_PROJECT=
|
|||||||
AI_KEY=
|
AI_KEY=
|
||||||
|
|
||||||
EMAIL_SERVICE=
|
EMAIL_SERVICE=
|
||||||
EMAIL_HOST=
|
BREVO_API_KEY=
|
||||||
EMAIL_USER=
|
|
||||||
EMAIL_PASS=
|
|
||||||
|
|
||||||
AUTH_JWT_SECRET=
|
AUTH_JWT_SECRET=
|
||||||
|
|
||||||
|
|||||||
11
dashboard/.gitignore
vendored
@@ -12,6 +12,7 @@ node_modules
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
winston-*.ndjson
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -23,7 +24,6 @@ logs
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
|
||||||
# Test reports
|
# Test reports
|
||||||
*.report.txt
|
*.report.txt
|
||||||
|
|
||||||
@@ -32,3 +32,12 @@ out.pdf
|
|||||||
|
|
||||||
# TESTS - TO REMOVE
|
# TESTS - TO REMOVE
|
||||||
tests
|
tests
|
||||||
|
|
||||||
|
# EXPLAINS MONGODB
|
||||||
|
explains
|
||||||
|
|
||||||
|
#Ecosystem
|
||||||
|
ecosystem.config.cjs
|
||||||
|
ecosystem.config.js
|
||||||
|
|
||||||
|
shared
|
||||||
|
|||||||
@@ -1,34 +1,25 @@
|
|||||||
ARG NODE_VERSION=21
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine as base
|
FROM node:21-alpine AS base
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
FROM base AS build
|
||||||
|
|
||||||
# Build stage
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
WORKDIR /home/app
|
WORKDIR /home/app
|
||||||
|
|
||||||
FROM base as build
|
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
|
||||||
|
|
||||||
COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
|
WORKDIR /home/app/dashboard
|
||||||
RUN npm install --production=false
|
RUN pnpm install
|
||||||
|
|
||||||
COPY --link dashboard/ ./
|
COPY --link ./dashboard ./
|
||||||
|
|
||||||
COPY --link shared/ /home/shared
|
RUN pnpm run build:compose
|
||||||
|
|
||||||
ARG GOOGLE_AUTH_CLIENT_ID
|
FROM node:21-alpine AS production
|
||||||
ENV GOOGLE_AUTH_CLIENT_ID=$GOOGLE_AUTH_CLIENT_ID
|
|
||||||
|
|
||||||
RUN npm run build
|
WORKDIR /home/app
|
||||||
RUN npm prune
|
|
||||||
|
|
||||||
# Final stage
|
COPY --from=build /home/app/dashboard/.output /home/app/.output
|
||||||
|
|
||||||
FROM base
|
CMD ["node", "/home/app/.output/server/index.mjs"]
|
||||||
|
|
||||||
COPY --from=build /home/app /home/app
|
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
|
||||||
|
|
||||||
CMD [ "node", "/home/app/.output/server/index.mjs" ]
|
|
||||||
9
dashboard/app.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
notifications: {
|
||||||
|
position: 'top-0 bottom-[unset]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -9,15 +9,25 @@ const debugMode = process.dev;
|
|||||||
const { alerts, closeAlert } = useAlert();
|
const { alerts, closeAlert } = useAlert();
|
||||||
|
|
||||||
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
||||||
|
|
||||||
|
const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
<div class="w-dvw h-dvh bg-lyx-lightmode-background-light dark:bg-lyx-background-light relative">
|
||||||
|
|
||||||
|
<Transition name="drawer">
|
||||||
|
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
|
||||||
|
class="bg-lyx-lightmode-background-light dark:bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="drawerVisible">
|
||||||
|
</LazyDrawerGeneric>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
|
||||||
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
||||||
<div v-for="alert of alerts"
|
<div v-for="alert of alerts"
|
||||||
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
class="w-[30vw] min-w-[20rem] relative bg-lyx-lightmode-background dark:bg-[#151515] overflow-hidden border-solid border-[2px] border-lyx-lightmode-widget dark:border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div> <i :class="alert.icon"></i> </div>
|
<div> <i :class="alert.icon"></i> </div>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
@@ -46,8 +56,8 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showDialog"
|
<div v-if="showDialog"
|
||||||
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] bg-black/50">
|
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] dark:bg-black/50">
|
||||||
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
|
<div :style="dialogStyle" class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-widget-lighter rounded-xl relative outline outline-1">
|
||||||
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
|
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
|
||||||
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +67,12 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<UModals />
|
||||||
|
<UNotifications />
|
||||||
|
|
||||||
|
<LazyOnboarding> </LazyOnboarding>
|
||||||
|
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage></NuxtPage>
|
<NuxtPage></NuxtPage>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
@@ -64,3 +80,19 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
|
|||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.drawer-enter-active,
|
||||||
|
.drawer-leave-active {
|
||||||
|
transition: all .5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-enter-from,
|
||||||
|
.drawer-leave-to {
|
||||||
|
transform: translateX(100%)
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-enter-to,
|
||||||
|
.drawer-leave-from {
|
||||||
|
transform: translateX(0)
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
14
dashboard/assets/main.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@import './font-awesome/css/all.css';
|
||||||
|
/* Are these many fonts required? For the time being switching to privacy friendly bunny.net for Google fonts. NOTE: No variable font support in bunnet.net yet. */
|
||||||
|
|
||||||
|
@import url('https://fonts.bunny.net/css?family=nunito:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=inter:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
|
||||||
|
|
||||||
|
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=manrope:300,400,500,600,700,800');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=lato:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
@import url('https://fonts.bunny.net/css?family=poppins:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||||
|
|
||||||
|
@import url('https://cdn.jsdelivr.net/npm/material-symbols@0.28.2/index.css');
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap');
|
@use './utilities.scss';
|
||||||
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
@use './colors.scss';
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
|
||||||
|
|
||||||
@import '../font-awesome/css/all.css';
|
:root{
|
||||||
@import './utilities.scss';
|
--font-sans: "SF Pro Text","SF Pro Icons", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "Google Sans", "Helvetica Neue", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"
|
||||||
@import './colors.scss';
|
}
|
||||||
|
|
||||||
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
|
|
||||||
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
@@ -19,8 +11,19 @@
|
|||||||
src: url("../fonts/GeistVF.ttf");
|
src: url("../fonts/GeistVF.ttf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionable-visits-color-checkbox {
|
||||||
|
color: #5655d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionable-sessions-color-checkbox {
|
||||||
|
color: #4abde8;
|
||||||
|
}
|
||||||
|
.actionable-events-color-checkbox {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
.geist {
|
.geist {
|
||||||
font-family: "Geist";
|
font-family: "Geist", var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -35,47 +38,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brockmann {
|
.brockmann {
|
||||||
font-family: "Brockmann" !important;
|
font-family: "Brockmann", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nunito {
|
.nunito {
|
||||||
font-family: "Nunito" !important;
|
font-family: "Nunito",var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter {
|
.inter {
|
||||||
font-family: "Inter" !important;
|
font-family: "Inter", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.geometric {
|
.geometric {
|
||||||
font-family: 'Geometric Sans Serif v1' !important;
|
font-family: "Geometric Sans Serif v1", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manrope {
|
.manrope {
|
||||||
font-family: 'Manrope' !important;
|
font-family: "Manrope", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lato {
|
.lato {
|
||||||
font-family: 'Lato' !important;
|
font-family: "Lato", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poppins {
|
.poppins {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poppins-childs {
|
.poppins-childs {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: 'Poppins' !important;
|
font-family: "Poppins", var(--font-sans)!important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.hide-scrollbars {
|
.hide-scrollbars {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none; /* Firefox */
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none; /* Chrome, Safari and Opera */
|
display: none;
|
||||||
|
/* Chrome, Safari and Opera */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,5 +109,5 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: 'Nunito';
|
font-family: 'Nunito', var(--font-sans);
|
||||||
}
|
}
|
||||||
@@ -13,3 +13,16 @@
|
|||||||
.test3 {
|
.test3 {
|
||||||
border: 3px solid green !important;
|
border: 3px solid green !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.bgtest {
|
||||||
|
background-color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgtest2 {
|
||||||
|
background-color: blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgtest3 {
|
||||||
|
background-color: green;
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ const chartData = ref<ChartData<'bar'>>({
|
|||||||
label: e.label || '?',
|
label: e.label || '?',
|
||||||
backgroundColor: [e.color],
|
backgroundColor: [e.color],
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderRadius: 8
|
borderRadius: 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
|
export type IconProvider = (e: { _id: string, count: string } & any) => ['img' | 'icon', string] | undefined;
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -54,7 +54,7 @@ function openExternalLink(link: string) {
|
|||||||
<div class="flex justify-between mb-3">
|
<div class="flex justify-between mb-3">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
<div class="poppins font-semibold text-[1.4rem] text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -63,22 +63,26 @@ function openExternalLink(link: string) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-[1rem] text-text-sub/90">
|
<div class="poppins text-[1rem] text-lyx-ligtmode-text-darker dark:text-text-sub/90">
|
||||||
{{ desc }}
|
{{ desc }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="rawButton" class="hidden lg:flex">
|
<div v-if="rawButton" class="hidden lg:flex">
|
||||||
<div @click="$emit('showRawData')"
|
|
||||||
class="cursor-pointer flex gap-1 items-center justify-center font-semibold poppins rounded-lg text-[#5680f8] hover:text-[#5681f8ce]">
|
<LyxUiButton @click="$emit('showRawData')" type="primary" class="h-fit">
|
||||||
<div> Raw data </div>
|
<div class="flex gap-1 items-center justify-center ">
|
||||||
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
<div> Show raw data </div>
|
||||||
</div>
|
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
|
||||||
|
</div>
|
||||||
|
</LyxUiButton>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="h-full flex flex-col">
|
||||||
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
|
<div
|
||||||
|
class="flex justify-between font-bold lyx-text-lightmode-text-dark dark:text-text-sub/80 text-[1.1rem] mb-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div v-if="isDetailView" class="flex items-center justify-center">
|
<div v-if="isDetailView" class="flex items-center justify-center">
|
||||||
<i @click="$emit('showGeneral')"
|
<i @click="$emit('showGeneral')"
|
||||||
@@ -104,37 +108,40 @@ function openExternalLink(link: string) {
|
|||||||
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
||||||
:class="{ 'cursor-pointer line-active': interactive }">
|
:class="{ 'cursor-pointer line-active': interactive }">
|
||||||
|
|
||||||
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
<div class="absolute rounded-sm w-full h-full bg-[#6f829c38] dark:bg-[#92abcf38]"
|
||||||
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
||||||
|
|
||||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||||
<div v-if="iconProvider && iconProvider(element._id) != undefined"
|
<div v-if="iconProvider && iconProvider(element) != undefined"
|
||||||
class="flex items-center h-[1.3rem]">
|
class="flex items-center h-[1.3rem]">
|
||||||
|
|
||||||
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
|
<img v-if="iconProvider(element)?.[0] == 'img'" class="h-full"
|
||||||
:style="customIconStyle" :src="iconProvider(element._id)?.[1]">
|
:style="customIconStyle" :src="iconProvider(element)?.[1]">
|
||||||
|
|
||||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
<i v-else :class="iconProvider(element)?.[1]"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
<span
|
||||||
|
class="text-ellipsis line-clamp-1 ui-font z-[19] text-[.95rem] text-lyx-lightmode-text-dark dark:text-text/70">
|
||||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
|
class="text-lyx-lightmode-text dark:text-lyx-text font-semibold text-[.9rem] md:text-[1rem] manrope">
|
||||||
formatNumberK(element.count) }} </div>
|
{{
|
||||||
|
formatNumberK(element.count) }} </div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]">
|
||||||
No visits yet
|
No data yet
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
|
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow">
|
||||||
<div @click="$emit('showMore')"
|
|
||||||
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
|
<LyxUiButton type="outline" @click="$emit('showMore')">
|
||||||
Show more
|
Show more
|
||||||
</div>
|
</LyxUiButton>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
66
dashboard/components/BarCard/Browsers.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
let name = e._id.toLowerCase().replace(/ /g, '-');
|
||||||
|
|
||||||
|
if (name === 'mobile-safari') name = 'safari';
|
||||||
|
if (name === 'chrome-headless') name = 'chrome'
|
||||||
|
if (name === 'chrome-webview') name = 'chrome'
|
||||||
|
|
||||||
|
if (name === 'duckduckgo') return ['icon', 'far fa-duck']
|
||||||
|
if (name === 'avast-secure-browser') return ['icon', 'far fa-bug']
|
||||||
|
if (name === 'avg-secure-browser') return ['icon', 'far fa-bug']
|
||||||
|
|
||||||
|
if (name === 'no_browser') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'gsa') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'miui-browser') return ['icon', 'far fa-question']
|
||||||
|
|
||||||
|
if (name === 'vivo-browser') return ['icon', 'far fa-question']
|
||||||
|
if (name === 'whale') return ['icon', 'far fa-question']
|
||||||
|
|
||||||
|
if (name === 'twitter') return ['icon', 'fab fa-twitter']
|
||||||
|
if (name === 'linkedin') return ['icon', 'fab fa-linkedin']
|
||||||
|
if (name === 'facebook') return ['icon', 'fab fa-facebook']
|
||||||
|
|
||||||
|
return [
|
||||||
|
'img',
|
||||||
|
`https://github.com/alrra/browser-logos/blob/main/src/${name}/${name}_256x256.png?raw=true`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const browsersData = useFetch('/api/data/browsers', {
|
||||||
|
headers: useComputedHeaders({ limit: 10, }), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value = [];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/browsers', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = res?.map(e => {
|
||||||
|
return { ...e, icon: iconProvider(e as any) }
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
|
||||||
|
desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider"
|
||||||
|
:loading="browsersData.pending.value" label="Browsers" sub-label="Browsers">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
54
dashboard/components/BarCard/Devices.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
if (e._id === 'desktop') return ['icon','far fa-desktop'];
|
||||||
|
if (e._id === 'tablet') return ['icon','far fa-tablet ml-1'];
|
||||||
|
if (e._id === 'mobile') return ['icon','far fa-mobile ml-1'];
|
||||||
|
if (e._id === 'smarttv') return ['icon','far fa-tv'];
|
||||||
|
if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
|
||||||
|
return ['icon', 'far fa-question ml-1 mr-1']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function transform(data: { _id: string, count: number }[]) {
|
||||||
|
console.log(data);
|
||||||
|
return data.map(e => ({ ...e, _id: e._id == null ? 'others' : e._id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const devicesData = useFetch('/api/data/devices', {
|
||||||
|
headers: useComputedHeaders({ limit: 10, }), lazy: true,
|
||||||
|
transform
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value = [];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/devices', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
dialogBarData.value = transform(res || []);
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []"
|
||||||
|
:iconProvider="iconProvider" :dataIcons="true" desc="The devices most used to access your website."
|
||||||
|
:loading="devicesData.pending.value" label="Devices" sub-label="Devices"></BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
42
dashboard/components/BarCard/Events.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goToView() {
|
||||||
|
router.push('/dashboard/events');
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsData = useFetch('/api/data/events', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value=[];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/events', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = res || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @showRawData="goToView()"
|
||||||
|
desc="Most frequent user events triggered in this project" @dataReload="eventsData.refresh()"
|
||||||
|
:data="eventsData.data.value || []" :loading="eventsData.pending.value" label="Top Events"
|
||||||
|
sub-label="Events" :rawButton="!isLiveDemo"></BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
59
dashboard/components/BarCard/Geolocations.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { IconProvider } from '../BarCard/Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
if (!e.flag) return ['icon', 'far fa-question']
|
||||||
|
return [
|
||||||
|
'img',
|
||||||
|
`https://raw.githubusercontent.com/hampusborgos/country-flags/refs/heads/main/svg/${e.flag.toLowerCase()}.svg`
|
||||||
|
// `https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${e.flag.toLowerCase()}.png`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const customIconStyle = `width: 2rem; padding: 1px;`
|
||||||
|
|
||||||
|
const geolocationData = useFetch('/api/data/countries', {
|
||||||
|
headers: useComputedHeaders({ limit: 10, }), lazy: true,
|
||||||
|
transform: (e) => {
|
||||||
|
if (!e) return e;
|
||||||
|
return e.map(k => {
|
||||||
|
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value = [];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/countries', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = res?.map(k => {
|
||||||
|
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
|
||||||
|
}).map(e => {
|
||||||
|
return { ...e, icon: iconProvider(e) }
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
|
||||||
|
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
|
||||||
|
label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"
|
||||||
|
desc=" Lists the countries where users access your website.">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
36
dashboard/components/BarCard/OperatingSystems.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
const ossData = useFetch('/api/data/oss', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
dialogBarData.value=[];
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/oss', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = res || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
|
||||||
|
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
||||||
|
:loading="ossData.pending.value" label="OS" sub-label="OSs"></BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
44
dashboard/components/BarCard/Pages.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const pagesData = useFetch('/api/data/pages', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
|
||||||
|
dialogBarData.value = [];
|
||||||
|
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/pages', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogBarData.value = (res || []);
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToView() {
|
||||||
|
router.push('/dashboard/visits');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showRawData="goToView()" @showMore="showMore()" @dataReload="pagesData.refresh()" :showLink=true
|
||||||
|
:data="pagesData.data.value || []" :interactive="false" desc="Most visited pages."
|
||||||
|
:rawButton="!isLiveDemo"
|
||||||
|
:dataIcons="true" :loading="pagesData.pending.value" label="Top Pages" sub-label="Referrers">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
53
dashboard/components/BarCard/Referrers.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import type { IconProvider } from './Base.vue';
|
||||||
|
|
||||||
|
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||||
|
if (e._id === 'self') return ['icon', 'fas fa-link'];
|
||||||
|
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${e._id}&sz=64`]
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementTextTransformer(element: string) {
|
||||||
|
if (element === 'self') return 'Direct Link';
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrersData = useFetch('/api/data/referrers', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
|
||||||
|
dialogBarData.value = [];
|
||||||
|
|
||||||
|
showDialog.value = true;
|
||||||
|
isDataLoading.value = true;
|
||||||
|
|
||||||
|
const res = await $fetch('/api/data/referrers', {
|
||||||
|
headers: useComputedHeaders({ limit: 1000 }).value
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
dialogBarData.value = res?.map(e => {
|
||||||
|
return { ...e, icon: iconProvider(e as any) }
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
isDataLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
|
||||||
|
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
|
||||||
|
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
|
||||||
|
:dataIcons="true" :loading="referrersData.pending.value" label="Top Sources" sub-label="Referrers">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
57
dashboard/components/BarCard/Websites.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const currentWebsite = ref<string>("");
|
||||||
|
|
||||||
|
const websitesData = useFetch('/api/data/websites', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagesData = useFetch('/api/data/websites_pages', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
limit: 10,
|
||||||
|
custom: {
|
||||||
|
'x-website-name': currentWebsite
|
||||||
|
}
|
||||||
|
}), lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const isPagesView = ref<boolean>(false);
|
||||||
|
|
||||||
|
const currentData = computed(() => {
|
||||||
|
return isPagesView.value ? pagesData : websitesData
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async function showDetails(website: string) {
|
||||||
|
currentWebsite.value = website;
|
||||||
|
isPagesView.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showGeneral() {
|
||||||
|
websitesData.execute();
|
||||||
|
isPagesView.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function goToView() {
|
||||||
|
router.push('/dashboard/visits');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<BarCardBase :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
|
||||||
|
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
|
||||||
|
:loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Domains'"
|
||||||
|
:sub-label="isPagesView ? 'Page' : 'Domains'"
|
||||||
|
:desc="isPagesView ? 'Most visited pages' : 'Most visited domains in this project'"
|
||||||
|
:interactive="!isPagesView" :rawButton="!isLiveDemo" :isDetailView="isPagesView">
|
||||||
|
</BarCardBase>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { TProject } from '@schema/ProjectSchema';
|
|
||||||
import CreateSnapshot from './dialog/CreateSnapshot.vue';
|
|
||||||
|
|
||||||
export type Entry = {
|
|
||||||
label: string,
|
|
||||||
disabled?: boolean,
|
|
||||||
to?: string,
|
|
||||||
icon?: string,
|
|
||||||
action?: () => any,
|
|
||||||
adminOnly?: boolean,
|
|
||||||
external?: boolean,
|
|
||||||
grow?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Section = {
|
|
||||||
title: string,
|
|
||||||
entries: Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
sections: Section[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const { isAdmin } = useUserRoles();
|
|
||||||
|
|
||||||
const debugMode = process.dev;
|
|
||||||
|
|
||||||
const { isOpen, close } = useMenu();
|
|
||||||
|
|
||||||
const { snapshots, snapshot, updateSnapshots } = useSnapshot();
|
|
||||||
|
|
||||||
const snapshotsItems = computed(() => {
|
|
||||||
if (!snapshots.value) return []
|
|
||||||
return snapshots.value as any[];
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const { openDialogEx } = useCustomDialog();
|
|
||||||
|
|
||||||
function openSnapshotDialog() {
|
|
||||||
openDialogEx(CreateSnapshot, {
|
|
||||||
width: "24rem",
|
|
||||||
height: "16rem",
|
|
||||||
closable: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { createAlert } = useAlert()
|
|
||||||
|
|
||||||
async function deleteSnapshot(close: () => any) {
|
|
||||||
await $fetch("/api/snapshot/delete", {
|
|
||||||
method: 'DELETE',
|
|
||||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: snapshot.value._id.toString(),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
await updateSnapshots();
|
|
||||||
snapshot.value = snapshots.value[1];
|
|
||||||
createAlert('Snapshot deleted', 'Snapshot deleted successfully', 'far fa-circle-check', 5000);
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generatePDF() {
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
|
||||||
...signHeaders(),
|
|
||||||
responseType: 'blob'
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(res);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `Report.pdf`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (ex: any) {
|
|
||||||
alert(ex.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { setToken } = useAccessToken();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
function onLogout() {
|
|
||||||
console.log('LOGOUT')
|
|
||||||
setToken('');
|
|
||||||
setLoggedUser(undefined);
|
|
||||||
router.push('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { projects } = useProjectsList();
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
|
|
||||||
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
|
||||||
headers: computed(() => {
|
|
||||||
return {
|
|
||||||
Authorization: authorizationHeaderComputed.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const selected = ref<TProject>(activeProject.value as TProject);
|
|
||||||
watch(selected, () => {
|
|
||||||
setActiveProject(selected.value._id.toString())
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="CVerticalNavigation h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg"
|
|
||||||
:class="{
|
|
||||||
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
|
|
||||||
'hidden lg:flex': !isOpen
|
|
||||||
}">
|
|
||||||
<div class="py-4 px-2 gap-6 flex flex-col w-full">
|
|
||||||
|
|
||||||
<div class="flex px-2 flex-col">
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 w-full">
|
|
||||||
|
|
||||||
<USelectMenu :uiMenu="{
|
|
||||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
|
||||||
base: '!bg-lyx-widget',
|
|
||||||
option: {
|
|
||||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
|
||||||
active: '!bg-lyx-widget-lighter'
|
|
||||||
}
|
|
||||||
}" class="w-full" v-if="projects" v-model="selected" :options="projects">
|
|
||||||
|
|
||||||
<template #option="{ option, active, selected }">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div>
|
|
||||||
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
|
|
||||||
</div>
|
|
||||||
<div> {{ option.name }} </div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div>
|
|
||||||
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
|
|
||||||
</div>
|
|
||||||
<div> {{ activeProject?.name || '???' }} </div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</USelectMenu>
|
|
||||||
|
|
||||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
|
||||||
<i @click="close()" class="fas fa-close"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
|
|
||||||
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
|
|
||||||
<div><i class="fas fa-plus"></i></div>
|
|
||||||
<div> Create new project </div>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="w-full flex-col px-2">
|
|
||||||
|
|
||||||
<div class="flex mb-2 items-center justify-between">
|
|
||||||
<div class="poppins text-[.8rem]">
|
|
||||||
Snapshots
|
|
||||||
</div>
|
|
||||||
<div @click="openSnapshotDialog()"
|
|
||||||
class="poppins text-[.8rem] px-2 rounded-lg outline outline-[2px] outline-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget-lighter">
|
|
||||||
<i class="far fa-plus"></i>
|
|
||||||
Add
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<USelectMenu :uiMenu="{
|
|
||||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
|
||||||
base: '!bg-lyx-widget',
|
|
||||||
option: {
|
|
||||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
|
||||||
active: '!bg-lyx-widget-lighter'
|
|
||||||
}
|
|
||||||
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
|
||||||
<template #label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
|
|
||||||
</div>
|
|
||||||
<div class="poppins"> {{ snapshot?.name }} </div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #option="{ option }">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
|
|
||||||
</div>
|
|
||||||
<div class="poppins"> {{ option.name }} </div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</USelectMenu>
|
|
||||||
|
|
||||||
<div v-if="snapshot" class="flex flex-col text-[.8rem] mt-2">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="grow poppins"> From:</div>
|
|
||||||
<div class="poppins"> {{ new Date(snapshot.from).toLocaleString('it-IT').split(',')[0].trim() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex">
|
|
||||||
<div class="grow poppins"> To:</div>
|
|
||||||
<div class="poppins"> {{ new Date(snapshot.to).toLocaleString('it-IT').split(',')[0].trim() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LyxUiButton @click="generatePDF()" type="secondary" class="w-full text-center mt-4">
|
|
||||||
Download report
|
|
||||||
</LyxUiButton>
|
|
||||||
|
|
||||||
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
|
|
||||||
<UPopover placement="bottom">
|
|
||||||
<LyxUiButton type="danger" class="w-full text-center">
|
|
||||||
Delete current snapshot
|
|
||||||
</LyxUiButton>
|
|
||||||
|
|
||||||
<template #panel="{ close }">
|
|
||||||
<div class="p-4 bg-lyx-widget">
|
|
||||||
<div class="poppins text-center font-medium">
|
|
||||||
Are you sure?
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 mt-4">
|
|
||||||
<LyxUiButton @click="close()" type="secondary"> Cancel </LyxUiButton>
|
|
||||||
<LyxUiButton type="danger" @click="deleteSnapshot(close)"> Delete </LyxUiButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UPopover>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-lyx-widget-lighter h-[2px] w-full"></div>
|
|
||||||
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
|
|
||||||
<div v-for="section of sections" class="flex flex-col gap-1">
|
|
||||||
|
|
||||||
<div v-for="entry of section.entries">
|
|
||||||
|
|
||||||
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
|
||||||
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
|
||||||
:class="{
|
|
||||||
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
|
||||||
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
|
||||||
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
|
|
||||||
}">
|
|
||||||
|
|
||||||
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
|
|
||||||
tag="div" class="w-full flex items-center" :to="entry.to || '/'">
|
|
||||||
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
|
|
||||||
<i :class="entry.icon"></i>
|
|
||||||
</div>
|
|
||||||
<div class="manrope">
|
|
||||||
{{ entry.label }}
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grow"></div>
|
|
||||||
<div class="text-lyx-text-dark poppins text-[.8rem] px-4 pb-3">
|
|
||||||
Litlyx is in Beta version.
|
|
||||||
</div>
|
|
||||||
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
|
|
||||||
<div class="flex justify-end px-2">
|
|
||||||
|
|
||||||
<div class="grow flex gap-3">
|
|
||||||
<NuxtLink to="https://github.com/litlyx/litlyx" target="_blank"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fab fa-github"></i>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fab fa-discord"></i>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="https://x.com/litlyx" target="_blank"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fab fa-x-twitter"></i>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="https://dev.to/litlyx-org" target="_blank"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fab fa-dev"></i>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/admin" v-if="isAdmin"
|
|
||||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="fas fa-cat"></i>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
|
||||||
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
|
||||||
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
|
|
||||||
</div>
|
|
||||||
</UTooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -6,20 +6,19 @@ const props = defineProps<{ title: string, sub?: string }>();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LyxUiCard>
|
<LyxUiCard>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4 h-full">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex flex-col grow">
|
<div class="flex flex-col grow">
|
||||||
<div class="poppins font-semibold text-[1.1rem] md:text-[1.4rem] text-text">
|
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-lyx-lightmode-text-dark dark:text-text">
|
||||||
{{ props.title }}
|
{{ props.title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.sub" class="poppins text-[.8rem] md:text-[1.1rem] text-text-sub">
|
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-lyx-lightmode-text-darker dark:text-text-sub">
|
||||||
{{ props.sub }}
|
{{ props.sub }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="h-full">
|
||||||
|
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,72 @@
|
|||||||
<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">
|
<div class="flex overflow-x-auto hide-scrollbars">
|
||||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
<div class="flex">
|
||||||
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
<div v-for="(tab, index) of items" @click="onChangeTab(index)"
|
||||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
class="px-6 whitespace-nowrap pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
|
||||||
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
:class="{
|
||||||
}">
|
'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
|
||||||
{{ tab.label }}
|
'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 }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
||||||
|
|
||||||
</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>
|
||||||
|
|||||||
234
dashboard/components/FirstInteraction.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { project } = useProject();
|
||||||
|
const { createAlert } = useAlert();
|
||||||
|
|
||||||
|
import 'highlight.js/styles/stackoverflow-dark.css';
|
||||||
|
import hljs from 'highlight.js';
|
||||||
|
import CardTitled from './CardTitled.vue';
|
||||||
|
|
||||||
|
import { Lit } from 'litlyx-js';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
firstInteraction: boolean,
|
||||||
|
refreshInteraction: () => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
hljs.highlightAll();
|
||||||
|
})
|
||||||
|
|
||||||
|
function copyProjectId() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
navigator.clipboard.writeText(project.value?._id?.toString() || '');
|
||||||
|
Lit.event('no_visit_copy_id');
|
||||||
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function copyScript() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
|
||||||
|
|
||||||
|
const createScriptText = () => {
|
||||||
|
return [
|
||||||
|
'<script defer ',
|
||||||
|
`data-project="${project.value?._id}" `,
|
||||||
|
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||||
|
'script>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
Lit.event('no_visit_copy_script');
|
||||||
|
navigator.clipboard.writeText(createScriptText());
|
||||||
|
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const scriptText = computed(() => {
|
||||||
|
return [
|
||||||
|
`<script defer data-project="${project.value?._id.toString()}"`,
|
||||||
|
`\nsrc="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">\n<`,
|
||||||
|
`/script>`
|
||||||
|
].join('');
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function reloadPage() {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div v-if="!firstInteraction && project" class="mt-[5vh] flex flex-col">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
|
||||||
|
<div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div>
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-text/90 poppins text-[1.1rem] font-medium">
|
||||||
|
Waiting for your first visit
|
||||||
|
</div>
|
||||||
|
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="far fa-refresh"></i>
|
||||||
|
<div> Refresh </div>
|
||||||
|
</div>
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center mt-10 w-full px-10">
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<div class="flex gap-6 xl:flex-row flex-col">
|
||||||
|
|
||||||
|
<div class="h-full w-full">
|
||||||
|
<CardTitled class="h-full w-full xl:min-w-[400px] xl:h-[35rem]" title="Quick setup tutorial"
|
||||||
|
sub="Quickly Set Up Litlyx in 30 Seconds!">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center h-full w-full">
|
||||||
|
|
||||||
|
<iframe class="w-full h-full min-h-[400px]"
|
||||||
|
src="https://www.youtube.com/embed/LInFoNLJ-CI?si=a97HVXpXFDgFg2Yp" title="Litlyx"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<CardTitled title="Quick Integration"
|
||||||
|
sub="Start tracking web analytics in one line. (works everywhere js is supported)">
|
||||||
|
<div class="flex flex-col items-end gap-4">
|
||||||
|
<div class="w-full xl:text-[1rem] text-[.8rem]">
|
||||||
|
<pre>
|
||||||
|
<code class="language-html rounded-md">{{ scriptText }}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<LyxUiButton type="secondary" @click="copyScript()">
|
||||||
|
Copy
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-full w-full">
|
||||||
|
<CardTitled class="h-full w-full" title="Project id"
|
||||||
|
sub="This is the identifier for this project, used to forward data">
|
||||||
|
<div class="flex items-center justify-between gap-4 mt-6">
|
||||||
|
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
|
||||||
|
<div class="w-full text-[.9rem] dark:text-[#acacac]"> {{ project?._id }} </div>
|
||||||
|
</div>
|
||||||
|
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<CardTitled class="w-full h-full" title="Wordpress + Elementor"
|
||||||
|
sub="Our WordPress plugin is coming soon!.">
|
||||||
|
<template #header>
|
||||||
|
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||||
|
to="https://docs.litlyx.com">
|
||||||
|
Visit documentation
|
||||||
|
</LyxUiButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="justify-center w-full hidden xl:flex gap-3">
|
||||||
|
<a href="#">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/wpel.png'" alt="Litlyx-Wordpress-Elementor">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<CardTitled class="w-full h-full" title="Modules"
|
||||||
|
sub="Get started with your favorite framework.">
|
||||||
|
<template #header>
|
||||||
|
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||||
|
to="https://docs.litlyx.com">
|
||||||
|
Visit documentation
|
||||||
|
</LyxUiButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="justify-center w-full hidden xl:flex gap-3">
|
||||||
|
<a href="https://docs.litlyx.com/techs/js" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/js.png'" alt="Litlyx-Javascript-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/nuxt" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/nuxt.png'" alt="Litlyx-Nuxt-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/next" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/next.png'" alt="Litlyx-Next-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/react" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/react.png'" alt="Litlyx-React-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/vue" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/vue.png'" alt="Litlyx-Vue-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/angular" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/angular.png'" alt="Litlyx-Angular-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/python" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/py.png'" alt="Litlyx-Python-Analytics">
|
||||||
|
</a>
|
||||||
|
<a href="https://docs.litlyx.com/techs/serverless" target="_blank">
|
||||||
|
<img class="cursor-pointer" :src="'tech-icons/serverless.png'" alt="Litlyx-Serverless-Analytics">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</CardTitled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- <div class="flex justify-center gap-10 flex-col xl:flex-row items-center xl:items-stretch px-10">
|
||||||
|
|
||||||
|
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
|
||||||
|
<div class="poppins font-semibold"> Copy your project_id: </div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div> <i @click="copyProjectId()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||||
|
<div class="text-[.9rem] text-[#acacac]"> {{ activeProject?._id }} </div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full xl:max-w-[40vw]">
|
||||||
|
<div class="poppins font-semibold">
|
||||||
|
Start logging visits in 1 click | Plug anywhere !
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div> <i @click="copyScript()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||||
|
|
||||||
|
<pre><code class="language-html">{{ scriptText }}</code></pre>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
26
dashboard/components/LyxUi/Button.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'outlined' | 'danger';
|
||||||
|
|
||||||
|
const props = defineProps<{ type: ButtonType, link?: string, target?: string, disabled?: boolean }>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
|
||||||
|
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-lyx-lightmode-text dark:text-lyx-text"
|
||||||
|
:class="{
|
||||||
|
|
||||||
|
'bg-[#85a3ff] hover:bg-[#9db5fc] outline-lyx-lightmode-widget-light dark:bg-lyx-primary-dark dark:outline-lyx-primary dark:hover:bg-lyx-primary-hover': type === 'primary',
|
||||||
|
|
||||||
|
'bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget-lighter hover:bg-lyx-lightmode-widget dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': type === 'secondary',
|
||||||
|
|
||||||
|
'bg-lyx-transparent outline-lyx-lightmode-widget hover:bg-lyx-lightmode-widget-light dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
|
||||||
|
|
||||||
|
'bg-[#fcd1cb] hover:bg-[#f8c5be] dark:bg-lyx-danger-dark outline-lyx-danger dark:hover:bg-lyx-danger': type === 'danger',
|
||||||
|
|
||||||
|
'text-lyx-text !bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
|
||||||
|
}">
|
||||||
|
<slot></slot>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
10
dashboard/components/LyxUi/Card.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-fit h-fit rounded-md bg-lyx-lightmode-background outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-background-lighter p-4 outline outline-[1px] ">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps<{ placeholder?: string, modelValue: string }>();
|
const props = defineProps<{ placeholder?: string, modelValue: string, type?: string }>();
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
(e: "update:modelValue", value: string): void
|
(e: "update:modelValue", value: string): void
|
||||||
@@ -18,8 +18,7 @@ const handleChange = (event: Event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input class="bg-lyx-widget-light text-lyx-text-dark poppins rounded-md outline outline-[1px] outline-lyx-widget-lighter" type="text"
|
<input
|
||||||
:placeholder="props.placeholder"
|
class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget text-lyx-lightmode-text dark:bg-lyx-widget-light dark:text-lyx-text-dark poppins rounded-md outline outline-[1px] dark:outline-lyx-widget-lighter"
|
||||||
:value="props.modelValue"
|
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
|
||||||
@input="handleChange">
|
|
||||||
</template>
|
</template>
|
||||||
15
dashboard/components/LyxUi/Separator.vue
Normal 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="dark:bg-lyx-widget-light bg-lyx-lightmode-widget"></div>
|
||||||
|
</template>
|
||||||
176
dashboard/components/Onboarding.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: needsOnboarding } = useFetch("/api/onboarding/exist", {
|
||||||
|
headers: useComputedHeaders({ useSnapshotDates: false, useTimeOffset: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const analyticsList = [
|
||||||
|
"I have no prior analytics tool",
|
||||||
|
"Google Analytics 4",
|
||||||
|
"Plausible",
|
||||||
|
"Umami",
|
||||||
|
"MixPanel",
|
||||||
|
"Simple Analytics",
|
||||||
|
"Matomo",
|
||||||
|
"Fathom",
|
||||||
|
"Adobe Analytics",
|
||||||
|
"Other"
|
||||||
|
]
|
||||||
|
|
||||||
|
const jobsList = [
|
||||||
|
"Developer",
|
||||||
|
"Marketing",
|
||||||
|
"Product",
|
||||||
|
"Startup founder",
|
||||||
|
"Indie hacker",
|
||||||
|
"Other",
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedIndex = ref<number>(-1);
|
||||||
|
const otherFieldVisisble = ref<boolean>(false);
|
||||||
|
const otherText = ref<string>('');
|
||||||
|
function selectIndex(index: number) {
|
||||||
|
selectedIndex.value = index;
|
||||||
|
otherFieldVisisble.value = index == analyticsList.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex2 = ref<number>(-1);
|
||||||
|
const otherFieldVisisble2 = ref<boolean>(false);
|
||||||
|
const otherText2 = ref<string>('');
|
||||||
|
function selectIndex2(index: number) {
|
||||||
|
selectedIndex2.value = index;
|
||||||
|
otherFieldVisisble2.value = index == jobsList.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = ref<number>(0);
|
||||||
|
|
||||||
|
function onNextPage() {
|
||||||
|
if (selectedIndex.value == -1) return;
|
||||||
|
saveAnalyticsType();
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFinish(skipped?: boolean) {
|
||||||
|
if (skipped) return location.reload();
|
||||||
|
if (selectedIndex2.value == -1) return;
|
||||||
|
saveJobTitle();
|
||||||
|
page.value = 2;
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAnalyticsType() {
|
||||||
|
await $fetch('/api/onboarding/add', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
useSnapshotDates: false, useTimeOffset: false,
|
||||||
|
custom: { 'Content-Type': 'application/json' }
|
||||||
|
}).value,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
analytics:
|
||||||
|
selectedIndex.value == analyticsList.length - 1 ?
|
||||||
|
otherText.value :
|
||||||
|
analyticsList[selectedIndex.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJobTitle() {
|
||||||
|
|
||||||
|
await $fetch('/api/onboarding/add', {
|
||||||
|
headers: useComputedHeaders({
|
||||||
|
useSnapshotDates: false, useTimeOffset: false,
|
||||||
|
custom: { 'Content-Type': 'application/json' }
|
||||||
|
}).value,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
job:
|
||||||
|
selectedIndex2.value == jobsList.length - 1 ?
|
||||||
|
otherText2.value :
|
||||||
|
jobsList[selectedIndex2.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const showOnboarding = computed(() => {
|
||||||
|
if (route.path === '/login') return false;
|
||||||
|
if (route.path === '/register') return false;
|
||||||
|
if ((needsOnboarding.value as any)?.exist === false) return true;
|
||||||
|
if ((needsOnboarding.value as any)?.exists === false) return true;
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="showOnboarding" class="absolute top-0 left-0 w-full h-full z-[30] bg-black/80 flex justify-center">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="page == 0" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
|
||||||
|
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
|
||||||
|
going to be your first/main analytics?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||||
|
<div v-for="(e, i) of analyticsList">
|
||||||
|
<div @click="selectIndex(i)"
|
||||||
|
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
|
||||||
|
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||||
|
{{ e }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||||
|
v-model="otherText"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-center flex-col items-center">
|
||||||
|
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
|
||||||
|
type="primary"> Next </LyxUiButton>
|
||||||
|
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div v-if="page == 1" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||||
|
|
||||||
|
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
|
||||||
|
What is your job title ?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||||
|
<div v-for="(e, i) of jobsList">
|
||||||
|
<div @click="selectIndex2(i)"
|
||||||
|
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
|
||||||
|
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||||
|
{{ e }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||||
|
v-model="otherText2"></LyxUiInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-center flex-col items-center">
|
||||||
|
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
|
||||||
|
Finish </LyxUiButton>
|
||||||
|
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
options: { label: string }[],
|
options: { label: string, disabled?: boolean }[],
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,10 +16,13 @@ const emits = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="flex gap-2 border-[1px] border-gray-400 p-1 md:p-2 rounded-xl">
|
<div class="flex gap-2 border-[1px] p-1 md:p-2 rounded-xl bg-lyx-lightmode-widget-light border-lyx-lightmode-widget dark:bg-lyx-widget dark:border-lyx-widget-lighter">
|
||||||
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
|
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||||
class="hover:bg-white/10 select-btn-animated cursor-pointer rounded-lg poppins font-semibold px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
class="hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
||||||
:class="{ 'bg-accent hover:!bg-accent': currentIndex == index }">
|
:class="{
|
||||||
|
'bg-lyx-lightmode-widget hover:!bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter dark:hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
|
||||||
|
'hover:!bg-lyx-lightmode-widget-light text-lyx-lightmode-widget dark:hover:!bg-lyx-widget !cursor-not-allowed dark:!text-lyx-widget-lighter': opt.disabled
|
||||||
|
}">
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
88
dashboard/components/admin/Backend.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<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 = computed(() => {
|
||||||
|
if (!backendData?.value?.durations) return [];
|
||||||
|
|
||||||
|
const sizes = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const e of backendData.value.durations.durations) {
|
||||||
|
if (!sizes.has(e[0])) {
|
||||||
|
sizes.set(e[0], 0);
|
||||||
|
} else {
|
||||||
|
const data = sizes.get(e[0]) ?? 0;
|
||||||
|
sizes.set(e[0], data + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Array.from(sizes.values()).reduce((a, e) => a > e ? a : e, 0);
|
||||||
|
return new Array(max).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 && !backendPending" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
|
||||||
|
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
|
||||||
|
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
|
||||||
|
</AdminBackendLineChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @click="refreshBackend()"> Refresh </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="backendPending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
31
dashboard/components/admin/Feedbacks.vue
Normal file
@@ -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>
|
||||||
271
dashboard/components/admin/MiniChart.vue
Normal 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>
|
||||||
45
dashboard/components/admin/Onboardings.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6 h-full">
|
||||||
|
|
||||||
|
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex gap-40 px-20">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Anaytics </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-lyx-primary"> Jobs </div>
|
||||||
|
<div class="flex items-center gap-2"
|
||||||
|
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
|
||||||
|
<div>{{ e._id }}</div>
|
||||||
|
<div>{{ e.count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="onboardings" class="flex flex-col gap-8">
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
|
||||||
|
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="pendingOnboardings"> Loading...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
204
dashboard/components/admin/Overview.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<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 } = 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 { data: metrics, pending: pendingMetrics } = useFetch(
|
||||||
|
() => `/api/admin/metrics?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 class="w-[80%]">
|
||||||
|
<div v-if="pendingMetrics"> Loading... </div>
|
||||||
|
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
|
||||||
|
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
|
||||||
|
<div>
|
||||||
|
Total visits: {{ formatNumberK(metrics.totalVisits) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
|
||||||
|
Dead: {{ metrics.deadProjects }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Total events: {{ formatNumberK(metrics.totalEvents) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
151
dashboard/components/admin/Users.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||||
|
import type { TAdminUser } from '~/server/api/admin/users';
|
||||||
|
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const filterText = ref<string>('');
|
||||||
|
|
||||||
|
watch(filterText, () => {
|
||||||
|
page.value = 1;
|
||||||
|
})
|
||||||
|
|
||||||
|
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() })
|
||||||
|
|
||||||
|
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}&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-6">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="flex items-centet gap-10">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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]">
|
||||||
|
|
||||||
|
<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>
|
||||||
132
dashboard/components/admin/backend/LineChart.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as datefns from 'date-fns';
|
||||||
|
|
||||||
|
const errored = ref<boolean>(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[],
|
||||||
|
title: string,
|
||||||
|
datasets: {
|
||||||
|
points: number[],
|
||||||
|
color: string,
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
// borderDash: [5, 10]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { display: true },
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawBorder: false,
|
||||||
|
color: '#CCCCCC22',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleFont: { size: 16, weight: 'bold' },
|
||||||
|
bodyFont: { size: 14 },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 4,
|
||||||
|
boxPadding: 10,
|
||||||
|
caretPadding: 20,
|
||||||
|
yAlign: 'bottom',
|
||||||
|
xAlign: 'center',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<'line'>>({
|
||||||
|
labels: props.labels.map(e => {
|
||||||
|
try {
|
||||||
|
return datefns.format(new Date(e), 'dd/MM');
|
||||||
|
} catch (ex) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
datasets: props.datasets.map(e => ({
|
||||||
|
data: e.points,
|
||||||
|
label: e.name,
|
||||||
|
backgroundColor: [e.color + '00'],
|
||||||
|
borderColor: e.color,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
type: 'line'
|
||||||
|
} as any))
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
|
function createGradient(startColor: string) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${startColor}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${startColor}99`);
|
||||||
|
gradient.addColorStop(0.35, `${startColor}66`);
|
||||||
|
gradient.addColorStop(1, `${startColor}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// chartData.value.datasets.forEach(dataset => {
|
||||||
|
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
|
||||||
|
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
|
||||||
|
// } else {
|
||||||
|
// dataset.backgroundColor = [createGradient('#3d59a4')]
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
} catch (ex) {
|
||||||
|
errored.value = true;
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="errored"> ERROR CREATING CHART </div>
|
||||||
|
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
48
dashboard/components/admin/dialog/ProjectDetails.vue
Normal 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>
|
||||||
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
import { type ChartData, type ChartOptions } from 'chart.js';
|
||||||
|
import { PieChart, usePieChart } from 'vue-chart-3';
|
||||||
|
|
||||||
|
const props = defineProps<{ data: { _id: string, count: number }[], title:string }>();
|
||||||
|
|
||||||
|
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: props.title,
|
||||||
|
color: '#EEECF6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
|
||||||
|
labels: props.data.map(e => e._id),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.data.map(e => e.count),
|
||||||
|
backgroundColor: [
|
||||||
|
"#295270",
|
||||||
|
"#304F71",
|
||||||
|
"#374C72",
|
||||||
|
"#3E4A73",
|
||||||
|
"#444773",
|
||||||
|
"#4B4474",
|
||||||
|
"#524175",
|
||||||
|
],
|
||||||
|
borderColor: '#222222'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="graph">
|
||||||
|
<PieChart v-bind="pieChartProps">
|
||||||
|
</PieChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
134
dashboard/components/admin/overview/ProjectCard.vue
Normal 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>
|
||||||
135
dashboard/components/admin/users/UserCard.vue
Normal 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>
|
||||||
138
dashboard/components/analyst/ComposableChart.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
import * as datefns from 'date-fns';
|
||||||
|
|
||||||
|
registerChartComponents();
|
||||||
|
|
||||||
|
const errored = ref<boolean>(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[],
|
||||||
|
title: string,
|
||||||
|
datasets: {
|
||||||
|
points: number[],
|
||||||
|
color: string,
|
||||||
|
chartType: 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 + '77'],
|
||||||
|
borderColor: e.color,
|
||||||
|
borderWidth: 4,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
hoverBackgroundColor: e.color,
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: e.chartType
|
||||||
|
} 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>
|
||||||
110
dashboard/components/analyst/LineChart.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
registerChartComponents();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: any[],
|
||||||
|
labels: string[]
|
||||||
|
color: 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: false },
|
||||||
|
title: { display: false },
|
||||||
|
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,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.data,
|
||||||
|
backgroundColor: [props.color + '77'],
|
||||||
|
borderColor: props.color,
|
||||||
|
borderWidth: 4,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
hoverBackgroundColor: props.color,
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
let gradient: any = `${props.color}22`;
|
||||||
|
if (ctx) {
|
||||||
|
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||||
|
gradient.addColorStop(0, `${props.color}99`);
|
||||||
|
gradient.addColorStop(0.35, `${props.color}66`);
|
||||||
|
gradient.addColorStop(1, `${props.color}22`);
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot get context for gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||||
|
|
||||||
|
watch(props, () => {
|
||||||
|
chartData.value.labels = props.labels;
|
||||||
|
chartData.value.datasets[0].data = props.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</template>
|
||||||
33
dashboard/components/banner/LimitsInfo.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
const limitsInfo = await useFetch("/api/project/limits_info", {
|
||||||
|
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showDrawer } = useDrawer();
|
||||||
|
|
||||||
|
function goToUpgrade() {
|
||||||
|
showDrawer('PRICING');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="limitsInfo.data.value && limitsInfo.data.value.limited"
|
||||||
|
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||||
|
<div class="flex flex-col grow">
|
||||||
|
<div class="poppins font-semibold text-[#fbbf24]">
|
||||||
|
Limit reached
|
||||||
|
</div>
|
||||||
|
<div class="poppins text-[#fbbf24]">
|
||||||
|
Litlyx cannot receive new data as you reached your plan's limit. Resume all the great
|
||||||
|
features and collect even more data with a higher plan.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
39
dashboard/components/banner/Offer.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
|
||||||
|
const { showDrawer } = useDrawer();
|
||||||
|
|
||||||
|
function goToUpgrade() {
|
||||||
|
showDrawer('PRICING');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project } = useProject()
|
||||||
|
|
||||||
|
const isPremium = computed(() => {
|
||||||
|
return project.value?.premium ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||||
|
<div class="flex flex-col grow">
|
||||||
|
<div class="poppins font-semibold text-lyx-primary">
|
||||||
|
Launch offer: 25% off forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at
|
||||||
|
checkout
|
||||||
|
from Acceleration Plan and beyond.
|
||||||
|
</div>
|
||||||
|
<!-- <div class="poppins text-lyx-primary">
|
||||||
|
We're offering an exclusive 25% discount forever on all plans starting from the Acceleration
|
||||||
|
Plan for our first 100 users who believe in our project.
|
||||||
|
<br>
|
||||||
|
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
|
||||||
|
claim your discount.
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
411
dashboard/components/dashboard/ActionableChart.vue
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
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: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
includeInvisible: true
|
||||||
|
},
|
||||||
|
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: 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',
|
||||||
|
enabled: false,
|
||||||
|
position: 'nearest',
|
||||||
|
external: externalTooltipHandler
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 externalTooltipElement = ref<null | HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) {
|
||||||
|
const { chart, tooltip } = context;
|
||||||
|
const tooltipEl = externalTooltipElement.value;
|
||||||
|
|
||||||
|
const currentIndex = tooltip.dataPoints[0].parsed.x;
|
||||||
|
|
||||||
|
const todayIndex = visitsData.data.value?.todayIndex;
|
||||||
|
if (todayIndex && todayIndex >= 0) {
|
||||||
|
if (currentIndex > todayIndex - 1) {
|
||||||
|
if (!tooltipEl) return;
|
||||||
|
return tooltipEl.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
|
||||||
|
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
|
||||||
|
currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
|
||||||
|
|
||||||
|
currentTooltipData.value.date = new Date(allDatesFull.value[tooltip.dataPoints[0].dataIndex]).toLocaleDateString();
|
||||||
|
|
||||||
|
if (!tooltipEl) return;
|
||||||
|
if (tooltip.opacity === 0) {
|
||||||
|
tooltipEl.style.opacity = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { left: positionX, top: positionY } = chart.canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
|
||||||
|
const xSwap = tooltip.caretX > (window.innerWidth * 0.5) ? -450 : -100;
|
||||||
|
|
||||||
|
tooltipEl.style.opacity = '1';
|
||||||
|
|
||||||
|
tooltipEl.style.left = positionX + (tooltip.caretX + xSwap) + 'px';
|
||||||
|
|
||||||
|
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
|
||||||
|
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const { snapshotDuration } = useSnapshot();
|
||||||
|
|
||||||
|
const selectLabels: { label: string, value: Slice }[] = [
|
||||||
|
{ label: 'Hour', value: 'hour' },
|
||||||
|
{ label: 'Day', value: 'day' },
|
||||||
|
{ label: 'Month', value: 'month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
|
||||||
|
return selectLabels.map(e => {
|
||||||
|
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedSlice = computed<Slice>(() => {
|
||||||
|
console.log({ available: selectLabelsAvailable.value })
|
||||||
|
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
|
||||||
|
if (!targetValue) return 'day';
|
||||||
|
if (targetValue.disabled) {
|
||||||
|
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
|
||||||
|
}
|
||||||
|
if (selectedLabelIndex.value === -1) {
|
||||||
|
console.error('ERROR CANNOT FIND CORRECT SLICE')
|
||||||
|
return 'day';
|
||||||
|
}
|
||||||
|
return selectLabelsAvailable.value[selectedLabelIndex.value].value
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedLabelIndex = ref<number>(1);
|
||||||
|
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.value));
|
||||||
|
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||||
|
|
||||||
|
const current = (Date.now());
|
||||||
|
//console.log(input.map(e => e._id));
|
||||||
|
//console.log(new Date(current));
|
||||||
|
|
||||||
|
const todayIndex = input.findIndex(e => new Date(e._id).getTime() >= current);
|
||||||
|
|
||||||
|
//console.log({ todayIndex })
|
||||||
|
return { data, labels, todayIndex: todayIndex + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 visitsData = useFetch('/api/timeline/visits', {
|
||||||
|
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||||
|
headers: useComputedHeaders({ slice: selectedSlice }), lazy: true,
|
||||||
|
transform: transformResponse, onResponseError, onResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsData = useFetch('/api/timeline/events', {
|
||||||
|
headers: useComputedHeaders({ slice: selectedSlice }), 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTooltipData = ref<{ visits: number, events: number, sessions: number, date: string }>({
|
||||||
|
visits: 0,
|
||||||
|
events: 0,
|
||||||
|
sessions: 0,
|
||||||
|
date: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipNameIndex = ['visits', 'sessions', 'events'];
|
||||||
|
|
||||||
|
function onLegendChange(dataset: any, index: number, checked: any) {
|
||||||
|
const newValue = !checked;
|
||||||
|
dataset.hidden = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
|
||||||
|
const legendClasses = ref<string[]>([
|
||||||
|
'actionable-visits-color-checkbox',
|
||||||
|
'actionable-sessions-color-checkbox',
|
||||||
|
'actionable-events-color-checkbox'
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardTitled title="Trend chart" sub="Easily match Visits, Unique sessions and Events trends." class="w-full">
|
||||||
|
<template #header>
|
||||||
|
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event" :currentIndex="selectedLabelIndex"
|
||||||
|
:options="selectLabelsAvailable">
|
||||||
|
</SelectButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex gap-6 w-full justify-between lg:flex-row flex-col">
|
||||||
|
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
|
||||||
|
<div class="flex items-center gap-2 px-10">
|
||||||
|
<i class="far fa-sparkles text-yellow-600 dark:text-yellow-400"></i>
|
||||||
|
<div class="poppins text-lyx-lightmode-text dark:text-lyx-text"> Ask AI </div>
|
||||||
|
</div>
|
||||||
|
</LyxUiButton>
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center text-[.9rem]">
|
||||||
|
|
||||||
|
<UCheckbox :ui="{
|
||||||
|
color: legendClasses[index]
|
||||||
|
}" :model-value="true" @change="onLegendChange(dataset, index, $event)"></UCheckbox>
|
||||||
|
|
||||||
|
<label class="mt-[2px]"> {{ dataset.label }} </label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
|
||||||
|
<LyxUiCard class="text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div> Date: </div>
|
||||||
|
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(dataset, index) of chartData.datasets" class="flex gap-2 items-center">
|
||||||
|
<div :style="`background-color: ${legendColors[index]}`" class="h-4 w-4 rounded-full">
|
||||||
|
</div>
|
||||||
|
<div> {{ dataset.label }}</div>
|
||||||
|
<div v-if="currentTooltipData" class="grow text-right px-4">
|
||||||
|
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 font-normal flex flex-col text-[.9rem] dark:text-lyx-text-dark text-lyx-lightmode-text-dark"
|
||||||
|
v-if="(currentTooltipData as any).sessions > (currentTooltipData as any).visits">
|
||||||
|
<div> Unique visitors are higher than total visits </div>
|
||||||
|
<div> which often means bots (automated scripts or crawlers)</div>
|
||||||
|
<div> are inflating the numbers.</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
|
||||||
|
</LyxUiCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="!readyToDisplay" class="flex justify-center py-40">
|
||||||
|
<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" 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>
|
||||||
|
|
||||||
|
</CardTitled>
|
||||||
|
</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>
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
const { safeSnapshotDates } = useSnapshot()
|
|
||||||
|
|
||||||
const isShowMore = ref<boolean>(false);
|
|
||||||
|
|
||||||
const headers = computed(() => {
|
|
||||||
return {
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: isShowMore.value === true ? '200' : '10'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const browsersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/browsers`, {
|
|
||||||
method: 'POST', headers, lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
isShowMore.value = true;
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = browsersData.data.value || [];
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
browsersData.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="browsersData.refresh()"
|
|
||||||
:data="browsersData.data.value || []" desc="The browsers most used to search your website."
|
|
||||||
:dataIcons="false" :loading="browsersData.pending.value" label="Top Browsers" sub-label="Browsers">
|
|
||||||
</DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -5,13 +5,19 @@ const props = defineProps<{
|
|||||||
value: string,
|
value: string,
|
||||||
text: string,
|
text: string,
|
||||||
avg?: string,
|
avg?: string,
|
||||||
trend?: number,
|
|
||||||
color: string,
|
color: string,
|
||||||
data?: number[],
|
data?: number[],
|
||||||
labels?: string[],
|
labels?: string[],
|
||||||
ready?: boolean
|
ready?: boolean,
|
||||||
|
slow?: boolean,
|
||||||
|
todayIndex: number,
|
||||||
|
tooltipText: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { snapshotDuration } = useSnapshot()
|
||||||
|
|
||||||
|
const { showDrawer } = useDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -19,35 +25,34 @@ const props = defineProps<{
|
|||||||
<LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
<LyxUiCard class="flex !p-0 flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||||
<div v-if="ready" class="flex p-4 items-start">
|
<div v-if="ready" class="flex p-4 items-start">
|
||||||
<div class="flex items-center mt-2 mr-4">
|
<div class="flex items-center mt-2 mr-4">
|
||||||
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
|
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.3rem] 2xl:text-[1.5rem]"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col grow">
|
<div class="flex flex-col grow">
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="brockmann text-text-dirty text-[1.6rem] 2xl:text-[1.9rem]"> {{ value }} </div>
|
<div class="brockmann text-lyx-lightmode-text-dark dark:text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
||||||
<div class="poppins text-text-sub text-[.7rem] 2xl:text-[.85rem] mb-2"> {{ avg }} </div>
|
{{ value }}
|
||||||
</div>
|
|
||||||
<div class="poppins text-text-sub text-[.9rem] 2xl:text-base"> {{ text }} </div>
|
|
||||||
</div>
|
|
||||||
<div v-if="trend" class="flex flex-col items-center gap-1">
|
|
||||||
<div class="flex items-center gap-3 rounded-xl px-2 py-1" :style="`background-color: ${props.color}33`">
|
|
||||||
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'"
|
|
||||||
class="far text-[.9rem] 2xl:text-[1rem]" :style="`color: ${props.color}`"></i>
|
|
||||||
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
|
|
||||||
{{ trend.toFixed(0) }} %
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-text-sub text-[.7rem]"> Daily variation </div>
|
<div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<UTooltip :text="props.tooltipText">
|
||||||
|
<i class="far fa-info-circle text-lyx-text-darker text-[1rem]"></i>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||||
v-if="((props.data?.length || 0) > 0) && ready">
|
v-if="((props.data?.length || 0) > 0) && ready">
|
||||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
<DashboardEmbedChartCard v-if="ready" :todayIndex="todayIndex" :data="props.data || []"
|
||||||
:color="props.color">
|
:labels="props.labels || []" :color="props.color">
|
||||||
</DashboardEmbedChartCard>
|
</DashboardEmbedChartCard>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full">
|
<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>
|
</div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
icon: string,
|
|
||||||
title: string,
|
|
||||||
text: string,
|
|
||||||
sub: string,
|
|
||||||
color: string
|
|
||||||
}>();
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
|
|
||||||
<div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
|
||||||
|
|
||||||
<!-- <div class="absolute flex items-center justify-center right-4 top-4 cursor-pointer hover:text-blue-400">
|
|
||||||
<i class="fal fa-info-circle text-[.9rem] lg:text-[1.4rem]"></i>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
|
||||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
|
||||||
:style="`background: ${props.color}`">
|
|
||||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
|
||||||
</div>
|
|
||||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
|
||||||
{{ title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center lg:items-end">
|
|
||||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
|
||||||
{{ text }}
|
|
||||||
</div>
|
|
||||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
const { safeSnapshotDates } = useSnapshot()
|
|
||||||
|
|
||||||
const isShowMore = ref<boolean>(false);
|
|
||||||
|
|
||||||
const headers = computed(() => {
|
|
||||||
return {
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: isShowMore.value === true ? '200' : '10'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const devicesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/devices`, {
|
|
||||||
method: 'POST', headers, lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
isShowMore.value = true;
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = devicesData.data.value || [];
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
devicesData.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []" :dataIcons="false"
|
|
||||||
desc="The devices most used to access your website." :loading="devicesData.pending.value" label="Top Devices"
|
|
||||||
sub-label="Devices"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -13,8 +13,8 @@ const columns = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="w-full h-full bg-bg rounded-xl p-8">
|
<div class="w-full h-full bg-lyx-lightmode-background dark:bg-lyx-background-light rounded-xl p-8">
|
||||||
<div class="full h-full overflow-y-auto">
|
<div class="full h-full overflow-y-auto text-lyx-lightmode-text dark:text-lyx-text">
|
||||||
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
||||||
<template #count-data="{ row }">
|
<template #count-data="{ row }">
|
||||||
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ const props = defineProps<{
|
|||||||
data: any[],
|
data: any[],
|
||||||
labels: string[]
|
labels: string[]
|
||||||
color: string,
|
color: string,
|
||||||
|
todayIndex: number
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
||||||
const chartOptions = ref<ChartOptions<'line'>>({
|
const chartOptions = ref<ChartOptions<'line'>>({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -48,10 +50,22 @@ const chartData = ref<ChartData<'line'>>({
|
|||||||
data: props.data,
|
data: props.data,
|
||||||
backgroundColor: [props.color + '77'],
|
backgroundColor: [props.color + '77'],
|
||||||
borderColor: props.color,
|
borderColor: props.color,
|
||||||
borderWidth: 4,
|
borderWidth: 2,
|
||||||
fill: true,
|
fill: false,
|
||||||
tension: 0.45,
|
tension: 0.35,
|
||||||
pointRadius: 0
|
pointRadius: 0,
|
||||||
|
segment: {
|
||||||
|
borderColor(ctx, options) {
|
||||||
|
if (!props.todayIndex || props.todayIndex == -1) return props.color;
|
||||||
|
if (ctx.p1DataIndex >= props.todayIndex) return props.color + '00';
|
||||||
|
return props.color;
|
||||||
|
},
|
||||||
|
borderDash(ctx, options) {
|
||||||
|
if (!props.todayIndex || props.todayIndex == -1) return undefined;
|
||||||
|
if (ctx.p1DataIndex == props.todayIndex -1) return [2, 4];
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
function goToView() {
|
|
||||||
router.push('/dashboard/events');
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
const { safeSnapshotDates } = useSnapshot()
|
|
||||||
|
|
||||||
const isShowMore = ref<boolean>(false);
|
|
||||||
|
|
||||||
const headers = computed(() => {
|
|
||||||
return {
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: isShowMore.value === true ? '200' : '10'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
|
||||||
method: 'POST', headers, lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
isShowMore.value = true;
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = eventsData.data.value || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
eventsData.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2 h-full">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()"
|
|
||||||
desc="Most frequent user events triggered in this project" @dataReload="eventsData.refresh()"
|
|
||||||
:data="eventsData.data.value || []" :loading="eventsData.pending.value" label="Top Events"
|
|
||||||
sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -27,7 +27,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
|||||||
position: 'top',
|
position: 'top',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
labels: {
|
labels: {
|
||||||
color: 'white',
|
|
||||||
font: {
|
font: {
|
||||||
family: 'Poppins',
|
family: 'Poppins',
|
||||||
size: 16
|
size: 16
|
||||||
@@ -46,7 +45,28 @@ const chartData = ref<ChartData<'doughnut'>>({
|
|||||||
{
|
{
|
||||||
rotation: 1,
|
rotation: 1,
|
||||||
data: [],
|
data: [],
|
||||||
backgroundColor: ['#6bbbe3', '#5655d0', '#a6d5cb', '#fae0b9'],
|
backgroundColor: [
|
||||||
|
"#5655d0",
|
||||||
|
"#6bbbe3",
|
||||||
|
"#a6d5cb",
|
||||||
|
"#fae0b9",
|
||||||
|
"#f28e8e",
|
||||||
|
"#e3a7e4",
|
||||||
|
"#c4a8e1",
|
||||||
|
"#8cc1d8",
|
||||||
|
"#f9c2cd",
|
||||||
|
"#b4e3b2",
|
||||||
|
"#ffdfba",
|
||||||
|
"#e9c3b5",
|
||||||
|
"#d5b8d6",
|
||||||
|
"#add7f6",
|
||||||
|
"#ffd1dc",
|
||||||
|
"#ffe7a1",
|
||||||
|
"#a8e6cf",
|
||||||
|
"#d4a5a5",
|
||||||
|
"#f3d6e4",
|
||||||
|
"#c3aed6"
|
||||||
|
],
|
||||||
borderColor: ['#1d1d1f'],
|
borderColor: ['#1d1d1f'],
|
||||||
borderWidth: 2
|
borderWidth: 2
|
||||||
},
|
},
|
||||||
@@ -56,7 +76,7 @@ const chartData = ref<ChartData<'doughnut'>>({
|
|||||||
|
|
||||||
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
const { projectId } = useProject();
|
||||||
|
|
||||||
const { safeSnapshotDates } = useSnapshot();
|
const { safeSnapshotDates } = useSnapshot();
|
||||||
|
|
||||||
@@ -77,17 +97,8 @@ function transformResponse(input: CustomEventsAggregated[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = computed(() => {
|
const eventsData = useFetch(`/api/data/events`, {
|
||||||
return {
|
headers: useComputedHeaders({ limit: 6 }), lazy: true, immediate: false, transform: transformResponse
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: "10"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
|
||||||
method: 'POST', headers, lazy: true, immediate: false,transform:transformResponse
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { IconProvider } from './BarsCard.vue';
|
|
||||||
|
|
||||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
|
||||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
|
||||||
return [
|
|
||||||
'img',
|
|
||||||
`https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${id.toLowerCase()}.png`
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const customIconStyle = `width: 2rem; padding: 1px;`
|
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
const { safeSnapshotDates } = useSnapshot()
|
|
||||||
|
|
||||||
const isShowMore = ref<boolean>(false);
|
|
||||||
|
|
||||||
const headers = computed(() => {
|
|
||||||
return {
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: isShowMore.value === true ? '200' : '10'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const geolocationData = useFetch(`/api/metrics/${activeProject.value?._id}/data/countries`, {
|
|
||||||
method: 'POST', headers, lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
|
|
||||||
isShowMore.value = true;
|
|
||||||
showDialog.value = true;
|
|
||||||
|
|
||||||
dialogBarData.value = geolocationData.data.value?.map(e => {
|
|
||||||
return { ...e, icon: iconProvider(e._id) }
|
|
||||||
}) || [];
|
|
||||||
isDataLoading.value = false;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
geolocationData.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="geolocationData.refresh()" :data="geolocationData.data.value || []" :dataIcons="false"
|
|
||||||
:loading="geolocationData.pending.value" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
|
|
||||||
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
|
|
||||||
</DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
const { safeSnapshotDates } = useSnapshot()
|
|
||||||
|
|
||||||
const isShowMore = ref<boolean>(false);
|
|
||||||
|
|
||||||
const headers = computed(() => {
|
|
||||||
return {
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: isShowMore.value === true ? '200' : '10'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const ossData = useFetch(`/api/metrics/${activeProject.value?._id}/data/oss`, {
|
|
||||||
method: 'POST', headers, lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
isShowMore.value = true;
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = ossData.data.value || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
ossData.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2 h-full">
|
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
|
|
||||||
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
|
||||||
:loading="ossData.pending.value" label="Top OS" sub-label="OSs"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { IconProvider } from './BarsCard.vue';
|
|
||||||
import ReferrerBarChart from '../referrer/ReferrerBarChart.vue';
|
|
||||||
|
|
||||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
|
||||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
|
||||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
|
|
||||||
}
|
|
||||||
|
|
||||||
function elementTextTransformer(element: string) {
|
|
||||||
if (element === 'self') return 'Direct Link';
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
const { safeSnapshotDates } = useSnapshot()
|
|
||||||
|
|
||||||
const isShowMore = ref<boolean>(false);
|
|
||||||
|
|
||||||
const headers = computed(() => {
|
|
||||||
return {
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: isShowMore.value === true ? '200' : '10'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const referrersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/referrers`, {
|
|
||||||
method: 'POST', headers, lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
|
||||||
|
|
||||||
// const customDialog = useCustomDialog();
|
|
||||||
|
|
||||||
// function onShowDetails(referrer: string) {
|
|
||||||
// customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
|
||||||
// }
|
|
||||||
|
|
||||||
function showMore() {
|
|
||||||
|
|
||||||
isShowMore.value = true;
|
|
||||||
showDialog.value = true;
|
|
||||||
dialogBarData.value = referrersData.data.value?.map(e => {
|
|
||||||
return { ...e, icon: iconProvider(e._id) }
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
referrersData.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<DashboardBarsCard @showMore="showMore()"
|
|
||||||
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider"
|
|
||||||
@dataReload="referrersData.refresh()" :showLink=true :data="referrersData.data.value || []"
|
|
||||||
:interactive="false" desc="Where users find your website." :dataIcons="true" :loading="referrersData.pending.value"
|
|
||||||
label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
|||||||
|
|
||||||
function transformResponse(input: { _id: string, count: number }[]) {
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
const data = input.map(e => e.count);
|
const data = input.map(e => e.count);
|
||||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||||
return { data, labels }
|
return { data, labels }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +1,154 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
import DateService from '@services/DateService';
|
import DateService, { type Slice } from '../../shared/services/DateService';
|
||||||
import type { Slice } from '@services/DateService';
|
|
||||||
|
|
||||||
const { data: metricsInfo } = useMetricsData();
|
|
||||||
|
|
||||||
const { snapshot, safeSnapshotDates } = useSnapshot()
|
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
|
||||||
|
|
||||||
const snapshotFrom = computed(() => new Date(snapshot.value?.from || '0').getTime());
|
|
||||||
const snapshotTo = computed(() => new Date(snapshot.value?.to || Date.now()).getTime());
|
|
||||||
|
|
||||||
const snapshotDays = computed(() => {
|
const chartSlice = computed(() => {
|
||||||
return (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
|
if (snapshotDuration.value <= 3) return 'hour' as Slice;
|
||||||
|
if (snapshotDuration.value <= 31 * 3) return 'day' as Slice;
|
||||||
|
return 'month' as Slice;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function findFirstZeroOrNullIndex(arr: (number | null)[]) {
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
if (arr.slice(i).every(val => val === 0 || val === null)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
|
|
||||||
|
const data = input.map(e => e.count || 0);
|
||||||
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), chartSlice.value));
|
||||||
|
|
||||||
|
return { data, labels, input }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitsData = useFetch('/api/timeline/visits', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
|
});
|
||||||
|
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
|
||||||
|
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgVisitDay = computed(() => {
|
const avgVisitDay = computed(() => {
|
||||||
if (!visitsData.data.value) return '0.00';
|
if (!visitsData.data.value) return '0.00';
|
||||||
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||||
return avg.toFixed(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
const avgEventsDay = computed(() => {
|
|
||||||
if (!eventsData.data.value) return '0.00';
|
|
||||||
const counts = eventsData.data.value.data.reduce((a, e) => e + a, 0);
|
|
||||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
|
||||||
return avg.toFixed(2);
|
return avg.toFixed(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgSessionsDay = computed(() => {
|
const avgSessionsDay = computed(() => {
|
||||||
if (!sessionsData.data.value) return '0.00';
|
if (!sessionsData.data.value) return '0.00';
|
||||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||||
return avg.toFixed(2);
|
return avg.toFixed(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const avgBouncingRate = computed(() => {
|
||||||
|
if (!bouncingRateData.data.value) return '0.00 %'
|
||||||
|
|
||||||
|
const counts = bouncingRateData.data.value.data
|
||||||
|
.filter(e => e > 0)
|
||||||
|
.reduce((a, e) => e + a, 0);
|
||||||
|
|
||||||
|
const avg = counts / Math.max(bouncingRateData.data.value.data.filter(e => e > 0).length, 1);
|
||||||
|
return avg.toFixed(2) + ' %';
|
||||||
|
})
|
||||||
|
|
||||||
|
function weightedAverage(data: number[]): number {
|
||||||
|
if (data.length === 0) return 0;
|
||||||
|
|
||||||
|
// Compute median
|
||||||
|
const sortedData = [...data].sort((a, b) => a - b);
|
||||||
|
const middle = Math.floor(sortedData.length / 2);
|
||||||
|
const median = sortedData.length % 2 === 0
|
||||||
|
? (sortedData[middle - 1] + sortedData[middle]) / 2
|
||||||
|
: sortedData[middle];
|
||||||
|
|
||||||
|
// Define a threshold (e.g., 3 times the median) to filter out extreme values
|
||||||
|
const threshold = median * 3;
|
||||||
|
const filteredData = data.filter(num => num <= threshold);
|
||||||
|
|
||||||
|
if (filteredData.length === 0) return median; // Fallback to median if all are removed
|
||||||
|
|
||||||
|
// Compute weights based on inverse absolute deviation from median
|
||||||
|
const weights = filteredData.map(num => 1 / (1 + Math.abs(num - median)));
|
||||||
|
|
||||||
|
// Compute weighted sum and sum of weights
|
||||||
|
const weightedSum = filteredData.reduce((sum, num, i) => sum + num * weights[i], 0);
|
||||||
|
const sumOfWeights = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
|
||||||
|
return weightedSum / sumOfWeights;
|
||||||
|
}
|
||||||
const avgSessionDuration = computed(() => {
|
const avgSessionDuration = computed(() => {
|
||||||
if (!metricsInfo.value) return '0.00';
|
if (!sessionsDurationData.data.value) return '0.00 %'
|
||||||
const avg = metricsInfo.value.avgSessionDuration;
|
|
||||||
|
const counts = sessionsDurationData.data.value.data
|
||||||
|
// .filter(e => e > 0)
|
||||||
|
.reduce((a, e) => e + a, 0);
|
||||||
|
|
||||||
|
const avg = weightedAverage(sessionsDurationData.data.value.data);
|
||||||
|
// counts / (Math.max(sessionsDurationData.data.value.data.length, 1));
|
||||||
|
|
||||||
let hours = 0;
|
let hours = 0;
|
||||||
let minutes = 0;
|
let minutes = 0;
|
||||||
let seconds = 0;
|
let seconds = 0;
|
||||||
seconds += avg * 60;
|
seconds += avg * 60;
|
||||||
while (seconds > 60) { seconds -= 60; minutes += 1; }
|
while (seconds >= 60) { seconds -= 60; minutes += 1; }
|
||||||
while (minutes > 60) { minutes -= 60; hours += 1; }
|
while (minutes >= 60) { minutes -= 60; hours += 1; }
|
||||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const todayIndex = computed(() => {
|
||||||
const chartSlice = computed(() => {
|
if (!visitsData.data.value) return -1;
|
||||||
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
|
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now()));
|
||||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
|
})
|
||||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 30) return 'day' as Slice;
|
|
||||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 90) return 'day' as Slice;
|
|
||||||
return 'month' as Slice;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
function transformResponse(input: { _id: string, count: number }[]) {
|
|
||||||
const data = input.map(e => e.count);
|
|
||||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
|
|
||||||
const pool = [...input.map(e => e.count)];
|
|
||||||
pool.pop();
|
|
||||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
|
||||||
const diffPercent: number = (100 / avg * (input.at(-1)?.count || 0)) - 100;
|
|
||||||
const trend = Math.max(Math.min(diffPercent, 99), -99);
|
|
||||||
return { data, labels, trend }
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
function getBody() {
|
|
||||||
return JSON.stringify({
|
|
||||||
from: safeSnapshotDates.value.from,
|
|
||||||
to: safeSnapshotDates.value.to,
|
|
||||||
slice: chartSlice.value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
|
||||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
|
||||||
lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
|
|
||||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
|
||||||
lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
|
|
||||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
|
||||||
lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionsDurationData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions_duration`, {
|
|
||||||
method: 'POST', ...signHeaders({ v2: 'true' }), body: getBody(), transform: transformResponse,
|
|
||||||
lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
visitsData.execute();
|
|
||||||
eventsData.execute();
|
|
||||||
sessionsData.execute();
|
|
||||||
sessionsDurationData.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
|
<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 :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
|
||||||
: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) || '...')"
|
||||||
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
|
:avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data"
|
||||||
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
|
tooltipText="Sum of all page views on your website." :labels="visitsData.data.value?.labels"
|
||||||
|
color="#5655d7">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
<DashboardCountCard :ready="!eventsData.pending.value" icon="far fa-flag" text="Total custom events"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user"
|
||||||
:value="formatNumberK(eventsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
text="Bouncing rate" :value="avgBouncingRate" :slow="true" :data="bouncingRateData.data.value?.data"
|
||||||
:avg="formatNumberK(avgEventsDay) + '/day'" :trend="eventsData.data.value?.trend"
|
tooltipText="Percentage of users who leave quickly (lower is better)."
|
||||||
:data="eventsData.data.value?.data" :labels="eventsData.data.value?.labels" color="#1e9b86">
|
:labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
|
|
||||||
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visits sessions"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user"
|
||||||
|
text="Unique visitors"
|
||||||
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
|
tooltipText="Count of distinct users visiting your website." :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||||
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
|
|
||||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Avg session time"
|
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||||
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data"
|
||||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
tooltipText="Average time users spend on your website." :labels="sessionsDurationData.data.value?.labels"
|
||||||
color="#f56523">
|
color="#f56523">
|
||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,65 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
const { project } = useProject();
|
||||||
|
|
||||||
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
||||||
onMounted(() => startWatching());
|
onMounted(() => startWatching());
|
||||||
onUnmounted(() => stopWatching());
|
onUnmounted(() => stopWatching());
|
||||||
|
|
||||||
|
const selfhosted = useSelfhosted();
|
||||||
|
|
||||||
const { createAlert } = useAlert();
|
const { createAlert } = useAlert();
|
||||||
|
|
||||||
function copyProjectId() {
|
function copyProjectId() {
|
||||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
if (!navigator.clipboard) return alert('You can\'t copy in HTTP');
|
||||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
if (!project.value) return alert('Project not loaded');
|
||||||
|
navigator.clipboard.writeText((project.value._id).toString());
|
||||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="w-full px-6 pb-2 lg:pb-6 font-bold flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
|
||||||
class="w-full px-6 py-2 lg:py-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg lg:text-2xl gap-2 xl:gap-12">
|
|
||||||
|
|
||||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
<div
|
||||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
|
||||||
<div class="poppins font-medium text-[1.2rem]"> {{ onlineUsers }} Online users</div>
|
<div class="animate-pulse w-[.8rem] h-[.8rem] bg-green-400 rounded-full"> </div>
|
||||||
|
<div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grow"></div>
|
<div class="grow"></div>
|
||||||
|
|
||||||
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
<!-- <div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
||||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
|
<div class="poppins font-medium text-lyx-text-darker text-[.9rem]">Project:</div>
|
||||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }} </div>
|
<div class="text-lyx-text poppins font-medium text-[.9rem]"> {{ project?.name || 'Loading...' }}
|
||||||
</div>
|
|
||||||
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
|
|
||||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]">
|
|
||||||
{{ activeProject?._id || 'Loading...' }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center ml-3">
|
|
||||||
<i @click="copyProjectId()" class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
|
||||||
|
<div class="poppins font-medium text-lyx-text-darker text-[.9rem]">Project id:</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="text-lyx-text poppins font-medium text-[.9rem]">
|
||||||
|
{{ project?._id || 'Loading...' }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center ml-3">
|
||||||
|
<i @click="copyProjectId()"
|
||||||
|
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[.9rem]"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
<!--
|
||||||
|
<div v-if="!selfhosted"
|
||||||
|
class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
|
||||||
|
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||||
|
<div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
|
||||||
|
@click="showAnomalyInfoAlert"></i>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
|||||||
|
|
||||||
function transformResponse(input: { _id: string, count: number }[]) {
|
function transformResponse(input: { _id: string, count: number }[]) {
|
||||||
const data = input.map(e => e.count);
|
const data = input.map(e => e.count);
|
||||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||||
return { data, labels }
|
return { data, labels }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
|
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
|
||||||
|
|
||||||
const { safeSnapshotDates } = useSnapshot()
|
|
||||||
|
|
||||||
const isShowMore = ref<boolean>(false);
|
|
||||||
|
|
||||||
const currentWebsite = ref<string>("");
|
|
||||||
|
|
||||||
const websitesHeaders = computed(() => {
|
|
||||||
return {
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: isShowMore.value === true ? '200' : '10'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const pagesHeaders = computed(() => {
|
|
||||||
return {
|
|
||||||
'x-from': safeSnapshotDates.value.from,
|
|
||||||
'x-to': safeSnapshotDates.value.to,
|
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
limit: isShowMore.value === true ? '200' : '10',
|
|
||||||
'x-website-name': currentWebsite.value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const websitesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/websites`, {
|
|
||||||
method: 'POST', headers: websitesHeaders, lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const pagesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
|
||||||
method: 'POST', headers: pagesHeaders, lazy: true, immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const isPagesView = ref<boolean>(false);
|
|
||||||
|
|
||||||
const currentData = computed(() => {
|
|
||||||
return isPagesView.value ? pagesData : websitesData
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
async function showDetails(website: string) {
|
|
||||||
currentWebsite.value = website;
|
|
||||||
pagesData.execute();
|
|
||||||
isPagesView.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showGeneral() {
|
|
||||||
websitesData.execute();
|
|
||||||
isPagesView.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
function goToView() {
|
|
||||||
router.push('/dashboard/visits');
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(()=>{
|
|
||||||
websitesData.execute();
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2 h-full">
|
|
||||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
|
|
||||||
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
|
|
||||||
:loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
|
||||||
:sub-label="isPagesView ? 'Page' : 'Website'"
|
|
||||||
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
|
|
||||||
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
|
|
||||||
</DashboardBarsCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||