45 Commits

Author SHA1 Message Date
Emily
fa5a37ece2 . 2024-09-17 13:41:52 +02:00
Emily
db32afe741 better processed logs 2024-09-17 13:39:56 +02:00
Emily
e813b3246d remove console.log 2024-09-17 13:38:47 +02:00
Emily
86011c38ce add logger 2024-09-17 13:38:02 +02:00
Emily
fd5eca29cc remove test error 2024-09-16 20:39:07 +02:00
Emily
a591b43600 fixes 2024-09-16 20:09:32 +02:00
Emily
cebb45484c add logger 2024-09-16 20:09:15 +02:00
Emily
e4e2c2a42a fix anomaly 2024-09-16 20:09:07 +02:00
Emily
dfa1407102 fix anomaly service 2024-09-16 20:08:58 +02:00
Emily
e6adbf9c7b enchance ai 2024-09-16 15:37:18 +02:00
Emily
c3904ebd55 Add advanced ai 2024-09-16 01:03:49 +02:00
Emily
4c46a36c75 add anomaly + fix billing + add emails templates 2024-09-14 17:07:46 +02:00
Emily
c253846b86 fix email 2024-09-13 19:02:15 +02:00
Emily
e7c2dbf237 fix dates 2024-09-13 15:25:26 +02:00
Emily
525a371a6e . 2024-09-12 16:16:19 +02:00
Emily
6a9a698b7a add colors + fix billing page 2024-09-11 15:13:03 +02:00
Emily
4134d33dc4 fix pdf + admin panel 2024-09-10 16:59:34 +02:00
Emily
5172ad4f4d add api support 2024-09-09 14:43:27 +02:00
Emily
be45448288 add api keys 2024-09-08 15:51:03 +02:00
Emily
73739dde9d fix pricing + limits email + redis 2024-09-07 15:47:13 +02:00
Emily
30b3ed80e2 fix pricing + stripe payments 2024-09-05 16:56:21 +02:00
antonio
8e56069b1a fix on readme curl example 2024-09-05 13:14:23 +02:00
Antonio Verdiglione
3ecdec9ca9 Merge pull request #16 from art-santos/issue-15
fix: css bug in header
2024-09-05 11:54:14 +02:00
Arthur Santos
7b41a3ed0d fix: css bug in header 2024-09-04 09:28:56 -03:00
Emily
5804d7a73b fix support type from Discord to Slack 2024-09-04 14:01:06 +02:00
Emily
8b026099de fix dashboard + live demo 2024-09-04 14:00:03 +02:00
Emily
d7e18d570f fix userAgent device type 2024-09-04 13:59:53 +02:00
Emily
023f2b5f4a aggregation optimization 2024-09-02 18:37:02 +02:00
Emily
c003b655ec aggregation optimization 2024-09-02 18:36:52 +02:00
Emily
d499aa2f39 remove project_max text 2024-09-02 18:14:25 +02:00
Emily
944996eb15 add proper limit + csv lock 2024-09-02 15:24:29 +02:00
antonio
87b1f9caf9 improvements on readme 2024-09-01 14:49:44 +02:00
Emily
748894b946 . 2024-08-30 17:32:36 +02:00
Emily
01e8a9ab1d . 2024-08-30 17:30:18 +02:00
Emily
a2034551ec add log to stream loop 2024-08-30 17:27:34 +02:00
Emily
6d26c3c8af add brevo email 2024-08-30 16:26:53 +02:00
Emily
518b4ce6c1 add blog-post + links 2024-08-30 14:59:17 +02:00
Emily
71bd4d0e58 remove beta text 2024-08-30 14:16:07 +02:00
Emily
0563a833eb . 2024-08-29 16:34:26 +02:00
Emily
ab07ffb108 fix limits 2024-08-29 16:31:50 +02:00
Emily
79309cc537 add tests infrastructure 2024-08-29 16:31:44 +02:00
Emily
9b9ed3e9ad update mailsave to https 2024-08-29 15:09:43 +02:00
Emily
1cb6b92d5c add mail + github stars 2024-08-29 14:55:42 +02:00
Antonio Verdiglione
c1bdc30933 Merge pull request #12 from bradenhirschi/readme
Updated readme for better English
2024-08-19 15:59:36 +02:00
Braden Hirschi
887ed45b4d Updated readme for better English 2024-08-09 12:15:08 -07:00
131 changed files with 9316 additions and 1436 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ PROCESS_EVENT
docker
dev
docker-compose.admin.yml
full_reload.sh

View File

@@ -1,18 +1,16 @@
<p align="center">
<img src="assets/claim-t.png"/>
<img src="assets/claim.png"/>
</p>
<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>
<br />
#
<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>
#
@@ -20,18 +18,14 @@
<br />
<p align="center">
<img src="assets/screen.png"/>
<img src="assets/dashboard-clip.png"/>
</p>
#
![GitHub Repo stars](https://img.shields.io/github/stars/Litlyx/litlyx)
![NPM Version](https://img.shields.io/npm/v/litlyx?logo=npm&color=orange)
![npm bundle size](https://img.shields.io/bundlephobia/min/litlyx)
## Pre-Requisites on Cloud Version
## Pre-Requisites
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.
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.
## 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>
```
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
@@ -49,10 +43,10 @@ You can install Litlyx using `npm`, `yarn`, or `pnpm`:
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">
<img src="assets/techs.png" />
<img src="assets/tech.png" />
</p>
# Import
@@ -69,17 +63,17 @@ Once imported, you need to initialize Litlyx:
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
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
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
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">
<img src="assets/agent.png" width="180px"/>
</p>
# Fire Your First Event with cURL
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
First thing first **Fork** this repository.
To self-host the Litlyx dashboard, first **fork** this repository.
Then run the following command:
```bash
docker-compose build
```
then, after the build finish, run:
after the build finishes, run:
```bash
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
@@ -124,11 +125,11 @@ For more info read our [documentation](https://docs.litlyx.com). (will be improv
# 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
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!
<a href="https://github.com/litlyx/litlyx/graphs/contributors">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/claim.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/dashboard-clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

BIN
assets/tech.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

1
broker/.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist
scripts/start_dev.js
package-lock.json
build_all.bat
tests

View File

@@ -7,9 +7,7 @@ module.exports = {
script: './dist/producer/src/index.js',
env: {
EMAIL_SERVICE: "",
EMAIL_HOST: "",
EMAIL_USER: "",
EMAIL_PASS: "",
BREVO_API_KEY: "",
PORT: "",
MONGO_CONNECTION_STRING: "",
REDIS_URL: "",

13
broker/jest.config.js Normal file
View 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',
}
};

View File

@@ -1,5 +1,6 @@
{
"dependencies": {
"@getbrevo/brevo": "^2.2.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"mongoose": "^8.3.2",
@@ -8,13 +9,17 @@
"ua-parser-js": "^1.0.37"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.13",
"@types/nodemailer": "^6.4.15",
"@types/ua-parser-js": "^0.7.39",
"glob": "^10.4.1",
"jest": "^29.7.0",
"node-ssh": "^13.2.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
@@ -28,7 +33,8 @@
"create_db": "cd scripts && ts-node create_database.ts",
"build_all": "npm run compile && npm run build && npm run create_db",
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
"docker-inspect": "docker run -it litlyx-broker sh"
"docker-inspect": "docker run -it litlyx-broker sh",
"test": "jest"
},
"keywords": [],
"author": "Emily",

3218
broker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,25 +6,62 @@ import { requireEnv } from "../../shared/utilts/requireEnv";
import { TProjectLimit } from "@schema/ProjectsLimits";
if (process.env.EMAIL_SERVICE) {
EmailService.createTransport(
requireEnv('EMAIL_SERVICE'),
requireEnv('EMAIL_HOST'),
requireEnv('EMAIL_USER'),
requireEnv('EMAIL_PASS'),
);
EmailService.init(requireEnv('BREVO_API_KEY'));
}
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) {
const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id });
if (notify && notify.limit1 === true) return;
const project = await ProjectModel.findById(projectCounts.project_id);
if (!project) return;
const owner = await UserModel.findById(project.owner);
if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email);
await LimitNotifyModel.updateOne({ project_id: projectCounts._id }, { limit1: true, limit2: false, limit3: false }, { upsert: true });
const project_id = projectCounts.project_id;
const hasNotifyEntry = await LimitNotifyModel.findOne({ project_id });
if (!hasNotifyEntry) {
await LimitNotifyModel.create({ project_id, limit1: false, limit2: false, limit3: false })
}
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
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 });
}
}

View File

@@ -19,27 +19,25 @@ export async function startStreamLoop() {
await RedisStreamService.startReadingLoop({
streamName: requireEnv('STREAM_NAME'),
delay: { base: 100, empty: 5000 },
readBlock: 2500
delay: { base: 10, empty: 5000 },
readBlock: 2000,
consumer: 'consumer_' + process.env.NODE_APP_INSTANCE
}, processStreamEvent);
}
async function processStreamEvent(data: Record<string, string>) {
export async function processStreamEvent(data: Record<string, string>) {
try {
const eventType = data._type;
if (!eventType) return;
const { pid, sessionHash } = data;
const project = await ProjectModel.exists({ _id: pid });
if (!project) return;
if (eventType === 'event') return await process_event(data, sessionHash);
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
if (eventType === 'visit') return await process_visit(data, sessionHash);
@@ -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) {
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
const projectLimits = await ProjectLimitModel.findOne({ project_id: pid });
if (!projectLimits) return;
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
const COUNT_LIMIT = projectLimits.limit;
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return;
await checkLimitsForEmail(projectLimits);
const canLog = await checkLimits(pid);
if (!canLog) return;
let referrerParsed;
try {
@@ -73,11 +77,13 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
const userAgentParsed = UAParser(userAgent);
const device = userAgentParsed.device.type;
const visit = new VisitModel({
project_id: pid, website, page, referrer: referrerParsed.hostname,
browser: userAgentParsed.browser.name || 'NO_BROWSER',
os: userAgentParsed.os.name || 'NO_OS',
device: userAgentParsed.device.type,
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
session: sessionHash,
flowHash,
continent: geoLocation[0],
@@ -97,7 +103,10 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
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) {
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 canLog = await checkLimits(pid);
if (!canLog) return;
let metadataObject;
try {
if (metadata) metadataObject = JSON.parse(metadata);

View File

@@ -1,9 +1,14 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
"outDir": "dist",
"types": [
"node",
"jest"
],
"paths": {
"@schema/*": [
"../shared/schema/*"
@@ -21,7 +26,9 @@
},
"include": [
"src/**/*.ts",
"scripts/**/*.ts"
"scripts/**/*.ts",
"tests/**/*.test.ts",
"tests/utils.ts"
],
"exclude": [
"node_modules"

View File

@@ -10,9 +10,7 @@ AI_PROJECT=
AI_KEY=
EMAIL_SERVICE=
EMAIL_HOST=
EMAIL_USER=
EMAIL_PASS=
BREVO_API_KEY=
AUTH_JWT_SECRET=

View File

@@ -12,6 +12,7 @@ node_modules
# Logs
logs
*.log
winston-*.ndjson
# Misc
.DS_Store
@@ -32,3 +33,7 @@ out.pdf
# TESTS - TO REMOVE
tests
# EXPLAINS MONGODB
explains

View File

@@ -9,12 +9,22 @@ const debugMode = process.dev;
const { alerts, closeAlert } = useAlert();
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
const { visible } = usePricingDrawer();
</script>
<template>
<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 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">
@@ -64,3 +74,19 @@ const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dia
</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>

View File

@@ -67,7 +67,7 @@ const chartData = ref<ChartData<'bar'>>({
label: e.label || '?',
backgroundColor: [e.color],
borderWidth: 0,
borderRadius: 8
borderRadius: 0
}
})
});

View File

@@ -10,6 +10,7 @@ export type Entry = {
icon?: string,
action?: () => any,
adminOnly?: boolean,
premiumOnly?: boolean,
external?: boolean,
grow?: boolean
}
@@ -27,6 +28,7 @@ const route = useRoute();
const props = defineProps<Props>();
const { isAdmin } = useUserRoles();
const loggedUser = useLoggedUser()
const debugMode = process.dev;
@@ -70,7 +72,11 @@ async function generatePDF() {
try {
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'
});
@@ -96,8 +102,15 @@ function onLogout() {
}
const { projects } = useProjectsList();
const { data: guestProjects } = useGuestProjectsList()
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", {
headers: computed(() => {
@@ -112,6 +125,18 @@ watch(selected, () => {
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>
<template>
@@ -122,6 +147,13 @@ watch(selected, () => {
}">
<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 items-center gap-2 w-full">
@@ -133,23 +165,26 @@ watch(selected, () => {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
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 }">
<div class="flex items-center gap-2">
<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> {{ option.name }} </div>
<div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
</div>
</template>
<template #label>
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'logo_32.png'" alt="Litlyx logo">
<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> {{ activeProject?.name || '???' }} </div>
</div>
</template>
</USelectMenu>
@@ -160,6 +195,7 @@ watch(selected, () => {
</div>
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
<div><i class="fas fa-plus"></i></div>
@@ -249,9 +285,9 @@ watch(selected, () => {
<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))"
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">
<i :class="entry.icon"></i>
</div>
<div class="manrope">
<div class="manrope grow">
{{ entry.label }}
</div>
<div v-if="entry.premiumOnly && !isPremium" class="flex items-center">
<i class="fal fa-lock"></i>
</div>
</NuxtLink>
</div>
@@ -278,9 +317,6 @@ watch(selected, () => {
</div>
<div class="grow"></div>
<div class="text-lyx-text-dark poppins text-[.8rem] px-4 pb-3">
Litlyx is in Beta version.
</div>
<div class="bg-lyx-widget-lighter h-[2px] px-4 w-full mb-3"></div>
<div class="flex justify-end px-2">

View File

@@ -9,10 +9,10 @@ const props = defineProps<{ title: string, sub?: string }>();
<div class="flex flex-col gap-4">
<div class="flex items-center">
<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 }}
</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 }}
</div>
</div>

View File

@@ -16,10 +16,10 @@ const emits = defineEmits<{
<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"
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="{ 'bg-accent hover:!bg-accent': currentIndex == index }">
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-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
{{ opt.label }}
</div>
</div>

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

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

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

View File

@@ -36,7 +36,7 @@ const props = defineProps<{
{{ trend.toFixed(0) }} %
</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>

View File

@@ -46,7 +46,28 @@ const chartData = ref<ChartData<'doughnut'>>({
{
rotation: 1,
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'],
borderWidth: 2
},
@@ -87,7 +108,7 @@ const headers = computed(() => {
});
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(() => {

View File

@@ -134,7 +134,7 @@ onMounted(async () => {
</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"
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
color="#f56523">

View File

@@ -13,6 +13,19 @@ function copyProjectId() {
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
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>
@@ -29,8 +42,10 @@ function copyProjectId() {
<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="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 class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
<div class="flex gap-2">
@@ -38,9 +53,20 @@ function copyProjectId() {
{{ activeProject?._id || 'Loading...' }}
</div>
<div class="flex items-center ml-3">
<i @click="copyProjectId()" class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
<i @click="copyProjectId()"
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
</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>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { Slice } from '@services/DateService';
import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService';
const props = defineProps<{ slice: Slice }>();
const slice = computed(() => props.slice);
@@ -22,7 +22,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
const fixed = fixMetrics({
data: input,
from: safeSnapshotDates.value.from,
from: input[0]._id,
to: safeSnapshotDates.value.to
}, slice.value, {
advanced: true,
@@ -30,7 +30,29 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
});
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++) {
const line: any = {
@@ -68,7 +90,8 @@ onMounted(async () => {
<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>
</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 || []">
</AdvancedStackedBarChart>
</div>

View File

@@ -38,7 +38,7 @@ async function analyzeEvent() {
<template>
<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">
<USelectMenu searchable searchable-placeholder="Search an event..." class="w-full"

View File

@@ -13,11 +13,11 @@ export type PricingCardProp = {
planId: number
}
const props = defineProps<{ datas: PricingCardProp[] }>();
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
const activeProject = useActiveProject();
const currentIndex = ref<number>(0);
const currentIndex = ref<number>(props.defaultIndex || 0);
const data = computed(() => {
return props.datas[currentIndex.value];
@@ -37,13 +37,19 @@ async function onUpgradeClick() {
<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="poppins text-xl font-light"> {{ data.title }} </div>
<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">
<div class="flex flex-col gap-3 text-center pt-3">
<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-sm">
Active
</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>
@@ -69,7 +75,7 @@ async function onUpgradeClick() {
<div class="flex flex-col gap-2">
<div class="flex gap-2" v-for="feature of data.features">
<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>{{ feature }}</div>
</div>

View File

@@ -2,197 +2,214 @@
import type { PricingCardProp } from './PricingCardGeneric.vue';
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
...signHeaders(),
lazy: true
});
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[] = [
{
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 Charts',
'Custom Data Aggregation'
],
cta: 'Let\'s Talk!',
link: 'mailto:help@litlyx.com',
active: false,
isDowngrade: false,
planId: -1
}
]
function getPricingsData() {
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',
'Data retention: 2 Months'
],
cta: 'Start For Free now!',
active: (planData.value?.premium_type || 0) == 0,
isDowngrade: (planData.value?.premium_type || 0) > 0,
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<{
(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>
<template>
<div class="p-8 overflow-y-auto xl:overflow-y-hidden">
<div class="p-8 overflow-y-auto">
<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">
@@ -200,11 +217,57 @@ const emits = defineEmits<{
</div>
<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="slidePricings"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="customPricing"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2"></PricingCardGeneric>
<PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
</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 flex-col gap-2">
<div class="poppins text-[2rem] font-semibold">
@@ -222,5 +285,8 @@ const emits = defineEmits<{
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup>
import type { TApiSettings } from '@schema/ApiSettingsSchema';
import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [
{ id: 'pname', title: 'Name', text: 'Project name' },
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
{ id: 'pid', title: 'Id', text: 'Project id' },
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
@@ -12,8 +14,53 @@ const entries: SettingsTemplateEntry[] = [
const activeProject = useActiveProject();
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, () => {
projectNameInputVal.value = activeProject.value?.name || "";
updateApiKeys();
})
const canChange = computed(() => {
@@ -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>
@@ -71,13 +144,36 @@ async function deleteProject() {
<template #pname>
<div class="flex items-center gap-4">
<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>
</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>
<LyxUiCard class="w-full flex items-center">
<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>
</template>
<template #pscript>
@@ -87,11 +183,11 @@ async function deleteProject() {
<script defer data-project="${activeProject?._id}"
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
</div>
<div><i class="far fa-copy"></i></div>
<div><i class="far fa-copy" @click="copyScript()"></i></div>
</LyxUiCard>
</template>
<template #pdelete>
<div class="flex justify-end">
<template #pdelete >
<div class="flex justify-end" v-if="!isGuest">
<LyxUiButton type="danger" @click="deleteProject()">
Delete project
</LyxUiButton>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import dayjs from 'dayjs';
import type { SettingsTemplateEntry } from './Template.vue';
import { getPlanFromId, PREMIUM_PLAN, type PREMIUM_TAG } from '@data/PREMIUM';
const activeProject = useActiveProject();
@@ -46,21 +47,22 @@ const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = u
lazy: true
})
const showPricingDrawer = ref<boolean>(false);
function onPlanUpgradeClick() {
showPricingDrawer.value = true;
}
function openInvoice(link: string) {
window.open(link, '_blank');
}
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[] = [
// { 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: 'usage', title: 'Usage', text: 'Show usage of current project' },
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
]
const currentBillingInfo = ref<any>({
address: ''
});
const { visible } = usePricingDrawer();
</script>
<template>
<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"
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>
@@ -114,8 +116,12 @@ const entries: SettingsTemplateEntry[] = [
</div>
</div>
<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="flex items-center ml-2">
<i class="far fa-info-circle text-[.8rem]"></i>
</div>
</div>
</div>
<div class="flex flex-col">
@@ -138,7 +144,7 @@ const entries: SettingsTemplateEntry[] = [
<div class="poppins"> Expire date:</div>
<div> {{ prettyExpireDate }}</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]">
<div class="poppins"> Upgrade plan </div>
<i class="fas fa-arrow-up-right"></i>

View File

@@ -1,11 +1,12 @@
import { Chart, registerables } from 'chart.js';
import annotaionPlugin from 'chartjs-plugin-annotation';
let registered = false;
export async function registerChartComponents() {
if (registered) return;
if (process.client) {
Chart.register(...registerables);
Chart.register(...registerables, annotaionPlugin);
registered = true;
}
}

View File

@@ -0,0 +1,9 @@
const pricingDrawerVisible = ref<boolean>(false);
export function usePricingDrawer() {
return { visible: pricingDrawerVisible };
}

View File

@@ -4,23 +4,47 @@ import type { Section } from '~/components/CVerticalNavigation.vue';
import { Lit } from 'litlyx-js';
const activeProject = useActiveProject();
const isPremium = computed(() => {
return activeProject.value?.premium;
});
const pricingDrawer = usePricingDrawer();
const sections: Section[] = [
{
title: 'Project',
title: '',
entries: [
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
{ 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: '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') },
},
{ 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 { isOpen, close, open } = useMenu();

View File

@@ -39,12 +39,12 @@ export default defineNuxtConfig({
AI_PROJECT: process.env.AI_PROJECT,
AI_KEY: process.env.AI_KEY,
EMAIL_SERVICE: process.env.EMAIL_SERVICE,
EMAIL_HOST: process.env.EMAIL_HOST,
EMAIL_USER: process.env.EMAIL_USER,
EMAIL_PASS: process.env.EMAIL_PASS,
BREVO_API_KEY: process.env.BREVO_API_KEY,
AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET,
GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID,
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_WH_SECRET: process.env.STRIPE_WH_SECRET,
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_NAME: process.env.NOAUTH_USER_NAME,
public: {
AUTH_MODE: process.env.AUTH_MODE
AUTH_MODE: process.env.AUTH_MODE,
GITHUB_CLIENT_ID: process.env.GITHUB_AUTH_CLIENT_ID || 'NONE'
}
},

View File

@@ -13,8 +13,10 @@
"docker-inspect": "docker run -it litlyx-dashboard sh"
},
"dependencies": {
"@getbrevo/brevo": "^2.2.0",
"@nuxtjs/tailwindcss": "^6.12.0",
"chart.js": "^3.9.1",
"chartjs-plugin-annotation": "^2.2.1",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11",
"google-auth-library": "^9.9.0",
@@ -24,7 +26,7 @@
"nodemailer": "^6.9.13",
"nuxt": "^3.11.2",
"nuxt-vue3-google-signin": "^0.0.11",
"openai": "^4.47.1",
"openai": "^4.61.0",
"pdfkit": "^0.15.0",
"primevue": "^3.52.0",
"redis": "^4.6.13",
@@ -33,11 +35,14 @@
"v-calendar": "^3.1.2",
"vue": "^3.4.21",
"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": {
"@nuxt/ui": "^2.15.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^6.4.15",
"@types/pdfkit": "^0.13.4",
"autoprefixer": "^10.4.19",

View File

@@ -5,6 +5,8 @@ import type { AdminProjectsList } from '~/server/api/admin/projects';
definePageMeta({ layout: 'dashboard' });
const { data: projects } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
const { data: counts } = await useFetch('/api/admin/counts', signHeaders());
type TProjectsGrouped = {
user: {
@@ -88,11 +90,6 @@ function onHideClicked() {
isAdminHidden.value = true;
}
const projectsCount = computed(() => {
return projects.value?.length || 0;
});
const premiumCount = computed(() => {
let premiums = 0;
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(() => {
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>
Users: {{ usersCount }}
Users: {{ counts?.users }}
</div>
<div>
Projects: {{ projectsCount }} ( {{ premiumCount }} premium )
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
</div>
<div>
Total visits: {{ formatNumberK(totalVisits) }}

View File

@@ -1,10 +1,18 @@
<script lang="ts" setup>
definePageMeta({ layout: 'dashboard' });
import VueMarkdown from 'vue-markdown-render';
definePageMeta({ layout: 'dashboard' });
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());
@@ -12,7 +20,7 @@ const currentText = ref<string>("");
const loading = ref<boolean>(false);
const currentChatId = ref<string>("");
const currentChatMessages = ref<any[]>([]);
const currentChatMessages = ref<{ role: string, content: string, charts?: any[] }[]>([]);
const scroller = ref<HTMLDivElement | null>(null);
@@ -39,7 +47,7 @@ async function sendMessage() {
body: JSON.stringify(body),
...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 reloadChatsList();
@@ -67,15 +75,18 @@ async function sendMessage() {
async function openChat(chat_id?: string) {
menuOpen.value = false;
if (!activeProject.value) return;
currentChatMessages.value = [];
if (!chat_id) {
currentChatMessages.value = [];
currentChatId.value = '';
return;
}
currentChatId.value = chat_id;
const messages = await $fetch(`/api/ai/${activeProject.value._id}/${chat_id}/get_messages`, signHeaders());
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);
}
@@ -99,10 +110,10 @@ function onKeyDown(e: KeyboardEvent) {
const menuOpen = ref<boolean>(false);
const defaultPrompts = [
'How many visits i got last week ?',
'How many visits i got last month ?',
'How many visits i got today ?',
'How many events i got last week ?',
"Create a line chart with this data: \n[100, 200, 30, 300, 500, 40]",
"Create a chart with Events (bar) and Visits (line) data from last week.",
"How many visits did I get last week?",
"Create a line chart of last week's visits."
]
async function deleteChat(chat_id: string) {
@@ -117,6 +128,8 @@ async function deleteChat(chat_id: string) {
await reloadChatsList();
}
const { visible: pricingDrawerVisible } = usePricingDrawer()
</script>
<template>
@@ -124,7 +137,7 @@ async function deleteChat(chat_id: string) {
<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="w-[10rem]">
@@ -138,7 +151,7 @@ async function deleteChat(chat_id: string) {
</div>
<div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest">
<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 }}
</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 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="bg-[#303030] px-5 py-3 rounded-lg">
<div class="bg-lyx-widget-light px-5 py-3 rounded-lg">
{{ message.content }}
</div>
</div>
<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">
<img class="h-[3.5rem] w-auto" :src="'analyst.png'">
</div>
<div v-html="parseMessageContent(message.content)"
class="max-w-[70%] text-text/90 whitespace-pre-wrap">
<div class="max-w-[70%] text-text/90 ai-message">
<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 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">
<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()"
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>
</div>
<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>
</div>
</div>
@@ -194,32 +222,37 @@ async function deleteChat(chat_id: string) {
<div :class="{
'absolute': 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="lg:hidden absolute right-4 top-4 text-[1.5rem]">
<i @click="menuOpen = false" class="fas fa-close cursor-pointer"></i>
</div>
<div class="poppins font-semibold text-[1.5rem]">
Lit, your AI Analyst is here!
What Lit can do for you?
</div>
<div class="poppins text-text/75">
Ask anything you want on your analytics,
and understand more Trends and Key Points to take Strategic moves!
Ask anything from your data history, visualize and overlap charts, explore events or metadata,
and enjoy a highly personalized data analysis experience.
</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>
<div class="manrope font-semibold"> {{ chatsRemaining }} remaining messages </div>
<div class="manrope font-semibold"> {{ chatsRemaining }} remaining AI requests </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 @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> New chat </div>
</div>
@@ -228,12 +261,13 @@ async function deleteChat(chat_id: string) {
<div class="overflow-y-auto">
<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())"
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())"
class="bg-menu px-4 py-3 w-full cursor-pointer hover:bg-menu/80 poppins rounded-lg"
:class="{ '!bg-accent/60': chat._id.toString() === currentChatId }">
class="py-3 w-full cursor-pointer poppins rounded-lg">
{{ chat.title }}
</div>
</div>
@@ -249,3 +283,75 @@ async function deleteChat(chat_id: string) {
</div>
</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>

View File

@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
const activeProject = useActiveProject();
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
const metricsInfo = ref<number>(0);
const columns = [
@@ -36,7 +38,36 @@ onMounted(async () => {
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>
@@ -47,14 +78,38 @@ onMounted(async () => {
<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">
Download CSV
</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>
<UTable v-if="tableData" class="utable px-8" :ui="{
wrapper: 'overflow-auto w-full h-full',
thead: 'sticky top-0 bg-menu',

View File

@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
const activeProject = useActiveProject();
const isPremium = computed(() => (activeProject.value?.premium_type || 0) > 0);
const metricsInfo = ref<number>(0);
const columns = [
@@ -68,6 +70,12 @@ const showWarning = computed(() => {
return options.indexOf(selectedTimeFrom.value) > 1
})
const pricingDrawer = usePricingDrawer();
function goToUpgrade() {
pricingDrawer.visible.value = true;
}
</script>
@@ -83,7 +91,9 @@ const showWarning = computed(() => {
</div>
</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>
@@ -91,12 +101,21 @@ const showWarning = computed(() => {
<div class="w-[15rem] flex flex-col gap-0">
<USelectMenu v-model="selectedTimeFrom" :options="options"></USelectMenu>
</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">
Download CSV
</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>
<UTable v-if="tableData" class="utable px-8" :ui="{
wrapper: 'overflow-auto w-full h-full',
thead: 'sticky top-0 bg-menu',

View File

@@ -24,6 +24,9 @@ const limitsInfo = ref<{
onMounted(async () => {
if (route.query.just_logged) return location.href = '/';
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 isPremium = computed(() => {
return activeProject.value?.premium;
})
const pricingDrawer = usePricingDrawer();
function goToUpgrade() {
pricingDrawer.visible.value = true;
}
</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 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"
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
<div class="flex flex-col grow">
@@ -94,21 +105,42 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
Limit reached
</div>
<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>
<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>
<DashboardTopSection></DashboardTopSection>
<DashboardTopSection></DashboardTopSection>
<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">
<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">
</SelectButton>
</template>
<div>
<DashboardVisitsLineChart :slice="(selectLabels[mainChartSelectIndex].value as any)">
</DashboardVisitsLineChart>
</div>
</CardTitled>
<div>
<DashboardVisitsLineChart :slice="(selectLabels[mainChartSelectIndex].value as any)">
</DashboardVisitsLineChart>
</div>
</CardTitled>
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions"
sub="Shows trends in sessions.">
<template #header>
<CardTitled :key="refreshKey" class="p-4 flex-1 w-full" title="Sessions" sub="Shows trends in sessions.">
<template #header>
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
</SelectButton>
</template>
<div>
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
</DashboardSessionsLineChart>
</div>
</CardTitled>
<div>
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
</DashboardSessionsLineChart>
</div>
</CardTitled>
</div>
</div> -->
<div class="flex w-full justify-center mt-6 px-6">
<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>
</div>
<div class="flex-1">
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
</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-1">
<DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>

View File

@@ -2,6 +2,7 @@
definePageMeta({ layout: 'none' });
const { snapshot, snapshots } = useSnapshot();
const { data: project } = useLiveDemo();
@@ -9,7 +10,7 @@ let interval: any;
onMounted(async () => {
await getOnlineUsers();
snapshot.value = snapshots.value[0];
interval = setInterval(async () => {
await getOnlineUsers();
}, 5000);
@@ -46,7 +47,7 @@ const selectLabelsEvents = [
{ label: 'Month', value: 'month' },
];
const { snapshot } = useSnapshot();
</script>
@@ -71,14 +72,10 @@ const { snapshot } = useSnapshot();
</div>
<div class="grow"></div>
<div class="flex gap-2 md:pt-0 pt-4">
<NuxtLink target="_blank" to="https://cal.com/litlyx/30min"
class="bg-white hover:bg-white/90 px-4 py-3 text-black poppins font-semibold text-[.9rem] lg:text-[1.2rem] rounded-lg">
Book a demo
</NuxtLink>
<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>
<LyxUiButton link="/" type="primary"
class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4">
Get started for free
</LyxUiButton>
</div>
</div>
</div>
@@ -90,7 +87,7 @@ const { snapshot } = useSnapshot();
<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>
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
:options="selectLabels">
@@ -102,7 +99,7 @@ const { snapshot } = useSnapshot();
</div>
</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>
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
@@ -112,13 +109,14 @@ const { snapshot } = useSnapshot();
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
</DashboardSessionsLineChart>
</div>
</CardTitled>
</CardTitled> -->
</div>
<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>
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
@@ -128,25 +126,17 @@ const { snapshot } = useSnapshot();
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
</EventsStackedBarChart>
</div>
</CardTitled> -->
</CardTitled>
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
<div class="flex flex-col gap-1">
<div class="poppins font-semibold text-[1.4rem] text-text">
Top events
</div>
<div class="poppins text-[1rem] text-text-sub/90">
Displays key events.
</div>
<CardTitled title="Top events" sub=" Displays key events." class="p-4 flex-[2] w-full h-full">
<div>
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
</div>
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
</div>
</CardTitled>
</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-1">
<DashboardWebsitesBarCard></DashboardWebsitesBarCard>
@@ -203,15 +193,15 @@ const { snapshot } = useSnapshot();
Do you want this KPIs for your website ?
</div>
<div class="poppins font-semibold text-text-sub">
Start now ! It's free.
Start now! It's free.
</div>
</div>
<div class="flex gap-2 flex-col md:flex-row">
<NuxtLink to="/"
class="bg-accent hover:bg-accent/90 px-14 py-4 poppins font-semibold text-[1.1rem] lg:text-[1.6rem] rounded-lg">
<LyxUiButton link="/" type="primary"
class="poppins font-semibold text-[1.1rem] lg:text-[1.6rem] flex items-center !px-14">
Get started
</NuxtLink>
</LyxUiButton>
<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">
Book a demo

View File

@@ -81,6 +81,33 @@ function handleOnError(errorResponse: any) {
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>
@@ -103,23 +130,34 @@ function handleOnError(errorResponse: any) {
</div>
<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>
with one-line code setup.
with extreme simplicity in under 30 sec.
<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.
</div>
</div> -->
</div>
<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 class="flex items-center">
<i class="fab fa-google"></i>
<div v-if="!isNoAuth" class="flex flex-col gap-2">
<div @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 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>
Continue with Google
</div>
<div v-if="isNoAuth" @click="loginWithoutAuth"
@@ -133,7 +171,7 @@ function handleOnError(errorResponse: any) {
</div>
<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>
our
<a class="underline" href="https://litlyx.com/terms" target="_blank">Terms of Service</a> and

View File

@@ -7,7 +7,7 @@ definePageMeta({ layout: 'header' });
<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">
<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> -->
</template>

Binary file not shown.

974
dashboard/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ export async function getUserProjectFromId(project_id: string, user: AuthContext
return project;
} else {
if (!user?.logged) return;
if (!project_id) return;
const project = await ProjectModel.findById(project_id);
if (!project) return;
const [hasAccess, role] = await hasAccessToProject(user.id, project_id, project);

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

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

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

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

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

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

View File

@@ -1,8 +1,5 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import { AiChatModel } from "@schema/ai/AiChatSchema";
import { sendMessageOnChat } from "~/server/services/AiService";
export default defineEventHandler(async event => {

View File

@@ -1,8 +1,7 @@
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
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 => {
@@ -19,11 +18,14 @@ export default defineEventHandler(async event => {
const chat = await AiChatModel.findOne({ _id: chat_id, project_id });
if (!chat) return;
const messages = chat.messages.filter(e => {
return (e.role == 'user' || (e.role == 'assistant' && e.content != undefined))
}).map(e => {
return { role: e.role, content: e.content }
});
return messages;
return (chat.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[])
.filter(e => e.role === 'assistant' || e.role === 'user')
.map(e => {
const charts = getChartsInMessage(e);
const content = e.content;
return { role: e.role, content, charts }
})
.filter(e=>{
return e.charts.length > 0 || e.content
})
});

View File

@@ -23,5 +23,6 @@ export default defineEventHandler(async event => {
if (chatsRemaining <= 0) return setResponseStatus(event, 400, 'CHAT_LIMIT_REACHED');
const response = await sendMessageOnChat(text, project._id.toString(), chat_id);
return response || 'Error getting response';
return response;
});

View File

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

View File

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

View File

@@ -51,6 +51,7 @@ export default defineEventHandler(async event => {
const savedUser = await newUser.save();
setImmediate(() => {
console.log('SENDING WELCOME EMAIL TO', payload.email);
if (payload.email) EmailService.sendWelcomeEmail(payload.email);
});

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

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

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

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

View File

@@ -2,7 +2,7 @@ import { EventModel } from "@schema/metrics/EventSchema";
import { getTimeline } from "./generic";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
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 => {
const project_id = getRequestProjectId(event);
@@ -27,7 +27,7 @@ export default defineEventHandler(async event => {
model: EventModel,
from, to, slice
});
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged;
});

View File

@@ -1,9 +1,7 @@
import { getTimeline } from "./generic";
import { VisitModel } from "@schema/metrics/VisitSchema";
import DateService from "@services/DateService";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
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 => {
const project_id = getRequestProjectId(event);
@@ -31,7 +29,7 @@ export default defineEventHandler(async event => {
referrer
}
});
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged;
});

View File

@@ -2,7 +2,7 @@ import { getTimeline } from "./generic";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
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 => {
const project_id = getRequestProjectId(event);
@@ -28,7 +28,7 @@ export default defineEventHandler(async event => {
model: SessionModel,
from, to, slice
});
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged;
});

View File

@@ -2,7 +2,7 @@ import { getTimeline } from "./generic";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
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 => {
const project_id = getRequestProjectId(event);
@@ -45,7 +45,7 @@ export default defineEventHandler(async event => {
count: { $divide: ["$duration", "$count"] }
},
});
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from ,to);
return timelineFilledMerged;
});

View File

@@ -2,8 +2,7 @@ import { VisitModel } from "@schema/metrics/VisitSchema";
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
import DateService from "@services/DateService";
import { executeTimelineAggregation, fillAndMergeTimelineAggregation } from "~/server/services/TimelineService";
import { executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
export default defineEventHandler(async event => {
const project_id = getRequestProjectId(event);
@@ -22,18 +21,16 @@ export default defineEventHandler(async event => {
return await Redis.useCache({
key: `timeline:visits:${project_id}:${slice}:${from || 'none'}:${to || 'none'}`,
exp: TIMELINE_EXPIRE_TIME
exp: TIMELINE_EXPIRE_TIME,
}, async () => {
const timelineData = await executeTimelineAggregation({
projectId: project._id,
model: VisitModel,
from, to, slice
from, to, slice,
});
const timelineFilledMerged = fillAndMergeTimelineAggregation(timelineData, slice);
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged;
});
});

View File

@@ -1,6 +0,0 @@
export default defineEventHandler(async event => {
console.log('TEST');
return;
});

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

View File

@@ -9,9 +9,13 @@ export default defineEventHandler(async 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;

View File

@@ -4,6 +4,8 @@ import type Event from 'stripe';
import { ProjectModel } from '@schema/ProjectSchema';
import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM';
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) {
const customer_id = event.data.object.customer as string;
@@ -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)
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 };
@@ -201,7 +250,11 @@ export default defineEventHandler(async event => {
const eventData = StripeService.parseWebhook(body, signature);
if (!eventData) return;
// console.log('WEBHOOK FIRED', eventData.type);
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 === 'customer.subscription.deleted') return await onSubscriptionDeleted(eventData);
if (eventData.type === 'customer.subscription.created') return await onSubscriptionCreated(eventData);

View File

@@ -22,6 +22,8 @@ export default defineEventHandler(async event => {
const { name } = await readBody(event);
if (name.length == 0) return setResponseStatus(event, 400, 'name is required');
project.name = name;
await project.save();

View File

@@ -18,6 +18,8 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id);
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 });
if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project');

View File

@@ -18,6 +18,10 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id);
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);
let timeSub = 1000 * 60 * 60 * 24;

View File

@@ -2,18 +2,25 @@
import pdfkit from 'pdfkit';
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 { VisitModel } from '@schema/metrics/VisitSchema';
import { EventModel } from '@schema/metrics/EventSchema';
type PDF_Data = {
pageVisits: number, customEvents: number,
visitsDay: number, eventsDay: number, visitsSessions: number,
visitsSessionsDay: number
type PDFGenerationData = {
projectName: string,
snapshotName: string,
totalVisits: string,
avgVisitsDay: string,
totalEvents: string,
topDomain: string,
topDevice: string,
topCountries: string[],
topReferrers: string[],
avgGrowthText: string,
}
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 pdf = new pdfkit({
size: 'A4',
margins: { top: 50, bottom: 50, left: 50, right: 50 },
const LINE_SPACING = 0.5;
function createPdf(data: PDFGenerationData) {
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
pdf.text('Average growth:', { align: 'left' }).moveDown(LINE_SPACING);
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')
.rect(0, 0, pdf.page.width, pdf.page.height)
.fill('#000000');
.text('Created with Litlyx.com', 50, 760, { align: 'center' });
// Title
pdf
.font('pdf_fonts/Poppins-Bold.ttf')
.fontSize(26)
.fillColor('#ffffff')
.text(`Report of: ${projectName}`, 50, 50);
pdf.image('pdf_images/logo.png', 460, 700, { width: 100 });
// 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();
return pdf;
}
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
@@ -114,51 +93,73 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id);
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({ project_id: project._id });
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 visitsCount = await VisitModel.countDocuments({
project_id: project._id,
created_at: { $gte: from, $lte: to }
});
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);
return avg;
};
const avgVisitsSessionsDay = () => {
const days = (Date.now() - (firstViewDate?.created_at.getTime() || 0)) / 1000 / 60 / 60 / 24;
const avg = sessionsVisitsCount[0].count / Math.max(days, 1);
return avg;
};
const topDevices = await VisitModel.aggregate([
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
{ $group: { _id: "$device", count: { $sum: 1 } } },
{ $match: { _id: { $ne: null } } },
{ $sort: { count: -1 } },
{ $limit: 1 }
]);
const pdf = createPdf(
project.name, {
customEvents: eventsCount,
eventsDay: avgEventsDay(),
pageVisits: visitsCount,
visitsDay: avgVisitDay(),
visitsSessions: sessionsVisitsCount[0].count,
visitsSessionsDay: avgVisitsSessionsDay()
const topDevice = topDevices?.[0]?._id || 'Not enough data';
const topDomains = await VisitModel.aggregate([
{ $match: { project_id: project._id, created_at: { $gte: from, $lte: to } } },
{ $group: { _id: "$website", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 1 }
]);
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();

View File

@@ -16,6 +16,25 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id);
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 projectLimits = await ProjectLimitModel.findOne({ project_id });

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

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

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

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

View File

@@ -2,39 +2,47 @@ import mongoose from "mongoose";
import { Redis } from "~/server/services/CacheService";
import EmailService from '@services/EmailService';
import StripeService from '~/server/services/StripeService';
import { anomalyLoop } from "./services/AnomalyService";
import { logger } from "./Logger";
const config = useRuntimeConfig();
let connection: mongoose.Mongoose;
export default async () => {
console.log('[SERVER] Initializing');
logger.info('[SERVER] Initializing');
if (config.EMAIL_SERVICE) {
EmailService.createTransport(config.EMAIL_SERVICE, config.EMAIL_HOST, config.EMAIL_USER, config.EMAIL_PASS);
console.log('[EMAIL] Initialized')
EmailService.init(config.BREVO_API_KEY);
logger.info('[EMAIL] Initialized');
}
if (config.STRIPE_SECRET) {
StripeService.init(config.STRIPE_SECRET, config.STRIPE_WH_SECRET, false);
console.log('[STRIPE] Initialized')
logger.info('[STRIPE] Initialized');
} else {
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) {
console.log('[DATABASE] Connecting');
logger.info('[DATABASE] Connecting');
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();
console.log('[REDIS] Connected');
logger.info('[REDIS] Connected');
console.log('[SERVER] Completed');
logger.info('[SERVER] Completed');
logger.warn('[ANOMALY LOOP] Disabled');
// anomalyLoop();
};

View File

@@ -0,0 +1,7 @@
import { logger } from "../Logger"
export default defineEventHandler(async (event) => {
const start = Date.now();
event.context['performance-start'] = start.toString();
});

View File

@@ -24,29 +24,21 @@ async function authorizationMiddleware(event: H3Event<EventHandlerRequest>) {
const authorization = event.headers.get('Authorization');
if (!authorization) {
event.context.auth = { logged: false, }
event.context.auth = { logged: false }
} else {
const [type, token] = authorization.split(' ');
const valid = readUserJwt(token);
const valid = readUserJwt(token);
if (!valid) return event.context.auth = { logged: false }
const user = await UserModel.findOne({ email: valid.email })
if (!user) return event.context.auth = { logged: false };
const premium: any = null;//await PremiumModel.findOne({ user_id: user.id });
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)) {
roles.push('ADMIN');
}
@@ -61,6 +53,7 @@ async function authorizationMiddleware(event: H3Event<EventHandlerRequest>) {
},
id: user._id.toString()
}
event.context.auth = authContext;
}

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

View File

@@ -1,59 +1,35 @@
import { getVisitsCountFromDateRange } from '~/server/api/ai/functions/AI_Visits';
import OpenAI from "openai";
import { AiChatModel } from '@schema/ai/AiChatSchema';
import { AI_EventsFunctions, AI_EventsTools } from '../api/ai/functions/AI_Events';
import { ProjectCountModel } from '@schema/ProjectsCounts';
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 OPENAI_MODEL: OpenAI.Chat.ChatModel = 'gpt-4o-mini';
const openai = new OpenAI({
organization: AI_ORG,
project: AI_PROJECT,
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[] = [
get_visits_count_Schema,
...AI_EventsTools
...AiVisitsInstance.getTools(),
...AiEventsInstance.getTools(),
...AiComposableChartInstance.getTools()
]
const functions: any = {
get_current_date: async ({ }) => {
return new Date().toISOString();
},
get_visits_count: async ({ pid, from, to }: any) => {
return await getVisitsCountFromDateRange(pid, from, to);
},
...AI_EventsFunctions
...AiVisitsInstance.getHandlers(),
...AiEventsInstance.getHandlers(),
...AiComposableChartInstance.getHandlers()
}
@@ -81,6 +57,14 @@ async function setChatTitle(title: string, chat_id?: string) {
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) {
@@ -92,7 +76,8 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_
messages.push(...chatMessages);
} else {
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);
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);
}
const userMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = {
role: 'user', content: text
}
const userMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = { role: 'user', content: text }
messages.push(userMessage);
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;
let toolCalls = responseMessage.tool_calls;
const chartsData: string[][] = [];
await addMessageToChat(responseMessage, chat_id);
messages.push(responseMessage);
while ((response.choices[0].message.tool_calls?.length || 0) > 0) {
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) {
console.log({ toolCalls: toolCalls.length });
for (const toolCall of toolCalls) {
const functionName = toolCall.function.name;
const functionToCall = functions[functionName];
const functionArgs = JSON.parse(toolCall.function.arguments);
console.log('CALLING FUNCTION', functionName, 'WITH PARAMS', functionArgs);
const functionResponse = await functionToCall({ pid, ...functionArgs });
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);
for (const toolCall of response.choices[0].message.tool_calls) {
const functionName = toolCall.function.name;
console.log('Calling tool function', functionName);
const functionToCall = functions[functionName];
const functionArgs = JSON.parse(toolCall.function.arguments);
const functionResponse = await functionToCall({ project_id: pid, ...functionArgs });
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 });
responseMessage = response.choices[0].message;
toolCalls = responseMessage.tool_calls;
await addMessageToChat(responseMessage, chat_id);
response = await openai.chat.completions.create({ model: OPENAI_MODEL, messages, n: 1, tools });
}
await addMessageToChat(response.choices[0].message, chat_id);
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { ai_messages: 1 } })
return responseMessage.content;
return { content: response.choices[0].message.content, charts: chartsData.filter(e => e.length > 0).flat() };
}

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

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

View File

@@ -4,15 +4,14 @@ import { createClient } from 'redis';
const runtimeConfig = useRuntimeConfig();
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_OLD_SESSIONS_EXPIRE_TIME = 60 * 5;
export const COUNTS_SESSIONS_EXPIRE_TIME = 60 * 3;
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 {

View File

@@ -1,4 +1,4 @@
import { getPlanFromTag } from '@data/PREMIUM';
import { getPlanFromId, getPlanFromTag, PREMIUM_TAG } from '@data/PREMIUM';
import Stripe from 'stripe';
class StripeService {
@@ -29,6 +29,33 @@ class StripeService {
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) {
if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized');
@@ -50,6 +77,13 @@ class StripeService {
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) {
if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized');
@@ -78,7 +112,6 @@ class StripeService {
return invoices;
}
async getCustomer(customer_id: string) {
if (this.disabledMode) return;
if (!this.stripe) throw Error('Stripe not initialized');
@@ -100,8 +133,41 @@ class StripeService {
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) {
if (this.disabledMode) return;

View File

@@ -62,3 +62,9 @@ export function fillAndMergeTimelineAggregation(timeline: { _id: string, count:
const merged = DateService.mergeFilledDates(filledDates, timeline, '_id', slice, { count: 0 });
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;
}

View File

@@ -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) return (num / 1_000).toFixed(decimals) + ' K';
return num.toFixed();
return isNaN(num) ? '0' : num.toFixed();
}

View File

@@ -40,10 +40,8 @@ services:
# Optional - Used to send welcome and quota emails
# EMAIL_SERVICE: ""
# EMAIL_HOST: ""
# EMAIL_USER: ""
# EMAIL_PASS: ""
# NUXT_EMAIL_SERVICE: "Brevo"
# NUXT_BREVO_API_KEY: ""
PORT: "3999"
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
# NUXT_EMAIL_SERVICE: ""
# NUXT_EMAIL_HOST: ""
# NUXT_EMAIL_USER: ""
# NUXT_EMAIL_PASS: ""
# NUXT_EMAIL_SERVICE: "Brevo"
# NUXT_BREVO_API_KEY: ""
NUXT_AUTH_JWT_SECRET: "litlyx_jwt_secret"

Some files were not shown because too many files have changed in this diff Show More