Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.gitignore
vendored
@@ -2,4 +2,5 @@ steps
|
|||||||
PROCESS_EVENT
|
PROCESS_EVENT
|
||||||
docker
|
docker
|
||||||
dev
|
dev
|
||||||
docker-compose.admin.yml
|
docker-compose.admin.yml
|
||||||
|
full_reload.sh
|
||||||
63
README.md
@@ -1,18 +1,16 @@
|
|||||||
|
|
||||||
<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://litlyx.com">Website</a> 📚 <a href="https://docs.litlyx.com">Docs</a> 👾 <a href="https://discord.gg/9cQykjsmWX">Join Discord</a> 🔥 <a href="https://dashboard.litlyx.com">Start for free!</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.
|
The easiest, developer-centric analytics tool.<br>
|
||||||
|
Litlyxis an open-source, self-hostable analytics solution for modern framework. Setup takes less than 30 seconds!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -20,18 +18,14 @@
|
|||||||
<br />
|
<br />
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/screen.png"/>
|
<img src="assets/dashboard-clip.png"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||

|
## Pre-Requisites on 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 OR Self-Host Litlyx with Docker.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -39,7 +33,7 @@ Sign-up on [Litlyx cloud](https://dashboard.litlyx.com) using OAuth & name your
|
|||||||
<script defer data-project="project_id_here" src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>
|
<script defer data-project="project_id_here" 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 10 KPIs, including `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
||||||
|
|
||||||
# All Javascript Runtimes
|
# All Javascript Runtimes
|
||||||
|
|
||||||
@@ -49,10 +43,10 @@ You can install Litlyx using `npm`, `yarn`, or `pnpm`:
|
|||||||
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 plug-in. Litlyx also works in serverless enviroments with Cloud (or Edge) Functions.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/techs.png" />
|
<img src="assets/tech.png" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Import
|
# Import
|
||||||
@@ -69,17 +63,17 @@ 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 analytics such as `Page visits`, `Browsers`, `Devices`, `Operating Systems`, `Real-Time Online Users`, `Unique Sessions`, `Countries`, and `Average Session Time`.
|
||||||
|
|
||||||
# Custom Events
|
# Custom Events
|
||||||
|
|
||||||
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', {
|
||||||
@@ -90,33 +84,40 @@ Lit.event('click_on_buy_item', {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
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">
|
# Fire Your First Event with cURL
|
||||||
<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.
|
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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
# Self-Hosting with Docker
|
# Self-Hosting with Docker
|
||||||
|
|
||||||
First thing first **Fork** this repository.
|
To self-host the Litlyx dashboard, first **fork** this repository.
|
||||||
|
|
||||||
Then run the following command:
|
Then run the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose build
|
docker-compose build
|
||||||
```
|
```
|
||||||
|
|
||||||
then, after the build finish, run:
|
after the build finishes, run:
|
||||||
```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
|
# Official Docs
|
||||||
|
|
||||||
@@ -124,11 +125,11 @@ For more info read our [documentation](https://docs.litlyx.com). (will be improv
|
|||||||
|
|
||||||
# Join Discord
|
# Join Discord
|
||||||
|
|
||||||
If you need more information, help, or want to provide general feedback, feel free to join us on[Discord](https://discord.gg/9cQykjsmWX)
|
If you need more information, interact with us or the community, help, or want to provide feedbacks, feel free to join us on the Litlyx [Discord](https://discord.gg/9cQykjsmWX)
|
||||||
|
|
||||||
# Contributors
|
# Contributors
|
||||||
|
|
||||||
Every kind of contribution is accepted in this stage of the project. In the future we will onboard you better.
|
Every kind of contribution is accepted in this stage of the project. In the future we will improve the contributor onboarding process.
|
||||||
|
|
||||||
### Thank you!
|
### Thank you!
|
||||||
<a href="https://github.com/litlyx/litlyx/graphs/contributors">
|
<a href="https://github.com/litlyx/litlyx/graphs/contributors">
|
||||||
|
|||||||
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: 14 KiB |
BIN
assets/dashboard-clip.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 144 KiB |
BIN
assets/tech.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/techs.png
|
Before Width: | Height: | Size: 29 KiB |
3
broker/.gitignore
vendored
@@ -4,4 +4,5 @@ ecosystem.config.cjs
|
|||||||
dist
|
dist
|
||||||
scripts/start_dev.js
|
scripts/start_dev.js
|
||||||
package-lock.json
|
package-lock.json
|
||||||
build_all.bat
|
build_all.bat
|
||||||
|
tests
|
||||||
@@ -7,9 +7,7 @@ module.exports = {
|
|||||||
script: './dist/producer/src/index.js',
|
script: './dist/producer/src/index.js',
|
||||||
env: {
|
env: {
|
||||||
EMAIL_SERVICE: "",
|
EMAIL_SERVICE: "",
|
||||||
EMAIL_HOST: "",
|
BREVO_API_KEY: "",
|
||||||
EMAIL_USER: "",
|
|
||||||
EMAIL_PASS: "",
|
|
||||||
PORT: "",
|
PORT: "",
|
||||||
MONGO_CONNECTION_STRING: "",
|
MONGO_CONNECTION_STRING: "",
|
||||||
REDIS_URL: "",
|
REDIS_URL: "",
|
||||||
|
|||||||
13
broker/jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: "node",
|
||||||
|
transform: {
|
||||||
|
"^.+.tsx?$": ["ts-jest",{}],
|
||||||
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'@services/(.*)': '<rootDir>/../shared/services/$1',
|
||||||
|
'@data/(.*)': '<rootDir>/../shared/data/$1',
|
||||||
|
'@functions/(.*)': '<rootDir>/../shared/functions/$1',
|
||||||
|
'@schema/(.*)': '<rootDir>/../shared/schema/$1',
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@getbrevo/brevo": "^2.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"mongoose": "^8.3.2",
|
"mongoose": "^8.3.2",
|
||||||
@@ -8,13 +9,17 @@
|
|||||||
"ua-parser-js": "^1.0.37"
|
"ua-parser-js": "^1.0.37"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@jest/globals": "^29.7.0",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.12.13",
|
"@types/node": "^20.12.13",
|
||||||
"@types/nodemailer": "^6.4.15",
|
"@types/nodemailer": "^6.4.15",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"glob": "^10.4.1",
|
"glob": "^10.4.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
"node-ssh": "^13.2.0",
|
"node-ssh": "^13.2.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
@@ -28,10 +33,11 @@
|
|||||||
"create_db": "cd scripts && ts-node create_database.ts",
|
"create_db": "cd scripts && ts-node create_database.ts",
|
||||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
"build_all": "npm run compile && npm run build && npm run create_db",
|
||||||
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
|
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
|
||||||
"docker-inspect": "docker run -it litlyx-broker sh"
|
"docker-inspect": "docker run -it litlyx-broker sh",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Emily",
|
"author": "Emily",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "Queue broker for Litlyx - Saves events to database."
|
"description": "Queue broker for Litlyx - Saves events to database."
|
||||||
}
|
}
|
||||||
|
|||||||
3218
broker/pnpm-lock.yaml
generated
@@ -6,25 +6,62 @@ import { requireEnv } from "../../shared/utilts/requireEnv";
|
|||||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
import { TProjectLimit } from "@schema/ProjectsLimits";
|
||||||
|
|
||||||
if (process.env.EMAIL_SERVICE) {
|
if (process.env.EMAIL_SERVICE) {
|
||||||
EmailService.createTransport(
|
EmailService.init(requireEnv('BREVO_API_KEY'));
|
||||||
requireEnv('EMAIL_SERVICE'),
|
|
||||||
requireEnv('EMAIL_HOST'),
|
|
||||||
requireEnv('EMAIL_USER'),
|
|
||||||
requireEnv('EMAIL_PASS'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||||
|
|
||||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) {
|
const project_id = projectCounts.project_id;
|
||||||
const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id });
|
const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
|
||||||
if (notify && notify.limit1 === true) return;
|
if (!hasNotifyEntry) {
|
||||||
const project = await ProjectModel.findById(projectCounts.project_id);
|
await LimitNotifyModel.create({ project_id, limit1: false, limit2: false, limit3: false })
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
|
||||||
|
|
||||||
|
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||||
|
if (notify && notify.limit3 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const owner = await UserModel.findById(project.owner);
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmailMax(owner.email, project.name);
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
|
||||||
|
|
||||||
|
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
||||||
|
|
||||||
|
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||||
|
if (notify && notify.limit2 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const owner = await UserModel.findById(project.owner);
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail90(owner.email, project.name);
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
|
||||||
|
|
||||||
|
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
||||||
|
|
||||||
|
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||||
|
if (notify && notify.limit1 === true) return;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(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, project.name);
|
||||||
|
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,27 +19,25 @@ export async function startStreamLoop() {
|
|||||||
|
|
||||||
await RedisStreamService.startReadingLoop({
|
await RedisStreamService.startReadingLoop({
|
||||||
streamName: requireEnv('STREAM_NAME'),
|
streamName: requireEnv('STREAM_NAME'),
|
||||||
delay: { base: 100, empty: 5000 },
|
delay: { base: 10, empty: 5000 },
|
||||||
readBlock: 2500
|
readBlock: 2000,
|
||||||
|
consumer: 'consumer_' + process.env.NODE_APP_INSTANCE
|
||||||
}, processStreamEvent);
|
}, processStreamEvent);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function processStreamEvent(data: Record<string, string>) {
|
||||||
|
|
||||||
async function processStreamEvent(data: Record<string, string>) {
|
|
||||||
try {
|
try {
|
||||||
const eventType = data._type;
|
const eventType = data._type;
|
||||||
if (!eventType) return;
|
if (!eventType) return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { pid, sessionHash } = data;
|
const { pid, sessionHash } = data;
|
||||||
|
|
||||||
const project = await ProjectModel.exists({ _id: pid });
|
const project = await ProjectModel.exists({ _id: pid });
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
|
||||||
|
|
||||||
if (eventType === 'event') return await process_event(data, sessionHash);
|
if (eventType === 'event') return await process_event(data, sessionHash);
|
||||||
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
|
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
|
||||||
if (eventType === 'visit') return await process_visit(data, sessionHash);
|
if (eventType === 'visit') return await process_visit(data, sessionHash);
|
||||||
@@ -50,17 +48,23 @@ async function processStreamEvent(data: Record<string, string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 * EVENT_LOG_LIMIT_PERCENT) return false;
|
||||||
|
await checkLimitsForEmail(projectLimits);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||||
|
|
||||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
||||||
|
|
||||||
const projectLimits = await ProjectLimitModel.findOne({ project_id: pid });
|
const canLog = await checkLimits(pid);
|
||||||
if (!projectLimits) return;
|
if (!canLog) 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;
|
let referrerParsed;
|
||||||
try {
|
try {
|
||||||
@@ -73,11 +77,13 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
|
|||||||
|
|
||||||
const userAgentParsed = UAParser(userAgent);
|
const userAgentParsed = UAParser(userAgent);
|
||||||
|
|
||||||
|
const device = userAgentParsed.device.type;
|
||||||
|
|
||||||
const visit = new VisitModel({
|
const visit = new VisitModel({
|
||||||
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
||||||
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
||||||
os: userAgentParsed.os.name || 'NO_OS',
|
os: userAgentParsed.os.name || 'NO_OS',
|
||||||
device: userAgentParsed.device.type,
|
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||||
session: sessionHash,
|
session: sessionHash,
|
||||||
flowHash,
|
flowHash,
|
||||||
continent: geoLocation[0],
|
continent: geoLocation[0],
|
||||||
@@ -97,7 +103,10 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
|
|||||||
|
|
||||||
const { pid, instant, flowHash } = data;
|
const { pid, instant, flowHash } = data;
|
||||||
|
|
||||||
const existingSession = await SessionModel.findOne({ project_id: pid }, { _id: 1 });
|
const canLog = await checkLimits(pid);
|
||||||
|
if (!canLog) return;
|
||||||
|
|
||||||
|
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||||
if (!existingSession) {
|
if (!existingSession) {
|
||||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
||||||
}
|
}
|
||||||
@@ -123,6 +132,9 @@ async function process_event(data: Record<string, string>, sessionHash: string)
|
|||||||
|
|
||||||
const { name, metadata, pid, flowHash } = data;
|
const { name, metadata, pid, flowHash } = data;
|
||||||
|
|
||||||
|
const canLog = await checkLimits(pid);
|
||||||
|
if (!canLog) return;
|
||||||
|
|
||||||
let metadataObject;
|
let metadataObject;
|
||||||
try {
|
try {
|
||||||
if (metadata) metadataObject = JSON.parse(metadata);
|
if (metadata) metadataObject = JSON.parse(metadata);
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
"types": [
|
||||||
|
"node",
|
||||||
|
"jest"
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@schema/*": [
|
"@schema/*": [
|
||||||
"../shared/schema/*"
|
"../shared/schema/*"
|
||||||
@@ -21,7 +26,9 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"scripts/**/*.ts"
|
"scripts/**/*.ts",
|
||||||
|
"tests/**/*.test.ts",
|
||||||
|
"tests/utils.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"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=
|
||||||
|
|
||||||
|
|||||||
7
dashboard/.gitignore
vendored
@@ -12,6 +12,7 @@ node_modules
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
winston-*.ndjson
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -31,4 +32,8 @@ logs
|
|||||||
out.pdf
|
out.pdf
|
||||||
|
|
||||||
# TESTS - TO REMOVE
|
# TESTS - TO REMOVE
|
||||||
tests
|
tests
|
||||||
|
|
||||||
|
# EXPLAINS MONGODB
|
||||||
|
|
||||||
|
explains
|
||||||
@@ -9,12 +9,22 @@ 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 { visible } = usePricingDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
||||||
|
|
||||||
|
<Transition name="pdrawer">
|
||||||
|
<LazyPricingDrawer @onCloseClick="visible = false"
|
||||||
|
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="visible">
|
||||||
|
</LazyPricingDrawer>
|
||||||
|
</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-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||||
@@ -64,3 +74,19 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
|
|||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pdrawer-enter-active,
|
||||||
|
.pdrawer-leave-active {
|
||||||
|
transition: all .5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdrawer-enter-from,
|
||||||
|
.pdrawer-leave-to {
|
||||||
|
transform: translateX(100%)
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdrawer-enter-to,
|
||||||
|
.pdrawer-leave-from {
|
||||||
|
transform: translateX(0)
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type Entry = {
|
|||||||
icon?: string,
|
icon?: string,
|
||||||
action?: () => any,
|
action?: () => any,
|
||||||
adminOnly?: boolean,
|
adminOnly?: boolean,
|
||||||
|
premiumOnly?: boolean,
|
||||||
external?: boolean,
|
external?: boolean,
|
||||||
grow?: boolean
|
grow?: boolean
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,7 @@ const route = useRoute();
|
|||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
const { isAdmin } = useUserRoles();
|
const { isAdmin } = useUserRoles();
|
||||||
|
const loggedUser = useLoggedUser()
|
||||||
|
|
||||||
const debugMode = process.dev;
|
const debugMode = process.dev;
|
||||||
|
|
||||||
@@ -70,7 +72,11 @@ async function generatePDF() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
||||||
...signHeaders(),
|
...signHeaders({
|
||||||
|
'x-snapshot-name': snapshot.value.name,
|
||||||
|
'x-from': snapshot.value.from.toISOString(),
|
||||||
|
'x-to': snapshot.value.to.toISOString(),
|
||||||
|
}),
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,8 +102,15 @@ function onLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { projects } = useProjectsList();
|
const { projects } = useProjectsList();
|
||||||
|
const { data: guestProjects } = useGuestProjectsList()
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
|
const selectorProjects = computed(() => {
|
||||||
|
const result: TProject[] = [];
|
||||||
|
if (projects.value) result.push(...projects.value);
|
||||||
|
if (guestProjects.value) result.push(...guestProjects.value);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||||
headers: computed(() => {
|
headers: computed(() => {
|
||||||
@@ -112,6 +125,18 @@ watch(selected, () => {
|
|||||||
setActiveProject(selected.value._id.toString())
|
setActiveProject(selected.value._id.toString())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isPremium = computed(() => {
|
||||||
|
return activeProject.value?.premium;
|
||||||
|
})
|
||||||
|
|
||||||
|
function isProjectMine(owner?: string) {
|
||||||
|
if (!owner) return false;
|
||||||
|
if (!loggedUser.value?.logged) return;
|
||||||
|
return loggedUser.value.id == owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricingDrawer = usePricingDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -122,6 +147,13 @@ watch(selected, () => {
|
|||||||
}">
|
}">
|
||||||
<div class="py-4 px-2 gap-6 flex flex-col w-full">
|
<div class="py-4 px-2 gap-6 flex flex-col w-full">
|
||||||
|
|
||||||
|
<!-- <div class="flex px-2" v-if="!isPremium">
|
||||||
|
<LyxUiButton type="primary" class="w-full text-center text-[.8rem] font-medium" @click="pricingDrawer.visible.value = true;">
|
||||||
|
Upgrade plan
|
||||||
|
</LyxUiButton>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
|
||||||
<div class="flex px-2 flex-col">
|
<div class="flex px-2 flex-col">
|
||||||
|
|
||||||
<div class="flex items-center gap-2 w-full">
|
<div class="flex items-center gap-2 w-full">
|
||||||
@@ -133,23 +165,26 @@ watch(selected, () => {
|
|||||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||||
active: '!bg-lyx-widget-lighter'
|
active: '!bg-lyx-widget-lighter'
|
||||||
}
|
}
|
||||||
}" class="w-full" v-if="projects" v-model="selected" :options="projects">
|
}" class="w-full" v-if="selectorProjects" v-model="selected" :options="selectorProjects">
|
||||||
|
|
||||||
<template #option="{ option, active, selected }">
|
<template #option="{ option, active, selected }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
|
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||||
</div>
|
</div>
|
||||||
<div> {{ option.name }} </div>
|
<div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #label>
|
<template #label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
|
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ activeProject?.name || '-' }}
|
||||||
|
{{ !isProjectMine(activeProject?.owner?.toString()) ? '(Guest)' : '' }}
|
||||||
</div>
|
</div>
|
||||||
<div> {{ activeProject?.name || '???' }} </div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
@@ -160,6 +195,7 @@ watch(selected, () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
|
<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">
|
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><i class="fas fa-plus"></i></div>
|
||||||
@@ -249,9 +285,9 @@ watch(selected, () => {
|
|||||||
|
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
|
|
||||||
<div v-for="section of sections" class="flex flex-col gap-1">
|
<div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
|
||||||
|
|
||||||
<div v-for="entry of section.entries">
|
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
|
||||||
|
|
||||||
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
||||||
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||||
@@ -266,9 +302,12 @@ watch(selected, () => {
|
|||||||
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
|
<div class="flex items-center w-[1.4rem] mr-2 text-[1.1rem] justify-center">
|
||||||
<i :class="entry.icon"></i>
|
<i :class="entry.icon"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="manrope">
|
<div class="manrope grow">
|
||||||
{{ entry.label }}
|
{{ entry.label }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="entry.premiumOnly && !isPremium" class="flex items-center">
|
||||||
|
<i class="fal fa-lock"></i>
|
||||||
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -278,9 +317,6 @@ watch(selected, () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grow"></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="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
|
||||||
<div class="flex justify-end px-2">
|
<div class="flex justify-end px-2">
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const props = defineProps<{ title: string, sub?: string }>();
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<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-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-text-sub">
|
||||||
{{ props.sub }}
|
{{ props.sub }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ 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] border-lyx-widget-lighter p-1 md:p-2 rounded-xl bg-lyx-widget">
|
||||||
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
|
<div @click="$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-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-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
329
dashboard/components/dashboard/ActionableChart.vue
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import DateService, { type Slice } from '@services/DateService';
|
||||||
|
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||||
|
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||||
|
registerChartComponents();
|
||||||
|
|
||||||
|
|
||||||
|
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',
|
||||||
|
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.45,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 10,
|
||||||
|
hoverBackgroundColor: '#5655d7',
|
||||||
|
hoverBorderColor: 'white',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unique sessions',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#4abde8'],
|
||||||
|
borderColor: '#4abde8',
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverBackgroundColor: '#4abde8',
|
||||||
|
hoverBorderColor: '#4abde8',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: 'bar'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Events',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#fbbf24'],
|
||||||
|
borderColor: '#fbbf24',
|
||||||
|
borderWidth: 2,
|
||||||
|
hoverBackgroundColor: '#fbbf24',
|
||||||
|
hoverBorderColor: '#fbbf24',
|
||||||
|
hoverBorderWidth: 2,
|
||||||
|
type: 'bubble',
|
||||||
|
stack: 'combined'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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();
|
||||||
|
tooltipEl.style.opacity = '1';
|
||||||
|
tooltipEl.style.left = positionX + tooltip.caretX + 'px';
|
||||||
|
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
|
||||||
|
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const selectLabels: { label: string, value: Slice }[] = [
|
||||||
|
{ label: 'Hour', value: 'hour' },
|
||||||
|
{ label: 'Day', value: 'day' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedLabelIndex = ref<number>(1);
|
||||||
|
|
||||||
|
|
||||||
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
|
const { safeSnapshotDates } = useSnapshot()
|
||||||
|
|
||||||
|
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, navigator.language, selectLabels[selectedLabelIndex.value].value));
|
||||||
|
allDatesFull.value = input.map(e => e._id.toString());
|
||||||
|
return { data, labels }
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = computed(() => {
|
||||||
|
return {
|
||||||
|
from: safeSnapshotDates.value.from,
|
||||||
|
to: safeSnapshotDates.value.to,
|
||||||
|
slice: selectLabels[selectedLabelIndex.value].value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||||
|
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||||
|
lazy: true, immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
|
||||||
|
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||||
|
lazy: true, immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
|
||||||
|
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||||
|
lazy: true, immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const readyToDisplay = computed(() => {
|
||||||
|
return !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(readyToDisplay, () => {
|
||||||
|
if (readyToDisplay.value === true) onDataReady();
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDataReady() {
|
||||||
|
console.log('DATA READY');
|
||||||
|
|
||||||
|
if (!visitsData.data.value) return;
|
||||||
|
if (!eventsData.data.value) return;
|
||||||
|
if (!sessionsData.data.value) return;
|
||||||
|
|
||||||
|
console.log('DATA READY 2');
|
||||||
|
|
||||||
|
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 = 25 / maxEventSize * e;
|
||||||
|
return { x: 0, y: maxChartY + 70, 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')];
|
||||||
|
|
||||||
|
console.log('UPDATE CHART');
|
||||||
|
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) {
|
||||||
|
dataset.hidden = !checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legendColors = [
|
||||||
|
'#5655d7',
|
||||||
|
'#4abde8',
|
||||||
|
'#fbbf24'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
visitsData.execute();
|
||||||
|
eventsData.execute();
|
||||||
|
sessionsData.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</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="selectLabels">
|
||||||
|
</SelectButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex gap-6 w-full justify-between">
|
||||||
|
<LyxUiButton type="secondary" to="/analyst">
|
||||||
|
<div class="flex items-center gap-2 px-10">
|
||||||
|
<i class="far fa-sparkles text-yellow-400"></i>
|
||||||
|
<div class="poppins 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: `text-[${legendColors[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>
|
||||||
|
<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="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">
|
||||||
|
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||||
|
</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>
|
||||||
@@ -36,7 +36,7 @@ const props = defineProps<{
|
|||||||
{{ trend.toFixed(0) }} %
|
{{ trend.toFixed(0) }} %
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-text-sub text-[.7rem]"> Daily variation </div>
|
<div class="poppins text-text-sub text-[.7rem]"> Trend </div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,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
|
||||||
},
|
},
|
||||||
@@ -87,7 +108,7 @@ const headers = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
||||||
method: 'POST', headers, lazy: true, immediate: false,transform:transformResponse
|
method: 'POST', headers, lazy: true, immediate: false, transform: transformResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ onMounted(async () => {
|
|||||||
</DashboardCountCard>
|
</DashboardCountCard>
|
||||||
|
|
||||||
|
|
||||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Avg session time"
|
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Total avg session time"
|
||||||
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
||||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
||||||
color="#f56523">
|
color="#f56523">
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ function copyProjectId() {
|
|||||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
||||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function showAnomalyInfoAlert() {
|
||||||
|
createAlert('AI Anomaly Detector info',
|
||||||
|
`Anomaly detector is running. It helps you detect a spike in visits or events, it could mean an
|
||||||
|
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
|
||||||
|
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
|
||||||
|
Litlyx will notify you via email with actionable advices`,
|
||||||
|
'far fa-bug',
|
||||||
|
10000
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -29,8 +42,10 @@ function copyProjectId() {
|
|||||||
|
|
||||||
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
|
||||||
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
|
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
|
||||||
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }} </div>
|
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
|
<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="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -38,9 +53,20 @@ function copyProjectId() {
|
|||||||
{{ activeProject?._id || 'Loading...' }}
|
{{ activeProject?._id || 'Loading...' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center ml-3">
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center text-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-[1rem]"> 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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Slice } from '@services/DateService';
|
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
import DateService, { type Slice } from '@services/DateService';
|
||||||
|
|
||||||
const props = defineProps<{ slice: Slice }>();
|
const props = defineProps<{ slice: Slice }>();
|
||||||
const slice = computed(() => props.slice);
|
const slice = computed(() => props.slice);
|
||||||
@@ -22,7 +22,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
|
|||||||
|
|
||||||
const fixed = fixMetrics({
|
const fixed = fixMetrics({
|
||||||
data: input,
|
data: input,
|
||||||
from: safeSnapshotDates.value.from,
|
from: input[0]._id,
|
||||||
to: safeSnapshotDates.value.to
|
to: safeSnapshotDates.value.to
|
||||||
}, slice.value, {
|
}, slice.value, {
|
||||||
advanced: true,
|
advanced: true,
|
||||||
@@ -30,7 +30,29 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
|
|||||||
});
|
});
|
||||||
|
|
||||||
const parsedDatasets: any[] = [];
|
const parsedDatasets: any[] = [];
|
||||||
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
|
|
||||||
|
const colors = [
|
||||||
|
"#5655d0",
|
||||||
|
"#6bbbe3",
|
||||||
|
"#a6d5cb",
|
||||||
|
"#fae0b9",
|
||||||
|
"#f28e8e",
|
||||||
|
"#e3a7e4",
|
||||||
|
"#c4a8e1",
|
||||||
|
"#8cc1d8",
|
||||||
|
"#f9c2cd",
|
||||||
|
"#b4e3b2",
|
||||||
|
"#ffdfba",
|
||||||
|
"#e9c3b5",
|
||||||
|
"#d5b8d6",
|
||||||
|
"#add7f6",
|
||||||
|
"#ffd1dc",
|
||||||
|
"#ffe7a1",
|
||||||
|
"#a8e6cf",
|
||||||
|
"#d4a5a5",
|
||||||
|
"#f3d6e4",
|
||||||
|
"#c3aed6"
|
||||||
|
];
|
||||||
|
|
||||||
for (let i = 0; i < fixed.allKeys.length; i++) {
|
for (let i = 0; i < fixed.allKeys.length; i++) {
|
||||||
const line: any = {
|
const line: any = {
|
||||||
@@ -68,7 +90,8 @@ onMounted(async () => {
|
|||||||
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
|
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
|
||||||
<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>
|
</div>
|
||||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value" :datasets="eventsStackedData.data.value?.datasets || []"
|
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value"
|
||||||
|
:datasets="eventsStackedData.data.value?.datasets || []"
|
||||||
:labels="eventsStackedData.data.value?.labels || []">
|
:labels="eventsStackedData.data.value?.labels || []">
|
||||||
</AdvancedStackedBarChart>
|
</AdvancedStackedBarChart>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ async function analyzeEvent() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardTitled title="Event User Flow"
|
<CardTitled title="Event User Flow"
|
||||||
sub="Track your user's journey from external links to custom events within your platform." class="w-full p-4">
|
sub="Track your user's journey from external links to in-app events, maintaining a complete view of their path from entry to engagement." class="w-full p-4">
|
||||||
|
|
||||||
<div class="p-2 flex flex-col gap-3">
|
<div class="p-2 flex flex-col gap-3">
|
||||||
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"
|
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ export type PricingCardProp = {
|
|||||||
planId: number
|
planId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ datas: PricingCardProp[] }>();
|
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
const currentIndex = ref<number>(0);
|
const currentIndex = ref<number>(props.defaultIndex || 0);
|
||||||
|
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
return props.datas[currentIndex.value];
|
return props.datas[currentIndex.value];
|
||||||
@@ -37,13 +37,19 @@ async function onUpgradeClick() {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
<div
|
||||||
|
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 text-center">
|
<div class="flex flex-col gap-3 text-center pt-3">
|
||||||
<div class="poppins text-xl font-light"> {{ data.title }} </div>
|
<div v-if="data.active"
|
||||||
<div v-if="data.active" class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-xl">
|
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#222A42] outline outline-[1px] outline-[#5680F8] px-3 py-[.1rem] rounded-sm">
|
||||||
Active
|
Active
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!data.active && data.title === 'Growth'"
|
||||||
|
class="absolute right-6 top-3 poppins text-[.75rem] bg-[#fbbe244f] outline outline-[1px] outline-[#fbbf24] px-3 py-[.1rem] rounded-sm">
|
||||||
|
Most popular
|
||||||
|
</div>
|
||||||
|
<div class="poppins text-xl font-light"> {{ data.title }} </div>
|
||||||
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
|
<div class="poppins text-4xl font-medium"> {{ data.price }} </div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,7 +75,7 @@ async function onUpgradeClick() {
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex gap-2" v-for="feature of data.features">
|
<div class="flex gap-2" v-for="feature of data.features">
|
||||||
<div class="h-6 w-6">
|
<div class="h-6 w-6">
|
||||||
<img class="w-full h-full" :src="'check.png'" alt="Check">
|
<img class="w-full h-full" :src="'/check.png'" alt="Check">
|
||||||
</div>
|
</div>
|
||||||
<div>{{ feature }}</div>
|
<div>{{ feature }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,197 +2,214 @@
|
|||||||
import type { PricingCardProp } from './PricingCardGeneric.vue';
|
import type { PricingCardProp } from './PricingCardGeneric.vue';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
|
||||||
|
...signHeaders(),
|
||||||
|
lazy: true
|
||||||
|
});
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
const props = defineProps<{ currentSub: number }>();
|
watch(activeProject, () => {
|
||||||
|
refreshPlanData();
|
||||||
|
});
|
||||||
|
|
||||||
const freePricing: PricingCardProp[] = [
|
|
||||||
{
|
|
||||||
title: 'Free',
|
|
||||||
price: '€0 / mo',
|
|
||||||
subs: [
|
|
||||||
'Up to 5000 visits/events per month',
|
|
||||||
'CPM 0€ per visit/event'
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
'Email support',
|
|
||||||
'Unlimited domains',
|
|
||||||
'Unlimited reports',
|
|
||||||
'AI Tokens: 10',
|
|
||||||
'Server type: SHARED',
|
|
||||||
'Projects: max 2',
|
|
||||||
'Data retention: 2 Months'
|
|
||||||
],
|
|
||||||
cta: 'Start For Free now!',
|
|
||||||
active: props.currentSub == 0,
|
|
||||||
isDowngrade: props.currentSub > 0,
|
|
||||||
planId: 0
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const customPricing: PricingCardProp[] = [
|
function getPricingsData() {
|
||||||
{
|
|
||||||
title: 'Enterprise',
|
const freePricing: PricingCardProp[] = [
|
||||||
price: 'Custom',
|
{
|
||||||
subs: [
|
title: 'Free',
|
||||||
'Unlimited visits/events per month',
|
price: '€0 / mo',
|
||||||
'Service Tailor-made on needs'
|
subs: [
|
||||||
],
|
'Up to 5000 visits/events per month',
|
||||||
features: [
|
'CPM 0€ per visit/event'
|
||||||
'Priority support',
|
],
|
||||||
'Server type: DEDICATED',
|
features: [
|
||||||
'DB instance: DEDICATED',
|
'Email support',
|
||||||
'Dedicated operator',
|
'Unlimited domains',
|
||||||
'White label',
|
'Unlimited reports',
|
||||||
'Custom Charts',
|
'AI Tokens: 10',
|
||||||
'Custom Data Aggregation'
|
'Server type: SHARED',
|
||||||
],
|
'Data retention: 2 Months'
|
||||||
cta: 'Let\'s Talk!',
|
],
|
||||||
link: 'mailto:help@litlyx.com',
|
cta: 'Start For Free now!',
|
||||||
active: false,
|
active: (planData.value?.premium_type || 0) == 0,
|
||||||
isDowngrade: false,
|
isDowngrade: (planData.value?.premium_type || 0) > 0,
|
||||||
planId: -1
|
planId: 0
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const customPricing: PricingCardProp[] = [
|
||||||
|
{
|
||||||
|
title: 'Enterprise',
|
||||||
|
price: 'Custom',
|
||||||
|
subs: [
|
||||||
|
'Unlimited visits/events per month',
|
||||||
|
'Service Tailor-made on needs'
|
||||||
|
],
|
||||||
|
features: [
|
||||||
|
'Priority support',
|
||||||
|
'Server type: DEDICATED',
|
||||||
|
'DB instance: DEDICATED',
|
||||||
|
'Dedicated operator',
|
||||||
|
'White label',
|
||||||
|
'Custom Data Aggregation'
|
||||||
|
],
|
||||||
|
cta: 'Let\'s Talk!',
|
||||||
|
link: 'mailto:help@litlyx.com',
|
||||||
|
active: false,
|
||||||
|
isDowngrade: false,
|
||||||
|
planId: -1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const slidePricings: PricingCardProp[] = [
|
||||||
|
{
|
||||||
|
title: 'Incubation',
|
||||||
|
price: '€4,99 / mo',
|
||||||
|
subs: [
|
||||||
|
'Up to 50.000 visits/events per month',
|
||||||
|
'CPM 0,10€ per visit/event'
|
||||||
|
],
|
||||||
|
features: [
|
||||||
|
'Slack support',
|
||||||
|
'Unlimited domains',
|
||||||
|
'Unlimited reports',
|
||||||
|
'AI Tokens: 30',
|
||||||
|
'Server type: SHARED',
|
||||||
|
'Data retention: 6 Months'
|
||||||
|
],
|
||||||
|
cta: 'Go to Cloud Dashboard',
|
||||||
|
active: (planData.value?.premium_type || 0) == 101,
|
||||||
|
isDowngrade: (planData.value?.premium_type || 0) > 101,
|
||||||
|
planId: 101
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Acceleration',
|
||||||
|
price: '€9,99 / mo',
|
||||||
|
subs: [
|
||||||
|
'Up to 150.000 visits/events per month',
|
||||||
|
'CPM 0,06€ per visit/event'
|
||||||
|
],
|
||||||
|
features: [
|
||||||
|
'Slack support',
|
||||||
|
'Unlimited domains',
|
||||||
|
'Unlimited reports',
|
||||||
|
'AI Tokens: 100',
|
||||||
|
'Server type: SHARED',
|
||||||
|
'Data retention: 9 Months'
|
||||||
|
],
|
||||||
|
cta: 'Go to Cloud Dashboard',
|
||||||
|
active: (planData.value?.premium_type || 0) == 102,
|
||||||
|
isDowngrade: (planData.value?.premium_type || 0) > 102,
|
||||||
|
planId: 102
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Growth',
|
||||||
|
price: '€29,99 / mo',
|
||||||
|
subs: [
|
||||||
|
'Up to 500.000 visits/events per month',
|
||||||
|
'CPM 0,059€ per visit/event'
|
||||||
|
],
|
||||||
|
features: [
|
||||||
|
'Slack support',
|
||||||
|
'Unlimited domains',
|
||||||
|
'Unlimited reports',
|
||||||
|
'AI Tokens: 3.000',
|
||||||
|
'Server type: SHARED',
|
||||||
|
'Data retention: 1 Year'
|
||||||
|
],
|
||||||
|
cta: 'Go to Cloud Dashboard',
|
||||||
|
active: (planData.value?.premium_type || 0) == 103,
|
||||||
|
isDowngrade: (planData.value?.premium_type || 0) > 103,
|
||||||
|
planId: 103
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Expansion',
|
||||||
|
price: '€59,99 / mo',
|
||||||
|
subs: [
|
||||||
|
'Up to 1.000.000 visits/events per month',
|
||||||
|
'CPM 0,059€ per visit/event'
|
||||||
|
],
|
||||||
|
features: [
|
||||||
|
'Slack support',
|
||||||
|
'Unlimited domains',
|
||||||
|
'Unlimited reports',
|
||||||
|
'AI Tokens: 5.000',
|
||||||
|
'Server type: SHARED',
|
||||||
|
'Data retention: 1 Year'
|
||||||
|
],
|
||||||
|
cta: 'Go to Cloud Dashboard',
|
||||||
|
active: (planData.value?.premium_type || 0) == 104,
|
||||||
|
isDowngrade: (planData.value?.premium_type || 0) > 104,
|
||||||
|
planId: 104
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Scaling',
|
||||||
|
price: '€99,99 / mo',
|
||||||
|
subs: [
|
||||||
|
'Up to 2.500.000 visits/events per month',
|
||||||
|
'CPM 0,039€ per visit/event'
|
||||||
|
],
|
||||||
|
features: [
|
||||||
|
'Slack support',
|
||||||
|
'Unlimited domains',
|
||||||
|
'Unlimited reports',
|
||||||
|
'AI Tokens: 10.000',
|
||||||
|
'Server type: DEDICATED',
|
||||||
|
'Data retention: 2 Years'
|
||||||
|
],
|
||||||
|
cta: 'Go to Cloud Dashboard',
|
||||||
|
active: (planData.value?.premium_type || 0) == 105,
|
||||||
|
isDowngrade: (planData.value?.premium_type || 0) > 105,
|
||||||
|
planId: 105
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Unicorn',
|
||||||
|
price: '€149,99 / mo',
|
||||||
|
subs: [
|
||||||
|
'Up to 5.000.000 visits/events per month',
|
||||||
|
'CPM 0,029€ per visit/event'
|
||||||
|
],
|
||||||
|
features: [
|
||||||
|
'Slack support',
|
||||||
|
'Unlimited domains',
|
||||||
|
'Unlimited reports',
|
||||||
|
'AI Tokens: 20.000',
|
||||||
|
'Server type: DEDICATED',
|
||||||
|
'Data retention: 3 Years'
|
||||||
|
],
|
||||||
|
cta: 'Go to Cloud Dashboard',
|
||||||
|
active: (planData.value?.premium_type || 0) == 106,
|
||||||
|
isDowngrade: (planData.value?.premium_type || 0) > 106,
|
||||||
|
planId: 106
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return { freePricing, customPricing, slidePricings }
|
||||||
|
}
|
||||||
|
|
||||||
const slidePricings: PricingCardProp[] = [
|
|
||||||
{
|
|
||||||
title: 'Incubation',
|
|
||||||
price: '€4,99 / mo',
|
|
||||||
subs: [
|
|
||||||
'Up to 50.000 visits/events per month',
|
|
||||||
'CPM 0,10€ per visit/event'
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
'Discord support',
|
|
||||||
'Unlimited domains',
|
|
||||||
'Unlimited reports',
|
|
||||||
'AI Tokens: 30',
|
|
||||||
'Server type: SHARED',
|
|
||||||
'Projects: max 3',
|
|
||||||
'Data retention: 6 Months'
|
|
||||||
],
|
|
||||||
cta: 'Go to Cloud Dashboard',
|
|
||||||
active: props.currentSub == 101,
|
|
||||||
isDowngrade: props.currentSub > 101,
|
|
||||||
planId: 101
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Acceleration',
|
|
||||||
price: '€9,99 / mo',
|
|
||||||
subs: [
|
|
||||||
'Up to 150.000 visits/events per month',
|
|
||||||
'CPM 0,06€ per visit/event'
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
'Discord support',
|
|
||||||
'Unlimited domains',
|
|
||||||
'Unlimited reports',
|
|
||||||
'AI Tokens: 100',
|
|
||||||
'Server type: SHARED',
|
|
||||||
'Projects: max 3',
|
|
||||||
'Data retention: 9 Months'
|
|
||||||
],
|
|
||||||
cta: 'Go to Cloud Dashboard',
|
|
||||||
active: props.currentSub == 102,
|
|
||||||
isDowngrade: props.currentSub > 102,
|
|
||||||
planId: 102
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Growth',
|
|
||||||
price: '€29,99 / mo',
|
|
||||||
subs: [
|
|
||||||
'Up to 500.000 visits/events per month',
|
|
||||||
'CPM 0,059€ per visit/event'
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
'Discord support',
|
|
||||||
'Unlimited domains',
|
|
||||||
'Unlimited reports',
|
|
||||||
'AI Tokens: 3.000',
|
|
||||||
'Server type: SHARED',
|
|
||||||
'Projects: max 3',
|
|
||||||
'Data retention: 1 Year'
|
|
||||||
],
|
|
||||||
cta: 'Go to Cloud Dashboard',
|
|
||||||
active: props.currentSub == 103,
|
|
||||||
isDowngrade: props.currentSub > 103,
|
|
||||||
planId: 103
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Expansion',
|
|
||||||
price: '€59,99 / mo',
|
|
||||||
subs: [
|
|
||||||
'Up to 1.000.000 visits/events per month',
|
|
||||||
'CPM 0,059€ per visit/event'
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
'Discord support',
|
|
||||||
'Unlimited domains',
|
|
||||||
'Unlimited reports',
|
|
||||||
'AI Tokens: 5.000',
|
|
||||||
'Server type: SHARED',
|
|
||||||
'Projects: max 3',
|
|
||||||
'Data retention: 1 Year'
|
|
||||||
],
|
|
||||||
cta: 'Go to Cloud Dashboard',
|
|
||||||
active: props.currentSub == 104,
|
|
||||||
isDowngrade: props.currentSub > 104,
|
|
||||||
planId: 104
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Scaling',
|
|
||||||
price: '€99,99 / mo',
|
|
||||||
subs: [
|
|
||||||
'Up to 2.500.000 visits/events per month',
|
|
||||||
'CPM 0,039€ per visit/event'
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
'Discord support',
|
|
||||||
'Unlimited domains',
|
|
||||||
'Unlimited reports',
|
|
||||||
'AI Tokens: 10.000',
|
|
||||||
'Server type: DEDICATED',
|
|
||||||
'Projects: max 3',
|
|
||||||
'Data retention: 2 Years'
|
|
||||||
],
|
|
||||||
cta: 'Go to Cloud Dashboard',
|
|
||||||
active: props.currentSub == 105,
|
|
||||||
isDowngrade: props.currentSub > 105,
|
|
||||||
planId: 105
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Unicorn',
|
|
||||||
price: '€149,99 / mo',
|
|
||||||
subs: [
|
|
||||||
'Up to 5.000.000 visits/events per month',
|
|
||||||
'CPM 0,029€ per visit/event'
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
'Discord support',
|
|
||||||
'Unlimited domains',
|
|
||||||
'Unlimited reports',
|
|
||||||
'AI Tokens: 20.000',
|
|
||||||
'Server type: DEDICATED',
|
|
||||||
'Projects: max 3',
|
|
||||||
'Data retention: 3 Years'
|
|
||||||
],
|
|
||||||
cta: 'Go to Cloud Dashboard',
|
|
||||||
active: props.currentSub == 106,
|
|
||||||
isDowngrade: props.currentSub > 106,
|
|
||||||
planId: 106
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
(evt: 'onCloseClick'): void
|
(evt: 'onCloseClick'): void
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
async function onLifetimeUpgradeClick() {
|
||||||
|
const res = await $fetch<string>(`/api/pay/${activeProject.value?._id.toString()}/create-onetime`, {
|
||||||
|
...signHeaders({ 'content-type': 'application/json' }),
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ planId: 2001 })
|
||||||
|
})
|
||||||
|
if (!res) alert('Something went wrong');
|
||||||
|
window.open(res);
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-8 overflow-y-auto xl:overflow-y-hidden">
|
<div class="p-8 overflow-y-auto">
|
||||||
|
|
||||||
<div @click="$emit('onCloseClick')"
|
<div @click="$emit('onCloseClick')"
|
||||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||||
@@ -200,11 +217,57 @@ const emits = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||||
<PricingCardGeneric class="flex-1" :datas="freePricing"></PricingCardGeneric>
|
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
|
||||||
<PricingCardGeneric class="flex-1" :datas="slidePricings"></PricingCardGeneric>
|
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2"></PricingCardGeneric>
|
||||||
<PricingCardGeneric class="flex-1" :datas="customPricing"></PricingCardGeneric>
|
<PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LyxUiCard class="w-full mt-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-lyx-primary font-semibold text-[1.4rem]">
|
||||||
|
LIFETIME DEAL
|
||||||
|
</span>
|
||||||
|
<span class="text-lyx-text-dark text-[.8rem]"> (Growth plan) </span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[2rem]"> € 2.399,00 </div>
|
||||||
|
<div> Up to 500.000 visits/events per month </div>
|
||||||
|
<LyxUiButton type="primary" @click="onLifetimeUpgradeClick()"> Purchase </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-evenly grow">
|
||||||
|
<div class="flex flex-col justify-evenly">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||||
|
<div> Slack support </div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||||
|
<div> Unlimited domanis </div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||||
|
<div> Unlimited reports </div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-evenly">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||||
|
<div> AI Tokens: 3.000 / month </div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||||
|
<div> Server type: SHARED </div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||||
|
<div> Data retention: 5 Years </div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LyxUiCard>
|
||||||
|
|
||||||
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="poppins text-[2rem] font-semibold">
|
<div class="poppins text-[2rem] font-semibold">
|
||||||
@@ -222,5 +285,8 @@ const emits = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { TApiSettings } from '@schema/ApiSettingsSchema';
|
||||||
import type { SettingsTemplateEntry } from './Template.vue';
|
import type { SettingsTemplateEntry } from './Template.vue';
|
||||||
|
|
||||||
|
|
||||||
const entries: SettingsTemplateEntry[] = [
|
const entries: SettingsTemplateEntry[] = [
|
||||||
{ id: 'pname', title: 'Name', text: 'Project name' },
|
{ id: 'pname', title: 'Name', text: 'Project name' },
|
||||||
|
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
|
||||||
{ id: 'pid', title: 'Id', text: 'Project id' },
|
{ id: 'pid', title: 'Id', text: 'Project id' },
|
||||||
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
|
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
|
||||||
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
|
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
|
||||||
@@ -12,8 +14,53 @@ const entries: SettingsTemplateEntry[] = [
|
|||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
||||||
|
|
||||||
|
const apiKeys = ref<TApiSettings[]>([]);
|
||||||
|
|
||||||
|
const newApiKeyName = ref<string>('');
|
||||||
|
|
||||||
|
async function updateApiKeys() {
|
||||||
|
newApiKeyName.value = '';
|
||||||
|
apiKeys.value = await $fetch<TApiSettings[]>('/api/keys/get_all', signHeaders());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApiKey() {
|
||||||
|
try {
|
||||||
|
const res = await $fetch<TApiSettings>('/api/keys/create', {
|
||||||
|
method: 'POST', ...signHeaders({
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({ name: newApiKeyName.value })
|
||||||
|
});
|
||||||
|
apiKeys.value.push(res);
|
||||||
|
newApiKeyName.value = '';
|
||||||
|
} catch (ex: any) {
|
||||||
|
alert(ex.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApiKey(api_id: string) {
|
||||||
|
try {
|
||||||
|
const res = await $fetch<TApiSettings>('/api/keys/delete', {
|
||||||
|
method: 'DELETE', ...signHeaders({
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({ api_id })
|
||||||
|
});
|
||||||
|
newApiKeyName.value = '';
|
||||||
|
await updateApiKeys();
|
||||||
|
} catch (ex: any) {
|
||||||
|
alert(ex.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateApiKeys();
|
||||||
|
})
|
||||||
|
|
||||||
watch(activeProject, () => {
|
watch(activeProject, () => {
|
||||||
projectNameInputVal.value = activeProject.value?.name || "";
|
projectNameInputVal.value = activeProject.value?.name || "";
|
||||||
|
updateApiKeys();
|
||||||
})
|
})
|
||||||
|
|
||||||
const canChange = computed(() => {
|
const canChange = computed(() => {
|
||||||
@@ -47,7 +94,7 @@ async function deleteProject() {
|
|||||||
|
|
||||||
const projectsList = useProjectsList()
|
const projectsList = useProjectsList()
|
||||||
await projectsList.refresh();
|
await projectsList.refresh();
|
||||||
|
|
||||||
const firstProjectId = projectsList.data.value?.[0]?._id.toString();
|
const firstProjectId = projectsList.data.value?.[0]?._id.toString();
|
||||||
if (firstProjectId) {
|
if (firstProjectId) {
|
||||||
await setActiveProject(firstProjectId);
|
await setActiveProject(firstProjectId);
|
||||||
@@ -61,6 +108,32 @@ async function deleteProject() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { createAlert } = useAlert()
|
||||||
|
|
||||||
|
function copyScript() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
|
||||||
|
|
||||||
|
const createScriptText = () => {
|
||||||
|
return [
|
||||||
|
'<script defer ',
|
||||||
|
`data-project="${activeProject.value?._id}" `,
|
||||||
|
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||||
|
'script>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(createScriptText());
|
||||||
|
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function copyProjectId() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||||
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -71,13 +144,36 @@ async function deleteProject() {
|
|||||||
<template #pname>
|
<template #pname>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
|
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
|
||||||
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton>
|
<LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary"> Change
|
||||||
|
</LyxUiButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template #api>
|
||||||
|
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
|
||||||
|
<LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput>
|
||||||
|
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3"
|
||||||
|
type="primary">
|
||||||
|
<i class="far fa-plus"></i>
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4">
|
||||||
|
<div v-for="apiKey of apiKeys" class="flex flex-col w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-8 items-center">
|
||||||
|
<div class="grow">Name: {{ apiKey.apiName }}</div>
|
||||||
|
<div>{{ apiKey.apiKey }}</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<i class="far fa-trash cursor-pointer" @click="deleteApiKey(apiKey._id.toString())"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</LyxUiCard>
|
||||||
|
</template>
|
||||||
<template #pid>
|
<template #pid>
|
||||||
<LyxUiCard class="w-full flex items-center">
|
<LyxUiCard class="w-full flex items-center">
|
||||||
<div class="grow">{{ activeProject?._id.toString() }}</div>
|
<div class="grow">{{ activeProject?._id.toString() }}</div>
|
||||||
<div><i class="far fa-copy"></i></div>
|
<div><i class="far fa-copy" @click="copyProjectId()"></i></div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
</template>
|
</template>
|
||||||
<template #pscript>
|
<template #pscript>
|
||||||
@@ -87,11 +183,11 @@ async function deleteProject() {
|
|||||||
<script defer data-project="${activeProject?._id}"
|
<script defer data-project="${activeProject?._id}"
|
||||||
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
||||||
</div>
|
</div>
|
||||||
<div><i class="far fa-copy"></i></div>
|
<div><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
</template>
|
</template>
|
||||||
<template #pdelete>
|
<template #pdelete >
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end" v-if="!isGuest">
|
||||||
<LyxUiButton type="danger" @click="deleteProject()">
|
<LyxUiButton type="danger" @click="deleteProject()">
|
||||||
Delete project
|
Delete project
|
||||||
</LyxUiButton>
|
</LyxUiButton>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { SettingsTemplateEntry } from './Template.vue';
|
import type { SettingsTemplateEntry } from './Template.vue';
|
||||||
|
import { getPlanFromId, PREMIUM_PLAN, type PREMIUM_TAG } from '@data/PREMIUM';
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
@@ -46,21 +47,22 @@ const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = u
|
|||||||
lazy: true
|
lazy: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const showPricingDrawer = ref<boolean>(false);
|
|
||||||
function onPlanUpgradeClick() {
|
|
||||||
showPricingDrawer.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openInvoice(link: string) {
|
function openInvoice(link: string) {
|
||||||
window.open(link, '_blank');
|
window.open(link, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPremiumName(type: number) {
|
function getPremiumName(type: number) {
|
||||||
if (type === 0) return 'FREE';
|
|
||||||
if (type === 1) return 'ACCELERATION';
|
|
||||||
if (type === 2) return 'EXPANSION';
|
|
||||||
return 'CUSTOM';
|
|
||||||
|
|
||||||
|
return Object.keys(PREMIUM_PLAN).map(e => ({
|
||||||
|
...PREMIUM_PLAN[e as PREMIUM_TAG], name: e
|
||||||
|
})).find(e => e.ID == type)?.name;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPremiumPrice(type: number) {
|
||||||
|
const PLAN = getPlanFromId(type);
|
||||||
|
if (!PLAN) return '0,00';
|
||||||
|
return (PLAN.COST / 100).toFixed(2).replace('.', ',')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -71,24 +73,24 @@ watch(activeProject, () => {
|
|||||||
|
|
||||||
|
|
||||||
const entries: SettingsTemplateEntry[] = [
|
const entries: SettingsTemplateEntry[] = [
|
||||||
|
// { id: 'info', title: 'Billing informations', text: 'Manage billing informations for this project' },
|
||||||
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
|
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
|
||||||
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
|
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
|
||||||
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
|
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
const currentBillingInfo = ref<any>({
|
||||||
|
address: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const { visible } = usePricingDrawer();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|
||||||
<Transition name="pdrawer">
|
|
||||||
<PricingDrawer @onCloseClick="showPricingDrawer = false" :currentSub="planData?.premium_type || 0"
|
|
||||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]"
|
|
||||||
v-if=showPricingDrawer>
|
|
||||||
</PricingDrawer>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<div v-if="invoicesPending || planPending"
|
<div v-if="invoicesPending || planPending"
|
||||||
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
|
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
|
||||||
<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>
|
||||||
@@ -114,8 +116,12 @@ const entries: SettingsTemplateEntry[] = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="poppins font-semibold text-[2rem]"> $0 </div>
|
<div class="poppins font-semibold text-[2rem]"> €
|
||||||
|
{{ getPremiumPrice(planData.premium_type) }} </div>
|
||||||
<div class="poppins text-text-sub mt-2"> per month </div>
|
<div class="poppins text-text-sub mt-2"> per month </div>
|
||||||
|
<div class="flex items-center ml-2">
|
||||||
|
<i class="far fa-info-circle text-[.8rem]"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
@@ -138,7 +144,7 @@ const entries: SettingsTemplateEntry[] = [
|
|||||||
<div class="poppins"> Expire date:</div>
|
<div class="poppins"> Expire date:</div>
|
||||||
<div> {{ prettyExpireDate }}</div>
|
<div> {{ prettyExpireDate }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isGuest" @click="onPlanUpgradeClick()"
|
<div v-if="!isGuest" @click="visible = true"
|
||||||
class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]">
|
class="cursor-pointer flex items-center gap-2 text-[.9rem] text-white font-semibold bg-accent px-4 py-1 rounded-lg drop-shadow-[0_0_8px_#000000]">
|
||||||
<div class="poppins"> Upgrade plan </div>
|
<div class="poppins"> Upgrade plan </div>
|
||||||
<i class="fas fa-arrow-up-right"></i>
|
<i class="fas fa-arrow-up-right"></i>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
|
||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
import annotaionPlugin from 'chartjs-plugin-annotation';
|
||||||
|
|
||||||
let registered = false;
|
let registered = false;
|
||||||
export async function registerChartComponents() {
|
export async function registerChartComponents() {
|
||||||
if (registered) return;
|
if (registered) return;
|
||||||
if (process.client) {
|
if (process.client) {
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables, annotaionPlugin);
|
||||||
registered = true;
|
registered = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
dashboard/composables/usePricingDrawer.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const pricingDrawerVisible = ref<boolean>(false);
|
||||||
|
|
||||||
|
|
||||||
|
export function usePricingDrawer() {
|
||||||
|
return { visible: pricingDrawerVisible };
|
||||||
|
}
|
||||||
@@ -4,23 +4,47 @@ import type { Section } from '~/components/CVerticalNavigation.vue';
|
|||||||
|
|
||||||
import { Lit } from 'litlyx-js';
|
import { Lit } from 'litlyx-js';
|
||||||
|
|
||||||
|
const activeProject = useActiveProject();
|
||||||
|
const isPremium = computed(() => {
|
||||||
|
return activeProject.value?.premium;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pricingDrawer = usePricingDrawer();
|
||||||
|
|
||||||
const sections: Section[] = [
|
const sections: Section[] = [
|
||||||
{
|
{
|
||||||
title: 'Project',
|
title: '',
|
||||||
entries: [
|
entries: [
|
||||||
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
|
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
|
||||||
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
|
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
|
||||||
{ label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' },
|
{ label: 'AI Analyst', to: '/analyst', icon: 'fal fa-sparkles' },
|
||||||
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
|
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
|
||||||
|
{ label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
|
||||||
|
{ label: 'Integrations (soon)', to: '#', icon: 'fal fa-cube', disabled: true },
|
||||||
|
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||||
{
|
{
|
||||||
label: 'Docs', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
|
grow: true,
|
||||||
|
label: 'Documentation', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
|
||||||
action() { Lit.event('docs_clicked') },
|
action() { Lit.event('docs_clicked') },
|
||||||
},
|
},
|
||||||
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
{
|
||||||
|
label: 'Slack support', icon: 'fab fa-slack',
|
||||||
|
to:'#',
|
||||||
|
premiumOnly: true,
|
||||||
|
action() {
|
||||||
|
if (isGuest.value === true) return;
|
||||||
|
if (isPremium.value === true) {
|
||||||
|
window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank');
|
||||||
|
} else {
|
||||||
|
pricingDrawer.visible.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const { showDialog, closeDialog } = useBarCardDialog();
|
const { showDialog, closeDialog } = useBarCardDialog();
|
||||||
|
|
||||||
const { isOpen, close, open } = useMenu();
|
const { isOpen, close, open } = useMenu();
|
||||||
|
|||||||
@@ -39,12 +39,12 @@ export default defineNuxtConfig({
|
|||||||
AI_PROJECT: process.env.AI_PROJECT,
|
AI_PROJECT: process.env.AI_PROJECT,
|
||||||
AI_KEY: process.env.AI_KEY,
|
AI_KEY: process.env.AI_KEY,
|
||||||
EMAIL_SERVICE: process.env.EMAIL_SERVICE,
|
EMAIL_SERVICE: process.env.EMAIL_SERVICE,
|
||||||
EMAIL_HOST: process.env.EMAIL_HOST,
|
BREVO_API_KEY: process.env.BREVO_API_KEY,
|
||||||
EMAIL_USER: process.env.EMAIL_USER,
|
|
||||||
EMAIL_PASS: process.env.EMAIL_PASS,
|
|
||||||
AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET,
|
AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET,
|
||||||
GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID,
|
GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID,
|
||||||
GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET,
|
GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET,
|
||||||
|
GITHUB_AUTH_CLIENT_ID: process.env.GITHUB_AUTH_CLIENT_ID,
|
||||||
|
GITHUB_AUTH_CLIENT_SECRET: process.env.GITHUB_AUTH_CLIENT_SECRET,
|
||||||
STRIPE_SECRET: process.env.STRIPE_SECRET,
|
STRIPE_SECRET: process.env.STRIPE_SECRET,
|
||||||
STRIPE_WH_SECRET: process.env.STRIPE_WH_SECRET,
|
STRIPE_WH_SECRET: process.env.STRIPE_WH_SECRET,
|
||||||
STRIPE_SECRET_TEST: process.env.STRIPE_SECRET_TEST,
|
STRIPE_SECRET_TEST: process.env.STRIPE_SECRET_TEST,
|
||||||
@@ -52,7 +52,8 @@ export default defineNuxtConfig({
|
|||||||
NOAUTH_USER_EMAIL: process.env.NOAUTH_USER_EMAIL,
|
NOAUTH_USER_EMAIL: process.env.NOAUTH_USER_EMAIL,
|
||||||
NOAUTH_USER_NAME: process.env.NOAUTH_USER_NAME,
|
NOAUTH_USER_NAME: process.env.NOAUTH_USER_NAME,
|
||||||
public: {
|
public: {
|
||||||
AUTH_MODE: process.env.AUTH_MODE
|
AUTH_MODE: process.env.AUTH_MODE,
|
||||||
|
GITHUB_CLIENT_ID: process.env.GITHUB_AUTH_CLIENT_ID || 'NONE'
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
"docker-inspect": "docker run -it litlyx-dashboard sh"
|
"docker-inspect": "docker run -it litlyx-dashboard sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@getbrevo/brevo": "^2.2.0",
|
||||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||||
"chart.js": "^3.9.1",
|
"chart.js": "^3.9.1",
|
||||||
|
"chartjs-plugin-annotation": "^2.2.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"google-auth-library": "^9.9.0",
|
"google-auth-library": "^9.9.0",
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
"nodemailer": "^6.9.13",
|
"nodemailer": "^6.9.13",
|
||||||
"nuxt": "^3.11.2",
|
"nuxt": "^3.11.2",
|
||||||
"nuxt-vue3-google-signin": "^0.0.11",
|
"nuxt-vue3-google-signin": "^0.0.11",
|
||||||
"openai": "^4.47.1",
|
"openai": "^4.61.0",
|
||||||
"pdfkit": "^0.15.0",
|
"pdfkit": "^0.15.0",
|
||||||
"primevue": "^3.52.0",
|
"primevue": "^3.52.0",
|
||||||
"redis": "^4.6.13",
|
"redis": "^4.6.13",
|
||||||
@@ -33,11 +35,14 @@
|
|||||||
"v-calendar": "^3.1.2",
|
"v-calendar": "^3.1.2",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-chart-3": "^3.1.8",
|
"vue-chart-3": "^3.1.8",
|
||||||
"vue-router": "^4.3.0"
|
"vue-markdown-render": "^2.2.1",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"winston": "^3.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/ui": "^2.15.2",
|
"@nuxt/ui": "^2.15.2",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/nodemailer": "^6.4.15",
|
"@types/nodemailer": "^6.4.15",
|
||||||
"@types/pdfkit": "^0.13.4",
|
"@types/pdfkit": "^0.13.4",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { AdminProjectsList } from '~/server/api/admin/projects';
|
|||||||
definePageMeta({ layout: 'dashboard' });
|
definePageMeta({ layout: 'dashboard' });
|
||||||
|
|
||||||
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
|
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
|
||||||
|
const { data: counts } = await useFetch('/api/admin/counts', signHeaders());
|
||||||
|
|
||||||
|
|
||||||
type TProjectsGrouped = {
|
type TProjectsGrouped = {
|
||||||
user: {
|
user: {
|
||||||
@@ -88,11 +90,6 @@ function onHideClicked() {
|
|||||||
isAdminHidden.value = true;
|
isAdminHidden.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const projectsCount = computed(() => {
|
|
||||||
return projects.value?.length || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const premiumCount = computed(() => {
|
const premiumCount = computed(() => {
|
||||||
let premiums = 0;
|
let premiums = 0;
|
||||||
projects.value?.forEach(e => {
|
projects.value?.forEach(e => {
|
||||||
@@ -102,12 +99,6 @@ const premiumCount = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const usersCount = computed(() => {
|
|
||||||
const uniqueUsers = new Set<string>();
|
|
||||||
projects.value?.forEach(e => uniqueUsers.add(e.user.email));
|
|
||||||
return uniqueUsers.size;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const totalVisits = computed(() => {
|
const totalVisits = computed(() => {
|
||||||
return projects.value?.reduce((a, e) => a + e.total_visits, 0) || 0;
|
return projects.value?.reduce((a, e) => a + e.total_visits, 0) || 0;
|
||||||
@@ -155,10 +146,10 @@ async function resetCount(project_id: string) {
|
|||||||
|
|
||||||
<div class="grid grid-cols-2">
|
<div class="grid grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
Users: {{ usersCount }}
|
Users: {{ counts?.users }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Projects: {{ projectsCount }} ( {{ premiumCount }} premium )
|
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Total visits: {{ formatNumberK(totalVisits) }}
|
Total visits: {{ formatNumberK(totalVisits) }}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
definePageMeta({ layout: 'dashboard' });
|
|
||||||
|
|
||||||
|
import VueMarkdown from 'vue-markdown-render';
|
||||||
|
|
||||||
|
definePageMeta({ layout: 'dashboard' });
|
||||||
|
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
const { data: chatsList, refresh: reloadChatsList } = useFetch(`/api/ai/${activeProject.value?._id}/chats_list`, signHeaders());
|
const { data: chatsList, refresh: reloadChatsList } = useFetch(`/api/ai/${activeProject.value?._id}/chats_list`, {
|
||||||
|
...signHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const viewChatsList = computed(() => (chatsList.value || []).toReversed());
|
||||||
|
|
||||||
const { data: chatsRemaining, refresh: reloadChatsRemaining } = useFetch(`/api/ai/${activeProject.value?._id}/chats_remaining`, signHeaders());
|
const { data: chatsRemaining, refresh: reloadChatsRemaining } = useFetch(`/api/ai/${activeProject.value?._id}/chats_remaining`, signHeaders());
|
||||||
|
|
||||||
@@ -12,7 +20,7 @@ const currentText = ref<string>("");
|
|||||||
const loading = ref<boolean>(false);
|
const loading = ref<boolean>(false);
|
||||||
|
|
||||||
const currentChatId = ref<string>("");
|
const currentChatId = ref<string>("");
|
||||||
const currentChatMessages = ref<any[]>([]);
|
const currentChatMessages = ref<{ role: string, content: string, charts?: any[] }[]>([]);
|
||||||
|
|
||||||
const scroller = ref<HTMLDivElement | null>(null);
|
const scroller = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -39,7 +47,7 @@ async function sendMessage() {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
...signHeaders({ 'Content-Type': 'application/json' })
|
...signHeaders({ 'Content-Type': 'application/json' })
|
||||||
});
|
});
|
||||||
currentChatMessages.value.push({ role: 'assistant', content: res });
|
currentChatMessages.value.push({ role: 'assistant', content: res.content || 'nocontent', charts: res.charts.map(e => JSON.parse(e)) });
|
||||||
|
|
||||||
await reloadChatsRemaining();
|
await reloadChatsRemaining();
|
||||||
await reloadChatsList();
|
await reloadChatsList();
|
||||||
@@ -67,15 +75,18 @@ async function sendMessage() {
|
|||||||
async function openChat(chat_id?: string) {
|
async function openChat(chat_id?: string) {
|
||||||
menuOpen.value = false;
|
menuOpen.value = false;
|
||||||
if (!activeProject.value) return;
|
if (!activeProject.value) return;
|
||||||
|
|
||||||
|
currentChatMessages.value = [];
|
||||||
|
|
||||||
if (!chat_id) {
|
if (!chat_id) {
|
||||||
currentChatMessages.value = [];
|
|
||||||
currentChatId.value = '';
|
currentChatId.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentChatId.value = chat_id;
|
currentChatId.value = chat_id;
|
||||||
const messages = await $fetch(`/api/ai/${activeProject.value._id}/${chat_id}/get_messages`, signHeaders());
|
const messages = await $fetch(`/api/ai/${activeProject.value._id}/${chat_id}/get_messages`, signHeaders());
|
||||||
if (!messages) return;
|
if (!messages) return;
|
||||||
currentChatMessages.value = messages;
|
|
||||||
|
currentChatMessages.value = messages.map(e => ({ ...e, charts: e.charts.map(k => JSON.parse(k)) })) as any;
|
||||||
setTimeout(() => scrollToBottom(), 1);
|
setTimeout(() => scrollToBottom(), 1);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -99,10 +110,10 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
const menuOpen = ref<boolean>(false);
|
const menuOpen = ref<boolean>(false);
|
||||||
|
|
||||||
const defaultPrompts = [
|
const defaultPrompts = [
|
||||||
'How many visits i got last week ?',
|
"Create a line chart with this data: \n[100, 200, 30, 300, 500, 40]",
|
||||||
'How many visits i got last month ?',
|
"Create a chart with Events (bar) and Visits (line) data from last week.",
|
||||||
'How many visits i got today ?',
|
"How many visits did I get last week?",
|
||||||
'How many events i got last week ?',
|
"Create a line chart of last week's visits."
|
||||||
]
|
]
|
||||||
|
|
||||||
async function deleteChat(chat_id: string) {
|
async function deleteChat(chat_id: string) {
|
||||||
@@ -117,6 +128,8 @@ async function deleteChat(chat_id: string) {
|
|||||||
await reloadChatsList();
|
await reloadChatsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { visible: pricingDrawerVisible } = usePricingDrawer()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -124,7 +137,7 @@ async function deleteChat(chat_id: string) {
|
|||||||
|
|
||||||
<div class="flex flex-row h-full">
|
<div class="flex flex-row h-full">
|
||||||
|
|
||||||
<div class="flex-[5] py-8 flex flex-col items-center relative">
|
<div class="flex-[5] py-8 flex flex-col items-center relative bg-lyx-background-light">
|
||||||
|
|
||||||
<div class="flex flex-col items-center mt-[20vh] px-28" v-if="currentChatMessages.length == 0">
|
<div class="flex flex-col items-center mt-[20vh] px-28" v-if="currentChatMessages.length == 0">
|
||||||
<div class="w-[10rem]">
|
<div class="w-[10rem]">
|
||||||
@@ -138,7 +151,7 @@ async function deleteChat(chat_id: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest">
|
<div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest">
|
||||||
<div v-for="prompt of defaultPrompts" @click="currentText = prompt"
|
<div v-for="prompt of defaultPrompts" @click="currentText = prompt"
|
||||||
class="bg-[#2f2f2f] hover:bg-[#424242] cursor-pointer p-4 rounded-lg poppins text-center">
|
class="bg-lyx-widget-light hover:bg-lyx-widget-lighter cursor-pointer p-4 rounded-lg poppins text-center whitespace-pre-wrap flex items-center justify-center text-[.9rem]">
|
||||||
{{ prompt }}
|
{{ prompt }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,21 +159,36 @@ async function deleteChat(chat_id: string) {
|
|||||||
|
|
||||||
<div ref="scroller" class="flex flex-col w-full gap-6 px-6 xl:px-28 overflow-y-auto pb-20">
|
<div ref="scroller" class="flex flex-col w-full gap-6 px-6 xl:px-28 overflow-y-auto pb-20">
|
||||||
|
|
||||||
<div class="flex w-full" v-for="message of currentChatMessages">
|
<div class="flex w-full flex-col" v-for="message of currentChatMessages">
|
||||||
|
|
||||||
<div class="flex justify-end w-full poppins text-[1.1rem]" v-if="message.role === 'user'">
|
<div class="flex justify-end w-full poppins text-[1.1rem]" v-if="message.role === 'user'">
|
||||||
<div class="bg-[#303030] px-5 py-3 rounded-lg">
|
<div class="bg-lyx-widget-light px-5 py-3 rounded-lg">
|
||||||
{{ message.content }}
|
{{ message.content }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]"
|
<div class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]"
|
||||||
v-if="message.role === 'assistant'">
|
v-if="message.role === 'assistant' && message.content">
|
||||||
<div class="flex items-center justify-center shrink-0">
|
<div class="flex items-center justify-center shrink-0">
|
||||||
<img class="h-[3.5rem] w-auto" :src="'analyst.png'">
|
<img class="h-[3.5rem] w-auto" :src="'analyst.png'">
|
||||||
</div>
|
</div>
|
||||||
<div v-html="parseMessageContent(message.content)"
|
<div class="max-w-[70%] text-text/90 ai-message">
|
||||||
class="max-w-[70%] text-text/90 whitespace-pre-wrap">
|
<vue-markdown :source="message.content" :options="{
|
||||||
|
html: true,
|
||||||
|
breaks: true,
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.charts && message.charts.length > 0"
|
||||||
|
class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem] flex-col mt-4">
|
||||||
|
<div v-for="chart of message.charts" class="w-full">
|
||||||
|
<AnalystComposableChart :datasets="chart.datasets" :labels="chart.labels"
|
||||||
|
:title="chart.title">
|
||||||
|
</AnalystComposableChart>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading"
|
<div v-if="loading"
|
||||||
@@ -177,13 +205,13 @@ async function deleteChat(chat_id: string) {
|
|||||||
|
|
||||||
<div v-if="!isGuest" class="flex gap-2 items-center absolute bottom-8 left-0 w-full px-10 xl:px-28">
|
<div v-if="!isGuest" class="flex gap-2 items-center absolute bottom-8 left-0 w-full px-10 xl:px-28">
|
||||||
<input @keydown="onKeyDown" v-model="currentText"
|
<input @keydown="onKeyDown" v-model="currentText"
|
||||||
class="bg-[#303030] w-full focus:outline-none px-4 py-2 rounded-lg" type="text">
|
class="bg-lyx-widget-light w-full focus:outline-none px-4 py-2 rounded-lg" type="text">
|
||||||
<div @click="sendMessage()"
|
<div @click="sendMessage()"
|
||||||
class="bg-[#303030] hover:bg-[#464646] cursor-pointer px-4 py-2 rounded-full">
|
class="bg-lyx-widget-light hhover:bg-lyx-widget-lighter cursor-pointer px-4 py-2 rounded-full">
|
||||||
<i class="far fa-arrow-up"></i>
|
<i class="far fa-arrow-up"></i>
|
||||||
</div>
|
</div>
|
||||||
<div @click="menuOpen = !menuOpen"
|
<div @click="menuOpen = !menuOpen"
|
||||||
class="bg-[#303030] lg:hidden hover:bg-[#464646] cursor-pointer px-4 py-2 rounded-full">
|
class="bg-lyx-widget-light lg:hidden hhover:bg-lyx-widget-lighter cursor-pointer px-4 py-2 rounded-full">
|
||||||
<i class="far fa-message"></i>
|
<i class="far fa-message"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,32 +222,37 @@ async function deleteChat(chat_id: string) {
|
|||||||
<div :class="{
|
<div :class="{
|
||||||
'absolute': menuOpen,
|
'absolute': menuOpen,
|
||||||
'hidden lg:flex': !menuOpen
|
'hidden lg:flex': !menuOpen
|
||||||
}" class="flex-[2] bg-[#303030] p-6 flex flex-col gap-4 h-full overflow-hidden">
|
}" class="flex-[2] bg-lyx-widget-light p-6 flex flex-col gap-4 h-full overflow-hidden">
|
||||||
|
|
||||||
<div class="gap-2 flex flex-col">
|
<div class="gap-2 flex flex-col">
|
||||||
<div class="lg:hidden absolute right-4 top-4 text-[1.5rem]">
|
<div class="lg:hidden absolute right-4 top-4 text-[1.5rem]">
|
||||||
<i @click="menuOpen = false" class="fas fa-close cursor-pointer"></i>
|
<i @click="menuOpen = false" class="fas fa-close cursor-pointer"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins font-semibold text-[1.5rem]">
|
<div class="poppins font-semibold text-[1.5rem]">
|
||||||
Lit, your AI Analyst is here!
|
What Lit can do for you?
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-text/75">
|
<div class="poppins text-text/75">
|
||||||
Ask anything you want on your analytics,
|
Ask anything from your data history, visualize and overlap charts, explore events or metadata,
|
||||||
and understand more Trends and Key Points to take Strategic moves!
|
and enjoy a highly personalized data analysis experience.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 items-center py-3">
|
<div class="flex gap-2 items-center pt-3">
|
||||||
<div class="bg-accent w-5 h-5 rounded-full animate-pulse">
|
<div class="bg-accent w-5 h-5 rounded-full animate-pulse">
|
||||||
</div>
|
</div>
|
||||||
<div class="manrope font-semibold"> {{ chatsRemaining }} remaining messages </div>
|
<div class="manrope font-semibold"> {{ chatsRemaining }} remaining AI requests </div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="poppins font-semibold text-[1.1rem]"> History: </div>
|
<LyxUiButton type="primary" class="text-[.9rem] text-center w-full"
|
||||||
|
@click="pricingDrawerVisible = true">
|
||||||
|
Upgrade plan for more requests
|
||||||
|
</LyxUiButton>
|
||||||
|
|
||||||
|
<div class="poppins font-semibold text-[1.1rem]"> History </div>
|
||||||
|
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<div @click="openChat()"
|
<div @click="openChat()"
|
||||||
class="bg-menu cursor-pointer hover:bg-menu/80 rounded-lg px-4 py-3 poppins flex gap-2 items-center">
|
class="bg-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget rounded-lg px-4 py-3 poppins flex gap-4 items-center">
|
||||||
<div> <i class="fas fa-plus"></i> </div>
|
<div> <i class="fas fa-plus"></i> </div>
|
||||||
<div> New chat </div>
|
<div> New chat </div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,12 +261,13 @@ async function deleteChat(chat_id: string) {
|
|||||||
|
|
||||||
<div class="overflow-y-auto">
|
<div class="overflow-y-auto">
|
||||||
<div class="flex flex-col gap-2 px-2">
|
<div class="flex flex-col gap-2 px-2">
|
||||||
<div class="flex items-center gap-4 w-full" v-for="chat of chatsList?.toReversed()">
|
<div :class="{ '!bg-accent/60': chat._id.toString() === currentChatId }"
|
||||||
|
class="flex rounded-lg items-center gap-4 w-full px-4 bg-lyx-widget-lighter hover:bg-lyx-widget"
|
||||||
|
v-for="chat of viewChatsList">
|
||||||
<i @click="deleteChat(chat._id.toString())"
|
<i @click="deleteChat(chat._id.toString())"
|
||||||
class="fas fa-trash hover:text-gray-300 cursor-pointer"></i>
|
class="far fa-trash hover:text-gray-300 cursor-pointer"></i>
|
||||||
<div @click="openChat(chat._id.toString())"
|
<div @click="openChat(chat._id.toString())"
|
||||||
class="bg-menu px-4 py-3 w-full cursor-pointer hover:bg-menu/80 poppins rounded-lg"
|
class="py-3 w-full cursor-pointer poppins rounded-lg">
|
||||||
:class="{ '!bg-accent/60': chat._id.toString() === currentChatId }">
|
|
||||||
{{ chat.title }}
|
{{ chat.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,4 +282,76 @@ async function deleteChat(chat_id: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.ai-message {
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
max-width: 750px;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 1.5em 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
color: #555;
|
||||||
|
border-left: 5px solid #ccc;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin-left: 30px;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #007acc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
|
|||||||
|
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
|
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
|
||||||
|
|
||||||
const metricsInfo = ref<number>(0);
|
const metricsInfo = ref<number>(0);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -36,7 +38,36 @@ onMounted(async () => {
|
|||||||
metricsInfo.value = counts.eventsCount;
|
metricsInfo.value = counts.eventsCount;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const creatingCsv = ref<boolean>(false);
|
||||||
|
|
||||||
|
async function downloadCSV() {
|
||||||
|
creatingCsv.value = true;
|
||||||
|
const result = await $fetch(`/api/project/generate_csv?mode=events&slice=${options.indexOf(selectedTimeFrom.value)}`, signHeaders());
|
||||||
|
const blob = new Blob([result], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'ReportVisits.csv';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
creatingCsv.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = ['Last day', 'Last week', 'Last month', 'Total']
|
||||||
|
const selectedTimeFrom = ref<string>(options[0]);
|
||||||
|
|
||||||
|
const showWarning = computed(() => {
|
||||||
|
return options.indexOf(selectedTimeFrom.value) > 1
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const pricingDrawer = usePricingDrawer();
|
||||||
|
|
||||||
|
function goToUpgrade() {
|
||||||
|
pricingDrawer.visible.value = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -47,14 +78,38 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="w-full h-dvh flex flex-col">
|
<div class="w-full h-dvh flex flex-col">
|
||||||
|
|
||||||
|
<div v-if="creatingCsv"
|
||||||
|
class="fixed z-[100] flex items-center justify-center left-0 top-0 w-full h-full bg-black/60 backdrop-blur-[4px]">
|
||||||
|
<div class="poppins text-[2rem]">
|
||||||
|
Creating csv...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end px-12 py-3">
|
|
||||||
<div
|
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
||||||
|
|
||||||
|
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
||||||
|
<i class="far fa-warning "></i>
|
||||||
|
<div> It can take a few minutes </div>
|
||||||
|
</div>
|
||||||
|
<div class="w-[15rem] flex flex-col gap-0">
|
||||||
|
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isPremium" @click="downloadCSV()"
|
||||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||||
Download CSV
|
Download CSV
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isPremium" @click="goToUpgrade()"
|
||||||
|
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||||
|
<i class="far fa-lock"></i>
|
||||||
|
Upgrade plan for CSV
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<UTable v-if="tableData" class="utable px-8" :ui="{
|
<UTable v-if="tableData" class="utable px-8" :ui="{
|
||||||
wrapper: 'overflow-auto w-full h-full',
|
wrapper: 'overflow-auto w-full h-full',
|
||||||
thead: 'sticky top-0 bg-menu',
|
thead: 'sticky top-0 bg-menu',
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
|
|||||||
|
|
||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
|
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
|
||||||
|
|
||||||
const metricsInfo = ref<number>(0);
|
const metricsInfo = ref<number>(0);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -68,6 +70,12 @@ const showWarning = computed(() => {
|
|||||||
return options.indexOf(selectedTimeFrom.value) > 1
|
return options.indexOf(selectedTimeFrom.value) > 1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pricingDrawer = usePricingDrawer();
|
||||||
|
|
||||||
|
function goToUpgrade() {
|
||||||
|
pricingDrawer.visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +91,9 @@ const showWarning = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
||||||
|
|
||||||
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
||||||
<i class="far fa-warning "></i>
|
<i class="far fa-warning "></i>
|
||||||
<div> It can take a few minutes </div>
|
<div> It can take a few minutes </div>
|
||||||
@@ -91,12 +101,21 @@ const showWarning = computed(() => {
|
|||||||
<div class="w-[15rem] flex flex-col gap-0">
|
<div class="w-[15rem] flex flex-col gap-0">
|
||||||
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||||
</div>
|
</div>
|
||||||
<div @click="downloadCSV()"
|
|
||||||
|
<div v-if="isPremium" @click="downloadCSV()"
|
||||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||||
Download CSV
|
Download CSV
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isPremium" @click="goToUpgrade()"
|
||||||
|
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||||
|
<i class="far fa-lock"></i>
|
||||||
|
Upgrade plan for CSV
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<UTable v-if="tableData" class="utable px-8" :ui="{
|
<UTable v-if="tableData" class="utable px-8" :ui="{
|
||||||
wrapper: 'overflow-auto w-full h-full',
|
wrapper: 'overflow-auto w-full h-full',
|
||||||
thead: 'sticky top-0 bg-menu',
|
thead: 'sticky top-0 bg-menu',
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const limitsInfo = ref<{
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (route.query.just_logged) return location.href = '/';
|
if (route.query.just_logged) return location.href = '/';
|
||||||
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
||||||
|
watch(activeProject, async () => {
|
||||||
|
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +78,16 @@ const { snapshot } = useSnapshot();
|
|||||||
|
|
||||||
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
||||||
|
|
||||||
|
const isPremium = computed(() => {
|
||||||
|
return activeProject.value?.premium;
|
||||||
|
})
|
||||||
|
|
||||||
|
const pricingDrawer = usePricingDrawer();
|
||||||
|
|
||||||
|
function goToUpgrade() {
|
||||||
|
pricingDrawer.visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -84,9 +97,7 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
|||||||
|
|
||||||
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value">
|
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value">
|
||||||
|
|
||||||
<div class="w-full px-4 py-2">
|
<div class="w-full px-4 py-2 gap-2 flex flex-col">
|
||||||
|
|
||||||
|
|
||||||
<div v-if="limitsInfo && limitsInfo.limited"
|
<div v-if="limitsInfo && limitsInfo.limited"
|
||||||
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
|
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||||
<div class="flex flex-col grow">
|
<div class="flex flex-col grow">
|
||||||
@@ -94,21 +105,42 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
|||||||
Limit reached
|
Limit reached
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins text-[#fbbf24]">
|
<div class="poppins text-[#fbbf24]">
|
||||||
Litlyx has stopped to collect yur data. Please upgrade the plan for a minimal data loss.
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<LyxUiButton type="outline"> Upgrade </LyxUiButton>
|
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DashboardTopSection></DashboardTopSection>
|
<DashboardTopSection></DashboardTopSection>
|
||||||
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full">
|
||||||
|
<DashboardActionableChart :key="refreshKey"></DashboardActionableChart>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
||||||
|
|
||||||
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Visits trends"
|
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Visits trends"
|
||||||
@@ -118,26 +150,25 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
|||||||
:options="selectLabels">
|
:options="selectLabels">
|
||||||
</SelectButton>
|
</SelectButton>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<div>
|
||||||
<DashboardVisitsLineChart :slice="(selectLabels[mainChartSelectIndex].value as any)">
|
<DashboardVisitsLineChart :slice="(selectLabels[mainChartSelectIndex].value as any)">
|
||||||
</DashboardVisitsLineChart>
|
</DashboardVisitsLineChart>
|
||||||
</div>
|
</div>
|
||||||
</CardTitled>
|
</CardTitled>
|
||||||
|
|
||||||
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions"
|
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions" sub="Shows trends in sessions.">
|
||||||
sub="Shows trends in sessions.">
|
<template #header>
|
||||||
<template #header>
|
|
||||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
||||||
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
||||||
</SelectButton>
|
</SelectButton>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<div>
|
||||||
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
||||||
</DashboardSessionsLineChart>
|
</DashboardSessionsLineChart>
|
||||||
</div>
|
</div>
|
||||||
</CardTitled>
|
</CardTitled>
|
||||||
|
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<div class="flex w-full justify-center mt-6 px-6">
|
<div class="flex w-full justify-center mt-6 px-6">
|
||||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||||
@@ -145,13 +176,12 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
|||||||
<DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard>
|
<DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
|
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full justify-center mt-6 px-6">
|
||||||
<div class="flex w-full justify-center mt-6 px-6">
|
|
||||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>
|
<DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>
|
||||||
@@ -181,7 +211,7 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
definePageMeta({ layout: 'none' });
|
definePageMeta({ layout: 'none' });
|
||||||
|
|
||||||
|
const { snapshot, snapshots } = useSnapshot();
|
||||||
|
|
||||||
const { data: project } = useLiveDemo();
|
const { data: project } = useLiveDemo();
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ let interval: any;
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await getOnlineUsers();
|
await getOnlineUsers();
|
||||||
|
snapshot.value = snapshots.value[0];
|
||||||
interval = setInterval(async () => {
|
interval = setInterval(async () => {
|
||||||
await getOnlineUsers();
|
await getOnlineUsers();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -46,7 +47,7 @@ const selectLabelsEvents = [
|
|||||||
{ label: 'Month', value: 'month' },
|
{ label: 'Month', value: 'month' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { snapshot } = useSnapshot();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -71,14 +72,10 @@ const { snapshot } = useSnapshot();
|
|||||||
</div>
|
</div>
|
||||||
<div class="grow"></div>
|
<div class="grow"></div>
|
||||||
<div class="flex gap-2 md:pt-0 pt-4">
|
<div class="flex gap-2 md:pt-0 pt-4">
|
||||||
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
|
<LyxUiButton link="/" type="primary"
|
||||||
class="bg-white hover:bg-white/90 px-4 py-3 text-black poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg">
|
class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4">
|
||||||
Book a demo
|
Get started for free
|
||||||
</NuxtLink>
|
</LyxUiButton>
|
||||||
<NuxtLink to="/"
|
|
||||||
class="bg-accent hover:bg-accent/90 px-4 py-3 poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg">
|
|
||||||
Go to dashboard
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +87,7 @@ const { snapshot } = useSnapshot();
|
|||||||
|
|
||||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
||||||
|
|
||||||
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits.">
|
<CardTitled class="p-4 flex-1 w-full" title="Visits trends" sub="Shows trends in page visits.">
|
||||||
<template #header>
|
<template #header>
|
||||||
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
|
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
|
||||||
:options="selectLabels">
|
:options="selectLabels">
|
||||||
@@ -102,7 +99,7 @@ const { snapshot } = useSnapshot();
|
|||||||
</div>
|
</div>
|
||||||
</CardTitled>
|
</CardTitled>
|
||||||
|
|
||||||
<CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
<!-- <CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
||||||
<template #header>
|
<template #header>
|
||||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
||||||
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
||||||
@@ -112,13 +109,14 @@ const { snapshot } = useSnapshot();
|
|||||||
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
||||||
</DashboardSessionsLineChart>
|
</DashboardSessionsLineChart>
|
||||||
</div>
|
</div>
|
||||||
</CardTitled>
|
</CardTitled> -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
||||||
<!-- <CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
|
||||||
|
<CardTitled class="p-4 flex-[4] w-full h-full" title="Events" sub="Events stacked bar chart.">
|
||||||
<template #header>
|
<template #header>
|
||||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||||
@@ -128,25 +126,17 @@ const { snapshot } = useSnapshot();
|
|||||||
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
||||||
</EventsStackedBarChart>
|
</EventsStackedBarChart>
|
||||||
</div>
|
</div>
|
||||||
</CardTitled> -->
|
</CardTitled>
|
||||||
|
|
||||||
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
|
<CardTitled title="Top events" sub=" Displays key events." class="p-4 flex-[2] w-full h-full">
|
||||||
<div class="flex flex-col gap-1">
|
<div>
|
||||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||||
Top events
|
|
||||||
</div>
|
|
||||||
<div class="poppins text-[1rem] text-text-sub/90">
|
|
||||||
Displays key events.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardTitled>
|
||||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="flex w-full justify-center mt-6 px-6">
|
<div class="flex w-full justify-center px-6">
|
||||||
<div class="flex w-full gap-6 flex-col lg:flex-row">
|
<div class="flex w-full gap-6 flex-col lg:flex-row">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardWebsitesBarCard></DashboardWebsitesBarCard>
|
<DashboardWebsitesBarCard></DashboardWebsitesBarCard>
|
||||||
@@ -203,15 +193,15 @@ const { snapshot } = useSnapshot();
|
|||||||
Do you want this KPIs for your website ?
|
Do you want this KPIs for your website ?
|
||||||
</div>
|
</div>
|
||||||
<div class="poppins font-semibold text-text-sub">
|
<div class="poppins font-semibold text-text-sub">
|
||||||
Start now ! It's free.
|
Start now! It's free.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 flex-col md:flex-row">
|
<div class="flex gap-2 flex-col md:flex-row">
|
||||||
<NuxtLink to="/"
|
<LyxUiButton link="/" type="primary"
|
||||||
class="bg-accent hover:bg-accent/90 px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
|
class="poppins font-semibold text-[1.1rem] lg:text-[1.6rem] flex items-center !px-14">
|
||||||
Get started
|
Get started
|
||||||
</NuxtLink>
|
</LyxUiButton>
|
||||||
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
|
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
|
||||||
class="bg-white hover:bg-white/90 text-black px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
|
class="bg-white hover:bg-white/90 text-black px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
|
||||||
Book a demo
|
Book a demo
|
||||||
|
|||||||
@@ -81,6 +81,33 @@ function handleOnError(errorResponse: any) {
|
|||||||
alert('Error' + errorResponse);
|
alert('Error' + errorResponse);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getRandomHex(size: number) {
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
|
window.crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function githubLogin() {
|
||||||
|
const client_id = config.public.GITHUB_CLIENT_ID;
|
||||||
|
const redirect_uri = window.location.origin + '/api';
|
||||||
|
console.log({ redirect_uri })
|
||||||
|
const state = getRandomHex(16);
|
||||||
|
localStorage.setItem("latestCSRFToken", state);
|
||||||
|
const link = `https://github.com/login/oauth/authorize?client_id=${client_id}&response_type=code&scope=repo&redirect_uri=${redirect_uri}/integrations/github/oauth2/callback&state=${state}`;
|
||||||
|
window.location.assign(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.github_access_token) {
|
||||||
|
//TODO: Something
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -103,23 +130,34 @@ function handleOnError(errorResponse: any) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-text/80 text-[1.2rem] text-center w-[70%] poppins mt-2">
|
<div class="text-text/80 text-[1.2rem] text-center w-[70%] poppins mt-2">
|
||||||
Real-time analytics for 15+ JS/TS frameworks
|
Track web analytics and custom events
|
||||||
<br>
|
<br>
|
||||||
with one-line code setup.
|
with extreme simplicity in under 30 sec.
|
||||||
<br>
|
<br>
|
||||||
<div class="font-bold poppins mt-4">
|
<!-- <div class="font-bold poppins mt-4">
|
||||||
Start for Free now! Up to 3k visits/events monthly.
|
Start for Free now! Up to 3k visits/events monthly.
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-12">
|
<div class="mt-12">
|
||||||
|
|
||||||
<div v-if="!isNoAuth" @click="login"
|
|
||||||
class="hover:bg-accent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
|
<div v-if="!isNoAuth" class="flex flex-col gap-2">
|
||||||
<div class="flex items-center">
|
<div @click="login"
|
||||||
<i class="fab fa-google"></i>
|
class="hover:bg-accent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fab fa-google"></i>
|
||||||
|
</div>
|
||||||
|
Continue with Google
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class=" opacity-35 cursor-not-allowed flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fab fa-github"></i>
|
||||||
|
</div>
|
||||||
|
Continue with GitHub
|
||||||
</div>
|
</div>
|
||||||
Continue with Google
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isNoAuth" @click="loginWithoutAuth"
|
<div v-if="isNoAuth" @click="loginWithoutAuth"
|
||||||
@@ -133,7 +171,7 @@ function handleOnError(errorResponse: any) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-[.9rem] poppins mt-12 text-text-sub text-center relative z-[2]">
|
<div class="text-[.9rem] poppins mt-12 text-text-sub text-center relative z-[2]">
|
||||||
By continuing you are indicating that you accept
|
By continuing you are accepting
|
||||||
<br>
|
<br>
|
||||||
our
|
our
|
||||||
<a class="underline" href="https://litlyx.com/terms" target="_blank">Terms of Service</a> and
|
<a class="underline" href="https://litlyx.com/terms" target="_blank">Terms of Service</a> and
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ definePageMeta({ layout: 'header' });
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="home h-full overflow-y-auto relative">
|
<!-- <div class="home h-full overflow-y-auto relative">
|
||||||
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center z-0 overflow-hidden">
|
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center z-0 overflow-hidden">
|
||||||
<HomeBgGrid :size="50" :spacing="18" opacity="0.3" class="w-fit h-fit"></HomeBgGrid>
|
<HomeBgGrid :size="50" :spacing="18" opacity="0.3" class="w-fit h-fit"></HomeBgGrid>
|
||||||
@@ -96,6 +96,6 @@ definePageMeta({ layout: 'header' });
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
BIN
dashboard/pdf_fonts/Poppins-Italic.ttf
Normal file
974
dashboard/pnpm-lock.yaml
generated
@@ -9,6 +9,7 @@ export async function getUserProjectFromId(project_id: string, user: AuthContext
|
|||||||
return project;
|
return project;
|
||||||
} else {
|
} else {
|
||||||
if (!user?.logged) return;
|
if (!user?.logged) return;
|
||||||
|
if (!project_id) return;
|
||||||
const project = await ProjectModel.findById(project_id);
|
const project = await ProjectModel.findById(project_id);
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
const [hasAccess, role] = await hasAccessToProject(user.id, project_id, project);
|
const [hasAccess, role] = await hasAccessToProject(user.id, project_id, project);
|
||||||
|
|||||||
46
dashboard/server/Logger.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
import winston from 'winston';
|
||||||
|
|
||||||
|
const { combine, timestamp, json, errors } = winston.format;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const logger = winston.createLogger({
|
||||||
|
format: combine(
|
||||||
|
errors({ stack: true }),
|
||||||
|
timestamp({
|
||||||
|
format: 'DD-MM-YYYY hh:mm:ss'
|
||||||
|
}),
|
||||||
|
json()
|
||||||
|
),
|
||||||
|
exceptionHandlers: [
|
||||||
|
new winston.transports.File({ filename: 'winston-logs.ndjson' }),
|
||||||
|
new winston.transports.File({ filename: 'winston-exceptions.ndjson' }),
|
||||||
|
],
|
||||||
|
rejectionHandlers: [
|
||||||
|
new winston.transports.File({ filename: 'winston-logs.ndjson' }),
|
||||||
|
new winston.transports.File({ filename: 'winston-rejections.ndjson' }),
|
||||||
|
],
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({
|
||||||
|
level: 'debug',
|
||||||
|
format: combine(
|
||||||
|
winston.format.colorize({ all: true }),
|
||||||
|
errors({ stack: true }),
|
||||||
|
timestamp({ format: 'DD-MM-YYYY hh:mm:ss' }),
|
||||||
|
winston.format.printf((info) => {
|
||||||
|
if (info instanceof Error) {
|
||||||
|
return `${info.timestamp} [${info.level}]: ${info.message}\n${info.stack}`;
|
||||||
|
} else {
|
||||||
|
return `${info.timestamp} [${info.level}]: ${info.message}`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
new winston.transports.File({ filename: 'winston-logs.ndjson' }),
|
||||||
|
new winston.transports.File({
|
||||||
|
level: 'debug',
|
||||||
|
filename: 'winston-debug.ndjson'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
30
dashboard/server/ai/Plugin.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
import type OpenAI from 'openai'
|
||||||
|
|
||||||
|
|
||||||
|
export type AIPlugin_TTool<T extends string> = (OpenAI.Chat.Completions.ChatCompletionTool & { function: { name: T } });
|
||||||
|
export type AIPlugin_TFunction<T extends string> = (...args: any[]) => any;
|
||||||
|
|
||||||
|
type AIPlugin_Constructor<Items extends string[]> = {
|
||||||
|
[Key in Items[number]]: {
|
||||||
|
tool: AIPlugin_TTool<Key>,
|
||||||
|
handler: AIPlugin_TFunction<Key>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class AIPlugin<Items extends string[] = []> {
|
||||||
|
constructor(public functions: AIPlugin_Constructor<Items>) { }
|
||||||
|
|
||||||
|
getTools() {
|
||||||
|
const keys = Object.keys(this.functions) as Items;
|
||||||
|
return keys.map((key: Items[number]) => { return this.functions[key].tool });
|
||||||
|
}
|
||||||
|
getHandlers() {
|
||||||
|
const keys = Object.keys(this.functions) as Items;
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
keys.forEach((key: Items[number]) => {
|
||||||
|
result[key] = this.functions[key].handler;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
dashboard/server/ai/functions/AI_ComposableChart.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
import { AIPlugin } from "../Plugin";
|
||||||
|
|
||||||
|
export class AiComposableChart extends AIPlugin<['createComposableChart']> {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
'createComposableChart': {
|
||||||
|
handler: (data: { labels: string, points: number[] }) => {
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'createComposableChart',
|
||||||
|
description: 'Creates a chart based on the provided datasets',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
labels: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Labels for each data point in the chart'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Title of the chart to let user understand what is displaying, not include dates'
|
||||||
|
},
|
||||||
|
datasets: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'List of datasets',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
chartType: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['line', 'bar'],
|
||||||
|
description: 'The type of chart to display the dataset, either "line" or "bar"'
|
||||||
|
},
|
||||||
|
points: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'number' },
|
||||||
|
description: 'Numerical values for each data point in the chart'
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Color used to represent the dataset in format "#RRGGBB"'
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Name of the dataset'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['points', 'color', 'chartType', 'name'],
|
||||||
|
description: 'Data points and style information for the dataset'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['labels', 'datasets', 'title']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiComposableChartInstance = new AiComposableChart();
|
||||||
87
dashboard/server/ai/functions/AI_Events.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { EventModel } from "@schema/metrics/EventSchema";
|
||||||
|
import { AdvancedTimelineAggregationOptions, executeAdvancedTimelineAggregation, executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { AIPlugin, AIPlugin_TTool } from "../Plugin";
|
||||||
|
|
||||||
|
|
||||||
|
const getEventsCountTool: AIPlugin_TTool<'getEventsCount'> = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'getEventsCount',
|
||||||
|
description: 'Gets the number of events received on a date range, can also specify the event name and the metadata associated',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from: { type: 'string', description: 'ISO string of start date including hours' },
|
||||||
|
to: { type: 'string', description: 'ISO string of end date including hours' },
|
||||||
|
name: { type: 'string', description: 'Name of the events to get' },
|
||||||
|
metadata: { type: 'object', description: 'Metadata of events to get' },
|
||||||
|
},
|
||||||
|
required: ['from', 'to']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEventsTimelineTool: AIPlugin_TTool<'getEventsTimeline'> = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'getEventsTimeline',
|
||||||
|
description: 'Gets an array of date and count for events received on a date range. Should be used to create charts.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from: { type: 'string', description: 'ISO string of start date including hours' },
|
||||||
|
to: { type: 'string', description: 'ISO string of end date including hours' },
|
||||||
|
name: { type: 'string', description: 'Name of the events to get' },
|
||||||
|
metadata: { type: 'object', description: 'Metadata of events to get' },
|
||||||
|
},
|
||||||
|
required: ['from', 'to']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiEvents extends AIPlugin<['getEventsCount', 'getEventsTimeline']> {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
|
||||||
|
super({
|
||||||
|
'getEventsCount': {
|
||||||
|
handler: async (data: { project_id: string, from?: string, to?: string, name?: string, metadata?: string }) => {
|
||||||
|
const query: any = {
|
||||||
|
project_id: data.project_id,
|
||||||
|
created_at: {
|
||||||
|
$gt: data.from ? new Date(data.from).getTime() : new Date(2023).getTime(),
|
||||||
|
$lt: data.to ? new Date(data.to).getTime() : new Date().getTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.metadata) query.metadata = data.metadata;
|
||||||
|
if (data.name) query.name = data.name;
|
||||||
|
const result = await EventModel.countDocuments(query);
|
||||||
|
return { count: result };
|
||||||
|
},
|
||||||
|
tool: getEventsCountTool
|
||||||
|
},
|
||||||
|
'getEventsTimeline': {
|
||||||
|
handler: async (data: { project_id: string, from: string, to: string, name?: string, metadata?: string }) => {
|
||||||
|
const query: AdvancedTimelineAggregationOptions & { customMatch: Record<string, any> } = {
|
||||||
|
projectId: new Types.ObjectId(data.project_id) as any,
|
||||||
|
model: EventModel,
|
||||||
|
from: data.from, to: data.to, slice: 'day',
|
||||||
|
customMatch: {}
|
||||||
|
}
|
||||||
|
if (data.metadata) query.customMatch.metadata = data.metadata;
|
||||||
|
if (data.name) query.customMatch.name = data.name;
|
||||||
|
|
||||||
|
const timelineData = await executeAdvancedTimelineAggregation(query);
|
||||||
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, 'day', data.from, data.to);
|
||||||
|
return { data: timelineFilledMerged };
|
||||||
|
},
|
||||||
|
tool: getEventsTimelineTool
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiEventsInstance = new AiEvents();
|
||||||
|
|
||||||
87
dashboard/server/ai/functions/AI_Visits.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
|
import { AdvancedTimelineAggregationOptions, executeAdvancedTimelineAggregation, executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { AIPlugin, AIPlugin_TTool } from "../Plugin";
|
||||||
|
|
||||||
|
|
||||||
|
const getVisitsCountsTool: AIPlugin_TTool<'getVisitsCount'> = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'getVisitsCount',
|
||||||
|
description: 'Gets the number of visits received on a date range',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from: { type: 'string', description: 'ISO string of start date including hours' },
|
||||||
|
to: { type: 'string', description: 'ISO string of end date including hours' },
|
||||||
|
website: { type: 'string', description: 'The website of the visits' },
|
||||||
|
page: { type: 'string', description: 'The page of the visit' }
|
||||||
|
},
|
||||||
|
required: ['from', 'to']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVisitsTimelineTool: AIPlugin_TTool<'getVisitsTimeline'> = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'getVisitsTimeline',
|
||||||
|
description: 'Gets an array of date and count for events received on a date range. Should be used to create charts.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from: { type: 'string', description: 'ISO string of start date including hours' },
|
||||||
|
to: { type: 'string', description: 'ISO string of end date including hours' },
|
||||||
|
website: { type: 'string', description: 'The website of the visits' },
|
||||||
|
page: { type: 'string', description: 'The page of the visit' }
|
||||||
|
},
|
||||||
|
required: ['from', 'to']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiVisits extends AIPlugin<['getVisitsCount', 'getVisitsTimeline']> {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
|
||||||
|
super({
|
||||||
|
'getVisitsCount': {
|
||||||
|
handler: async (data: { project_id: string, from?: string, to?: string, website?: string, page?: string }) => {
|
||||||
|
const query: any = {
|
||||||
|
project_id: data.project_id,
|
||||||
|
created_at: {
|
||||||
|
$gt: data.from ? new Date(data.from).getTime() : new Date(2023).getTime(),
|
||||||
|
$lt: data.to ? new Date(data.to).getTime() : new Date().getTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.website) query.website = data.website;
|
||||||
|
if (data.page) query.page = data.page;
|
||||||
|
const result = await VisitModel.countDocuments(query);
|
||||||
|
return { count: result };
|
||||||
|
},
|
||||||
|
tool: getVisitsCountsTool
|
||||||
|
},
|
||||||
|
'getVisitsTimeline': {
|
||||||
|
handler: async (data: { project_id: string, from: string, to: string, website?: string, page?: string }) => {
|
||||||
|
const query: AdvancedTimelineAggregationOptions & { customMatch: Record<string, any> } = {
|
||||||
|
projectId: new Types.ObjectId(data.project_id) as any,
|
||||||
|
model: VisitModel,
|
||||||
|
from: data.from, to: data.to, slice: 'day',
|
||||||
|
customMatch: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.website) query.customMatch.website = data.website;
|
||||||
|
if (data.page) query.customMatch.page = data.page;
|
||||||
|
|
||||||
|
const timelineData = await executeAdvancedTimelineAggregation(query);
|
||||||
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, 'day', data.from, data.to);
|
||||||
|
return { data: timelineFilledMerged };
|
||||||
|
},
|
||||||
|
tool: getVisitsTimelineTool
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiVisitsInstance = new AiVisits();
|
||||||
17
dashboard/server/api/admin/counts.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
|
import { UserModel } from "@schema/UserSchema";
|
||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return;
|
||||||
|
if (!userData.user.roles.includes('ADMIN')) return;
|
||||||
|
|
||||||
|
|
||||||
|
const projectsCount = await ProjectModel.countDocuments({});
|
||||||
|
const usersCount = await UserModel.countDocuments({});
|
||||||
|
|
||||||
|
return { users: usersCount, projects: projectsCount }
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
import { AiChatModel } from "@schema/ai/AiChatSchema";
|
import { AiChatModel } from "@schema/ai/AiChatSchema";
|
||||||
import { sendMessageOnChat } from "~/server/services/AiService";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
import { AiChatModel } from "@schema/ai/AiChatSchema";
|
import { AiChatModel } from "@schema/ai/AiChatSchema";
|
||||||
import { sendMessageOnChat } from "~/server/services/AiService";
|
import type OpenAI from "openai";
|
||||||
|
import { getChartsInMessage } from "~/server/services/AiService";
|
||||||
|
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
@@ -19,11 +18,14 @@ export default defineEventHandler(async event => {
|
|||||||
const chat = await AiChatModel.findOne({ _id: chat_id, project_id });
|
const chat = await AiChatModel.findOne({ _id: chat_id, project_id });
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
const messages = chat.messages.filter(e => {
|
return (chat.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[])
|
||||||
return (e.role == 'user' || (e.role == 'assistant' && e.content != undefined))
|
.filter(e => e.role === 'assistant' || e.role === 'user')
|
||||||
}).map(e => {
|
.map(e => {
|
||||||
return { role: e.role, content: e.content }
|
const charts = getChartsInMessage(e);
|
||||||
});
|
const content = e.content;
|
||||||
|
return { role: e.role, content, charts }
|
||||||
return messages;
|
})
|
||||||
|
.filter(e=>{
|
||||||
|
return e.charts.length > 0 || e.content
|
||||||
|
})
|
||||||
});
|
});
|
||||||
@@ -23,5 +23,6 @@ export default defineEventHandler(async event => {
|
|||||||
if (chatsRemaining <= 0) return setResponseStatus(event, 400, 'CHAT_LIMIT_REACHED');
|
if (chatsRemaining <= 0) return setResponseStatus(event, 400, 'CHAT_LIMIT_REACHED');
|
||||||
|
|
||||||
const response = await sendMessageOnChat(text, project._id.toString(), chat_id);
|
const response = await sendMessageOnChat(text, project._id.toString(), chat_id);
|
||||||
return response || 'Error getting response';
|
|
||||||
|
return response;
|
||||||
});
|
});
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
|
|
||||||
import OpenAI from "openai";
|
|
||||||
import { EventModel } from "@schema/metrics/EventSchema";
|
|
||||||
|
|
||||||
|
|
||||||
export const AI_EventsFunctions = {
|
|
||||||
getEventsCount: ({ pid, from, to, name, metadata }: any) => {
|
|
||||||
return getEventsCountForAI(pid, from, to, name, metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const getEventsCountForAIDeclaration: OpenAI.Chat.Completions.ChatCompletionTool = {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: 'getEventsCount',
|
|
||||||
description: 'Gets the number of events received on a date range, can also specify the event name and the metadata associated',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
from: { type: 'string', description: 'ISO string of start date including hours' },
|
|
||||||
to: { type: 'string', description: 'ISO string of end date including hours' },
|
|
||||||
name: { type: 'string', description: 'Name of the events to get' },
|
|
||||||
metadata: { type: 'object', description: 'Metadata of events to get' },
|
|
||||||
},
|
|
||||||
required: ['from', 'to']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AI_EventsTools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
|
|
||||||
getEventsCountForAIDeclaration
|
|
||||||
]
|
|
||||||
|
|
||||||
export async function getEventsCountForAI(project_id: string, from?: string, to?: string, name?: string, metadata?: string) {
|
|
||||||
|
|
||||||
const query: any = {
|
|
||||||
project_id,
|
|
||||||
created_at: {
|
|
||||||
$gt: from ? new Date(from).getTime() : new Date(2023).getTime(),
|
|
||||||
$lt: to ? new Date(to).getTime() : new Date().getTime(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata) query.metadata = metadata;
|
|
||||||
if (name) query.name = name;
|
|
||||||
|
|
||||||
const result = await EventModel.countDocuments(query);
|
|
||||||
|
|
||||||
return { count: result };
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function getVisitsCountFromDateRange(project_id: string, from?: string, to?: string) {
|
|
||||||
const result = await VisitModel.countDocuments({
|
|
||||||
project_id,
|
|
||||||
created_at: {
|
|
||||||
$gt: from ? new Date(from).getTime() : new Date(2023).getTime(),
|
|
||||||
$lt: to ? new Date(to).getTime() : new Date().getTime(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { count: result };
|
|
||||||
}
|
|
||||||
@@ -51,6 +51,7 @@ export default defineEventHandler(async event => {
|
|||||||
const savedUser = await newUser.save();
|
const savedUser = await newUser.save();
|
||||||
|
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
|
console.log('SENDING WELCOME EMAIL TO', payload.email);
|
||||||
if (payload.email) EmailService.sendWelcomeEmail(payload.email);
|
if (payload.email) EmailService.sendWelcomeEmail(payload.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
72
dashboard/server/api/integrations/github/oauth2/callback.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
import { createUserJwt } from '~/server/AuthManager';
|
||||||
|
import { UserModel } from '@schema/UserSchema';
|
||||||
|
import EmailService from '@services/EmailService';
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const { code } = getQuery(event);
|
||||||
|
console.log('CODE', code);
|
||||||
|
|
||||||
|
const redirect_uri = 'http://127.0.0.1:3000'
|
||||||
|
|
||||||
|
const res = await fetch(`https://github.com/login/oauth/access_token?client_id=${config.GITHUB_AUTH_CLIENT_ID}&client_secret=${config.GITHUB_AUTH_CLIENT_SECRET}&code=${code}&redirect_url=${redirect_uri}`, {
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Encoding": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const access_token = data.access_token;
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
return sendRedirect(event,`http://127.0.0.1:3000/login?github_access_token=${access_token}`)
|
||||||
|
|
||||||
|
|
||||||
|
// const origin = event.headers.get('origin');
|
||||||
|
|
||||||
|
// const tokenResponse = await client.getToken({
|
||||||
|
// code: body.code,
|
||||||
|
// redirect_uri: origin || ''
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const tokens = tokenResponse.tokens;
|
||||||
|
|
||||||
|
// const ticket = await client.verifyIdToken({
|
||||||
|
// idToken: tokens.id_token || '',
|
||||||
|
// audience: GOOGLE_AUTH_CLIENT_ID,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const payload = ticket.getPayload();
|
||||||
|
// if (!payload) return { error: true, access_token: '' };
|
||||||
|
|
||||||
|
|
||||||
|
// const user = await UserModel.findOne({ email: payload.email });
|
||||||
|
|
||||||
|
// if (user) return { error: false, access_token: createUserJwt({ email: user.email, name: user.name }) }
|
||||||
|
|
||||||
|
|
||||||
|
// const newUser = new UserModel({
|
||||||
|
// email: payload.email,
|
||||||
|
// given_name: payload.given_name,
|
||||||
|
// name: payload.name,
|
||||||
|
// locale: payload.locale,
|
||||||
|
// picture: payload.picture,
|
||||||
|
// created_at: Date.now()
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const savedUser = await newUser.save();
|
||||||
|
|
||||||
|
// setImmediate(() => {
|
||||||
|
// console.log('SENDING WELCOME EMAIL TO', payload.email);
|
||||||
|
// if (payload.email) EmailService.sendWelcomeEmail(payload.email);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return { error: false, access_token: createUserJwt({ email: savedUser.email, name: savedUser.name }) }
|
||||||
|
|
||||||
|
});
|
||||||
47
dashboard/server/api/keys/create.post.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
|
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||||
|
import { UserSettingsModel } from "@schema/UserSettings";
|
||||||
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
function generateApiKey() {
|
||||||
|
return 'lit_' + crypto.randomBytes(6).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
if (body.name.length == 0) return setResponseStatus(event, 400, 'name is required');
|
||||||
|
|
||||||
|
if (body.name.length < 3) return setResponseStatus(event, 400, 'name too short');
|
||||||
|
if (body.name.length > 32) return setResponseStatus(event, 400, 'name too long');
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||||
|
|
||||||
|
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||||
|
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||||
|
|
||||||
|
const project_id = currentActiveProject.active_project_id;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
if (project.owner.toString() != userData.id) {
|
||||||
|
return setResponseStatus(event, 400, 'You are not the owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = generateApiKey();
|
||||||
|
|
||||||
|
const keyNumbers = await ApiSettingsModel.countDocuments({ project_id });
|
||||||
|
|
||||||
|
if (keyNumbers >= 5) return setResponseStatus(event, 400, 'Api key limit reached');
|
||||||
|
|
||||||
|
const newApiSettings = await ApiSettingsModel.create({ project_id, apiKey: key, apiName: body.name, created_at: Date.now(), usage: 0 });
|
||||||
|
|
||||||
|
return newApiSettings.toJSON();
|
||||||
|
|
||||||
|
});
|
||||||
28
dashboard/server/api/keys/delete.delete.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import { ApiSettingsModel } from "@schema/ApiSettingsSchema";
|
||||||
|
import { UserSettingsModel } from "@schema/UserSettings";
|
||||||
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||||
|
|
||||||
|
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||||
|
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||||
|
|
||||||
|
const project_id = currentActiveProject.active_project_id;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
if (project.owner.toString() != userData.id) {
|
||||||
|
return setResponseStatus(event, 400, 'You are not the owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletation = await ApiSettingsModel.deleteOne({ _id: body.api_id });
|
||||||
|
return { ok: deletation.acknowledged };
|
||||||
|
|
||||||
|
});
|
||||||
33
dashboard/server/api/keys/get_all.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
|
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||||
|
import { UserSettingsModel } from "@schema/UserSettings";
|
||||||
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
|
|
||||||
|
|
||||||
|
function cryptApiKeyName(apiSettings: TApiSettings): TApiSettings {
|
||||||
|
return { ...apiSettings, apiKey: apiSettings.apiKey.substring(0, 6) + '******' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||||
|
|
||||||
|
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||||
|
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||||
|
|
||||||
|
const project_id = currentActiveProject.active_project_id;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
if (project.owner.toString() != userData.id) {
|
||||||
|
return setResponseStatus(event, 400, 'You are not the owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeys = await ApiSettingsModel.find({ project_id }, { project_id: 0 })
|
||||||
|
|
||||||
|
return apiKeys.map(e => cryptApiKeyName(e.toJSON())) as TApiSettings[];
|
||||||
|
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { EventModel } from "@schema/metrics/EventSchema";
|
|||||||
import { getTimeline } from "./generic";
|
import { getTimeline } from "./generic";
|
||||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService";
|
import { executeTimelineAggregation, fillAndMergeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
const project_id = getRequestProjectId(event);
|
const project_id = getRequestProjectId(event);
|
||||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async event => {
|
|||||||
model: EventModel,
|
model: EventModel,
|
||||||
from, to, slice
|
from, to, slice
|
||||||
});
|
});
|
||||||
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
|
||||||
return timelineFilledMerged;
|
return timelineFilledMerged;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { getTimeline } from "./generic";
|
|
||||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
import DateService from "@services/DateService";
|
|
||||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
import { executeAdvancedTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService";
|
import { executeAdvancedTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
const project_id = getRequestProjectId(event);
|
const project_id = getRequestProjectId(event);
|
||||||
@@ -31,7 +29,7 @@ export default defineEventHandler(async event => {
|
|||||||
referrer
|
referrer
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
|
||||||
return timelineFilledMerged;
|
return timelineFilledMerged;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getTimeline } from "./generic";
|
|||||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||||
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService";
|
import { executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
const project_id = getRequestProjectId(event);
|
const project_id = getRequestProjectId(event);
|
||||||
@@ -28,7 +28,7 @@ export default defineEventHandler(async event => {
|
|||||||
model: SessionModel,
|
model: SessionModel,
|
||||||
from, to, slice
|
from, to, slice
|
||||||
});
|
});
|
||||||
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
|
||||||
return timelineFilledMerged;
|
return timelineFilledMerged;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getTimeline } from "./generic";
|
|||||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||||
import { executeAdvancedTimelineAggregation, executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService";
|
import { executeAdvancedTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
const project_id = getRequestProjectId(event);
|
const project_id = getRequestProjectId(event);
|
||||||
@@ -45,7 +45,7 @@ export default defineEventHandler(async event => {
|
|||||||
count: { $divide: ["$duration", "$count"] }
|
count: { $divide: ["$duration", "$count"] }
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from ,to);
|
||||||
return timelineFilledMerged;
|
return timelineFilledMerged;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { VisitModel } from "@schema/metrics/VisitSchema";
|
|||||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
import DateService from "@services/DateService";
|
import DateService from "@services/DateService";
|
||||||
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService";
|
import { executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||||
|
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
const project_id = getRequestProjectId(event);
|
const project_id = getRequestProjectId(event);
|
||||||
@@ -22,18 +21,16 @@ export default defineEventHandler(async event => {
|
|||||||
|
|
||||||
return await Redis.useCache({
|
return await Redis.useCache({
|
||||||
key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
|
||||||
exp: TIMELINE_EXPIRE_TIME
|
exp: TIMELINE_EXPIRE_TIME,
|
||||||
}, async () => {
|
}, async () => {
|
||||||
const timelineData = await executeTimelineAggregation({
|
const timelineData = await executeTimelineAggregation({
|
||||||
projectId: project._id,
|
projectId: project._id,
|
||||||
model: VisitModel,
|
model: VisitModel,
|
||||||
from, to, slice
|
from, to, slice,
|
||||||
});
|
});
|
||||||
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
|
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
|
||||||
return timelineFilledMerged;
|
return timelineFilledMerged;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
|
|
||||||
export default defineEventHandler(async event => {
|
|
||||||
|
|
||||||
console.log('TEST');
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
44
dashboard/server/api/pay/[project_id]/create-onetime.post.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { getPlanFromId } from "@data/PREMIUM";
|
||||||
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
|
import StripeService from '~/server/services/StripeService';
|
||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const project_id = getRequestProjectId(event);
|
||||||
|
if (!project_id) return;
|
||||||
|
|
||||||
|
const user = getRequestUser(event);
|
||||||
|
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
|
||||||
|
|
||||||
|
const project = await getUserProjectFromId(project_id, user);
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
if (project.owner.toString() != user.id) return setResponseStatus(event, 400, 'You cannot upgrade a project as guest');
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const { planId } = body;
|
||||||
|
|
||||||
|
const PLAN = getPlanFromId(planId);
|
||||||
|
|
||||||
|
if (!PLAN) {
|
||||||
|
console.error('PLAN', planId, 'NOT EXIST');
|
||||||
|
return setResponseStatus(event, 400, 'Plan not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent = await StripeService.createOnetimePayment(
|
||||||
|
StripeService.testMode ? PLAN.PRICE_TEST : PLAN.PRICE,
|
||||||
|
'https://dashboard.litlyx.com/payment_ok',
|
||||||
|
project_id,
|
||||||
|
project.customer_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!intent) {
|
||||||
|
console.error('Cannot create Intent', { plan: PLAN });
|
||||||
|
return setResponseStatus(event, 400, 'Cannot create intent');
|
||||||
|
}
|
||||||
|
|
||||||
|
return intent.url;
|
||||||
|
|
||||||
|
});
|
||||||
@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
|
|||||||
if (!project_id) return;
|
if (!project_id) return;
|
||||||
|
|
||||||
const user = getRequestUser(event);
|
const user = getRequestUser(event);
|
||||||
|
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
|
||||||
|
|
||||||
const project = await getUserProjectFromId(project_id, user);
|
const project = await getUserProjectFromId(project_id, user);
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
|
||||||
|
if (project.owner.toString() != user.id) return setResponseStatus(event, 400, 'You cannot upgrade a project as guest');
|
||||||
|
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
|
|
||||||
const { planId } = body;
|
const { planId } = body;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type Event from 'stripe';
|
|||||||
import { ProjectModel } from '@schema/ProjectSchema';
|
import { ProjectModel } from '@schema/ProjectSchema';
|
||||||
import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM';
|
import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM';
|
||||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||||
|
import EmailService from '@services/EmailService'
|
||||||
|
import { UserModel } from '@schema/UserSchema';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +65,44 @@ async function onPaymentFailed(event: Event.InvoicePaymentFailedEvent) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function onPaymentOnetimeSuccess(event: Event.PaymentIntentSucceededEvent) {
|
||||||
|
const customer_id = event.data.object.customer as string;
|
||||||
|
const project = await ProjectModel.findOne({ customer_id });
|
||||||
|
if (!project) return { error: 'CUSTOMER NOT EXIST' }
|
||||||
|
|
||||||
|
if (event.data.object.status === 'succeeded') {
|
||||||
|
|
||||||
|
const PLAN = getPlanFromPrice(event.data.object.metadata.price, StripeService.testMode || false);
|
||||||
|
if (!PLAN) return { error: 'Plan not found' }
|
||||||
|
const dummyPlan = PLAN.ID + 3000;
|
||||||
|
|
||||||
|
const subscription = await StripeService.createOneTimeSubscriptionDummy(customer_id, dummyPlan);
|
||||||
|
if (!subscription) return { error: 'Error creating subscription' }
|
||||||
|
|
||||||
|
const allSubscriptions = await StripeService.getAllSubscriptions(customer_id);
|
||||||
|
if (!allSubscriptions) return;
|
||||||
|
for (const subscription of allSubscriptions.data) {
|
||||||
|
if (subscription.id === subscription.id) continue;
|
||||||
|
await StripeService.deleteSubscription(subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await addSubscriptionToProject(project._id.toString(), PLAN, subscription.id, subscription.current_period_start, subscription.current_period_end)
|
||||||
|
|
||||||
|
|
||||||
|
const user = await UserModel.findOne({ _id: project.owner });
|
||||||
|
if (!user) return { ok: false, error: 'USER NOT EXIST FOR PROJECT' + project.id }
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
EmailService.sendPurchaseEmail(user.email, project.name);
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { received: true, warn: 'object status not succeeded' }
|
||||||
|
}
|
||||||
|
|
||||||
async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
|
async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
|
||||||
|
|
||||||
const customer_id = event.data.object.customer as string;
|
const customer_id = event.data.object.customer as string;
|
||||||
@@ -76,7 +116,7 @@ async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
|
|||||||
|
|
||||||
const allSubscriptions = await StripeService.getAllSubscriptions(customer_id);
|
const allSubscriptions = await StripeService.getAllSubscriptions(customer_id);
|
||||||
if (!allSubscriptions) return;
|
if (!allSubscriptions) return;
|
||||||
|
|
||||||
const currentSubscription = allSubscriptions.data.find(e => e.id === subscription_id);
|
const currentSubscription = allSubscriptions.data.find(e => e.id === subscription_id);
|
||||||
if (!currentSubscription) return { error: 'SUBSCRIPTION NOT EXIST' }
|
if (!currentSubscription) return { error: 'SUBSCRIPTION NOT EXIST' }
|
||||||
|
|
||||||
@@ -95,6 +135,15 @@ async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
|
|||||||
|
|
||||||
await addSubscriptionToProject(project._id.toString(), PLAN, subscription_id, currentSubscription.current_period_start, currentSubscription.current_period_end)
|
await addSubscriptionToProject(project._id.toString(), PLAN, subscription_id, currentSubscription.current_period_start, currentSubscription.current_period_end)
|
||||||
|
|
||||||
|
const user = await UserModel.findOne({ _id: project.owner });
|
||||||
|
if (!user) return { ok: false, error: 'USER NOT EXIST FOR PROJECT' + project.id }
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (PLAN.ID == 0) return;
|
||||||
|
EmailService.sendPurchaseEmail(user.email, project.name);
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
||||||
|
|
||||||
@@ -201,7 +250,11 @@ export default defineEventHandler(async event => {
|
|||||||
|
|
||||||
const eventData = StripeService.parseWebhook(body, signature);
|
const eventData = StripeService.parseWebhook(body, signature);
|
||||||
if (!eventData) return;
|
if (!eventData) return;
|
||||||
|
|
||||||
|
// console.log('WEBHOOK FIRED', eventData.type);
|
||||||
|
|
||||||
if (eventData.type === 'invoice.paid') return await onPaymentSuccess(eventData);
|
if (eventData.type === 'invoice.paid') return await onPaymentSuccess(eventData);
|
||||||
|
if (eventData.type === 'payment_intent.succeeded') return await onPaymentOnetimeSuccess(eventData);
|
||||||
if (eventData.type === 'invoice.payment_failed') return await onPaymentFailed(eventData);
|
if (eventData.type === 'invoice.payment_failed') return await onPaymentFailed(eventData);
|
||||||
if (eventData.type === 'customer.subscription.deleted') return await onSubscriptionDeleted(eventData);
|
if (eventData.type === 'customer.subscription.deleted') return await onSubscriptionDeleted(eventData);
|
||||||
if (eventData.type === 'customer.subscription.created') return await onSubscriptionCreated(eventData);
|
if (eventData.type === 'customer.subscription.created') return await onSubscriptionCreated(eventData);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export default defineEventHandler(async event => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { name } = await readBody(event);
|
const { name } = await readBody(event);
|
||||||
|
|
||||||
|
if (name.length == 0) return setResponseStatus(event, 400, 'name is required');
|
||||||
|
|
||||||
project.name = name;
|
project.name = name;
|
||||||
await project.save();
|
await project.save();
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default defineEventHandler(async event => {
|
|||||||
const project = await ProjectModel.findById(project_id);
|
const project = await ProjectModel.findById(project_id);
|
||||||
if (!project) return setResponseStatus(event, 400, 'Project not exist');
|
if (!project) return setResponseStatus(event, 400, 'Project not exist');
|
||||||
|
|
||||||
|
if (userData.id != project.owner.toString()) return setResponseStatus(event, 400, 'You cannot delete a project as guest');
|
||||||
|
|
||||||
const projects = await ProjectModel.countDocuments({ owner: userData.id });
|
const projects = await ProjectModel.countDocuments({ owner: userData.id });
|
||||||
if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project');
|
if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project');
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export default defineEventHandler(async event => {
|
|||||||
const project = await ProjectModel.findById(project_id);
|
const project = await ProjectModel.findById(project_id);
|
||||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
const PREMIUM_TYPE = project.premium_type;
|
||||||
|
|
||||||
|
if (PREMIUM_TYPE === 0) return setResponseStatus(event, 400, 'Project not premium');
|
||||||
|
|
||||||
const { mode, slice } = getQuery(event);
|
const { mode, slice } = getQuery(event);
|
||||||
|
|
||||||
let timeSub = 1000 * 60 * 60 * 24;
|
let timeSub = 1000 * 60 * 60 * 24;
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
import pdfkit from 'pdfkit';
|
import pdfkit from 'pdfkit';
|
||||||
|
|
||||||
import { PassThrough } from 'node:stream';
|
import { PassThrough } from 'node:stream';
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
import { UserSettingsModel } from "@schema/UserSettings";
|
import { UserSettingsModel } from "@schema/UserSettings";
|
||||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||||
import { EventModel } from '@schema/metrics/EventSchema';
|
import { EventModel } from '@schema/metrics/EventSchema';
|
||||||
|
|
||||||
|
|
||||||
type PDF_Data = {
|
type PDFGenerationData = {
|
||||||
pageVisits: number, customEvents: number,
|
projectName: string,
|
||||||
visitsDay: number, eventsDay: number, visitsSessions: number,
|
snapshotName: string,
|
||||||
visitsSessionsDay: number
|
totalVisits: string,
|
||||||
|
avgVisitsDay: string,
|
||||||
|
totalEvents: string,
|
||||||
|
topDomain: string,
|
||||||
|
topDevice: string,
|
||||||
|
topCountries: string[],
|
||||||
|
topReferrers: string[],
|
||||||
|
avgGrowthText: string,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumberK(value: string | number, decimals: number = 1) {
|
function formatNumberK(value: string | number, decimals: number = 1) {
|
||||||
@@ -25,82 +32,54 @@ function formatNumberK(value: string | number, decimals: number = 1) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPdf(projectName: string, data: PDF_Data) {
|
const LINE_SPACING = 0.5;
|
||||||
const pdf = new pdfkit({
|
|
||||||
size: 'A4',
|
function createPdf(data: PDFGenerationData) {
|
||||||
margins: { top: 50, bottom: 50, left: 50, right: 50 },
|
|
||||||
|
const pdf = new pdfkit({ size: 'A4', margins: { top: 50, bottom: 50, left: 50, right: 50 }, });
|
||||||
|
pdf.fillColor('#ffffff').rect(0, 0, pdf.page.width, pdf.page.height).fill('#000000');
|
||||||
|
|
||||||
|
pdf.font('pdf_fonts/Poppins-Bold.ttf').fontSize(16).fillColor('#ffffff');
|
||||||
|
|
||||||
|
pdf.text(`Project name: ${data.projectName}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
pdf.text(`Snapshot name: ${data.snapshotName}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
|
||||||
|
pdf.font('pdf_fonts/Poppins-Regular.ttf').fontSize(12).fillColor('#ffffff')
|
||||||
|
|
||||||
|
pdf.text(`Total visits: ${data.totalVisits}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
pdf.text(`Average visits per day: ${data.avgVisitsDay}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
pdf.text(`Total events: ${data.totalEvents}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
pdf.text(`Top domain: ${data.topDomain}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
pdf.text(`Top device: ${data.topDevice}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
|
||||||
|
pdf.text('Top 3 countries:', { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
data.topCountries.forEach((country: any) => {
|
||||||
|
pdf.text(`• ${country}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
});
|
});
|
||||||
|
|
||||||
pdf.pipe(fs.createWriteStream('out.pdf'));
|
pdf.text('Top 3 best acquisition channels (referrers):', { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
data.topReferrers.forEach((channel: any) => {
|
||||||
|
pdf.text(`• ${channel}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
});
|
||||||
|
|
||||||
// Set up fonts and colors
|
pdf.text('Average growth:', { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
pdf
|
pdf.text(`${data.avgGrowthText}`, { align: 'left' }).moveDown(LINE_SPACING);
|
||||||
|
|
||||||
|
pdf.font('pdf_fonts/Poppins-Italic.ttf')
|
||||||
|
.text('This gives you an idea of the average growth your website is experiencing over time.', { align: 'left' })
|
||||||
|
.moveDown(LINE_SPACING);
|
||||||
|
|
||||||
|
pdf.font('pdf_fonts/Poppins-Regular.ttf')
|
||||||
|
.fontSize(10)
|
||||||
.fillColor('#ffffff')
|
.fillColor('#ffffff')
|
||||||
.rect(0, 0, pdf.page.width, pdf.page.height)
|
.text('Created with Litlyx.com', 50, 760, { align: 'center' });
|
||||||
.fill('#000000');
|
|
||||||
|
|
||||||
// Title
|
pdf.image('pdf_images/logo.png', 460, 700, { width: 100 });
|
||||||
pdf
|
|
||||||
.font('pdf_fonts/Poppins-Bold.ttf')
|
|
||||||
.fontSize(26)
|
|
||||||
.fillColor('#ffffff')
|
|
||||||
.text(`Report of: ${projectName}`, 50, 50);
|
|
||||||
|
|
||||||
// Section 1
|
|
||||||
pdf
|
|
||||||
.font('pdf_fonts/Poppins-SemiBold.ttf')
|
|
||||||
.fontSize(20)
|
|
||||||
.fillColor('#ffffff')
|
|
||||||
.text('-> This month has seen a lot of visits!', 50, 120);
|
|
||||||
|
|
||||||
pdf
|
|
||||||
.image('pdf_images/d.png', 50, 160, { width: 300 })
|
|
||||||
.font('pdf_fonts/Poppins-Bold.ttf')
|
|
||||||
.fontSize(28)
|
|
||||||
.fillColor('#ffffff')
|
|
||||||
.text(`${formatNumberK(data.pageVisits, 2)}`, 400, 180)
|
|
||||||
.text('WOW!', 400, 210);
|
|
||||||
|
|
||||||
// Section 2
|
|
||||||
pdf
|
|
||||||
.font('pdf_fonts/Poppins-SemiBold.ttf')
|
|
||||||
.fontSize(20)
|
|
||||||
.fillColor('#ffffff')
|
|
||||||
.text('-> There are also many recorded events!', 50, 350);
|
|
||||||
|
|
||||||
pdf
|
|
||||||
.image('pdf_images/c.png', 50, 390, { width: 300 })
|
|
||||||
.font('pdf_fonts/Poppins-Bold.ttf')
|
|
||||||
.fontSize(28)
|
|
||||||
.fillColor('#ffffff')
|
|
||||||
.text(`${formatNumberK(data.customEvents, 2)}`, 400, 420)
|
|
||||||
.text('Let\'s go!', 400, 450);
|
|
||||||
|
|
||||||
// Final section
|
|
||||||
pdf
|
|
||||||
.font('pdf_fonts/Poppins-SemiBold.ttf')
|
|
||||||
.fontSize(20)
|
|
||||||
.fillColor('#ffffff')
|
|
||||||
.text('This report is not final, it only serves to demonstrate the potential of this tool. LitLyx will improve soon! Stay tuned!', 50, 600);
|
|
||||||
|
|
||||||
pdf
|
|
||||||
.font('pdf_fonts/Poppins-Regular.ttf')
|
|
||||||
.fontSize(14)
|
|
||||||
.fillColor('#ffffff')
|
|
||||||
.text('Generated on litlyx.com', 50, 760);
|
|
||||||
pdf
|
|
||||||
.image('pdf_images/logo.png', 460, 700, { width: 100 }) // replace with the correct path to your Unsplash image
|
|
||||||
|
|
||||||
// End PDF creation and save to file
|
|
||||||
pdf.end();
|
pdf.end();
|
||||||
return pdf;
|
return pdf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default defineEventHandler(async event => {
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
const userData = getRequestUser(event);
|
const userData = getRequestUser(event);
|
||||||
@@ -114,51 +93,73 @@ export default defineEventHandler(async event => {
|
|||||||
const project = await ProjectModel.findById(project_id);
|
const project = await ProjectModel.findById(project_id);
|
||||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
const snapshotHeader = getHeader(event, 'x-snapshot-name');
|
||||||
|
const fromHeader = getHeader(event, 'x-from');
|
||||||
|
const toHeader = getHeader(event, 'x-to');
|
||||||
|
|
||||||
|
const from = fromHeader ? new Date(fromHeader) : new Date(2020, 0);
|
||||||
|
const to = toHeader ? new Date(toHeader) : new Date(3001, 0);
|
||||||
|
|
||||||
|
const eventsCount = await EventModel.countDocuments({
|
||||||
|
project_id: project._id,
|
||||||
|
created_at: { $gte: from, $lte: to }
|
||||||
|
});
|
||||||
|
|
||||||
const eventsCount = await EventModel.countDocuments({ project_id: project._id });
|
const visitsCount = await VisitModel.countDocuments({
|
||||||
const visitsCount = await VisitModel.countDocuments({ project_id: project._id });
|
project_id: project._id,
|
||||||
|
created_at: { $gte: from, $lte: to }
|
||||||
const sessionsVisitsCount: any[] = await VisitModel.aggregate([
|
});
|
||||||
{ $match: { project_id: project._id } },
|
|
||||||
{ $group: { _id: "$session" } },
|
|
||||||
{ $count: "count" }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const firstEventDate = await EventModel.findOne({ project_id: project._id }, { created_at: 1 }, { sort: { created_at: 1 } });
|
|
||||||
const firstViewDate = await VisitModel.findOne({ project_id: project._id }, { created_at: 1 }, { sort: { created_at: 1 } });
|
|
||||||
|
|
||||||
if (!firstEventDate || !firstViewDate) {
|
|
||||||
return setResponseStatus(event, 400, 'Not enough data to generate report');
|
|
||||||
}
|
|
||||||
|
|
||||||
const avgEventsDay = () => {
|
|
||||||
const days = (Date.now() - (firstEventDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24;
|
|
||||||
const avg = eventsCount / Math.max(days, 1);
|
|
||||||
return avg;
|
|
||||||
};
|
|
||||||
|
|
||||||
const avgVisitDay = () => {
|
const avgVisitDay = () => {
|
||||||
const days = (Date.now() - (firstViewDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24;
|
const days = (Date.now() - (from.getTime())) / 1000 / 60 / 60 / 24;
|
||||||
const avg = visitsCount / Math.max(days, 1);
|
const avg = visitsCount / Math.max(days, 1);
|
||||||
return avg;
|
return avg;
|
||||||
};
|
};
|
||||||
|
|
||||||
const avgVisitsSessionsDay = () => {
|
const topDevices = await VisitModel.aggregate([
|
||||||
const days = (Date.now() - (firstViewDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24;
|
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||||
const avg = sessionsVisitsCount[0].count / Math.max(days, 1);
|
{ $group: { _id: "$device", count: { $sum: 1 } } },
|
||||||
return avg;
|
{ $match: { _id: { $ne: null } } },
|
||||||
};
|
{ $sort: { count: -1 } },
|
||||||
|
{ $limit: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
const pdf = createPdf(
|
const topDevice = topDevices?.[0]?._id || 'Not enough data';
|
||||||
project.name, {
|
|
||||||
customEvents: eventsCount,
|
const topDomains = await VisitModel.aggregate([
|
||||||
eventsDay: avgEventsDay(),
|
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||||
pageVisits: visitsCount,
|
{ $group: { _id: "$website", count: { $sum: 1 } } },
|
||||||
visitsDay: avgVisitDay(),
|
{ $sort: { count: -1 } },
|
||||||
visitsSessions: sessionsVisitsCount[0].count,
|
{ $limit: 1 }
|
||||||
visitsSessionsDay: avgVisitsSessionsDay()
|
]);
|
||||||
|
|
||||||
|
const topDomain = topDomains?.[0]?._id || 'Not enough data';
|
||||||
|
|
||||||
|
const topCountries = await VisitModel.aggregate([
|
||||||
|
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||||
|
{ $group: { _id: "$country", count: { $sum: 1 } } },
|
||||||
|
{ $sort: { count: -1 } },
|
||||||
|
{ $limit: 3 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const topReferrers = await VisitModel.aggregate([
|
||||||
|
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
|
||||||
|
{ $group: { _id: "$referrer", count: { $sum: 1 } } },
|
||||||
|
{ $sort: { count: -1 } },
|
||||||
|
{ $limit: 3 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pdf = createPdf({
|
||||||
|
projectName: project.name,
|
||||||
|
snapshotName: snapshotHeader || 'NO_NAME',
|
||||||
|
totalVisits: formatNumberK(visitsCount),
|
||||||
|
avgVisitsDay: formatNumberK(avgVisitDay()) + '/day',
|
||||||
|
totalEvents: formatNumberK(eventsCount),
|
||||||
|
avgGrowthText: 'Insufficient Data (Requires at least 2 months of tracking)',
|
||||||
|
topDevice: topDevice,
|
||||||
|
topDomain: topDomain,
|
||||||
|
topCountries: topCountries.map(e => e._id),
|
||||||
|
topReferrers: topReferrers.map(e => e._id)
|
||||||
});
|
});
|
||||||
|
|
||||||
const passThrough = new PassThrough();
|
const passThrough = new PassThrough();
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ export default defineEventHandler(async event => {
|
|||||||
const project = await ProjectModel.findById(project_id);
|
const project = await ProjectModel.findById(project_id);
|
||||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
|
||||||
|
if (project.subscription_id === 'onetime') {
|
||||||
|
|
||||||
|
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||||
|
if (!projectLimits) return setResponseStatus(event, 400, 'Project limits not found');
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
premium: project.premium,
|
||||||
|
premium_type: project.premium_type,
|
||||||
|
billing_start_at: projectLimits.billing_start_at,
|
||||||
|
billing_expire_at: projectLimits.billing_expire_at,
|
||||||
|
limit: projectLimits.limit,
|
||||||
|
count: projectLimits.events + projectLimits.visits,
|
||||||
|
subscription_status: StripeService.isDisabled() ? 'Disabled mode' : ('One time payment')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const subscription = await StripeService.getSubscription(project.subscription_id);
|
const subscription = await StripeService.getSubscription(project.subscription_id);
|
||||||
|
|
||||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||||
|
|||||||
27
dashboard/server/api/v1/events.post.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import { checkApiKey, checkAuthorization, eventsListApi } from '~/server/services/ApiService';
|
||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const { rows, from, to, limit } = await readBody(event);
|
||||||
|
|
||||||
|
const token = checkAuthorization(event);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const apiKeyResult = await checkApiKey(token);
|
||||||
|
if (!apiKeyResult.ok) return setResponseStatus(event, 401, 'ApiKey not valid');
|
||||||
|
|
||||||
|
if (!rows) return setResponseStatus(event, 400, 'rows is required');
|
||||||
|
if (!Array.isArray(rows)) return setResponseStatus(event, 400, 'rows must be an array');
|
||||||
|
if (rows.length == 0) return setResponseStatus(event, 400, 'rows cannot be empty');
|
||||||
|
|
||||||
|
if (Array.isArray(from)) return setResponseStatus(event, 400, 'Only one "from" is allowed');
|
||||||
|
if (Array.isArray(to)) return setResponseStatus(event, 400, 'Only one "to" is allowed');
|
||||||
|
|
||||||
|
const result = await eventsListApi(apiKeyResult.data.apiKey, apiKeyResult.data.project_id.toString(), rows, limit as string, from as string, to as string);
|
||||||
|
|
||||||
|
if (result.ok) return result;
|
||||||
|
return setResponseStatus(event, result.code, result.error);
|
||||||
|
|
||||||
|
});
|
||||||
25
dashboard/server/api/v1/events.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
import { checkApiKey, checkAuthorization, eventsListApi, } from '~/server/services/ApiService';
|
||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const { row, from, to, limit } = getQuery(event);
|
||||||
|
|
||||||
|
const token = checkAuthorization(event);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const apiKeyResult = await checkApiKey(token);
|
||||||
|
if (!apiKeyResult.ok) return setResponseStatus(event, 401, 'ApiKey not valid');
|
||||||
|
|
||||||
|
if (Array.isArray(from)) return setResponseStatus(event, 400, 'Only one "from" is allowed');
|
||||||
|
if (Array.isArray(to)) return setResponseStatus(event, 400, 'Only one "to" is allowed');
|
||||||
|
|
||||||
|
const rows: string[] = Array.isArray(row) ? row as string[] : [row as string];
|
||||||
|
|
||||||
|
const result = await eventsListApi(apiKeyResult.data.apiKey, apiKeyResult.data.project_id.toString(), rows, limit as string, from as string, to as string);
|
||||||
|
|
||||||
|
if (result.ok) return result;
|
||||||
|
return setResponseStatus(event, result.code, result.error);
|
||||||
|
|
||||||
|
});
|
||||||
28
dashboard/server/api/v1/visits.post.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import { checkApiKey, checkAuthorization } from '~/server/services/ApiService';
|
||||||
|
import { visitsListApi } from '../../services/ApiService';
|
||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const { rows, from, to, limit } = await readBody(event);
|
||||||
|
|
||||||
|
const token = checkAuthorization(event);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const apiKeyResult = await checkApiKey(token);
|
||||||
|
if (!apiKeyResult.ok) return setResponseStatus(event, 401, 'ApiKey not valid');
|
||||||
|
|
||||||
|
if (!rows) return setResponseStatus(event, 400, 'rows is required');
|
||||||
|
if (!Array.isArray(rows)) return setResponseStatus(event, 400, 'rows must be an array');
|
||||||
|
if (rows.length == 0) return setResponseStatus(event, 400, 'rows cannot be empty');
|
||||||
|
|
||||||
|
if (Array.isArray(from)) return setResponseStatus(event, 400, 'Only one "from" is allowed');
|
||||||
|
if (Array.isArray(to)) return setResponseStatus(event, 400, 'Only one "to" is allowed');
|
||||||
|
|
||||||
|
const result = await visitsListApi(apiKeyResult.data.apiKey, apiKeyResult.data.project_id.toString(), rows, limit as string, from as string, to as string);
|
||||||
|
|
||||||
|
if (result.ok) return result;
|
||||||
|
return setResponseStatus(event, result.code, result.error);
|
||||||
|
|
||||||
|
});
|
||||||
27
dashboard/server/api/v1/visits.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import { ApiSettingsModel } from '@schema/ApiSettingsSchema';
|
||||||
|
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||||
|
import { checkApiKey, checkAuthorization, visitsListApi } from '~/server/services/ApiService';
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const { row, from, to, limit } = getQuery(event);
|
||||||
|
|
||||||
|
const token = checkAuthorization(event);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const apiKeyResult = await checkApiKey(token);
|
||||||
|
if (!apiKeyResult.ok) return setResponseStatus(event, 401, 'ApiKey not valid');
|
||||||
|
|
||||||
|
if (Array.isArray(from)) return setResponseStatus(event, 400, 'Only one "from" is allowed');
|
||||||
|
if (Array.isArray(to)) return setResponseStatus(event, 400, 'Only one "to" is allowed');
|
||||||
|
|
||||||
|
const rows: string[] = Array.isArray(row) ? row as string[] : [row as string];
|
||||||
|
|
||||||
|
const result = await visitsListApi(apiKeyResult.data.apiKey, apiKeyResult.data.project_id.toString(), rows, limit as string, from as string, to as string);
|
||||||
|
|
||||||
|
if (result.ok) return result;
|
||||||
|
return setResponseStatus(event, result.code, result.error);
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -2,39 +2,47 @@ import mongoose from "mongoose";
|
|||||||
import { Redis } from "~/server/services/CacheService";
|
import { Redis } from "~/server/services/CacheService";
|
||||||
import EmailService from '@services/EmailService';
|
import EmailService from '@services/EmailService';
|
||||||
import StripeService from '~/server/services/StripeService';
|
import StripeService from '~/server/services/StripeService';
|
||||||
|
import { anomalyLoop } from "./services/AnomalyService";
|
||||||
|
import { logger } from "./Logger";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
let connection: mongoose.Mongoose;
|
let connection: mongoose.Mongoose;
|
||||||
|
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
|
|
||||||
console.log('[SERVER] Initializing');
|
logger.info('[SERVER] Initializing');
|
||||||
|
|
||||||
if (config.EMAIL_SERVICE) {
|
if (config.EMAIL_SERVICE) {
|
||||||
EmailService.createTransport(config.EMAIL_SERVICE, config.EMAIL_HOST, config.EMAIL_USER, config.EMAIL_PASS);
|
EmailService.init(config.BREVO_API_KEY);
|
||||||
console.log('[EMAIL] Initialized')
|
logger.info('[EMAIL] Initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (config.STRIPE_SECRET) {
|
if (config.STRIPE_SECRET) {
|
||||||
StripeService.init(config.STRIPE_SECRET, config.STRIPE_WH_SECRET, false);
|
StripeService.init(config.STRIPE_SECRET, config.STRIPE_WH_SECRET, false);
|
||||||
console.log('[STRIPE] Initialized')
|
logger.info('[STRIPE] Initialized');
|
||||||
} else {
|
} else {
|
||||||
StripeService.disable();
|
StripeService.disable();
|
||||||
console.log('[STRIPE] No stripe key - Disabled mode')
|
logger.warn('[STRIPE] No stripe key - Disabled mode');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!connection || connection.connection.readyState == mongoose.ConnectionStates.disconnected) {
|
if (!connection || connection.connection.readyState == mongoose.ConnectionStates.disconnected) {
|
||||||
console.log('[DATABASE] Connecting');
|
logger.info('[DATABASE] Connecting');
|
||||||
connection = await mongoose.connect(config.MONGO_CONNECTION_STRING);
|
connection = await mongoose.connect(config.MONGO_CONNECTION_STRING);
|
||||||
console.log('[DATABASE] Connected');
|
logger.info('[DATABASE] Connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[REDIS] Connecting');
|
logger.info('[REDIS] Connecting');
|
||||||
await Redis.init();
|
await Redis.init();
|
||||||
console.log('[REDIS] Connected');
|
logger.info('[REDIS] Connected');
|
||||||
|
|
||||||
console.log('[SERVER] Completed');
|
logger.info('[SERVER] Completed');
|
||||||
|
|
||||||
|
logger.warn('[ANOMALY LOOP] Disabled');
|
||||||
|
// anomalyLoop();
|
||||||
|
|
||||||
};
|
};
|
||||||
7
dashboard/server/middleware/00-performance-start.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { logger } from "../Logger"
|
||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const start = Date.now();
|
||||||
|
event.context['performance-start'] = start.toString();
|
||||||
|
});
|
||||||
@@ -24,29 +24,21 @@ async function authorizationMiddleware(event: H3Event<EventHandlerRequest>) {
|
|||||||
const authorization = event.headers.get('Authorization');
|
const authorization = event.headers.get('Authorization');
|
||||||
|
|
||||||
if (!authorization) {
|
if (!authorization) {
|
||||||
event.context.auth = { logged: false, }
|
|
||||||
|
event.context.auth = { logged: false }
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
const [type, token] = authorization.split(' ');
|
const [type, token] = authorization.split(' ');
|
||||||
|
|
||||||
const valid = readUserJwt(token);
|
const valid = readUserJwt(token);
|
||||||
|
|
||||||
if (!valid) return event.context.auth = { logged: false }
|
if (!valid) return event.context.auth = { logged: false }
|
||||||
|
|
||||||
const user = await UserModel.findOne({ email: valid.email })
|
const user = await UserModel.findOne({ email: valid.email })
|
||||||
|
|
||||||
if (!user) return event.context.auth = { logged: false };
|
if (!user) return event.context.auth = { logged: false };
|
||||||
|
|
||||||
const premium: any = null;//await PremiumModel.findOne({ user_id: user.id });
|
|
||||||
|
|
||||||
const roles: string[] = [];
|
const roles: string[] = [];
|
||||||
|
|
||||||
if (premium && premium.ends_at.getTime() < Date.now()) {
|
|
||||||
// await PremiumModel.deleteOne({ user_id: user.id });
|
|
||||||
} else if (premium) {
|
|
||||||
roles.push('PREMIUM');
|
|
||||||
roles.push('PREMIUM_' + premium.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ADMIN_EMAILS.includes(user.email)) {
|
if (ADMIN_EMAILS.includes(user.email)) {
|
||||||
roles.push('ADMIN');
|
roles.push('ADMIN');
|
||||||
}
|
}
|
||||||
@@ -61,6 +53,7 @@ async function authorizationMiddleware(event: H3Event<EventHandlerRequest>) {
|
|||||||
},
|
},
|
||||||
id: user._id.toString()
|
id: user._id.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
event.context.auth = authContext;
|
event.context.auth = authContext;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
dashboard/server/middleware/02-logging.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { logger } from "../Logger"
|
||||||
|
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
|
const ip = getRequestAddress(event);
|
||||||
|
const user = getRequestUser(event);
|
||||||
|
|
||||||
|
event.node.res.on('finish', () => {
|
||||||
|
if (!event.context['performance-start']) return;
|
||||||
|
const start = parseInt(event.context['performance-start']);
|
||||||
|
if (isNaN(start)) return;
|
||||||
|
|
||||||
|
const end = Date.now();
|
||||||
|
const duration = (end - start);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.debug('Request without user', { path: event.path, method: event.method, ip, duration });
|
||||||
|
} else if (!user.logged) {
|
||||||
|
logger.debug('Request as guest', { path: event.path, method: event.method, ip, duration });
|
||||||
|
} else {
|
||||||
|
logger.debug(`(${duration}ms) [${event.method}] ${event.path} { ${user.user.email} }`, { ip });
|
||||||
|
}
|
||||||
|
|
||||||
|
// event.node.res.setHeader('X-Total-Response-Time', `${duration.toFixed(2)} ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
})
|
||||||
@@ -1,59 +1,35 @@
|
|||||||
|
|
||||||
import { getVisitsCountFromDateRange } from '~/server/api/ai/functions/AI_Visits';
|
|
||||||
|
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { AiChatModel } from '@schema/ai/AiChatSchema';
|
import { AiChatModel } from '@schema/ai/AiChatSchema';
|
||||||
import { AI_EventsFunctions, AI_EventsTools } from '../api/ai/functions/AI_Events';
|
|
||||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
||||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||||
|
|
||||||
|
import { AiEventsInstance } from '../ai/functions/AI_Events';
|
||||||
|
import { AiVisitsInstance } from '../ai/functions/AI_Visits';
|
||||||
|
import { AiComposableChartInstance } from '../ai/functions/AI_ComposableChart';
|
||||||
|
|
||||||
const { AI_ORG, AI_PROJECT, AI_KEY } = useRuntimeConfig();
|
const { AI_ORG, AI_PROJECT, AI_KEY } = useRuntimeConfig();
|
||||||
|
|
||||||
|
const OPENAI_MODEL: OpenAI.Chat.ChatModel = 'gpt-4o-mini';
|
||||||
|
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
organization: AI_ORG,
|
organization: AI_ORG,
|
||||||
project: AI_PROJECT,
|
project: AI_PROJECT,
|
||||||
apiKey: AI_KEY
|
apiKey: AI_KEY
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// const get_current_date: OpenAI.Chat.Completions.ChatCompletionTool = {
|
|
||||||
// type: 'function',
|
|
||||||
// function: {
|
|
||||||
// name: 'get_current_date',
|
|
||||||
// description: 'Gets the current date as ISO string',
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
const get_visits_count_Schema: OpenAI.Chat.Completions.ChatCompletionTool = {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: 'get_visits_count',
|
|
||||||
description: 'Gets the number of visits received on a date range',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
from: { type: 'string', description: 'ISO string of start date including hours' },
|
|
||||||
to: { type: 'string', description: 'ISO string of end date including hours' }
|
|
||||||
},
|
|
||||||
required: ['from', 'to']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
|
const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
|
||||||
get_visits_count_Schema,
|
...AiVisitsInstance.getTools(),
|
||||||
...AI_EventsTools
|
...AiEventsInstance.getTools(),
|
||||||
|
...AiComposableChartInstance.getTools()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
const functions: any = {
|
const functions: any = {
|
||||||
get_current_date: async ({ }) => {
|
...AiVisitsInstance.getHandlers(),
|
||||||
return new Date().toISOString();
|
...AiEventsInstance.getHandlers(),
|
||||||
},
|
...AiComposableChartInstance.getHandlers()
|
||||||
get_visits_count: async ({ pid, from, to }: any) => {
|
|
||||||
return await getVisitsCountFromDateRange(pid, from, to);
|
|
||||||
},
|
|
||||||
...AI_EventsFunctions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +57,14 @@ async function setChatTitle(title: string, chat_id?: string) {
|
|||||||
await AiChatModel.updateOne({ _id: chat_id }, { title });
|
await AiChatModel.updateOne({ _id: chat_id }, { title });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function getChartsInMessage(message: OpenAI.Chat.Completions.ChatCompletionMessageParam) {
|
||||||
|
if (message.role != 'assistant') return [];
|
||||||
|
if (!message.tool_calls) return [];
|
||||||
|
if (message.tool_calls.length == 0) return [];
|
||||||
|
return message.tool_calls.filter(e => e.function.name === 'createComposableChart').map(e => e.function.arguments);
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessageOnChat(text: string, pid: string, initial_chat_id?: string) {
|
export async function sendMessageOnChat(text: string, pid: string, initial_chat_id?: string) {
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +76,8 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_
|
|||||||
messages.push(...chatMessages);
|
messages.push(...chatMessages);
|
||||||
} else {
|
} else {
|
||||||
const roleMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = {
|
const roleMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = {
|
||||||
role: 'system', content: "Today is " + new Date().toISOString()
|
role: 'system',
|
||||||
|
content: + "Today is " + new Date().toISOString()
|
||||||
}
|
}
|
||||||
messages.push(roleMessage);
|
messages.push(roleMessage);
|
||||||
await addMessageToChat(roleMessage, chat_id);
|
await addMessageToChat(roleMessage, chat_id);
|
||||||
@@ -100,43 +85,36 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_
|
|||||||
await setChatTitle(text.substring(0, 110), chat_id);
|
await setChatTitle(text.substring(0, 110), chat_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = {
|
const userMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = { role: 'user', content: text }
|
||||||
role: 'user', content: text
|
|
||||||
}
|
|
||||||
messages.push(userMessage);
|
messages.push(userMessage);
|
||||||
await addMessageToChat(userMessage, chat_id);
|
await addMessageToChat(userMessage, chat_id);
|
||||||
|
|
||||||
let response = await openai.chat.completions.create({ model: 'gpt-3.5-turbo', messages, n: 1, tools });
|
let response = await openai.chat.completions.create({ model: OPENAI_MODEL, messages, n: 1, tools });
|
||||||
|
|
||||||
let responseMessage = response.choices[0].message;
|
const chartsData: string[][] = [];
|
||||||
let toolCalls = responseMessage.tool_calls;
|
|
||||||
|
|
||||||
await addMessageToChat(responseMessage, chat_id);
|
while ((response.choices[0].message.tool_calls?.length || 0) > 0) {
|
||||||
messages.push(responseMessage);
|
await addMessageToChat(response.choices[0].message, chat_id);
|
||||||
|
messages.push(response.choices[0].message);
|
||||||
|
if (response.choices[0].message.tool_calls) {
|
||||||
|
|
||||||
|
console.log('Tools to call', response.choices[0].message.tool_calls.length);
|
||||||
|
chartsData.push(getChartsInMessage(response.choices[0].message));
|
||||||
|
|
||||||
if (toolCalls) {
|
for (const toolCall of response.choices[0].message.tool_calls) {
|
||||||
console.log({ toolCalls: toolCalls.length });
|
const functionName = toolCall.function.name;
|
||||||
for (const toolCall of toolCalls) {
|
console.log('Calling tool function', functionName);
|
||||||
const functionName = toolCall.function.name;
|
const functionToCall = functions[functionName];
|
||||||
const functionToCall = functions[functionName];
|
const functionArgs = JSON.parse(toolCall.function.arguments);
|
||||||
const functionArgs = JSON.parse(toolCall.function.arguments);
|
const functionResponse = await functionToCall({ project_id: pid, ...functionArgs });
|
||||||
console.log('CALLING FUNCTION', functionName, 'WITH PARAMS', functionArgs);
|
messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) });
|
||||||
const functionResponse = await functionToCall({ pid, ...functionArgs });
|
await addMessageToChat({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }, chat_id);
|
||||||
console.log('RESPONSE FUNCTION', functionName, 'WITH VALUE', functionResponse);
|
}
|
||||||
messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) });
|
|
||||||
await addMessageToChat({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }, chat_id);
|
|
||||||
}
|
}
|
||||||
response = await openai.chat.completions.create({ model: 'gpt-3.5-turbo', messages, n: 1, tools });
|
response = await openai.chat.completions.create({ model: OPENAI_MODEL, messages, n: 1, tools });
|
||||||
responseMessage = response.choices[0].message;
|
|
||||||
toolCalls = responseMessage.tool_calls;
|
|
||||||
|
|
||||||
await addMessageToChat(responseMessage, chat_id);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
await addMessageToChat(response.choices[0].message, chat_id);
|
||||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { ai_messages: 1 } })
|
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { ai_messages: 1 } })
|
||||||
|
return { content: response.choices[0].message.content, charts: chartsData.filter(e => e.length > 0).flat() };
|
||||||
return responseMessage.content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
dashboard/server/services/AnomalyService.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
import { executeTimelineAggregation } from "./TimelineService";
|
||||||
|
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||||
|
import { AnomalyDomainModel } from '@schema/anomalies/AnomalyDomainSchema';
|
||||||
|
import { AnomalyVisitModel } from '@schema/anomalies/AnomalyVisitSchema';
|
||||||
|
import { AnomalyEventsModel } from '@schema/anomalies/AnomalyEventsSchema';
|
||||||
|
import { EventModel } from "@schema/metrics/EventSchema";
|
||||||
|
import EmailService from "@services/EmailService";
|
||||||
|
|
||||||
|
import * as url from 'url';
|
||||||
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
|
import { UserModel } from "@schema/UserSchema";
|
||||||
|
|
||||||
|
type TAvgInput = { _id: string, count: number }
|
||||||
|
|
||||||
|
const anomalyData = { minutes: 0 }
|
||||||
|
|
||||||
|
async function anomalyCheckAll() {
|
||||||
|
const start = performance.now();
|
||||||
|
console.log('[ANOMALY] START ANOMALY CHECK');
|
||||||
|
const projects = await ProjectModel.find({}, { _id: 1 });
|
||||||
|
for (const project of projects) {
|
||||||
|
await findAnomalies(project.id);
|
||||||
|
}
|
||||||
|
const end = performance.now() - start;
|
||||||
|
console.log('END ANOMALY CHECK', end, 'ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anomalyLoop() {
|
||||||
|
if (anomalyData.minutes == 60 * 12) {
|
||||||
|
anomalyCheckAll();
|
||||||
|
anomalyData.minutes = 0;
|
||||||
|
}
|
||||||
|
anomalyData.minutes++;
|
||||||
|
setTimeout(() => anomalyLoop(), 1000 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function movingAverageAnomaly(visits: TAvgInput[], windowSize: number, threshold: number): TAvgInput[] {
|
||||||
|
const anomalies: TAvgInput[] = [];
|
||||||
|
for (let i = windowSize; i < visits.length; i++) {
|
||||||
|
const window = visits.slice(i - windowSize, i);
|
||||||
|
const mean = window.reduce((a, b) => a + b.count, 0) / window.length;
|
||||||
|
const stdDev = Math.sqrt(window.reduce((sum, visit) => sum + Math.pow(visit.count - mean, 2), 0) / window.length);
|
||||||
|
const currentVisit = visits[i];
|
||||||
|
if (Math.abs(currentVisit.count - mean) > threshold * stdDev) {
|
||||||
|
if (currentVisit.count <= mean) continue;
|
||||||
|
anomalies.push(currentVisit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return anomalies;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrlFromString(str: string) {
|
||||||
|
const res = str.startsWith('http') ? str : 'http://' + str;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findAnomalies(project_id: string) {
|
||||||
|
|
||||||
|
const THRESHOLD = 6;
|
||||||
|
const WINDOW_SIZE = 14;
|
||||||
|
|
||||||
|
const pid = new mongoose.Types.ObjectId(project_id) as any;
|
||||||
|
|
||||||
|
const from = Date.now() - 1000 * 60 * 60 * 24 * 30;
|
||||||
|
const to = Date.now() - 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
|
const visitsTimelineData = await executeTimelineAggregation({
|
||||||
|
projectId: pid,
|
||||||
|
model: VisitModel,
|
||||||
|
from, to, slice: 'day'
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsTimelineData = await executeTimelineAggregation({
|
||||||
|
projectId: pid,
|
||||||
|
model: EventModel,
|
||||||
|
from, to, slice: 'day'
|
||||||
|
});
|
||||||
|
|
||||||
|
const websites: { _id: string, count: number }[] = await VisitModel.aggregate([
|
||||||
|
{ $match: { project_id: pid, created_at: { $gte: new Date(from), $lte: new Date(to) } }, },
|
||||||
|
{ $group: { _id: "$website", count: { $sum: 1, } } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
const detectedWebsites: string[] = [];
|
||||||
|
|
||||||
|
if (websites.length > 0) {
|
||||||
|
const rootWebsite = websites.reduce((a, e) => {
|
||||||
|
return a.count > e.count ? a : e;
|
||||||
|
});
|
||||||
|
const rootDomain = new url.URL(getUrlFromString(rootWebsite._id)).hostname;
|
||||||
|
for (const website of websites) {
|
||||||
|
const websiteDomain = new url.URL(getUrlFromString(website._id)).hostname;
|
||||||
|
|
||||||
|
if (websiteDomain === 'localhost') continue;
|
||||||
|
if (websiteDomain === '127.0.0.1') continue;
|
||||||
|
if (websiteDomain === '0.0.0.0') continue;
|
||||||
|
|
||||||
|
if (!websiteDomain.includes(rootDomain)) { detectedWebsites.push(website._id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const visitAnomalies = movingAverageAnomaly(visitsTimelineData, WINDOW_SIZE, THRESHOLD);
|
||||||
|
const eventAnomalies = movingAverageAnomaly(eventsTimelineData, WINDOW_SIZE, THRESHOLD);
|
||||||
|
|
||||||
|
const shouldSendMail = {
|
||||||
|
visitsEvents: false,
|
||||||
|
domains: false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const visit of visitAnomalies) {
|
||||||
|
const anomalyAlreadyExist = await AnomalyVisitModel.findOne({ visitDate: visit._id }, { _id: 1 });
|
||||||
|
if (anomalyAlreadyExist) continue;
|
||||||
|
await AnomalyVisitModel.create({ project_id: pid, visitDate: visit._id, created_at: Date.now() });
|
||||||
|
shouldSendMail.visitsEvents = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of eventAnomalies) {
|
||||||
|
const anomalyAlreadyExist = await AnomalyEventsModel.findOne({ eventDate: event._id }, { _id: 1 });
|
||||||
|
if (anomalyAlreadyExist) continue;
|
||||||
|
await AnomalyEventsModel.create({ project_id: pid, eventDate: event._id, created_at: Date.now() });
|
||||||
|
shouldSendMail.visitsEvents = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const website of detectedWebsites) {
|
||||||
|
const anomalyAlreadyExist = await AnomalyDomainModel.findOne({ domain: website }, { _id: 1 });
|
||||||
|
if (anomalyAlreadyExist) continue;
|
||||||
|
await AnomalyDomainModel.create({ project_id: pid, domain: website, created_at: Date.now() });
|
||||||
|
shouldSendMail.domains = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(pid);
|
||||||
|
if (!project) return { ok: false, error: 'Cannot find project with id ' + pid.toString() }
|
||||||
|
const user = await UserModel.findById(project.owner);
|
||||||
|
if (!user) return { ok: false, error: 'Cannot find user with id ' + project.owner.toString() }
|
||||||
|
|
||||||
|
if (shouldSendMail.visitsEvents === true) {
|
||||||
|
await EmailService.sendAnomalyVisitsEventsEmail(user.email, project.name);
|
||||||
|
}
|
||||||
|
if (shouldSendMail.domains === true) {
|
||||||
|
await EmailService.sendAnomalyDomainEmail(user.email, project.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
86
dashboard/server/services/ApiService.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
|
||||||
|
import { ApiSettingsModel, TApiSettings } from '@schema/ApiSettingsSchema';
|
||||||
|
import { EventModel } from '@schema/metrics/EventSchema';
|
||||||
|
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||||
|
import type { H3Event, EventHandlerRequest } from 'h3'
|
||||||
|
|
||||||
|
export function checkAuthorization(event: H3Event<EventHandlerRequest>) {
|
||||||
|
const authorization = getHeader(event, 'Authorization');
|
||||||
|
if (!authorization) return setResponseStatus(event, 403, 'Authorization is required');
|
||||||
|
|
||||||
|
const [type, token] = authorization.split(' ');
|
||||||
|
if (type != 'Bearer') return setResponseStatus(event, 401, 'Malformed authorization');
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckApiKeyResult = { ok: false } | { ok: true, data: TApiSettings };
|
||||||
|
|
||||||
|
export async function checkApiKey(apiKey: string): Promise<CheckApiKeyResult> {
|
||||||
|
const apiSettings = await ApiSettingsModel.findOne({ apiKey });
|
||||||
|
if (!apiSettings) return { ok: false }
|
||||||
|
return { ok: true, data: apiSettings }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function incrementApiUsage(apiKey: string, value: number) {
|
||||||
|
await ApiSettingsModel.updateOne({ apiKey }, { $inc: { usage: value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkApiUsage(apiKey: string) {
|
||||||
|
const data = await ApiSettingsModel.findOne({ apiKey }, { usage: 1 });
|
||||||
|
if (!data) return false;
|
||||||
|
if (data.usage > 100000) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResult = { ok: true, data: any } | { ok: false, code: number, error: string }
|
||||||
|
|
||||||
|
export async function eventsListApi(apiKey: string, project_id: string, rows: string[], limit?: number | string, from?: string, to?: string): Promise<ApiResult> {
|
||||||
|
|
||||||
|
const canMakeRequest = await checkApiUsage(apiKey);
|
||||||
|
|
||||||
|
if (!canMakeRequest) return { ok: false, code: 429, error: 'Api limit reached (100.000)' }
|
||||||
|
|
||||||
|
const projection = Object.fromEntries(rows.map(e => [e, 1]));
|
||||||
|
|
||||||
|
const limitNumber = parseInt((limit?.toString() as string));
|
||||||
|
const limitValue = isNaN(limitNumber) ? 100 : limitNumber;
|
||||||
|
|
||||||
|
const events = await EventModel.find({
|
||||||
|
project_id,
|
||||||
|
created_at: {
|
||||||
|
$gte: from || new Date(2023, 0),
|
||||||
|
$lte: to || new Date(3000, 0)
|
||||||
|
}
|
||||||
|
}, { _id: 0, ...projection }, { limit: limitValue });
|
||||||
|
|
||||||
|
await incrementApiUsage(apiKey, events.length);
|
||||||
|
|
||||||
|
return { ok: true, data: events.map(e => e.toJSON()) }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function visitsListApi(apiKey: string, project_id: string, rows: string[], limit?: number | string, from?: string, to?: string): Promise<ApiResult> {
|
||||||
|
|
||||||
|
const canMakeRequest = await checkApiUsage(apiKey);
|
||||||
|
|
||||||
|
if (!canMakeRequest) return { ok: false, code: 429, error: 'Api limit reached (100.000)' }
|
||||||
|
|
||||||
|
const projection = Object.fromEntries(rows.map(e => [e, 1]));
|
||||||
|
|
||||||
|
const limitNumber = parseInt((limit?.toString() as string));
|
||||||
|
const limitValue = isNaN(limitNumber) ? 100 : limitNumber;
|
||||||
|
|
||||||
|
const visits = await VisitModel.find({
|
||||||
|
project_id,
|
||||||
|
created_at: {
|
||||||
|
$gte: from || new Date(2023, 0),
|
||||||
|
$lte: to || new Date(3000, 0)
|
||||||
|
}
|
||||||
|
}, { _id: 0, ...projection }, { limit: limitValue });
|
||||||
|
|
||||||
|
await incrementApiUsage(apiKey, visits.length);
|
||||||
|
|
||||||
|
return { ok: true, data: visits.map(e => e.toJSON()) };
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,15 +4,14 @@ import { createClient } from 'redis';
|
|||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
|
||||||
export const DATA_EXPIRE_TIME = 30;
|
export const DATA_EXPIRE_TIME = 30;
|
||||||
export const TIMELINE_EXPIRE_TIME = 60 * 5;
|
export const TIMELINE_EXPIRE_TIME = 60;
|
||||||
export const COUNTS_EXPIRE_TIME = 10;
|
export const COUNTS_EXPIRE_TIME = 10;
|
||||||
|
|
||||||
export const COUNTS_OLD_SESSIONS_EXPIRE_TIME = 60 * 5;
|
|
||||||
export const COUNTS_SESSIONS_EXPIRE_TIME = 60 * 3;
|
export const COUNTS_SESSIONS_EXPIRE_TIME = 60 * 3;
|
||||||
|
|
||||||
export const EVENT_NAMES_EXPIRE_TIME = 60;
|
export const EVENT_NAMES_EXPIRE_TIME = 60;
|
||||||
|
|
||||||
export const EVENT_METADATA_FIELDS_EXPIRE_TIME = 120;
|
export const EVENT_METADATA_FIELDS_EXPIRE_TIME = 30;
|
||||||
|
|
||||||
|
|
||||||
export class Redis {
|
export class Redis {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getPlanFromTag } from '@data/PREMIUM';
|
import { getPlanFromId, getPlanFromTag, PREMIUM_TAG } from '@data/PREMIUM';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
class StripeService {
|
class StripeService {
|
||||||
@@ -29,6 +29,33 @@ class StripeService {
|
|||||||
return this.stripe.webhooks.constructEvent(body, sig, this.webhookSecret);
|
return this.stripe.webhooks.constructEvent(body, sig, this.webhookSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async createOnetimePayment(price: string, success_url: string, pid: string, customer?: string) {
|
||||||
|
if (this.disabledMode) return;
|
||||||
|
if (!this.stripe) throw Error('Stripe not initialized');
|
||||||
|
|
||||||
|
const checkout = await this.stripe.checkout.sessions.create({
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
invoice_creation: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
line_items: [
|
||||||
|
{ price, quantity: 1 }
|
||||||
|
],
|
||||||
|
payment_intent_data: {
|
||||||
|
metadata: {
|
||||||
|
pid, price
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customer,
|
||||||
|
success_url,
|
||||||
|
mode: 'payment'
|
||||||
|
});
|
||||||
|
|
||||||
|
return checkout;
|
||||||
|
}
|
||||||
|
|
||||||
async cretePayment(price: string, success_url: string, pid: string, customer?: string) {
|
async cretePayment(price: string, success_url: string, pid: string, customer?: string) {
|
||||||
if (this.disabledMode) return;
|
if (this.disabledMode) return;
|
||||||
if (!this.stripe) throw Error('Stripe not initialized');
|
if (!this.stripe) throw Error('Stripe not initialized');
|
||||||
@@ -50,6 +77,13 @@ class StripeService {
|
|||||||
return checkout;
|
return checkout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPriceData(priceId: string) {
|
||||||
|
if (this.disabledMode) return;
|
||||||
|
if (!this.stripe) throw Error('Stripe not initialized');
|
||||||
|
const priceData = await this.stripe.prices.retrieve(priceId);
|
||||||
|
return priceData;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteSubscription(subscriptionId: string) {
|
async deleteSubscription(subscriptionId: string) {
|
||||||
if (this.disabledMode) return;
|
if (this.disabledMode) return;
|
||||||
if (!this.stripe) throw Error('Stripe not initialized');
|
if (!this.stripe) throw Error('Stripe not initialized');
|
||||||
@@ -78,7 +112,6 @@ class StripeService {
|
|||||||
return invoices;
|
return invoices;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getCustomer(customer_id: string) {
|
async getCustomer(customer_id: string) {
|
||||||
if (this.disabledMode) return;
|
if (this.disabledMode) return;
|
||||||
if (!this.stripe) throw Error('Stripe not initialized');
|
if (!this.stripe) throw Error('Stripe not initialized');
|
||||||
@@ -100,8 +133,41 @@ class StripeService {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createStripeCode(plan: PREMIUM_TAG) {
|
||||||
|
if (this.disabledMode) return;
|
||||||
|
if (!this.stripe) throw Error('Stripe not initialized');
|
||||||
|
|
||||||
|
const INCUBATION_COUPON = 'sDD7Weh3';
|
||||||
|
|
||||||
|
if (plan === 'INCUBATION') {
|
||||||
|
await this.stripe.promotionCodes.create({
|
||||||
|
coupon: INCUBATION_COUPON,
|
||||||
|
active: true,
|
||||||
|
code: 'TESTCACCA1',
|
||||||
|
max_redemptions: 1,
|
||||||
|
})
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOneTimeSubscriptionDummy(customer_id: string, planId: number) {
|
||||||
|
if (this.disabledMode) return;
|
||||||
|
if (!this.stripe) throw Error('Stripe not initialized');
|
||||||
|
|
||||||
|
const PLAN = getPlanFromId(planId);
|
||||||
|
if (!PLAN) throw Error('Plan not found');
|
||||||
|
|
||||||
|
const subscription = await this.stripe.subscriptions.create({
|
||||||
|
customer: customer_id,
|
||||||
|
items: [
|
||||||
|
{ price: this.testMode ? PLAN.PRICE_TEST : PLAN.PRICE, quantity: 1 }
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
async createFreeSubscription(customer_id: string) {
|
async createFreeSubscription(customer_id: string) {
|
||||||
if (this.disabledMode) return;
|
if (this.disabledMode) return;
|
||||||
|
|||||||
@@ -61,4 +61,10 @@ export function fillAndMergeTimelineAggregation(timeline: { _id: string, count:
|
|||||||
const filledDates = DateService.fillDates(timeline.map(e => e._id), slice);
|
const filledDates = DateService.fillDates(timeline.map(e => e._id), slice);
|
||||||
const merged = DateService.mergeFilledDates(filledDates, timeline, '_id', slice, { count: 0 });
|
const merged = DateService.mergeFilledDates(filledDates, timeline, '_id', slice, { count: 0 });
|
||||||
return merged;
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fillAndMergeTimelineAggregationV2(timeline: { _id: string, count: number }[], slice: Slice, from: string, to: string) {
|
||||||
|
const filledDates = DateService.createBetweenDates(from, to, slice);
|
||||||
|
const merged = DateService.mergeFilledDates(filledDates.dates, timeline, '_id', slice, { count: 0 });
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ export function formatNumberK(value: string | number, decimals: number = 1) {
|
|||||||
|
|
||||||
if (num > 1_000_000) return (num / 1_000_000).toFixed(decimals) + ' M';
|
if (num > 1_000_000) return (num / 1_000_000).toFixed(decimals) + ' M';
|
||||||
if (num > 1_000) return (num / 1_000).toFixed(decimals) + ' K';
|
if (num > 1_000) return (num / 1_000).toFixed(decimals) + ' K';
|
||||||
return num.toFixed();
|
|
||||||
|
return isNaN(num) ? '0' : num.toFixed();
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -40,10 +40,8 @@ services:
|
|||||||
|
|
||||||
# Optional - Used to send welcome and quota emails
|
# Optional - Used to send welcome and quota emails
|
||||||
|
|
||||||
# EMAIL_SERVICE: ""
|
# NUXT_EMAIL_SERVICE: "Brevo"
|
||||||
# EMAIL_HOST: ""
|
# NUXT_BREVO_API_KEY: ""
|
||||||
# EMAIL_USER: ""
|
|
||||||
# EMAIL_PASS: ""
|
|
||||||
|
|
||||||
PORT: "3999"
|
PORT: "3999"
|
||||||
MONGO_CONNECTION_STRING: "mongodb://litlyx:litlyx@mongo:27017/SimpleMetrics?readPreference=primaryPreferred&authSource=admin"
|
MONGO_CONNECTION_STRING: "mongodb://litlyx:litlyx@mongo:27017/SimpleMetrics?readPreference=primaryPreferred&authSource=admin"
|
||||||
@@ -77,10 +75,8 @@ services:
|
|||||||
|
|
||||||
# Optional - Used to send welcome and quota emails
|
# Optional - Used to send welcome and quota emails
|
||||||
|
|
||||||
# NUXT_EMAIL_SERVICE: ""
|
# NUXT_EMAIL_SERVICE: "Brevo"
|
||||||
# NUXT_EMAIL_HOST: ""
|
# NUXT_BREVO_API_KEY: ""
|
||||||
# NUXT_EMAIL_USER: ""
|
|
||||||
# NUXT_EMAIL_PASS: ""
|
|
||||||
|
|
||||||
NUXT_AUTH_JWT_SECRET: "litlyx_jwt_secret"
|
NUXT_AUTH_JWT_SECRET: "litlyx_jwt_secret"
|
||||||
|
|
||||||
|
|||||||