83 Commits

Author SHA1 Message Date
Emily
7658dbe85c fix members 2025-03-26 16:15:46 +01:00
Emily
1f9ef5d18c add payment service 2025-03-26 15:30:22 +01:00
Emily
94a28b31d3 update shields 2025-03-24 18:54:15 +01:00
Emily
87c9aca5c4 shields update 2025-03-20 16:04:00 +01:00
Emily
afda29997d Fix 2025-03-14 16:40:50 +01:00
Emily
d1b3e997c1 fix bug on settings page 2025-03-11 15:14:37 +01:00
Emily
be82f7046f fix projection UI on chart 2025-03-11 13:24:53 +01:00
Emily
45e9a9c6a7 update snapthots + admin panel users 2025-03-10 15:54:00 +01:00
Emily
942d074f99 refactoring 2025-03-06 10:55:46 +01:00
Emily
63fa3995c5 refactoring 2025-03-03 19:31:35 +01:00
Emily
76e5e07f79 Merge pull request #29 from wpgaurav/wpgaurav-patch-2
Fallback fonts
2025-02-17 14:20:04 +01:00
Emily
b8f9e598a7 Merge pull request #30 from wpgaurav/wpgaurav-patch-1
Switch from Google Fonts to Bunny Fonts and other privacy first alternatives
2025-02-17 14:19:46 +01:00
Emily
0ee4895e1a Merge pull request #31 from wpgaurav/main
Compress file sizes
2025-02-17 14:19:25 +01:00
Gaurav Tiwari
72d6b97383 Compress file sizes
40% size reduction
2025-02-15 22:40:14 +05:30
Gaurav Tiwari
3f22c655a5 Fallback fonts 2025-02-15 22:34:27 +05:30
Gaurav Tiwari
4fea549a5a Switch from Google Fonts to Bunny Fonts and other privacy first alternatives 2025-02-15 22:29:02 +05:30
Emily
4c1d10f8b7 fix dockerfiles 2025-02-14 17:15:38 +01:00
Emily
50d275e0ff fix dashboard dockerfile 2025-02-14 17:10:47 +01:00
Emily
98238fa180 fix dashboard dockerfile 2025-02-14 17:06:34 +01:00
Emily
c07bccd0dd fix dockerfile dashboard 2025-02-14 17:03:44 +01:00
Emily
7192c31136 fix dockerfiles 2025-02-14 16:55:34 +01:00
Emily
56d7e71d90 add username+pass login on NO_AUTH mode 2025-02-14 16:33:33 +01:00
Emily
30229d4b97 Merge branch 'refactoring' 2025-02-14 16:13:46 +01:00
Emily
b2303468a4 update ui 2025-02-14 16:13:06 +01:00
Emily
af6dff57ed update admin panel 2025-02-12 16:49:19 +01:00
antonio
dd8b089c46 updated readme 2025-02-12 11:00:47 +01:00
antonio
a5750d556a update readme 2025-02-12 10:58:56 +01:00
Emily
f5882bff9f admin panel 2025-02-12 03:20:54 +01:00
Emily
f18cdc8278 Merge branch 'refactoring' 2025-02-11 14:51:09 +01:00
Emily
a7ebbc22c0 update 2025-02-11 14:27:35 +01:00
Emily
346eecc928 update guests logic + fix pdf 2025-02-10 16:28:34 +01:00
Emily
abc485a9ef update dashboard + server 2025-02-10 15:30:19 +01:00
Emily
0292829805 implement domain filter 2025-02-06 15:23:55 +01:00
Emily
4e2c8468f8 change position of docs + text 2025-02-06 15:23:47 +01:00
Emily
38cfd4315d fix ai UI + add domain filter on visits 2025-02-06 15:23:38 +01:00
Emily
b592695a49 add domain filter on events 2025-02-05 16:02:32 +01:00
Emily
0963201a32 rewrite consumer + testmode utils 2025-02-01 15:26:26 +01:00
Emily
4da840f2ec remove shared 2025-01-31 18:47:29 +01:00
Emily
a1718875d9 remove shared 2025-01-31 18:46:13 +01:00
Emily
e931235533 removed shared 2025-01-31 18:10:22 +01:00
Emily
881a7800ce updating consumer 2025-01-31 15:33:26 +01:00
Emily
487c3ac7b4 change consumer 2025-01-31 14:58:46 +01:00
Emily
0dd94be6e6 change text 2025-01-31 14:56:45 +01:00
Emily
29a220b21e fix testmode push 2025-01-30 16:21:55 +01:00
Emily
8cc2f07b95 update gitignore 2025-01-30 14:36:27 +01:00
Emily
88cec21df1 Delete dashboard/ecosystem.config.js 2025-01-30 14:36:11 +01:00
Emily
8183ae1e68 update deploy script 2025-01-30 14:33:54 +01:00
Emily
0f39cab26a update deploy scripts 2025-01-30 14:33:31 +01:00
Emily
a2e4ed9ee0 updates for testmode 2025-01-29 17:14:10 +01:00
antonio
30b5db4200 changed upgrade email text 2025-01-29 16:07:58 +01:00
Emily
bfeee8673c use new mail service in dashboard 2025-01-29 16:03:01 +01:00
Emily
39b8dd84f1 update deploy scripts + dashboard ecosystem 2025-01-28 15:29:20 +01:00
Emily
19b7c7664a update scripts to typescript 2025-01-28 15:08:42 +01:00
Emily
a3e74adf9c . 2025-01-27 16:48:52 +01:00
Emily
ad9aabcbf6 add email service deploy 2025-01-27 16:42:07 +01:00
Emily
510bc2545a add email service 2025-01-27 15:12:22 +01:00
Emily
65c682c75d add appsumo_unicorn 2025-01-27 14:10:28 +01:00
Emily
04acc0b18e add appsumo_unicorn 2025-01-27 14:09:51 +01:00
Emily
852fea45a5 writing shared 2025-01-27 14:08:03 +01:00
Emily
6f3e59e72e fix path 2025-01-25 15:31:50 +01:00
Emily
3960eaa8ad refactoring 2025-01-25 15:31:37 +01:00
Emily
e4bdf7e4c3 refactoring dashboard 2025-01-23 17:34:43 +01:00
Emily
afeaac1b0d update endpoints to support domains 2025-01-22 17:46:59 +01:00
Emily
8922507a64 implementing domain selector 2025-01-21 18:07:01 +01:00
Emily
13e94cb0f0 align icons of devices 2025-01-20 14:55:12 +01:00
Emily
3923a06e9b fix actionable + lightmode 2025-01-20 14:47:57 +01:00
Emily
6b5d23566c fix SELFHOST env on docker-compose 2025-01-17 18:07:53 +01:00
Emily
dbcda95823 fix selfhost 2025-01-17 17:40:20 +01:00
Emily
fb89c87489 fix selfhost 2025-01-17 16:44:22 +01:00
Emily
b59eea47e9 active snapshot on creation 2025-01-17 16:44:16 +01:00
Emily
473331047d fix dockercompose 2025-01-16 18:21:59 +01:00
Emily
5af77ff63e update docker-compose 2025-01-16 18:16:16 +01:00
Emily
e6e2340432 fix lightmode 2025-01-16 16:47:35 +01:00
Emily
0b90c2fe3c add lightmode to alerts 2025-01-15 16:34:10 +01:00
Emily
a6d1797a4f add lightmode 2025-01-15 16:31:10 +01:00
Emily
d1abe1a91f change packages 2025-01-15 14:45:02 +01:00
Emily
b733cd2a68 navbar lightmode 2025-01-14 17:38:33 +01:00
Emily
88ebfc188c add password reset + password change 2025-01-13 17:01:34 +01:00
antonio
ab95772dd4 read-me 2025-01-13 15:28:20 +01:00
Emily
0d5dbc69ad update docker compose 2025-01-13 15:24:14 +01:00
Emily
8a359936d1 . 2025-01-04 18:16:37 +01:00
Emily
ffd2e96138 fix emails templates 2025-01-04 18:14:12 +01:00
Emily
b8e434be9a fix chat limit reached message 2025-01-04 15:52:32 +01:00
354 changed files with 14831 additions and 15537 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,5 +0,0 @@
{
"files.exclude": {
"**/node_modules": true
}
}

View File

@@ -1,30 +0,0 @@
FROM node:21-alpine as base
FROM base as build
RUN npm i -g pnpm
RUN npm i -g pm2
# COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
# RUN npm install --production=false
WORKDIR /home/app
COPY --link dashboard ./dashboard
COPY --link lyx-ui ./lyx-ui
COPY --link consumer ./consumer
COPY --link producer ./producer
COPY --link shared ./shared
WORKDIR /home/app/producer
RUN pnpm install
WORKDIR /home/app/consumer
RUN pnpm install
WORKDIR /home/app/dashboard
RUN pnpm install
RUN pnpm run dev
# CMD [ "node", "/home/app/.output/server/index.mjs" ]

View File

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

BIN
assets/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

12
consumer/.gitignore vendored
View File

@@ -1,8 +1,10 @@
node_modules node_modules
static
ecosystem.config.cjs ecosystem.config.cjs
dist ecosystem.config.js
scripts/start_dev.js scripts/start_dev.js
package-lock.json scripts/start_dev_prod.js
build_all.bat dist
tests src/shared

View File

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

View File

@@ -1,21 +0,0 @@
module.exports = {
apps: [
{
name: 'consumer',
port: '3031',
exec_mode: 'cluster',
instances: '2',
script: './dist/consumer/src/index.js',
env: {
EMAIL_SERVICE: '',
BREVO_API_KEY: '',
MONGO_CONNECTION_STRING: '',
REDIS_URL: "",
REDIS_USERNAME: "",
REDIS_PASSWORD: "",
STREAM_NAME: "",
GROUP_NAME: ''
}
}
]
}

View File

@@ -1,9 +1,13 @@
{ {
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"express": "^4.19.2", "express": "^4.19.2",
"mongoose": "^8.9.5",
"redis": "^4.7.0",
"ua-parser-js": "^1.0.37" "ua-parser-js": "^1.0.37"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^20.12.13", "@types/node": "^20.12.13",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -14,12 +18,14 @@
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"dev": "node scripts/start_dev.js", "dev": "node scripts/start_dev.js",
"dev_prod": "node scripts/start_dev_prod.js",
"compile": "tsc", "compile": "tsc",
"build_project": "node ../scripts/build.js", "build": "npm run compile && npm run create_db",
"build": "npm run compile && npm run build_project && npm run create_db",
"create_db": "cd scripts && ts-node create_database.ts", "create_db": "cd scripts && ts-node create_database.ts",
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../", "docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
"docker-inspect": "docker run -it litlyx-consumer sh" "docker-inspect": "docker run -it litlyx-consumer sh",
"workspace:shared": "ts-node ../scripts/consumer/shared.ts",
"workspace:deploy": "ts-node ../scripts/consumer/deploy.ts"
}, },
"keywords": [], "keywords": [],
"author": "Emily", "author": "Emily",

1493
consumer/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,10 @@
import { ProjectModel } from "@schema/project/ProjectSchema"; import { ProjectModel } from "./shared/schema/project/ProjectSchema";
import { UserModel } from "@schema/UserSchema"; import { UserModel } from "./shared/schema/UserSchema";
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema"; import { LimitNotifyModel } from "./shared/schema/broker/LimitNotifySchema";
import EmailService from '@services/EmailService'; import { EmailService } from './shared/services/EmailService';
import { requireEnv } from "@utils/requireEnv"; import { TProjectLimit } from "./shared/schema/project/ProjectsLimits";
import { TProjectLimit } from "@schema/project/ProjectsLimits"; import { EmailServiceHelper } from "./EmailServiceHelper";
if (process.env.EMAIL_SERVICE) {
EmailService.init(requireEnv('BREVO_API_KEY'));
}
export async function checkLimitsForEmail(projectCounts: TProjectLimit) { export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
@@ -27,7 +24,14 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
const owner = await UserModel.findById(project.owner); const owner = await UserModel.findById(project.owner);
if (!owner) return; if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmailMax(owner.email, project.name); setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('limit_max', {
target: owner.email,
projectName: project.name
});
EmailServiceHelper.sendEmail(emailData);
});
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true }); await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: true });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) { } else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
@@ -40,7 +44,14 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
const owner = await UserModel.findById(project.owner); const owner = await UserModel.findById(project.owner);
if (!owner) return; if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail90(owner.email, project.name); setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('limit_90', {
target: owner.email,
projectName: project.name
});
EmailServiceHelper.sendEmail(emailData);
});
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false }); await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: true, limit3: false });
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) { } else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
@@ -53,7 +64,14 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
const owner = await UserModel.findById(project.owner); const owner = await UserModel.findById(project.owner);
if (!owner) return; if (!owner) return;
if (process.env.EMAIL_SERVICE) await EmailService.sendLimitEmail50(owner.email, project.name); setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('limit_50', {
target: owner.email,
projectName: project.name
});
EmailServiceHelper.sendEmail(emailData);
});
await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false }); await LimitNotifyModel.updateOne({ project_id: projectCounts.project_id }, { limit1: true, limit2: false, limit3: false });
} }

View File

@@ -0,0 +1,19 @@
import { EmailServerInfo } from './shared/services/EmailService'
import axios from 'axios';
const EMAIL_SECRET = process.env.EMAIL_SECRET;
export class EmailServiceHelper {
static async sendEmail(data: EmailServerInfo) {
try {
await axios(data.url, {
method: 'POST',
data: data.body,
headers: { ...data.headers, 'x-litlyx-token': EMAIL_SECRET }
})
} catch (ex) {
console.error(ex);
}
}
}

View File

@@ -1,7 +1,7 @@
import { ProjectLimitModel } from '@schema/project/ProjectsLimits'; import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits'; import { MAX_LOG_LIMIT_PERCENT } from './shared/data/broker/Limits';
import { checkLimitsForEmail } from './EmailController'; import { checkLimitsForEmail } from './EmailController';
export async function checkLimits(project_id: string) { export async function checkLimits(project_id: string) {

28
consumer/src/Metrics.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Router } from 'express';
import { RedisStreamService } from './shared/services/RedisStreamService';
import { requireEnv } from './shared/utils/requireEnv';
const stream_name = requireEnv('STREAM_NAME');
export const metricsRouter = Router();
metricsRouter.get('/queue', async (req, res) => {
try {
const size = await RedisStreamService.getQueueInfo(stream_name);
res.json({ size });
} catch (ex) {
console.error(ex);
res.status(500).json({ error: ex.message });
}
})
metricsRouter.get('/durations', async (req, res) => {
try {
const durations = await RedisStreamService.METRICS_get()
res.json({ durations });
} catch (ex) {
console.error(ex);
res.status(500).json({ error: ex.message });
}
})

View File

@@ -1,39 +1,31 @@
import { requireEnv } from '@utils/requireEnv'; import { requireEnv } from './shared/utils/requireEnv';
import { connectDatabase } from '@services/DatabaseService'; import { connectDatabase } from './shared/services/DatabaseService';
import { RedisStreamService } from '@services/RedisStreamService'; import { RedisStreamService } from './shared/services/RedisStreamService';
import { ProjectModel } from "@schema/project/ProjectSchema"; import { ProjectModel } from "./shared/schema/project/ProjectSchema";
import { VisitModel } from "@schema/metrics/VisitSchema"; import { VisitModel } from "./shared/schema/metrics/VisitSchema";
import { SessionModel } from "@schema/metrics/SessionSchema"; import { SessionModel } from "./shared/schema/metrics/SessionSchema";
import { EventModel } from "@schema/metrics/EventSchema"; import { EventModel } from "./shared/schema/metrics/EventSchema";
import { lookup } from './lookup'; import { lookup } from './lookup';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { checkLimits } from './LimitChecker'; import { checkLimits } from './LimitChecker';
import express from 'express'; import express from 'express';
import { ProjectLimitModel } from '@schema/project/ProjectsLimits'; import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
import { ProjectCountModel } from '@schema/project/ProjectsCounts'; import { ProjectCountModel } from './shared/schema/project/ProjectsCounts';
import { metricsRouter } from './Metrics';
const app = express(); const app = express();
let durations: number[] = []; app.use('/metrics', metricsRouter);
app.get('/status', async (req, res) => { app.listen(process.env.PORT, () => console.log(`Listening on port ${process.env.PORT}`));
try {
return res.json({ status: 'ALIVE', durations })
} catch (ex) {
console.error(ex);
return res.setStatus(500).json({ error: ex.message });
}
})
app.listen(process.env.PORT);
connectDatabase(requireEnv('MONGO_CONNECTION_STRING')); connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
main(); main();
const CONSUMER_NAME = `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
async function main() { async function main() {
@@ -43,7 +35,7 @@ async function main() {
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop" const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "startReadingLoop"
await RedisStreamService.startReadingLoop({ await RedisStreamService.startReadingLoop({
stream_name, group_name, consumer_name: `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}` stream_name, group_name, consumer_name: CONSUMER_NAME
}, processStreamEntry); }, processStreamEntry);
} }
@@ -55,7 +47,7 @@ async function processStreamEntry(data: Record<string, string>) {
try { try {
const eventType = data._type; const eventType = data._type;
if (!eventType) return; if (!eventType) return console.log('No type');
const { pid, sessionHash } = data; const { pid, sessionHash } = data;
@@ -73,18 +65,13 @@ async function processStreamEntry(data: Record<string, string>) {
await process_visit(data, sessionHash); await process_visit(data, sessionHash);
} }
// console.log('Entry processed in', duration, 'ms');
} catch (ex: any) { } catch (ex: any) {
console.error('ERROR PROCESSING STREAM EVENT', ex.message); console.error('ERROR PROCESSING STREAM EVENT', ex.message);
} }
const duration = Date.now() - start; const duration = Date.now() - start;
durations.push(duration); RedisStreamService.METRICS_onProcess(CONSUMER_NAME, duration);
if (durations.length > 1000) {
durations = durations.splice(500);
}
} }
@@ -125,7 +112,7 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
async function process_keep_alive(data: Record<string, string>, sessionHash: string) { async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
const { pid, instant, flowHash, timestamp } = data; const { pid, instant, flowHash, timestamp, website } = data;
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 }); const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
if (!existingSession) { if (!existingSession) {
@@ -136,13 +123,15 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, { await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 0 }, $inc: { duration: 0 },
flowHash, flowHash,
updated_at: Date.now() website,
updated_at: new Date(parseInt(timestamp))
}, { upsert: true }); }, { upsert: true });
} else { } else {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, { await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 1 }, $inc: { duration: 1 },
flowHash, flowHash,
updated_at: Date.now() website,
updated_at: new Date(parseInt(timestamp))
}, { upsert: true }); }, { upsert: true });
} }
@@ -150,7 +139,7 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
async function process_event(data: Record<string, string>, sessionHash: string) { async function process_event(data: Record<string, string>, sessionHash: string) {
const { name, metadata, pid, flowHash, timestamp } = data; const { name, metadata, pid, flowHash, timestamp, website } = data;
let metadataObject; let metadataObject;
try { try {
@@ -162,6 +151,7 @@ async function process_event(data: Record<string, string>, sessionHash: string)
await Promise.all([ await Promise.all([
EventModel.create({ EventModel.create({
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash, project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
website,
created_at: new Date(parseInt(timestamp)) created_at: new Date(parseInt(timestamp))
}), }),
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }), ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),

View File

@@ -1,9 +1,7 @@
{ {
"extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "NodeNext", "module": "NodeNext",
"target": "ESNext", "target": "ESNext",
"esModuleInterop": true,
"outDir": "dist" "outDir": "dist"
}, },
"include": [ "include": [

View File

@@ -24,7 +24,6 @@ winston-*.ndjson
.env.* .env.*
!.env.example !.env.example
# Test reports # Test reports
*.report.txt *.report.txt
@@ -35,5 +34,10 @@ out.pdf
tests tests
# EXPLAINS MONGODB # EXPLAINS MONGODB
explains explains
#Ecosystem
ecosystem.config.cjs
ecosystem.config.js
shared

View File

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

9
dashboard/app.config.ts Normal file
View File

@@ -0,0 +1,9 @@
export default defineAppConfig({
ui: {
notifications: {
position: 'top-0 bottom-[unset]'
}
}
})

View File

@@ -16,18 +16,18 @@ const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
<template> <template>
<div class="w-dvw h-dvh bg-lyx-background-light relative"> <div class="w-dvw h-dvh bg-lyx-lightmode-background-light dark:bg-lyx-background-light relative">
<Transition name="drawer"> <Transition name="drawer">
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses" <LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="drawerVisible"> class="bg-lyx-lightmode-background-light dark:bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="drawerVisible">
</LazyDrawerGeneric> </LazyDrawerGeneric>
</Transition> </Transition>
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0"> <div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
<div v-for="alert of alerts" <div v-for="alert of alerts"
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg"> class="w-[30vw] min-w-[20rem] relative bg-lyx-lightmode-background dark:bg-[#151515] overflow-hidden border-solid border-[2px] border-lyx-lightmode-widget dark:border-[#262626] rounded-lg p-6 drop-shadow-lg">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div> <i :class="alert.icon"></i> </div> <div> <i :class="alert.icon"></i> </div>
<div class="grow"> <div class="grow">
@@ -56,8 +56,8 @@ const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
</div> </div>
<div v-if="showDialog" <div v-if="showDialog"
class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] bg-black/50"> class="custom-dialog w-full h-full flex items-center justify-center lg:pl-32 lg:p-20 p-4 absolute left-0 top-0 z-[100] backdrop-blur-[2px] dark:bg-black/50">
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter"> <div :style="dialogStyle" class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-widget-lighter rounded-xl relative outline outline-1">
<div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8"> <div v-if="dialogClosable" class="flex justify-end absolute z-[100] right-8 top-8">
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i> <i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
</div> </div>
@@ -69,6 +69,7 @@ const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
<UModals /> <UModals />
<UNotifications />
<LazyOnboarding> </LazyOnboarding> <LazyOnboarding> </LazyOnboarding>

14
dashboard/assets/main.css Normal file
View File

@@ -0,0 +1,14 @@
@import './font-awesome/css/all.css';
/* Are these many fonts required? For the time being switching to privacy friendly bunny.net for Google fonts. NOTE: No variable font support in bunnet.net yet. */
@import url('https://fonts.bunny.net/css?family=nunito:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.cdnfonts.com/css/brockmann');
@import url('https://fonts.bunny.net/css?family=inter:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
@import url('https://fonts.bunny.net/css?family=manrope:300,400,500,600,700,800');
@import url('https://fonts.bunny.net/css?family=lato:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.bunny.net/css?family=poppins:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://cdn.jsdelivr.net/npm/material-symbols@0.28.2/index.css');

View File

@@ -1,18 +1,9 @@
@use './utilities.scss'; @use './utilities.scss';
@use './colors.scss'; @use './colors.scss';
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap'); :root{
@import url('https://fonts.cdnfonts.com/css/brockmann'); --font-sans: "SF Pro Text","SF Pro Icons", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "Google Sans", "Helvetica Neue", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap'); }
@import '../font-awesome/css/all.css';
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
@font-face { @font-face {
@@ -20,7 +11,6 @@
src: url("../fonts/GeistVF.ttf"); src: url("../fonts/GeistVF.ttf");
} }
.actionable-visits-color-checkbox { .actionable-visits-color-checkbox {
color: #5655d7; color: #5655d7;
} }
@@ -33,7 +23,7 @@
} }
.geist { .geist {
font-family: "Geist"; font-family: "Geist", var(--font-sans);
} }
@@ -48,38 +38,38 @@
} }
.brockmann { .brockmann {
font-family: "Brockmann" !important; font-family: "Brockmann", var(--font-sans)!important;
} }
.nunito { .nunito {
font-family: "Nunito" !important; font-family: "Nunito",var(--font-sans)!important;
} }
.inter { .inter {
font-family: "Inter" !important; font-family: "Inter", var(--font-sans)!important;
} }
.geometric { .geometric {
font-family: 'Geometric Sans Serif v1' !important; font-family: "Geometric Sans Serif v1", var(--font-sans)!important;
} }
.manrope { .manrope {
font-family: 'Manrope' !important; font-family: "Manrope", var(--font-sans)!important;
} }
.lato { .lato {
font-family: 'Lato' !important; font-family: "Lato", var(--font-sans)!important;
} }
.poppins { .poppins {
font-family: 'Poppins' !important; font-family: "Poppins", var(--font-sans)!important;
} }
.poppins-childs { .poppins-childs {
font-family: 'Poppins' !important; font-family: "Poppins", var(--font-sans)!important;
* { * {
font-family: 'Poppins' !important; font-family: "Poppins", var(--font-sans)!important;
} }
} }
@@ -119,5 +109,5 @@ body {
} }
* { * {
font-family: 'Nunito'; font-family: 'Nunito', var(--font-sans);
} }

View File

@@ -54,7 +54,7 @@ function openExternalLink(link: string) {
<div class="flex justify-between mb-3"> <div class="flex justify-between mb-3">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<div class="poppins font-semibold text-[1.4rem] text-text"> <div class="poppins font-semibold text-[1.4rem] text-lyx-lightmode-text dark:text-lyx-text">
{{ label }} {{ label }}
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
@@ -63,7 +63,7 @@ function openExternalLink(link: string) {
</div> </div>
</div> </div>
<div class="poppins text-[1rem] text-text-sub/90"> <div class="poppins text-[1rem] text-lyx-ligtmode-text-darker dark:text-text-sub/90">
{{ desc }} {{ desc }}
</div> </div>
</div> </div>
@@ -81,7 +81,8 @@ function openExternalLink(link: string) {
</div> </div>
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4"> <div
class="flex justify-between font-bold lyx-text-lightmode-text-dark dark:text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center"> <div v-if="isDetailView" class="flex items-center justify-center">
<i @click="$emit('showGeneral')" <i @click="$emit('showGeneral')"
@@ -107,7 +108,7 @@ function openExternalLink(link: string) {
<div class="flex gap-1 items-center" @click="showDetails(element._id)" <div class="flex gap-1 items-center" @click="showDetails(element._id)"
:class="{ 'cursor-pointer line-active': interactive }"> :class="{ 'cursor-pointer line-active': interactive }">
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]" <div class="absolute rounded-sm w-full h-full bg-[#6f829c38] dark:bg-[#92abcf38]"
:style="'width:' + 100 / maxData * element.count + '%;'"></div> :style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4"> <div class="flex px-2 py-1 relative items-center gap-4">
@@ -119,13 +120,16 @@ function openExternalLink(link: string) {
<i v-else :class="iconProvider(element)?.[1]"></i> <i v-else :class="iconProvider(element)?.[1]"></i>
</div> </div>
<span class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70"> <span
class="text-ellipsis line-clamp-1 ui-font z-[19] text-[.95rem] text-lyx-lightmode-text-dark dark:text-text/70">
{{ elementTextTransformer?.(element._id) || element._id }} {{ elementTextTransformer?.(element._id) || element._id }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{ <div
class="text-lyx-lightmode-text dark:text-lyx-text font-semibold text-[.9rem] md:text-[1rem] manrope">
{{
formatNumberK(element.count) }} </div> formatNumberK(element.count) }} </div>
</div> </div>
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]"> <div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]">
@@ -133,10 +137,11 @@ function openExternalLink(link: string) {
</div> </div>
</div> </div>
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow"> <div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow">
<div @click="$emit('showMore')"
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]"> <LyxUiButton type="outline" @click="$emit('showMore')">
Show more Show more
</div> </LyxUiButton>
</div> </div>
</div> </div>

View File

@@ -57,7 +57,7 @@ async function showMore() {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []" <BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider" desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider"
:loading="browsersData.pending.value" label="Browsers" sub-label="Browsers"> :loading="browsersData.pending.value" label="Browsers" sub-label="Browsers">

View File

@@ -5,17 +5,17 @@ import type { IconProvider } from './Base.vue';
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> { function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
if (e._id === 'desktop') return ['icon','far fa-desktop']; if (e._id === 'desktop') return ['icon','far fa-desktop'];
if (e._id === 'tablet') return ['icon','far fa-tablet']; if (e._id === 'tablet') return ['icon','far fa-tablet ml-1'];
if (e._id === 'mobile') return ['icon','far fa-mobile']; if (e._id === 'mobile') return ['icon','far fa-mobile ml-1'];
if (e._id === 'smarttv') return ['icon','far fa-tv']; if (e._id === 'smarttv') return ['icon','far fa-tv'];
if (e._id === 'console') return ['icon','far fa-game-console-handheld']; if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
return ['icon', 'far fa-question'] return ['icon', 'far fa-question ml-1 mr-1']
} }
function transform(data: { _id: string, count: number }[]) { function transform(data: { _id: string, count: number }[]) {
console.log(data); console.log(data);
return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : e._id })) return data.map(e => ({ ...e, _id: e._id == null ? 'others' : e._id }))
} }
const devicesData = useFetch('/api/data/devices', { const devicesData = useFetch('/api/data/devices', {

View File

@@ -49,7 +49,7 @@ async function showMore() {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()" <BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value" :data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle" label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"

View File

@@ -0,0 +1,44 @@
<script lang="ts" setup>
const router = useRouter();
const pagesData = useFetch('/api/data/pages', {
headers: useComputedHeaders({
limit: 10,
}), lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/pages', {
headers: useComputedHeaders({ limit: 1000 }).value
});
dialogBarData.value = (res || []);
isDataLoading.value = false;
}
function goToView() {
router.push('/dashboard/visits');
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showRawData="goToView()" @showMore="showMore()" @dataReload="pagesData.refresh()" :showLink=true
:data="pagesData.data.value || []" :interactive="false" desc="Most visited pages."
:rawButton="!isLiveDemo"
:dataIcons="true" :loading="pagesData.pending.value" label="Top Pages" sub-label="Referrers">
</BarCardBase>
</div>
</template>

View File

@@ -43,7 +43,7 @@ async function showMore() {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer" <BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true :iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website." :data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."

View File

@@ -9,10 +9,10 @@ const props = defineProps<{ title: string, sub?: string }>();
<div class="flex flex-col gap-4 h-full"> <div class="flex flex-col gap-4 h-full">
<div class="flex items-center"> <div class="flex items-center">
<div class="flex flex-col grow"> <div class="flex flex-col grow">
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-text"> <div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-lyx-lightmode-text-dark dark:text-text">
{{ props.title }} {{ props.title }}
</div> </div>
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-text-sub"> <div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-lyx-lightmode-text-darker dark:text-text-sub">
{{ props.sub }} {{ props.sub }}
</div> </div>
</div> </div>

View File

@@ -1,27 +1,72 @@
<script lang="ts" setup> <script lang="ts" setup>
type CItem = { label: string, slot: string }
const props = defineProps<{ items: CItem[] }>();
export type CItem = { label: string, slot: string, tab?: string }
const props = defineProps<{
items: CItem[],
manualScroll?: boolean,
route?: boolean
}>();
const router = useRouter();
const route = useRoute();
const activeTabIndex = ref<number>(0); const activeTabIndex = ref<number>(0);
function updateTab() {
const target = props.items.findIndex(e => e.tab == route.query.tab);
if (target == -1) {
activeTabIndex.value = 0;
} else {
activeTabIndex.value = target;
}
}
function onChangeTab(newIndex: number) {
activeTabIndex.value = newIndex;
const target = props.items[newIndex];
if (!target) return;
router.push({ query: { tab: target.tab } });
}
onMounted(() => {
if (props.route !== true) return;
updateTab();
watch(route, () => {
updateTab();
})
})
</script> </script>
<template> <template>
<div> <div class="h-full flex flex-col">
<div class="flex overflow-x-auto hide-scrollbars">
<div class="flex"> <div class="flex">
<div v-for="(tab, index) of items" @click="activeTabIndex = index" <div v-for="(tab, index) of items" @click="onChangeTab(index)"
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{ class="px-6 whitespace-nowrap pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index, :class="{
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index 'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
}"> }">
{{ tab.label }} {{ tab.label }}
</div> </div>
</div>
<div class="border-b-[1px] border-lyx-text-darker w-full"> <div class="border-b-[1px] border-lyx-text-darker w-full">
</div> </div>
</div> </div>
<div> <div :class="{ 'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
<slot :name="props.items[activeTabIndex].slot"></slot> <slot :name="props.items[activeTabIndex].slot"></slot>
</div> </div>
</div> </div>

View File

@@ -67,7 +67,7 @@ function reloadPage() {
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div> <div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div>
<div class="text-text/90 poppins text-[1.1rem] font-medium"> <div class="text-lyx-lightmode-text dark:text-text/90 poppins text-[1.1rem] font-medium">
Waiting for your first visit Waiting for your first visit
</div> </div>
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()"> <LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
@@ -79,13 +79,13 @@ function reloadPage() {
</div> </div>
<div class="flex items-center justify-center mt-10"> <div class="flex items-center justify-center mt-10 w-full px-10">
<div class="flex flex-col-reverse gap-6"> <div class="flex flex-col gap-6">
<div class="flex gap-6 xl:flex-row flex-col"> <div class="flex gap-6 xl:flex-row flex-col">
<div class="h-full w-full"> <div class="h-full w-full">
<CardTitled class="h-full w-full xl:min-w-[500px] xl:h-[35rem]" title="Quick setup tutorial" <CardTitled class="h-full w-full xl:min-w-[400px] xl:h-[35rem]" title="Quick setup tutorial"
sub="Quickly Set Up Litlyx in 30 Seconds!"> sub="Quickly Set Up Litlyx in 30 Seconds!">
<div class="flex items-center justify-center h-full w-full"> <div class="flex items-center justify-center h-full w-full">
@@ -122,8 +122,8 @@ function reloadPage() {
<CardTitled class="h-full w-full" title="Project id" <CardTitled class="h-full w-full" title="Project id"
sub="This is the identifier for this project, used to forward data"> sub="This is the identifier for this project, used to forward data">
<div class="flex items-center justify-between gap-4 mt-6"> <div class="flex items-center justify-between gap-4 mt-6">
<div class="p-2 bg-[#1c1b1b] rounded-md w-full"> <div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
<div class="w-full text-[.9rem] text-[#acacac]"> {{ project?._id }} </div> <div class="w-full text-[.9rem] dark:text-[#acacac]"> {{ project?._id }} </div>
</div> </div>
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton> <LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
</div> </div>
@@ -135,8 +135,30 @@ function reloadPage() {
</div> </div>
<div> <div>
<div> <div>
<CardTitled class="w-full h-full" title="Documentation" <CardTitled class="w-full h-full" title="Wordpress + Elementor"
sub="Learn how to use Litlyx in every tech stack"> sub="Our WordPress plugin is coming soon!.">
<template #header>
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com">
Visit documentation
</LyxUiButton>
</template>
<div class="flex flex-col items-end">
<div class="justify-center w-full hidden xl:flex gap-3">
<a href="#">
<img class="cursor-pointer" :src="'tech-icons/wpel.png'" alt="Litlyx-Wordpress-Elementor">
</a>
</div>
</div>
</CardTitled>
</div>
</div>
<div>
<div>
<CardTitled class="w-full h-full" title="Modules"
sub="Get started with your favorite framework.">
<template #header> <template #header>
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary" <LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com"> to="https://docs.litlyx.com">

View File

@@ -8,12 +8,18 @@ const props = defineProps<{ type: ButtonType, link?: string, target?: string, di
<template> <template>
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target" <NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-text" :class="{ class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-lyx-lightmode-text dark:text-lyx-text"
'bg-lyx-primary-dark outline-lyx-primary hover:bg-lyx-primary-hover': type === 'primary', :class="{
'bg-lyx-widget-lighter outline-lyx-widget-lighter hover:bg-lyx-widget-light': type === 'secondary',
'bg-lyx-transparent outline-lyx-widget-lighter hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'), 'bg-[#85a3ff] hover:bg-[#9db5fc] outline-lyx-lightmode-widget-light dark:bg-lyx-primary-dark dark:outline-lyx-primary dark:hover:bg-lyx-primary-hover': type === 'primary',
'bg-lyx-danger-dark outline-lyx-danger hover:bg-lyx-danger': type === 'danger',
'!bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true, 'bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget-lighter hover:bg-lyx-lightmode-widget dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': type === 'secondary',
'bg-lyx-transparent outline-lyx-lightmode-widget hover:bg-lyx-lightmode-widget-light dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
'bg-[#fcd1cb] hover:bg-[#f8c5be] dark:bg-lyx-danger-dark outline-lyx-danger dark:hover:bg-lyx-danger': type === 'danger',
'text-lyx-text !bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
}"> }">
<slot></slot> <slot></slot>
</NuxtLink> </NuxtLink>

View File

@@ -4,7 +4,7 @@
</script> </script>
<template> <template>
<div class="w-fit h-fit rounded-md bg-lyx-widget p-4 outline outline-[1px] outline-lyx-background-lighter"> <div class="w-fit h-fit rounded-md bg-lyx-lightmode-background outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-background-lighter p-4 outline outline-[1px] ">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View File

@@ -19,6 +19,6 @@ const handleChange = (event: Event) => {
<template> <template>
<input <input
class="bg-lyx-widget-light text-lyx-text-dark poppins rounded-md outline outline-[1px] outline-lyx-widget-lighter" class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget text-lyx-lightmode-text dark:bg-lyx-widget-light dark:text-lyx-text-dark poppins rounded-md outline outline-[1px] dark:outline-lyx-widget-lighter"
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange"> :type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
</template> </template>

View File

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

View File

@@ -98,7 +98,8 @@ async function saveJobTitle() {
const showOnboarding = computed(() => { const showOnboarding = computed(() => {
if (route.path === '/login') return false; if (route.path === '/login') return false;
if (route.path === '/register') return false; if (route.path === '/register') return false;
if (needsOnboarding.value?.exist === false) return true; if ((needsOnboarding.value as any)?.exist === false) return true;
if ((needsOnboarding.value as any)?.exists === false) return true;
}) })
</script> </script>
@@ -109,11 +110,11 @@ const showOnboarding = computed(() => {
<div v-if="page == 0" class="bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md"> <div v-if="page == 0" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
<div class="text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div> <div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
<div class="text-lyx-text mt-4"> <div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
going to be your first/main analytics? going to be your first/main analytics?
</div> </div>
@@ -122,7 +123,7 @@ const showOnboarding = computed(() => {
<div v-for="(e, i) of analyticsList"> <div v-for="(e, i) of analyticsList">
<div @click="selectIndex(i)" <div @click="selectIndex(i)"
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }" :class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
class="bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer"> class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
{{ e }} {{ e }}
</div> </div>
</div> </div>
@@ -140,11 +141,11 @@ const showOnboarding = computed(() => {
</div> </div>
</div> </div>
<div v-if="page == 1" class="bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md"> <div v-if="page == 1" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
<div class="text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div> <div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
<div class="text-lyx-text mt-4"> <div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
What is your job title ? What is your job title ?
</div> </div>
@@ -152,7 +153,7 @@ const showOnboarding = computed(() => {
<div v-for="(e, i) of jobsList"> <div v-for="(e, i) of jobsList">
<div @click="selectIndex2(i)" <div @click="selectIndex2(i)"
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }" :class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
class="bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer"> class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
{{ e }} {{ e }}
</div> </div>
</div> </div>

View File

@@ -16,12 +16,12 @@ const emits = defineEmits<{
<template> <template>
<div class="flex gap-2 border-[1px] border-lyx-widget-lighter p-1 md:p-2 rounded-xl bg-lyx-widget"> <div class="flex gap-2 border-[1px] p-1 md:p-2 rounded-xl bg-lyx-lightmode-widget-light border-lyx-lightmode-widget dark:bg-lyx-widget dark:border-lyx-widget-lighter">
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options" <div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
class="hover:bg-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="hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
:class="{ :class="{
'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled, 'bg-lyx-lightmode-widget hover:!bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter dark:hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
'hover:!bg-lyx-widget !cursor-not-allowed text-lyx-widget-lighter': opt.disabled 'hover:!bg-lyx-lightmode-widget-light text-lyx-lightmode-widget dark:hover:!bg-lyx-widget !cursor-not-allowed dark:!text-lyx-widget-lighter': opt.disabled
}"> }">
{{ opt.label }} {{ opt.label }}
</div> </div>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
const avgDuration = computed(() => {
if (!backendData?.value?.durations) return -1;
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
})
const labels = computed(() => {
if (!backendData?.value?.durations) return [];
const sizes = new Map<string, number>();
for (const e of backendData.value.durations.durations) {
if (!sizes.has(e[0])) {
sizes.set(e[0], 0);
} else {
const data = sizes.get(e[0]) ?? 0;
sizes.set(e[0], data + 1);
}
}
const max = Array.from(sizes.values()).reduce((a, e) => a > e ? a : e, 0);
return new Array(max).fill('-');
});
const durationsDatasets = computed(() => {
if (!backendData?.value?.durations) return [];
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
const datasets = [];
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
for (let i = 0; i < uniqueConsumers.length; i++) {
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
datasets.push({
points: consumerDurations.map((e: any) => {
return 1000 / parseInt(e[1])
}),
color: colors[i],
chartType: 'line',
name: uniqueConsumers[i]
})
}
return datasets;
})
</script>
<template>
<div class="mt-6 h-full">
<div class="cursor-default flex justify-center w-full">
<div v-if="backendData && !backendPending" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
<div class="flex gap-8">
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
</div>
<div class="w-full">
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
</AdminBackendLineChart>
</div>
<div @click="refreshBackend()"> Refresh </div>
</div>
<div v-if="backendPending">
Loading...
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
const { data: feedbacks, pending: pendingFeedbacks } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
</script>
<template>
<div class="mt-6 h-full">
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
v-for="feedback of feedbacks">
<div class="flex flex-col gap-1">
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
<div class="text-lyx-text-dark"> {{ feedback.project[0]?.name || 'DELETED PROJECT' }} </div>
</div>
{{ feedback.text }}
</div>
</div>
<div v-if="pendingFeedbacks"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

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

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
</script>
<template>
<div class="mt-6 h-full">
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<div v-if="onboardings" class="flex gap-40 px-20">
<div class="flex flex-col gap-4">
<div class="text-lyx-primary"> Anaytics </div>
<div class="flex items-center gap-2"
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
<div>{{ e._id }}</div>
<div>{{ e.count }}</div>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="text-lyx-primary"> Jobs </div>
<div class="flex items-center gap-2"
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
<div>{{ e._id }}</div>
<div>{{ e.count }}</div>
</div>
</div>
<div v-if="onboardings" class="flex flex-col gap-8">
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
</div>
</div>
<div v-if="pendingOnboardings"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,204 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const page = ref<number>(1);
const ordersList = [
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
{ label: 'active -->', id: '{ "last_log_at": 1 }' },
{ label: 'active <--', id: '{ "last_log_at": -1 }' },
{ label: 'visits -->', id: '{ "visits": 1 }' },
{ label: 'visits <--', id: '{ "visits": -1 }' },
{ label: 'events -->', id: '{ "events": 1 }' },
{ label: 'events <--', id: '{ "events": -1 }' },
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const limitList = [
{ label: '10', id: 10 },
{ label: '20', id: 20 },
{ label: '50', id: 50 },
{ label: '100', id: 100 },
]
const limit = ref<number>(20);
const filterList = [
{ label: 'ALL', id: '{}' },
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
]
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
onMounted(() => {
for (const key in PREMIUM_PLAN) {
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
}
})
const filter = ref<string>('{}');
const { data: projectsInfo, pending: pendingProjects } = useFetch<{ count: number, projects: TAdminProject[] }>(
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { data: metrics, pending: pendingMetrics } = useFetch(
() => `/api/admin/metrics?filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<div class="flex flex-col items-center gap-8">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Limit:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
value-attribute="id" option-attribute="label" v-model="limit">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Filter:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
value-attribute="id" option-attribute="label" v-model="filter">
</USelectMenu>
</div>
</div>
<div class="flex items-center gap-10 justify-center px-10 w-full">
<div class="flex gap-2 items-center shrink-0">
<div>Page {{ page }} </div>
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
}}</div>
</div>
<div>
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
</div>
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
<div class="w-[80%]">
<div v-if="pendingMetrics"> Loading... </div>
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
<div>
Total visits: {{ formatNumberK(metrics.totalVisits) }}
</div>
<div>
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
Dead: {{ metrics.deadProjects }}
</div>
<div>
Total events: {{ formatNumberK(metrics.totalEvents) }}
</div>
</div>
</div>
</div>
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
class="w-[26rem]" v-for="project of projectsInfo?.projects" />
<div v-if="pendingProjects"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import type { TAdminUser } from '~/server/api/admin/users';
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const filterText = ref<string>('');
watch(filterText, () => {
page.value = 1;
})
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
const filter = computed(() => {
return JSON.stringify({
$or: [
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
]
})
})
const page = ref<number>(1);
const ordersList = [
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const limitList = [
{ label: '10', id: 10 },
{ label: '20', id: 20 },
{ label: '50', id: 50 },
{ label: '100', id: 100 },
]
const limit = ref<number>(20);
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<div class="flex flex-col items-center gap-6">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Limit:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
value-attribute="id" option-attribute="label" v-model="limit">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
</div>
</div>
<div class="flex items-centet gap-10">
<div class="flex gap-2 items-center">
<div>Page {{ page }} </div>
<div>
{{ Math.min(limit, usersInfo?.count || 0) }}
of
{{ usersInfo?.count || 0 }}
</div>
</div>
<div>
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
</div>
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
</div>
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
v-for="user of usersInfo?.users" />
<div v-if="pendingUsers"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as datefns from 'date-fns';
const errored = ref<boolean>(false);
const props = defineProps<{
labels: string[],
title: string,
datasets: {
points: number[],
color: string,
name: string
}[]
}>();
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
// borderDash: [5, 10]
},
},
x: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: true },
title: {
display: true,
text: props.title
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: { size: 16, weight: 'bold' },
bodyFont: { size: 14 },
padding: 10,
cornerRadius: 4,
boxPadding: 10,
caretPadding: 20,
yAlign: 'bottom',
xAlign: 'center',
}
},
});
const chartData = ref<ChartData<'line'>>({
labels: props.labels.map(e => {
try {
return datefns.format(new Date(e), 'dd/MM');
} catch (ex) {
return e;
}
}),
datasets: props.datasets.map(e => ({
data: e.points,
label: e.name,
backgroundColor: [e.color + '00'],
borderColor: e.color,
borderWidth: 2,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
type: 'line'
} as any))
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
function createGradient(startColor: string) {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${startColor}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${startColor}99`);
gradient.addColorStop(0.35, `${startColor}66`);
gradient.addColorStop(1, `${startColor}22`);
} else {
console.warn('Cannot get context for gradient');
}
return gradient;
}
onMounted(async () => {
try {
// chartData.value.datasets.forEach(dataset => {
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
// } else {
// dataset.backgroundColor = [createGradient('#3d59a4')]
// }
// });
} catch (ex) {
errored.value = true;
console.error(ex);
}
});
</script>
<template>
<div>
<div v-if="errored"> ERROR CREATING CHART </div>
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</div>
</template>

View File

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

View File

@@ -0,0 +1,62 @@
<script lang="ts" setup>
import { type ChartData, type ChartOptions } from 'chart.js';
import { PieChart, usePieChart } from 'vue-chart-3';
const props = defineProps<{ data: { _id: string, count: number }[], title:string }>();
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
title: {
display: true,
text: props.title,
color: '#EEECF6',
},
},
});
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
labels: props.data.map(e => e._id),
datasets: [
{
data: props.data.map(e => e.count),
backgroundColor: [
"#295270",
"#304F71",
"#374C72",
"#3E4A73",
"#444773",
"#4B4474",
"#524175",
],
borderColor: '#222222'
},
],
}));
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
</script>
<template>
<div>
<div class="graph">
<PieChart v-bind="pieChartProps">
</PieChart>
</div>
</div>
</template>

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService'; import DateService, { type Slice } from '@services/DateService';
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js'; import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3'; import { useLineChart, LineChart } from 'vue-chart-3';
@@ -113,7 +112,7 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
}, },
}, },
{ {
label: 'Unique sessions', label: 'Unique visitors',
data: [], data: [],
backgroundColor: ['#4abde8'], backgroundColor: ['#4abde8'],
borderColor: '#4abde8', borderColor: '#4abde8',
@@ -136,7 +135,7 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
type: 'bubble', type: 'bubble',
stack: 'combined', stack: 'combined',
borderColor: ["#fbbf24"] borderColor: ["#fbbf24"]
}, }
], ],
}); });
@@ -199,11 +198,16 @@ const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled:
}) })
const selectedSlice = computed<Slice>(() => { const selectedSlice = computed<Slice>(() => {
console.log({ available: selectLabelsAvailable.value })
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value]; const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
if (!targetValue) return 'day'; if (!targetValue) return 'day';
if (targetValue.disabled) { if (targetValue.disabled) {
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled); selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
} }
if (selectedLabelIndex.value === -1) {
console.error('ERROR CANNOT FIND CORRECT SLICE')
return 'day';
}
return selectLabelsAvailable.value[selectedLabelIndex.value].value return selectLabelsAvailable.value[selectedLabelIndex.value].value
}); });
@@ -216,9 +220,14 @@ function transformResponse(input: { _id: string, count: number }[]) {
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value)); const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value));
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString()); if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60)); const current = (Date.now());
//console.log(input.map(e => e._id));
//console.log(new Date(current));
return { data, labels, todayIndex } const todayIndex = input.findIndex(e => new Date(e._id).getTime() >= current);
//console.log({ todayIndex })
return { data, labels, todayIndex: todayIndex + 1 }
} }
function onResponseError(e: any) { function onResponseError(e: any) {
@@ -254,8 +263,6 @@ watch(readyToDisplay, () => {
}) })
function onDataReady() { function onDataReady() {
if (!visitsData.data.value) return; if (!visitsData.data.value) return;
if (!eventsData.data.value) return; if (!eventsData.data.value) return;
@@ -279,7 +286,6 @@ function onDataReady() {
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')]; chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')]; chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => { (chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0; const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return true; if (i == todayIndex - 1) return true;
@@ -333,8 +339,8 @@ const legendClasses = ref<string[]>([
<div class="flex gap-6 w-full justify-between lg:flex-row flex-col"> <div class="flex gap-6 w-full justify-between lg:flex-row flex-col">
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo"> <LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
<div class="flex items-center gap-2 px-10"> <div class="flex items-center gap-2 px-10">
<i class="far fa-sparkles text-yellow-400"></i> <i class="far fa-sparkles text-yellow-600 dark:text-yellow-400"></i>
<div class="poppins text-lyx-text"> Ask AI </div> <div class="poppins text-lyx-lightmode-text dark:text-lyx-text"> Ask AI </div>
</div> </div>
</LyxUiButton> </LyxUiButton>
<div class="flex gap-6"> <div class="flex gap-6">
@@ -352,7 +358,7 @@ const legendClasses = ref<string[]>([
<div id='external-tooltip' ref="externalTooltipElement" class="z-[400]"> <div id='external-tooltip' ref="externalTooltipElement" class="z-[400]">
<LyxUiCard> <LyxUiCard class="text-lyx-lightmode-text dark:text-lyx-text">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div> Date: </div> <div> Date: </div>
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div> <div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
@@ -365,6 +371,12 @@ const legendClasses = ref<string[]>([
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }} {{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
</div> </div>
</div> </div>
<div class="mt-3 font-normal flex flex-col text-[.9rem] dark:text-lyx-text-dark text-lyx-lightmode-text-dark"
v-if="(currentTooltipData as any).sessions > (currentTooltipData as any).visits">
<div> Unique visitors are higher than total visits </div>
<div> which often means bots (automated scripts or crawlers)</div>
<div> are inflating the numbers.</div>
</div>
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> --> <!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
</LyxUiCard> </LyxUiCard>
</div> </div>

View File

@@ -29,12 +29,12 @@ const { showDrawer } = useDrawer();
</div> </div>
<div class="flex flex-col grow"> <div class="flex flex-col grow">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="brockmann text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]"> <div class="brockmann text-lyx-lightmode-text-dark dark:text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
{{ value }} {{ value }}
</div> </div>
<div class="poppins text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div> <div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
</div> </div>
<div class="poppins text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div> <div class="poppins text-lyx-lightmode-darker dark:text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
</div> </div>
<div class="flex flex-col items-center gap-1"> <div class="flex flex-col items-center gap-1">
@@ -52,7 +52,7 @@ const { showDrawer } = useDrawer();
</div> </div>
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2"> <div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i> <i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
<div v-if="props.slow"> Can be very slow on large snapshots </div> <!-- <div v-if="props.slow"> Can be very slow on large timeframes </div> -->
</div> </div>
</LyxUiCard> </LyxUiCard>

View File

@@ -13,8 +13,8 @@ const columns = [
<template> <template>
<div class="w-full h-full bg-bg rounded-xl p-8"> <div class="w-full h-full bg-lyx-lightmode-background dark:bg-lyx-background-light rounded-xl p-8">
<div class="full h-full overflow-y-auto"> <div class="full h-full overflow-y-auto text-lyx-lightmode-text dark:text-lyx-text">
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData"> <UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
<template #count-data="{ row }"> <template #count-data="{ row }">
<div class="font-bold"> {{ formatNumberK(row.count) }} </div> <div class="font-bold"> {{ formatNumberK(row.count) }} </div>

View File

@@ -27,7 +27,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
position: 'top', position: 'top',
align: 'center', align: 'center',
labels: { labels: {
color: 'white',
font: { font: {
family: 'Poppins', family: 'Poppins',
size: 16 size: 16

View File

@@ -1,14 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import DateService from '@services/DateService'; import DateService, { type Slice } from '../../shared/services/DateService';
import type { Slice } from '@services/DateService';
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot() const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
const chartSlice = computed(() => { const chartSlice = computed(() => {
if (snapshotDuration.value <= 3) return 'hour' as Slice; if (snapshotDuration.value <= 3) return 'hour' as Slice;
if (snapshotDuration.value <= 32) return 'day' as Slice; if (snapshotDuration.value <= 31 * 3) return 'day' as Slice;
return 'month' as Slice; return 'month' as Slice;
}); });
@@ -68,27 +68,53 @@ const avgBouncingRate = computed(() => {
return avg.toFixed(2) + ' %'; return avg.toFixed(2) + ' %';
}) })
function weightedAverage(data: number[]): number {
if (data.length === 0) return 0;
// Compute median
const sortedData = [...data].sort((a, b) => a - b);
const middle = Math.floor(sortedData.length / 2);
const median = sortedData.length % 2 === 0
? (sortedData[middle - 1] + sortedData[middle]) / 2
: sortedData[middle];
// Define a threshold (e.g., 3 times the median) to filter out extreme values
const threshold = median * 3;
const filteredData = data.filter(num => num <= threshold);
if (filteredData.length === 0) return median; // Fallback to median if all are removed
// Compute weights based on inverse absolute deviation from median
const weights = filteredData.map(num => 1 / (1 + Math.abs(num - median)));
// Compute weighted sum and sum of weights
const weightedSum = filteredData.reduce((sum, num, i) => sum + num * weights[i], 0);
const sumOfWeights = weights.reduce((sum, weight) => sum + weight, 0);
return weightedSum / sumOfWeights;
}
const avgSessionDuration = computed(() => { const avgSessionDuration = computed(() => {
if (!sessionsDurationData.data.value) return '0.00 %' if (!sessionsDurationData.data.value) return '0.00 %'
const counts = sessionsDurationData.data.value.data const counts = sessionsDurationData.data.value.data
.filter(e => e > 0) // .filter(e => e > 0)
.reduce((a, e) => e + a, 0); .reduce((a, e) => e + a, 0);
const avg = counts / Math.max(sessionsDurationData.data.value.data.filter(e => e > 0).length, 1); const avg = weightedAverage(sessionsDurationData.data.value.data);
// counts / (Math.max(sessionsDurationData.data.value.data.length, 1));
let hours = 0; let hours = 0;
let minutes = 0; let minutes = 0;
let seconds = 0; let seconds = 0;
seconds += avg * 60; seconds += avg * 60;
while (seconds > 60) { seconds -= 60; minutes += 1; } while (seconds >= 60) { seconds -= 60; minutes += 1; }
while (minutes > 60) { minutes -= 60; hours += 1; } while (minutes >= 60) { minutes -= 60; hours += 1; }
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s` return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
}); });
const todayIndex = computed(() => { const todayIndex = computed(() => {
if (!visitsData.data.value) return -1; if (!visitsData.data.value) return -1;
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60)); return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now()));
}) })
@@ -96,13 +122,13 @@ const todayIndex = computed(() => {
<template> <template>
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4"> <div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 m-cards-wrap:grid-cols-4">
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth" <DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')" text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
:avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data" :avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data"
tooltipText="Sum of all page views on your website." tooltipText="Sum of all page views on your website." :labels="visitsData.data.value?.labels"
:labels="visitsData.data.value?.labels" color="#5655d7"> color="#5655d7">
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user" <DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user"
@@ -115,16 +141,15 @@ const todayIndex = computed(() => {
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user" <DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user"
text="Unique visitors" text="Unique visitors"
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')" :value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
tooltipText="Count of distinct users visiting your website." tooltipText="Count of distinct users visiting your website." :avg="formatNumberK(avgSessionsDay) + '/day'"
:avg="formatNumberK(avgSessionsDay) + '/day'" :data="sessionsData.data.value?.data" :data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
:labels="sessionsData.data.value?.labels" color="#4abde8">
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer" <DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data" text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data"
tooltipText="Average time users spend on your website." tooltipText="Average time users spend on your website." :labels="sessionsDurationData.data.value?.labels"
:labels="sessionsDurationData.data.value?.labels" color="#f56523"> color="#f56523">
</DashboardCountCard> </DashboardCountCard>
</div> </div>

View File

@@ -18,27 +18,15 @@ function copyProjectId() {
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000); createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
} }
function showAnomalyInfoAlert() {
createAlert('AI Anomaly Detector info',
`Anomaly detector is running. It helps you detect a spike in visits or events, it could mean an
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
Litlyx will notify you via email with actionable advices`,
'far fa-shield',
10000
)
}
</script> </script>
<template> <template>
<div <div class="w-full px-6 pb-2 lg:pb-6 font-bold flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
class="w-full px-6 pb-2 lg:pb-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start"> <div
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div> class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
<div class="animate-pulse w-[.8rem] h-[.8rem] bg-green-400 rounded-full"> </div>
<div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div> <div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div>
</div> </div>
@@ -62,15 +50,16 @@ function showAnomalyInfoAlert() {
</div> </div>
</div> </div>
</div> --> </div> -->
<!--
<div v-if="!selfhosted" class="flex gap-2 items-center text-text/90 justify-center md:justify-start"> <div v-if="!selfhosted"
class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div> <div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
<div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div> <div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
<div class="flex items-center"> <div class="flex items-center">
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer" <i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
@click="showAnomalyInfoAlert"></i> @click="showAnomalyInfoAlert"></i>
</div> </div>
</div> </div> -->
</div> </div>
</template> </template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel'])
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-medium">
Are you sure to logout ?
</div>
<div class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')">
Cancel
</LyxUiButton>
<LyxUiButton @click="emit('success')" type="danger">
Confirm
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -36,7 +36,7 @@ function onColorChange() {
const snapshotName = ref<string>(""); const snapshotName = ref<string>("");
const { updateSnapshots } = useSnapshot(); const { updateSnapshots, snapshot, snapshots } = useSnapshot();
const { createAlert } = useAlert() const { createAlert } = useAlert()
async function confirmSnapshot() { async function confirmSnapshot() {
@@ -53,7 +53,10 @@ async function confirmSnapshot() {
await updateSnapshots(); await updateSnapshots();
closeDialog(); closeDialog();
createAlert('Snapshot created', 'Snapshot created successfully', 'far fa-circle-check', 5000); createAlert('Timeframe created', 'Timeframe created successfully', 'far fa-circle-check', 5000);
const newSnapshot = snapshots.value.at(-1);
if (newSnapshot) snapshot.value = newSnapshot;
} }
</script> </script>
@@ -61,8 +64,8 @@ async function confirmSnapshot() {
<template> <template>
<div class="w-full h-full flex flex-col"> <div class="w-full h-full flex flex-col">
<div class="poppins text-center"> <div class="poppins text-center text-lyx-lightmode-text dark:text-lyx-text">
Create a snapshot Create a timeframe
</div> </div>
<div class="mt-10 flex items-center gap-2"> <div class="mt-10 flex items-center gap-2">
@@ -71,7 +74,7 @@ async function confirmSnapshot() {
<input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color"> <input @input="onColorChange" ref="colorpicker" class="relative w-0 h-0 z-[-100]" type="color">
</div> </div>
<div class="grow"> <div class="grow">
<LyxUiInput placeholder="Snapshot name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput> <LyxUiInput placeholder="Timeframe name" v-model="snapshotName" class="px-4 py-1 w-full"></LyxUiInput>
</div> </div>
</div> </div>
@@ -104,7 +107,7 @@ async function confirmSnapshot() {
Cancel Cancel
</LyxUiButton> </LyxUiButton>
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center" <LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
:disabled="snapshotName.length == 0"> :disabled="snapshotName.trim().length == 0">
Confirm Confirm
</LyxUiButton> </LyxUiButton>
</div> </div>

View File

@@ -1,9 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ButtonType } from '../LyxUi/Button.vue';
const emit = defineEmits(['success', 'cancel']) const emit = defineEmits(['success', 'cancel'])
const props = defineProps<{ const props = defineProps<{
buttonType: string, buttonType: ButtonType,
message: string, message: string,
deleteData: { isAll: boolean, visits: boolean, sessions: boolean, events: boolean, domain: string } deleteData: { isAll: boolean, visits: boolean, sessions: boolean, events: boolean, domain: string }
}>(); }>();
@@ -47,7 +48,7 @@ async function deleteData() {
overlay: { overlay: {
background: 'bg-lyx-background/85' background: 'bg-lyx-background/85'
}, },
background: 'bg-lyx-widget', background: 'bg-lyx-lightmode-widget dark:bg-lyx-widget',
ring: 'border-solid border-[1px] border-[#262626]' ring: 'border-solid border-[1px] border-[#262626]'
}"> }">
<div class="h-full flex flex-col gap-2 p-4"> <div class="h-full flex flex-col gap-2 p-4">
@@ -71,7 +72,8 @@ async function deleteData() {
<div v-if="!isDone" class="flex justify-end gap-2"> <div v-if="!isDone" class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton> <LyxUiButton type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton>
<LyxUiButton :disabled="!canDelete" @click="canDelete ? deleteData() : () => { }" :type="buttonType"> Confirm </LyxUiButton> <LyxUiButton :disabled="!canDelete" @click="canDelete ? deleteData() : () => { }" :type="buttonType">
Confirm </LyxUiButton>
</div> </div>
<div v-if="isDone" class="flex justify-end w-full"> <div v-if="isDone" class="flex justify-end w-full">

View File

@@ -35,7 +35,7 @@ async function sendFeedback() {
overlay: { overlay: {
background: 'bg-lyx-background/85' background: 'bg-lyx-background/85'
}, },
background: 'bg-lyx-widget', background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]' ring: 'border-solid border-[1px] border-[#262626]'
}"> }">
<div class="h-full flex flex-col gap-2 p-4"> <div class="h-full flex flex-col gap-2 p-4">
@@ -43,7 +43,7 @@ async function sendFeedback() {
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div> Share everything with us. </div> <div> Share everything with us. </div>
<textarea v-model="text" placeholder="Leave your feedback" <textarea v-model="text" placeholder="Leave your feedback"
class="p-2 w-full h-[8rem] resize-none rounded-md outline outline-[2px] outline-[#3a3f47]"></textarea> class="p-2 w-full h-[8rem] dark:bg-lyx-widget bg-lyx-lightmode-widget-light resize-none rounded-md outline outline-[2px] outline-[#3a3f47]"></textarea>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div>Need help ? Check the docs <a href="https://docs.litlyx.com" target="_blank" <div>Need help ? Check the docs <a href="https://docs.litlyx.com" target="_blank"
class="text-blue-500">here</a> </div> class="text-blue-500">here</a> </div>

View File

@@ -0,0 +1,58 @@
<script lang="ts" setup>
const { createAlert } = useAlert();
const { close } = useModal()
function copyEmail() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText('help@litlyx.com');
createAlert('Success', 'Email copied successfully.', 'far fa-circle-check', 5000);
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-medium">
Contact Support
</div>
<div class="dark:text-lyx-text-dark">
Contact Support for any questions or issues you have.
</div>
<div class="dark:bg-lyx-widget-lighter bg-lyx-lightmode-widget h-[1px]"></div>
<div class="flex items-center justify-between gap-4">
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
<div class="w-full text-[.9rem] dark:text-[#acacac]"> help@litlyx.com </div>
</div>
<LyxUiButton type="secondary" @click="copyEmail()"> Copy </LyxUiButton>
<LyxUiButton type="secondary" to="mailto:help@litlyx.com"> Send </LyxUiButton>
</div>
<div class="dark:text-lyx-text-dark mt-2">
or text us on Discord, we will reply to you personally.
</div>
<LyxUiButton to="https://discord.gg/9cQykjsmWX" target="_blank" type="secondary">
Discord Support
</LyxUiButton>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import Accept_invite from '~/pages/accept_invite.vue';
const { createAlert } = useAlert();
const { close } = useModal()
const emit = defineEmits(['success', 'cancel'])
const props = defineProps<{
invites: {
project_name: string, project_id: string
}[]
}>();
async function acceptInvite(project_id: string) {
try {
await $fetch('/api/project/members/accept', {
method: 'POST',
body: JSON.stringify({ project_id }),
headers: useComputedHeaders({
custom: {
'Content-Type': 'application/json'
}
}).value
});
emit('success');
} catch (ex) {
console.error(ex);
alert('Error accepting invite');
emit('cancel');
}
}
async function declineInvite(project_id: string) {
try {
await $fetch('/api/project/members/decline', {
method: 'POST',
body: JSON.stringify({ project_id }),
headers: useComputedHeaders({
custom: {
'Content-Type': 'application/json'
}
}).value
});
emit('success');
} catch (ex) {
console.error(ex);
alert('Error accepting invite');
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-8 p-6">
<div class="flex flex-col gap-6" v-for="invite of invites">
<div class="dark:text-lyx-text text-lyx-lightmode-text">
You are invited to join
<span class="font-semibold">{{ invite.project_name }}</span>.
Do you accept?
</div>
<div class="flex gap-4 w-full justify-end">
<LyxUiButton @click="declineInvite(invite.project_id)" type="secondary"> Decline </LyxUiButton>
<LyxUiButton @click="acceptInvite(invite.project_id)" type="primary"> Accept </LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,118 @@
<script lang="ts" setup>
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import type { TTeamMember } from '~/shared/schema/TeamMemberSchema';
const emit = defineEmits(['success', 'cancel'])
const props = defineProps<{ member_id: string }>();
const { domainList, domain, setActiveDomain, refreshDomains, refreshingDomains } = useDomain();
const { data: member } = useFetch<TTeamMember>(`/api/project/members/get?member_id=${props.member_id}`, {
headers: useComputedHeaders({})
})
const { createAlert } = useAlert()
async function save(member_id: string) {
if (!member.value) return;
const res = await $fetch('/api/project/members/edit', {
method: 'POST',
headers: useComputedHeaders({ custom: { 'Content-Type': 'application/json' } }).value,
body: JSON.stringify({
member_id,
webAnalytics: member.value.permission.webAnalytics,
events: member.value.permission.events,
ai: member.value.permission.ai,
domains: member.value.permission.domains
})
});
createAlert('Saved', 'Permission saved successfully', 'fas fa-check', 2500);
emit('success')
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'bg-lyx-lightmode-widget dark:bg-lyx-widget',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="p-8">
<div v-if="member" class="manage flex flex-col gap-4">
<div class="flex flex-col gap-1">
<div class="poppins text-[1.1rem]"> Manage permissions </div>
<div class="poppins text-[.9rem] dark:text-lyx-text-dark"> Choose what this member can do on this project. </div>
</div>
<LyxUiSeparator></LyxUiSeparator>
<div class="flex flex-col gap-1">
<div>
<div class="mb-1"> Select what domain is allowed to see: </div>
<div class="mb-1">
<USelectMenu v-model="member.permission.domains" :options="domainList" multiple
value-attribute="_id">
<template #option="{ option, active, selected }">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'"
alt="Litlyx logo">
</div>
<div> {{ option._id }} </div>
</div>
</template>
<template #label="e">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'"
alt="Litlyx logo">
</div>
<div>
{{
member.permission.domains.length > 2 ?
`${member.permission.domains.length} domains` :
(member.permission.domains.map(e => e).join(' & ') || 'No domains')
}}
</div>
</div>
</template>
</USelectMenu>
</div>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="member.permission.webAnalytics"></UCheckbox>
<div> Allow web analytics page </div>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="member.permission.events"></UCheckbox>
<div> Allow events page </div>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="member.permission.ai"></UCheckbox>
<div> Allow to use AI data analyst </div>
</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-8">
<LyxUiButton class="!w-[6rem] text-center" type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton>
<LyxUiButton class="!w-[6rem] text-center" v-if="member?.permission" @click="save(member._id.toString())" type="primary">
Save
</LyxUiButton>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,80 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel']);
const address = ref<string>('');
const description = ref<string>('');
const { data: currentIP } = useFetch<any>('https://api.ipify.org/?format=json');
const canAddAddress = computed(() => {
return address.value.trim().length > 0;
})
async function addAddress() {
if (!canAddAddress.value) return;
try {
const res = await $fetch('/api/shields/ip/add', {
method: 'POST',
headers: useComputedHeaders({}).value,
body: JSON.stringify({ address: address.value, description: description.value })
});
address.value = '';
emit('success');
} catch (ex: any) {
alert(ex.message);
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-semibold text-[1.1rem]"> Add IP to Block List </div>
<div class="flex flex-col gap-2 dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
<div> Your current IP address is: {{ currentIP?.ip || '...' }} </div>
<div> Copy and Paste your IP address in the box below or enter a custom address </div>
</div>
<div class="flex flex-col gap-2">
<div class="font-medium"> IP Address </div>
<LyxUiInput class="px-2 py-1" v-model="address" placeholder="127.0.0.1"></LyxUiInput>
</div>
<div class="flex flex-col gap-2">
<div class="font-medium"> Description (optional) </div>
<LyxUiInput class="px-2 py-1" v-model="description" placeholder="e.g. localhost or office">
</LyxUiInput>
</div>
<div class="flex flex-col gap-2 dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
<div> Once added, we will start rejecting traffic from this IP within a few minutes.</div>
</div>
<div class="flex">
<LyxUiButton class="w-full text-center" :disabled="!canAddAddress" @click="addAddress()"
type="primary">
Add IP Address
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,67 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel']);
const domain = ref<string>('');
const canAddDomain = computed(() => {
return domain.value.trim().length > 0;
})
async function addDomain() {
if (!canAddDomain.value) return;
try {
const res = await $fetch('/api/shields/domains/add', {
method: 'POST',
headers: useComputedHeaders({}).value,
body: JSON.stringify({ domain: domain.value })
});
domain.value = '';
emit('success');
} catch (ex: any) {
alert(ex.message);
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-semibold text-[1.1rem]"> Add Domain to Allow List </div>
<LyxUiInput class="px-2 py-1" v-model="domain"></LyxUiInput>
<div class="flex flex-col gap-2 dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
<div>
<div> You can use a wildcard (*) to match multiple hostnames. </div>
<div> For example, *.domain.com will only record traffic on the main domain and all the
subdomains.
</div>
</div>
<div> NB: Once added, we will start allowing traffic only from matching hostnames within a few
minutes.</div>
</div>
<div class="flex">
<LyxUiButton class="w-full text-center" :disabled="!canAddDomain" @click="addDomain()" type="primary">
Add domain
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel']);
const props = defineProps<{ address: string }>();
async function deleteAddress() {
if (!props.address) return;
try {
const res = await $fetch('/api/shields/ip/delete', {
method: 'DELETE',
headers: useComputedHeaders({}).value,
body: JSON.stringify({ address: props.address })
});
emit('success');
} catch (ex: any) {
alert(ex.message);
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-semibold text-[1.1rem]"> IP Address delete </div>
<div> Are you sure to delete the blacklisted IP Address
<span class="font-semibold">{{ props.address }}</span>
</div>
<div class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')">
Cancel
</LyxUiButton>
<LyxUiButton @click="deleteAddress()" type="danger">
Delete
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel']);
const props = defineProps<{ domain: string }>();
async function deleteDomain() {
if (!props.domain) return;
try {
const res = await $fetch('/api/shields/domains/delete', {
method: 'DELETE',
headers: useComputedHeaders({}).value,
body: JSON.stringify({ domain: props.domain })
});
emit('success');
} catch (ex: any) {
alert(ex.message);
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-semibold text-[1.1rem]"> Domain delete </div>
<div> Are you sure to delete the whitelisted domain
<span class="font-semibold">{{ props.domain }}</span>
</div>
<div class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')">
Cancel
</LyxUiButton>
<LyxUiButton @click="deleteDomain()" type="danger">
Delete
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -10,7 +10,7 @@ const { drawerComponent } = useDrawer();
<div class="p-8 overflow-y-auto"> <div class="p-8 overflow-y-auto">
<div @click="$emit('onCloseClick')" <div @click="$emit('onCloseClick')"
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center"> class="cursor-pointer fixed top-4 right-4 rounded-full dark:bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
<i class="fas fa-close text-[1.6rem]"></i> <i class="fas fa-close text-[1.6rem]"></i>
</div> </div>

View File

@@ -195,19 +195,27 @@ function getPricingsData() {
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row"> <div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="poppins text-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
*Plan upgrades are applicable exclusively to this project(workspace).
</div>
<div class="poppins text-[2rem] font-semibold"> <div class="poppins text-[2rem] font-semibold">
Do you need help ? Do you need help ?
</div> </div>
<div class="poppins text-[1.2rem] text-text/90"> <div class="poppins text-[1.2rem]">
We respond in max. 1-2 days We respond in max. 1-2 days
</div> </div>
</div> </div>
<div class="mt-2"> <div class="flex flex-col gap-2">
<div class="rounded-lg px-10 py-3 bg-[#151515]"> <LyxUiButton type="secondary">
<a href="mailto:help@litlyx.com" class="poppins text-[1.3rem]"> <a href="mailto:help@litlyx.com" class="poppins text-[1.1rem]">
help@litlyx.com help@litlyx.com
</a> </a>
</div> </LyxUiButton>
<LyxUiButton type="secondary">
<a href="https://discord.com/invite/9cQykjsmWX" class="poppins text-[1.1rem]">
Discord support
</a>
</LyxUiButton>
</div> </div>
</div> </div>

View File

@@ -81,11 +81,11 @@ const canSearch = computed(() => {
<div class="flex-[2]"> <div class="flex-[2]">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<USelectMenu :uiMenu="{ <USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter', select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-widget', base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: { option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer', base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter' active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
} }
}" searchable searchable-placeholder="Search an event..." class="w-full" }" searchable searchable-placeholder="Search an event..." class="w-full"
placeholder="Select an event" :options="eventNames.data.value || []" placeholder="Select an event" :options="eventNames.data.value || []"
@@ -93,11 +93,11 @@ const canSearch = computed(() => {
</USelectMenu> </USelectMenu>
<USelectMenu :uiMenu="{ <USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter', select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-widget', base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: { option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer', base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter' active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
} }
}" searchable searchable-placeholder="Search a field..." class="w-full" }" searchable searchable-placeholder="Search a field..." class="w-full"
placeholder="Select a field" :options="metadataFields" v-model="selectedMetadataField"> placeholder="Select a field" :options="metadataFields" v-model="selectedMetadataField">
@@ -110,7 +110,7 @@ const canSearch = computed(() => {
</div> </div>
<div v-if="canSearch" class="h-full flex items-center text-[1.2rem]"> <div v-if="canSearch" class="h-full flex items-center text-[1.2rem]">
<div class="bg-lyx-widget-light flex items-center rounded-md pl-4"> <div class="bg-lyx-lightmode-widget dark:bg-lyx-widget-light flex items-center rounded-md pl-4">
<div><i class="far fa-search"></i></div> <div><i class="far fa-search"></i></div>
<input class="bg-transparent px-4 py-2 text-[1rem] outline-none" type="text" <input class="bg-transparent px-4 py-2 text-[1rem] outline-none" type="text"
placeholder="Filter by metadata name" v-model="currentSearchText"> placeholder="Filter by metadata name" v-model="currentSearchText">
@@ -121,7 +121,7 @@ const canSearch = computed(() => {
<div class="flex flex-wrap gap-2 lg:mt-4 mt-10"> <div class="flex flex-wrap gap-2 lg:mt-4 mt-10">
<div class="bg-lyx-widget-light text-lyx-text-dark px-3 py-2 rounded-md w-fit" <div class="bg-lyx-lightmode-widget dark:bg-lyx-widget-light text-lyx-lightmode-text dark:text-lyx-text-dark px-3 py-2 rounded-md w-fit"
v-for="item of metadataFieldGroupedFiltered"> v-for="item of metadataFieldGroupedFiltered">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div> {{ item._id || 'OLD_EVENTS' }} </div> <div> {{ item._id || 'OLD_EVENTS' }} </div>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import DateService, { type Slice } from '@services/DateService'; import { type Slice } from '@services/DateService';
const props = defineProps<{ slice: Slice }>(); const props = defineProps<{ slice: Slice }>();
const slice = computed(() => props.slice); const slice = computed(() => props.slice);
@@ -10,45 +10,23 @@ const { safeSnapshotDates } = useSnapshot()
function transformResponse(input: { _id: string, name: string, count: number }[]) { function transformResponse(input: { _id: string, name: string, count: number }[]) {
const fixed = fixMetrics({ const fixed = fixMetrics(
data: input, { data: input, from: input[0]._id, to: safeSnapshotDates.value.to },
from: input[0]._id,
to: safeSnapshotDates.value.to
},
slice.value, slice.value,
{ advanced: true, advancedGroupKey: 'name' }); { advanced: true, advancedGroupKey: 'name' }
);
const parsedDatasets: any[] = []; const parsedDatasets: any[] = [];
const colors = [ const colors = [
"#5655d0", "#5655d0", "#6bbbe3", "#a6d5cb", "#fae0b9", "#f28e8e",
"#6bbbe3", "#e3a7e4", "#c4a8e1", "#8cc1d8", "#f9c2cd", "#b4e3b2",
"#a6d5cb", "#ffdfba", "#e9c3b5", "#d5b8d6", "#add7f6", "#ffd1dc",
"#fae0b9", "#ffe7a1", "#a8e6cf", "#d4a5a5", "#f3d6e4", "#c3aed6"
"#f28e8e",
"#e3a7e4",
"#c4a8e1",
"#8cc1d8",
"#f9c2cd",
"#b4e3b2",
"#ffdfba",
"#e9c3b5",
"#d5b8d6",
"#add7f6",
"#ffd1dc",
"#ffe7a1",
"#a8e6cf",
"#d4a5a5",
"#f3d6e4",
"#c3aed6"
]; ];
for (let i = 0; i < fixed.allKeys.length; i++) { for (let i = 0; i < fixed.allKeys.length; i++) {
const line: any = { const line: any = { data: [], color: colors[i] || '#FF0000', label: fixed.allKeys[i] };
data: [],
color: colors[i] || '#FF0000',
label: fixed.allKeys[i]
};
parsedDatasets.push(line) parsedDatasets.push(line)
fixed.data.forEach((e: { key: string, value: number }[]) => { fixed.data.forEach((e: { key: string, value: number }[]) => {
const target = e.find(e => e.key == fixed.allKeys[i]); const target = e.find(e => e.key == fixed.allKeys[i]);
@@ -56,12 +34,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
line.data.push(target.value); line.data.push(target.value);
}); });
} }
return { datasets: parsedDatasets, labels: fixed.labels }
return {
datasets: parsedDatasets,
labels: fixed.labels
}
} }
const errorData = ref<{ errored: boolean, text: string }>({ const errorData = ref<{ errored: boolean, text: string }>({
@@ -88,7 +61,6 @@ const eventsStackedData = useFetch(`/api/timeline/events_stacked`, {
onResponse onResponse
}); });
onMounted(async () => { onMounted(async () => {
eventsStackedData.execute(); eventsStackedData.execute();
}); });

View File

@@ -46,11 +46,11 @@ async function analyzeEvent() {
<div class="py-2 flex items-center gap-3"> <div class="py-2 flex items-center gap-3">
<USelectMenu :uiMenu="{ <USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter', select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-widget', base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: { option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer', base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter' active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
} }
}" searchable searchable-placeholder="Search an event..." class="w-full" placeholder="Select an event" }" searchable searchable-placeholder="Search an event..." class="w-full" placeholder="Select an event"
:options="eventNames.data.value || []" v-model="selectedEventName"> :options="eventNames.data.value || []" v-model="selectedEventName">
@@ -71,7 +71,7 @@ async function analyzeEvent() {
</div> </div>
<div class="flex flex-col gap-2" v-if="userFlowData"> <div class="flex flex-col gap-2" v-if="userFlowData">
<div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-widget-light rounded-lg" <div class="flex gap-4 items-center bg-bg py-2 px-2 bg-lyx-lightmode-widget dark:bg-lyx-widget-light rounded-lg"
v-for="(count, referrer) in userFlowData"> v-for="(count, referrer) in userFlowData">
<div class="w-5 h-5 flex items-center justify-center"> <div class="w-5 h-5 flex items-center justify-center">
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`" <img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"

View File

@@ -22,86 +22,27 @@ const widthHeight = computed(() => {
return 9 + props.size * props.spacing; return 9 + props.size * props.spacing;
}); });
const colorMode = useColorMode();
</script> </script>
<template> <template>
<div class="w-fit h-fit"> <div class="w-fit h-fit">
<svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`" <svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`"
fill="none"> fill="none">
<template v-for="(p, x) of sizeArr"> <template v-for="(p, x) of sizeArr">
<template v-for="(p, y) of sizeArr"> <template v-for="(p, y) of sizeArr">
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" fill="#fff" <circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" :fill="colorMode.value === 'light' ? '#000' : '#FFF'"
:fill-opacity="calculateOpacity(x, y)" /> :fill-opacity="calculateOpacity(x, y)" />
</template> </template>
</template> </template>
<!-- <circle cx="27" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="9" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="27" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="45" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="63" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="81" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="99" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="117" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="9" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="27" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="45" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="63" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="81" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="99" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="117" cy="135" r="1" fill="#fff" fill-opacity=".9" />
<circle cx="135" cy="135" r="1" fill="#fff" fill-opacity=".9" />
-->
</svg> </svg>
</div> </div>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import { DialogFeedback, DialogHelp } from '#components';
const modal = useModal();
const selfhosted = useSelfhosted();
const { domain } = useDomain();
const colorMode = useColorMode()
const isDark = computed({
get() {
return colorMode.value === 'dark'
},
set() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
})
const { safeSnapshotDates } = useSnapshot();
</script>
<template>
<div
class="w-full hide-scrollbars relative h-[4rem] border-solid border-[#D9D9E0] dark:border-[#202020] border-b-[1px] bg-lyx-lightmode-background dark:bg-lyx-background dark:shadow-[1px_0_10px_#000000]">
<div class="absolute flex h-full w-full">
<div class="flex items-center px-6">
<SelectorDomainSelector></SelectorDomainSelector>
</div>
<div class="hidden lg:flex items-center popping text-[.9rem] dark:text-lyx-text-dark">
Timeframe:
{{ new Date(safeSnapshotDates.from).toLocaleDateString() }}
to
{{ new Date(safeSnapshotDates.to).toLocaleDateString() }}
</div>
<div class="grow"></div>
<div class="flex items-center gap-6 mr-10">
<div v-if="!selfhosted" @click="modal.open(DialogFeedback, {});"
class="flex gap-2 items-center cursor-pointer outline-[1px] outline-lyx-widget-lighter p-1 px-3 rounded-md outline">
<i class="far fa-message"></i>
Feedback
</div>
<div @click="modal.open(DialogHelp, {});" class="cursor-pointer"> Help </div>
<NuxtLink to="https://docs.litlyx.com" target="_blank" class="cursor-pointer">
Docs
</NuxtLink>
<div>
<UTooltip :text="isDark ? 'Toggle light mode' : 'Toggle dark mode'">
<i @click="isDark = !isDark"
class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark"
:class="isDark ? 'far fa-moon' : 'far fa-sun'"></i>
</UTooltip>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import CreateSnapshot from './dialog/CreateSnapshot.vue'; import { DialogConfirmLogout, DialogInviteManager } from '#components';
import CreateSnapshot from '../dialog/CreateSnapshot.vue';
export type Entry = { export type Entry = {
label: string, label: string,
@@ -23,9 +24,14 @@ type Props = {
sections: Section[] sections: Section[]
} }
const route = useRoute(); const route = useRoute();
const props = defineProps<Props>(); const props = defineProps<Props>();
const { data: pendingInvites, refresh: refreshInvites } = useFetch('/api/project/members/pending', {
headers: useComputedHeaders({})
});
const { userRoles, setLoggedUser } = useLoggedUser(); const { userRoles, setLoggedUser } = useLoggedUser();
const { projectList } = useProject(); const { projectList } = useProject();
@@ -88,12 +94,25 @@ async function generatePDF() {
const { setToken } = useAccessToken(); const { setToken } = useAccessToken();
const router = useRouter(); const router = useRouter();
const { actions } = useProject();
const modal = useModal();
function onLogout() { function onLogout() {
console.log('LOGOUT') modal.open(DialogConfirmLogout, {
onSuccess() {
modal.close();
console.log('LOGOUT');
setToken(''); setToken('');
setLoggedUser(undefined); setLoggedUser(undefined);
router.push('/login'); router.push('/login');
},
onCancel() {
modal.close();
}
})
} }
const { data: maxProjects } = useFetch("/api/user/max_projects", { const { data: maxProjects } = useFetch("/api/user/max_projects", {
@@ -105,10 +124,32 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
}); });
function openPendingInvites() {
if (!pendingInvites.value) return;
if (pendingInvites.value.length == 0) return;
console.log(pendingInvites);
modal.open(DialogInviteManager, {
invites: pendingInvites.value.map(e => {
return { project_id: e.project_id, project_name: e.project_name }
}),
onSuccess: () => {
modal.close();
actions.refreshProjectsList();
refreshInvites();
},
onCancel: () => {
modal.close();
actions.refreshProjectsList();
refreshInvites();
},
});
}
</script> </script>
<template> <template>
<div class="CVerticalNavigation border-solid border-[#202020] border-r-[1px] h-full w-[20rem] bg-lyx-background flex shadow-[1px_0_10px_#000000] rounded-r-lg" <div class="CVerticalNavigation border-solid border-[#D9D9E0] dark:border-[#202020] border-r-[1px] h-full w-[20rem] bg-lyx-lightmode-background dark:bg-lyx-background flex shadow-[1px_0_10px_#000000]"
:class="{ :class="{
'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen, 'absolute top-0 w-full md:w-[20rem] z-[45] open': isOpen,
'hidden lg:flex': !isOpen 'hidden lg:flex': !isOpen
@@ -126,7 +167,7 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
<div class="flex items-center gap-2 w-full"> <div class="flex items-center gap-2 w-full">
<ProjectSelector></ProjectSelector> <SelectorProjectSelector></SelectorProjectSelector>
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden"> <div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
<i @click="close()" class="fas fa-close"></i> <i @click="close()" class="fas fa-close"></i>
@@ -157,9 +198,9 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
<div class="w-full flex-col px-2"> <div class="w-full flex-col px-2">
<div class="flex mb-2 items-center justify-between"> <div class="flex mb-2 items-center justify-between text-lyx-lightmode-text dark:text-lyx-text">
<div class="poppins text-[.8rem]"> <div class="poppins text-[.8rem]">
Snapshots Timeframes
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -168,9 +209,9 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
<div><i class="far fa-download text-[.8rem]"></i></div> <div><i class="far fa-download text-[.8rem]"></i></div>
</LyxUiButton> </LyxUiButton>
</UTooltip> --> </UTooltip> -->
<UTooltip text="Create new snapshot"> <UTooltip text="Create new timeframe">
<LyxUiButton @click="openSnapshotDialog()" type="outlined" class="!px-3 !py-1"> <LyxUiButton @click="openSnapshotDialog()" type="outlined" class="!px-3 !py-1">
<div><i class="fas fa-plus text-[.9rem]"></i></div> <div><i class="fas fa-plus text-[.8rem]"></i></div>
</LyxUiButton> </LyxUiButton>
</UTooltip> </UTooltip>
</div> </div>
@@ -179,11 +220,11 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<USelectMenu :uiMenu="{ <USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter', select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-widget', base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: { option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer', base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter' active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
} }
}" class="w-full" v-model="snapshot" :options="snapshotsItems"> }" class="w-full" v-model="snapshot" :options="snapshotsItems">
<template #label> <template #label>
@@ -204,7 +245,8 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
</div> </div>
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2"> <div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
<div class="flex gap-1 items-center justify-center text-lyx-text-dark"> <div
class="flex gap-1 items-center justify-center text-lyx-lightmode-text-dark dark:text-lyx-text-dark">
<div class="poppins"> <div class="poppins">
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }} {{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
</div> </div>
@@ -237,27 +279,26 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
</div> </div>
<div class="w-full flex mt-4"> <div class="w-full flex mt-4">
<LyxUiButton type="outline" class="w-full text-center text-[.7rem]"> <LyxUiButton @click="generatePDF()" type="outline" class="w-full text-center text-[.8rem]">
Export report Export report
</LyxUiButton> </LyxUiButton>
</div> </div>
</div> </div>
<div class="bg-[#202020] h-[1px] w-full"></div> <div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full"></div>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6"> <div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }"> <div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
<div v-if="(!entry.adminOnly || (userRoles.isAdmin.value && !isAdminHidden))" <div v-if="(!entry.adminOnly || (userRoles.isAdmin.value && !isAdminHidden))"
class="bg-lyx-background w-full cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center" class="bg-lyx-lightmode-background text-lyx-lightmode-text-dark dark:bg-lyx-background dark:text-lyx-text-dark w-full cursor-pointer py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
:class="{ :class="{
'!text-lyx-text-darker pointer-events-none': entry.disabled, '!text-lyx-text-darker pointer-events-none': entry.disabled,
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'), 'bg-lyx-lightmode-background-light !text-lyx-lightmode-text dark:bg-lyx-background-lighter dark:!text-lyx-text': route.path == (entry.to || '#'),
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'), 'hover:bg-lyx-lightmode-background-light hover:!text-lyx-lightmode-text dark:hover:bg-lyx-background-light dark:hover:!text-lyx-text': route.path != (entry.to || '#'),
}"> }">
<NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''" <NuxtLink @click="close() && entry.action?.()" :target="entry.external ? '_blank' : ''"
@@ -281,36 +322,33 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
<div class="grow"></div> <div class="grow"></div>
<div class="bg-[#202020] h-[1px] w-full px-4 mb-3"></div> <div v-if="pendingInvites && pendingInvites.length > 0" @click="openPendingInvites()"
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex flex-col justify-center cursor-pointer">
<div class="poppins font-medium dark:text-lyx-text text-lyx-lightmode-text">
Pending invitation
</div>
<div class="poppins dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
You have {{ pendingInvites.length }}
pending invitation{{ pendingInvites.length != 1 ? 's' : '' }}
awaiting your response
</div>
</div>
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full px-4 mb-3"></div>
<div class="flex justify-end px-2"> <div class="flex justify-end px-2">
<div class="grow flex gap-3"> <div class="grow flex gap-3">
<!-- <NuxtLink to="https://github.com/litlyx/litlyx" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-github"></i>
</NuxtLink> -->
<!-- <NuxtLink to="https://discord.gg/9cQykjsmWX" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-discord"></i>
</NuxtLink> -->
<NuxtLink to="https://x.com/litlyx" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-x-twitter"></i>
</NuxtLink>
<!-- <NuxtLink to="https://dev.to/litlyx-org" target="_blank"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
<i class="fab fa-dev"></i>
</NuxtLink> -->
<NuxtLink to="/admin" v-if="userRoles.isAdmin.value" <NuxtLink to="/admin" v-if="userRoles.isAdmin.value"
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark"> class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
<i class="fas fa-cat"></i> <i class="far fa-cat"></i>
</NuxtLink> </NuxtLink>
</div> </div>
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }"> <UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark"> <div @click="onLogout()"
class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i> <i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
</div> </div>
</UTooltip> </UTooltip>

View File

@@ -41,7 +41,7 @@ async function onUpgradeClick() {
<template> <template>
<div <div
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]"> class="relative bg-lyx-lightmode-widget-light dark:bg-[#151515] outline outline-[1px] outline-lyx-lightmode-widget dark:outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
<div class="flex flex-col gap-3 text-center pt-3"> <div class="flex flex-col gap-3 text-center pt-3">
<div v-if="data.active" <div v-if="data.active"
@@ -56,7 +56,7 @@ async function onUpgradeClick() {
<div class="poppins text-4xl font-medium"> {{ data.price }} </div> <div class="poppins text-4xl font-medium"> {{ data.price }} </div>
</div> </div>
<div class="sep bg-[#262626] h-[1px] my-8"></div> <div class="sep bg-lyx-lightmode-widget dark:bg-[#262626] h-[1px] my-8"></div>
<div class="flex flex-col text-center h-[6rem] justify-center gap-2"> <div class="flex flex-col text-center h-[6rem] justify-center gap-2">
<div v-if="datas.length > 1"> <div v-if="datas.length > 1">
@@ -76,7 +76,7 @@ async function onUpgradeClick() {
</div> </div>
</div> </div>
<div class="sep bg-[#262626] h-[1px] my-8"></div> <div class="sep bg-lyx-lightmode-widget dark:bg-[#262626] h-[1px] my-8"></div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex gap-2" v-for="feature of data.features"> <div class="flex gap-2" v-for="feature of data.features">

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
const { domainList, domain, setActiveDomain, refreshDomains, refreshingDomains } = useDomain();
function onChange(e: string) {
setActiveDomain(e);
}
</script>
<template>
<div class="flex gap-2">
<USelectMenu :uiMenu="{
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget w-max',
option: {
base: 'z-[990] hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
},
input: 'z-[999] !bg-lyx-lightmode-widget dark:!bg-lyx-widget-light'
}" class="w-full" searchable searchable-placeholder="Search domain..." v-if="domainList" @change="onChange"
:value="domain" :loading="refreshingDomains" value-attribute="_id" :options="domainList">
<template #option="{ option, active, selected }">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div>
<div> {{ option._id }} </div>
</div>
</template>
<template #label="e">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div>
<div>
{{ refreshingDomains ? 'Loading...' : (domain || '-') }}
</div>
</div>
</template>
</USelectMenu>
<UTooltip text="Manage domains">
<NuxtLink to="/settings?tab=domains"
class="flex items-center hover:rotate-[60deg] transition-all duration-200 ease-in-out cursor-pointer">
<i class="far fa-gear"></i>
</NuxtLink>
</UTooltip>
</div>
</template>

View File

@@ -4,8 +4,8 @@ import type { TProject } from '@schema/project/ProjectSchema';
const { user } = useLoggedUser() const { user } = useLoggedUser()
const { projectList, guestProjectList,allProjectList, actions, project } = useProject(); const { projectList, guestProjectList, allProjectList, actions, project } = useProject();
const { setActiveDomain } = useDomain();
function isProjectMine(owner?: string) { function isProjectMine(owner?: string) {
if (!owner) return false; if (!owner) return false;
@@ -22,11 +22,11 @@ function onChange(e: TProject) {
<template> <template>
<USelectMenu :uiMenu="{ <USelectMenu :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter', select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-widget', base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: { option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer', base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter' active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
} }
}" class="w-full" v-if="allProjectList" @change="onChange" :value="project" :options="allProjectList"> }" class="w-full" v-if="allProjectList" @change="onChange" :value="project" :options="allProjectList">

View File

@@ -2,12 +2,18 @@
import type { SettingsTemplateEntry } from './Template.vue'; import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [ const entries: SettingsTemplateEntry[] = [
{ id: 'change_pass', title: 'Change password', text: 'Change your password' },
{ id: 'delete', title: 'Delete account', text: 'Delete your account' }, { id: 'delete', title: 'Delete account', text: 'Delete your account' },
] ]
const { user } = useLoggedUser();
const { setToken } = useAccessToken(); const { setToken } = useAccessToken();
const canChangePassword = useFetch('/api/user/password/can_change', {
headers: useComputedHeaders({ useSnapshotDates: false })
});
async function deleteAccount() { async function deleteAccount() {
const sure = confirm("Are you sure you want to delete this account ?"); const sure = confirm("Are you sure you want to delete this account ?");
if (!sure) return; if (!sure) return;
@@ -20,17 +26,63 @@ async function deleteAccount() {
location.href = "/login" location.href = "/login"
} }
const old_password = ref<string>("");
const new_password = ref<string>("");
const { createAlert } = useAlert()
async function changePassword() {
try {
const res = await $fetch("/api/user/password/change", {
...signHeaders({ 'Content-Type': 'application/json' }),
method: "POST",
body: JSON.stringify({ old_password: old_password.value, new_password: new_password.value })
})
if (!res) throw Error('No response');
if (res.error) return createAlert('Error', res.message, 'far fa-triangle-exclamation', 5000);
old_password.value = '';
new_password.value = '';
return createAlert('Success', 'Password changed successfully', 'far fa-circle-check', 5000);
} catch (ex) {
console.error(ex);
createAlert('Error', 'Internal error', 'far fa-triangle-exclamation', 5000);
}
}
</script> </script>
<template> <template>
<SettingsTemplate :entries="entries"> <SettingsTemplate :entries="entries">
<template #change_pass>
<div v-if="canChangePassword.data.value?.can_change">
<div class="flex flex-col gap-4">
<LyxUiInput type="password" class="py-1 px-2" v-model="old_password" placeholder="Current password"></LyxUiInput>
<LyxUiInput type="password" class="py-1 px-2" v-model="new_password" placeholder="New password"></LyxUiInput>
<LyxUiButton type="primary" @click="changePassword()"> Change password </LyxUiButton>
</div>
</div>
<div v-if="!canChangePassword.data.value?.can_change">
You cannot change the password for accounts created using social login options.
</div>
</template>
<template #delete> <template #delete>
<div <div
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]"> class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div> <div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
<div @click="deleteAccount()" <div @click="deleteAccount()"
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]"> class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
Delete account Delete account
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import type { TApiSettings } from '@schema/ApiSettingsSchema'; import type { TApiSettings } from '@schema/ApiSettingsSchema';
import type { SettingsTemplateEntry } from './Template.vue'; import type { SettingsTemplateEntry } from './Template.vue';
const { project } = useProject(); const { project, isGuest } = useProject();
const entries: SettingsTemplateEntry[] = [ const entries: SettingsTemplateEntry[] = [
{ id: 'acodes', title: 'Appsumo codes', text: 'Redeem appsumo codes' }, { id: 'acodes', title: 'Appsumo codes', text: 'Redeem appsumo codes' },
@@ -39,7 +39,7 @@ async function redeemCode() {
<template> <template>
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'"> <SettingsTemplate v-if="!isGuest" :entries="entries" :key="project?.name || 'NONE'">
<template #acodes> <template #acodes>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" placeholder="Appsumo code" v-model="currentCode"></LyxUiInput> <LyxUiInput class="w-full px-4 py-2" placeholder="Appsumo code" v-model="currentCode"></LyxUiInput>
@@ -53,6 +53,14 @@ async function redeemCode() {
<div class="text-lyx-text-darker mt-1 text-[.9rem] poppins"> <div class="text-lyx-text-darker mt-1 text-[.9rem] poppins">
Redeemed codes: {{ valid_codes.data.value?.count || '0' }} Redeemed codes: {{ valid_codes.data.value?.count || '0' }}
</div> </div>
<div class="poppins text-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
*Plan upgrades are applicable exclusively to this project(workspace).
</div>
</template> </template>
</SettingsTemplate> </SettingsTemplate>
<div v-if="isGuest" class="text-lyx-text-darker flex w-full h-full justify-center mt-20">
Guests cannot view billing
</div>
</template> </template>

View File

@@ -2,6 +2,9 @@
import DeleteDomainData from '../dialog/DeleteDomainData.vue'; import DeleteDomainData from '../dialog/DeleteDomainData.vue';
import type { SettingsTemplateEntry } from './Template.vue'; import type { SettingsTemplateEntry } from './Template.vue';
const { isGuest } = useProject();
const entries: SettingsTemplateEntry[] = [ const entries: SettingsTemplateEntry[] = [
{ id: 'delete_dns', title: 'Delete domain data', text: 'Delete data of a specific domain from this project' }, { id: 'delete_dns', title: 'Delete domain data', text: 'Delete data of a specific domain from this project' },
{ id: 'delete_data', title: 'Delete project data', text: 'Delete all data from this project' }, { id: 'delete_data', title: 'Delete project data', text: 'Delete all data from this project' },
@@ -105,15 +108,17 @@ const sessionsLabel = computed(() => {
<div class="flex flex-col"> <div class="flex flex-col">
<!-- <div class="text-[.9rem] text-lyx-text-darker"> Select a domain </div> --> <!-- <div class="text-[.9rem] text-lyx-text-darker"> Select a domain </div> -->
<USelectMenu placeholder="Select a domain" :uiMenu="{ <USelectMenu v-if="!isGuest" placeholder="Select a domain" :uiMenu="{
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter', select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-widget', base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: { option: {
base: 'hover:!bg-lyx-widget-lighter cursor-pointer', base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter' active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
} }
}" :options="domains.data.value ?? []" v-model="selectedDomain"></USelectMenu> }" :options="domains.data.value ?? []" v-model="selectedDomain"></USelectMenu>
<div v-if="isGuest" class="text-lyx-text-darker"> Guests cannot delete data</div>
<div v-if="selectedDomain" class="flex flex-col gap-2 mt-4"> <div v-if="selectedDomain" class="flex flex-col gap-2 mt-4">
<div class="text-[.9rem] text-lyx-text-dark"> Select data to delete </div> <div class="text-[.9rem] text-lyx-text-dark"> Select data to delete </div>
@@ -140,15 +145,18 @@ const sessionsLabel = computed(() => {
</div> </div>
</template> </template>
<template #delete_data> <template #delete_data>
<div
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-[#1e1412]"> <div v-if="!isGuest"
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
<div class="poppins font-semibold"> This operation will reset this project to it's initial state (0 <div class="poppins font-semibold"> This operation will reset this project to it's initial state (0
visits 0 events 0 sessions)</div> visits 0 events 0 sessions) </div>
<div @click="openDeleteAllDomainDataDialog()" <div @click="openDeleteAllDomainDataDialog()"
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-[#291415]"> class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
Delete all data Delete all data
</div> </div>
</div> </div>
<div v-if="isGuest" class="text-lyx-text-darker"> Guests cannot delete data</div>
</template> </template>
</SettingsTemplate> </SettingsTemplate>
</template> </template>

View File

@@ -4,6 +4,8 @@ import type { SettingsTemplateEntry } from './Template.vue';
const { project, actions, projectList, isGuest, projectId } = useProject(); const { project, actions, projectList, isGuest, projectId } = useProject();
const { createErrorAlert, createAlert } = useAlert();
const entries: SettingsTemplateEntry[] = [ const entries: SettingsTemplateEntry[] = [
{ id: 'pname', title: 'Name', text: 'Project name' }, { id: 'pname', title: 'Name', text: 'Project name' },
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' }, { id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
@@ -37,7 +39,7 @@ async function createApiKey() {
apiKeys.value.push(res); apiKeys.value.push(res);
newApiKeyName.value = ''; newApiKeyName.value = '';
} catch (ex: any) { } catch (ex: any) {
alert(ex.message); createErrorAlert('Error', ex.message, 10000);
} }
} }
@@ -53,7 +55,7 @@ async function deleteApiKey(api_id: string) {
newApiKeyName.value = ''; newApiKeyName.value = '';
await updateApiKeys(); await updateApiKeys();
} catch (ex: any) { } catch (ex: any) {
alert(ex.message); createErrorAlert('Error', ex.message, 10000);
} }
} }
@@ -116,14 +118,12 @@ async function deleteProject() {
} catch (ex: any) { } catch (ex: any) {
alert(ex.message); createErrorAlert('Error', ex.message);
} }
} }
const { createAlert } = useAlert()
function copyScript() { function copyScript() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP'); if (!navigator.clipboard) alert('You can\'t copy in HTTP');
@@ -156,21 +156,29 @@ function copyProjectId() {
<template> <template>
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'"> <SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
<template #pname> <template #pname>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" :disabled="isGuest" v-model="projectNameInputVal"></LyxUiInput> <LyxUiInput class="w-full px-4 py-2" :disabled="isGuest" v-model="projectNameInputVal"></LyxUiInput>
<LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary"> Change <LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary">
Change
</LyxUiButton> </LyxUiButton>
</div> </div>
<div v-if="isGuest" class="text-lyx-text-darker"> *Guests cannot change project name </div>
</div>
</template> </template>
<template #api> <template #api>
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5"> <div class="flex flex-col gap-2" v-if="apiKeys && apiKeys.length < 5">
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName" v-model="newApiKeyName"> <div class="flex items-center gap-4">
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName"
v-model="newApiKeyName">
</LyxUiInput> </LyxUiInput>
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3" <LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.trim().length < 3"
type="primary"> type="primary">
<i class="far fa-plus"></i> <i class="far fa-plus"></i>
</LyxUiButton> </LyxUiButton>
</div> </div>
<div v-if="isGuest" class="text-lyx-text-darker"> *Guests cannot manage api keys </div>
</div>
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4"> <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 v-for="apiKey of apiKeys" class="flex flex-col w-full">
@@ -212,6 +220,7 @@ function copyProjectId() {
Delete project Delete project
</LyxUiButton> </LyxUiButton>
</div> </div>
<div v-if="isGuest"> *Guests cannot delete project </div>
</template> </template>
</SettingsTemplate> </SettingsTemplate>
</template> </template>

View File

@@ -20,10 +20,10 @@ const props = defineProps<SettingsTemplateProp>();
<div v-for="(entry, index) of props.entries" class="flex flex-col"> <div v-for="(entry, index) of props.entries" class="flex flex-col">
<div class="flex xl:flex-row flex-col gap-4 xl:gap-0"> <div class="flex xl:flex-row flex-col gap-4 xl:gap-0">
<div class="xl:flex-[2]"> <div class="xl:flex-[2]">
<div class="poppins font-medium text-lyx-text"> <div class="poppins font-medium text-lyx-lightmode-text dark:text-lyx-text">
{{ entry.title }} {{ entry.title }}
</div> </div>
<div class="poppins font-regular text-lyx-text-dark whitespace-pre-wrap"> <div class="poppins font-regular text-lyx-lightmode-text-dark dark:text-lyx-text-dark whitespace-pre-wrap">
{{ entry.text }} {{ entry.text }}
</div> </div>
</div> </div>
@@ -31,7 +31,7 @@ const props = defineProps<SettingsTemplateProp>();
<slot :name="entry.id"></slot> <slot :name="entry.id"></slot>
</div> </div>
</div> </div>
<div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-widget-lighter w-full my-10"></div> <div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter w-full my-10"></div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -68,7 +68,7 @@ function getPremiumPrice(type: number) {
} }
const entries: SettingsTemplateEntry[] = [ const entries: SettingsTemplateEntry[] = [
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' }, { id: 'plan', title: 'Current plan', text: 'Manage current plan for this project' },
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' }, { id: 'usage', title: 'Usage', text: 'Show usage of current project' },
{ id: 'info', title: 'Billing address', text: 'This will be reflected in every upcoming invoice,\npast invoices are not affected' }, { id: 'info', title: 'Billing address', text: 'This will be reflected in every upcoming invoice,\npast invoices are not affected' },
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' }, { id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
@@ -116,36 +116,36 @@ const { showDrawer } = useDrawer();
</script> </script>
<template> <template>
<div class="relative"> <div class="relative pb-[6rem]">
<div v-if="invoicesPending || planPending" <div v-if="invoicesPending || planPending"
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold"> class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i> <i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div> </div>
<SettingsTemplate v-if="!invoicesPending && !planPending" :entries="entries"> <SettingsTemplate v-if="!invoicesPending && !planPending && !isGuest" :entries="entries">
<template #info> <template #info>
<div v-if="!isGuest"> <div v-if="!isGuest">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 1" <LyxUiInput class="px-2 py-2 dark:!bg-[#161616]" placeholder="Address line 1"
v-model="currentBillingInfo.line1"> v-model="currentBillingInfo.line1">
</LyxUiInput> </LyxUiInput>
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 2" <LyxUiInput class="px-2 py-2 dark:!bg-[#161616]" placeholder="Address line 2"
v-model="currentBillingInfo.line2"> v-model="currentBillingInfo.line2">
</LyxUiInput> </LyxUiInput>
<div class="flex gap-4 w-full"> <div class="flex gap-4 w-full">
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="Country" <LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="Country"
v-model="currentBillingInfo.country"> v-model="currentBillingInfo.country">
</LyxUiInput> </LyxUiInput>
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="Postal code" <LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="Postal code"
v-model="currentBillingInfo.postal_code"> v-model="currentBillingInfo.postal_code">
</LyxUiInput> </LyxUiInput>
</div> </div>
<div class="flex gap-4 w-full"> <div class="flex gap-4 w-full">
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="City" <LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="City"
v-model="currentBillingInfo.city"> v-model="currentBillingInfo.city">
</LyxUiInput> </LyxUiInput>
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="State" <LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="State"
v-model="currentBillingInfo.state"> v-model="currentBillingInfo.state">
</LyxUiInput> </LyxUiInput>
</div> </div>
@@ -175,7 +175,8 @@ const { showDrawer } = useDrawer();
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="poppins font-semibold text-[2rem]"> <div class="poppins font-semibold text-[2rem]">
{{ getPremiumPrice(planData.premium_type) }} </div> {{ getPremiumPrice(planData.premium_type) }} </div>
<div class="poppins text-text-sub mt-2"> per month </div> <div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub mt-2"> per month
</div>
</div> </div>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
@@ -194,7 +195,7 @@ const { showDrawer } = useDrawer();
<div class="my-4 w-full bg-gray-400/30 h-[1px]"> <div class="my-4 w-full bg-gray-400/30 h-[1px]">
</div> </div>
<div class="flex justify-between px-8 flex-col lg:flex-row gap-2 lg:gap-0 items-center"> <div class="flex justify-between px-8 flex-col lg:flex-row gap-2 lg:gap-0 items-center">
<div class="flex gap-2 text-text-sub text-[.9rem]"> <div class="flex gap-2 text-lyx-lightmode-text-dark dark:text-text-sub text-[.9rem]">
<div class="poppins"> Expire date:</div> <div class="poppins"> Expire date:</div>
<div> {{ prettyExpireDate }}</div> <div> {{ prettyExpireDate }}</div>
</div> </div>
@@ -212,7 +213,7 @@ const { showDrawer } = useDrawer();
<div class="poppins font-semibold text-[1.1rem]"> <div class="poppins font-semibold text-[1.1rem]">
Usage Usage
</div> </div>
<div class="poppins text-text-sub text-[.9rem]"> <div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub text-[.9rem]">
Check the usage limits of your project. Check the usage limits of your project.
</div> </div>
</div> </div>
@@ -240,7 +241,7 @@ const { showDrawer } = useDrawer();
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex justify-between items-center bg-[#161616] p-4 rounded-lg" <div class="flex justify-between items-center outline-[1px] outline outline-lyx-lightmode-widget dark:outline-none bg-lyx-lightmode-widget-light dark:bg-[#161616] p-4 rounded-lg"
v-for="invoice of invoices"> v-for="invoice of invoices">
<div> <i class="fal fa-file-invoice"></i> </div> <div> <i class="fal fa-file-invoice"></i> </div>
@@ -266,6 +267,10 @@ const { showDrawer } = useDrawer();
</CardTitled> </CardTitled>
</template> </template>
</SettingsTemplate> </SettingsTemplate>
<div v-if="isGuest" class="text-lyx-text-darker flex w-full h-full justify-center mt-20">
Guests cannot view billing
</div>
</div> </div>

View File

@@ -1,122 +0,0 @@
<script setup lang="ts">
import type { SettingsTemplateEntry } from './Template.vue';
const { projectId, isGuest } = useProject();
definePageMeta({ layout: 'dashboard' });
const columns = [
{ key: 'me', label: '' },
{ key: 'email', label: 'Email' },
{ key: 'name', label: 'Name' },
{ key: 'role', label: 'Role' },
{ key: 'action', label: 'Actions' },
// { key: 'pending', label: 'Pending' },
]
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', {
headers: useComputedHeaders({ useSnapshotDates: false })
});
const showAddMember = ref<boolean>(false);
const addMemberEmail = ref<string>("");
async function kickMember(email: string) {
const sure = confirm('Are you sure to kick ' + email + ' ?');
if (!sure) return;
try {
await $fetch('/api/project/members/kick', {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify({ email }),
onResponseError({ request, response, options }) {
alert(response.statusText);
}
});
refreshMembers();
} catch (ex: any) { }
}
async function addMember() {
if (addMemberEmail.value.length === 0) return;
try {
showAddMember.value = false;
await $fetch('/api/project/members/add', {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify({ email: addMemberEmail.value }),
onResponseError({ request, response, options }) {
alert(response.statusText);
}
});
addMemberEmail.value = '';
refreshMembers();
} catch (ex: any) { }
}
const entries: SettingsTemplateEntry[] = [
{ id: 'add', title: 'Add member', text: 'Add new member to project' },
{ id: 'members', title: 'Members', text: 'Manage members of current project' },
]
</script>
<template>
<SettingsTemplate :entries="entries">
<template #add>
<div v-if="!isGuest" class="flex flex-col">
<div class="flex gap-4 items-center">
<LyxUiInput class="px-4 py-1 w-full" placeholder="User email" v-model="addMemberEmail"></LyxUiInput>
<LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
</div>
<div class="poppins text-[.8rem] mt-2 text-lyx-text-darker">
User should have been registered to Litlyx
</div>
</div>
</template>
<template #members>
<UTable :rows="members || []" :columns="columns">
<template #me-data="e">
<i v-if="e.row.me" class="far fa-user"></i>
<i v-if="!e.row.me"></i>
</template>
<template #action-data="e" v-if="!isGuest">
<div @click="kickMember(e.row.email)" v-if="e.row.role != 'OWNER'"
class="text-red-500 hover:bg-black/20 cursor-pointer outline outline-[1px] outline-red-500 px-3 py-1 rounded-lg text-center">
Kick
</div>
</template>
</UTable>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,101 @@
<script lang="ts" setup>
import { DialogShieldsDeleteAddress, DialogShieldsAddAddress } from '#components';
definePageMeta({ layout: 'dashboard' });
const { data: blackAddresses, refresh: refreshAddresses, pending: pendingAddresses } = useFetch('/api/shields/ip/list', {
headers: useComputedHeaders({})
});
const toast = useToast()
const modal = useModal();
function showAddAddressModal() {
modal.open(DialogShieldsAddAddress, {
onSuccess: () => {
refreshAddresses();
modal.close();
toast.add({
id: 'shield_address_add_success',
title: 'Success',
description: 'Blacklist updated with the new address',
timeout: 5000
});
},
onCancel: () => {
modal.close();
}
})
}
function showDeleteAddressModal(address: string) {
modal.open(DialogShieldsDeleteAddress, {
address,
onSuccess: () => {
refreshAddresses();
modal.close();
toast.add({
id: 'shield_address_remove_success',
title: 'Deleted',
description: 'Blacklist address deleted successfully',
timeout: 5000
});
},
onCancel: () => {
modal.close();
}
})
}
</script>
<template>
<div class="py-4 flex">
<LyxUiCard class="w-full mx-2">
<div>
<div class="text-[1.2rem] font-semibold"> IP Block List </div>
<div class="dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
Reject incoming traffic from specific IP addresses
</div>
</div>
<LyxUiSeparator class="my-3"></LyxUiSeparator>
<div class="flex justify-end pb-3">
<LyxUiButton type="primary" @click="showAddAddressModal()"> Add IP Address </LyxUiButton>
</div>
<div class="flex justify-center pb-8 text-[1.2rem]" v-if="pendingAddresses">
<i class="fas fa-loader animate-spin"></i>
</div>
<div v-if="!pendingAddresses && blackAddresses && blackAddresses.length == 0"
class="flex flex-col items-center pb-8">
<div>
No domain rules configured for this project.
</div>
<div class="font-semibold">
Traffic from all domains is currently accepted.
</div>
</div>
<div v-if="!pendingAddresses && blackAddresses && blackAddresses.length > 0"
class="grid grid-cols-[auto_auto_auto_auto] px-10">
<div> Domain </div>
<div class="col-span-2"> Description </div>
<div> Actions </div>
<LyxUiSeparator class="col-span-4 my-3"></LyxUiSeparator>
<template v-for="entry of blackAddresses">
<div class="mb-2"> {{ entry.address }} </div>
<div class="col-span-2">{{ entry.description || 'No description' }}</div>
<div> <i @click="showDeleteAddressModal(entry.address)"
class="far fa-trash cursor-pointer hover:text-lyx-text-dark"></i> </div>
</template>
</div>
</LyxUiCard>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
definePageMeta({ layout: 'dashboard' });
const { data: botOptions, refresh: refreshBotOptions, pending: pendingBotOptions } = useFetch('/api/shields/bots/options', {
headers: useComputedHeaders({})
});
async function onChange(newValue: boolean) {
await $fetch('/api/shields/bots/update_options', {
method: 'POST',
headers: useComputedHeaders({ custom: { 'Content-Type': 'application/json' } }).value,
body: JSON.stringify({ block: newValue })
})
await refreshBotOptions();
}
</script>
<template>
<div class="py-4 flex">
<LyxUiCard class="w-full mx-2">
<div>
<div class="text-[1.2rem] font-semibold"> Block bot traffic </div>
<div class="dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
Automatically block unwanted bot and crawler traffic to protect your site from spam, scrapers, and
unnecessary server load.
</div>
</div>
<LyxUiSeparator class="my-3"></LyxUiSeparator>
<div class="flex justify-center pb-8 text-[1.2rem]" v-if="pendingBotOptions">
<i class="fas fa-loader animate-spin"></i>
</div>
<div v-if="!pendingBotOptions && botOptions">
<div class="flex gap-2">
<UToggle :modelValue="botOptions.block" @change="onChange"></UToggle>
<div> Enable bot protection </div>
</div>
</div>
</LyxUiCard>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script lang="ts" setup>
import { DialogShieldsAddDomain, DialogShieldsDeleteDomain } from '#components';
definePageMeta({ layout: 'dashboard' });
const { data: allowedDomains, refresh: refreshDomains, pending: pendingDomains } = useFetch('/api/shields/domains/list', {
headers: useComputedHeaders({})
});
const toast = useToast()
const modal = useModal();
function showAddDomainModal() {
modal.open(DialogShieldsAddDomain, {
onSuccess: () => {
refreshDomains();
modal.close();
toast.add({
id: 'shield_domain_add_success',
title: 'Success',
description: 'Whitelist updated with the new domain',
timeout: 5000
});
},
onCancel: () => {
modal.close();
}
})
}
function showDeleteDomainModal(domain: string) {
modal.open(DialogShieldsDeleteDomain, {
domain,
onSuccess: () => {
refreshDomains();
modal.close();
toast.add({
id: 'shield_domain_remove_success',
title: 'Deleted',
description: 'Whitelist domain deleted successfully',
timeout: 5000
});
},
onCancel: () => {
modal.close();
}
})
}
</script>
<template>
<div class="py-4 flex">
<LyxUiCard class="w-full mx-2">
<div>
<div class="text-[1.2rem] font-semibold"> Domains allow list </div>
<div class="dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
Accept incoming traffic only from familiar domains.
</div>
</div>
<LyxUiSeparator class="my-3"></LyxUiSeparator>
<div class="flex justify-end pb-3">
<LyxUiButton type="primary" @click="showAddDomainModal()"> Add Domain </LyxUiButton>
</div>
<div class="flex justify-center pb-8 text-[1.2rem]" v-if="pendingDomains">
<i class="fas fa-loader animate-spin"></i>
</div>
<div v-if="!pendingDomains && allowedDomains && allowedDomains.length == 0"
class="flex flex-col items-center pb-8">
<div>
No domain rules configured for this project.
</div>
<div class="font-semibold">
Traffic from all domains is currently accepted.
</div>
</div>
<div v-if="!pendingDomains && allowedDomains && allowedDomains.length > 0"
class="grid grid-cols-[auto_auto_auto_auto] px-10">
<div class="col-span-3">Domain</div>
<div>Actions</div>
<LyxUiSeparator class="col-span-4 my-3"></LyxUiSeparator>
<template v-for="domain of allowedDomains">
<div class="col-span-3 mb-3">{{ domain }}</div>
<div> <i @click="showDeleteDomainModal(domain)"
class="far fa-trash cursor-pointer hover:text-lyx-text-dark"></i> </div>
</template>
</div>
</LyxUiCard>
</div>
</template>

View File

@@ -71,20 +71,56 @@ export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'],
} }
const allTime: DefaultSnapshot = { const allTime: DefaultSnapshot = {
project_id, project_id,
_id: '___allTime' as any, _id: '___allTime' as any,
name: 'All Time', name: 'All Time',
from: new Date(project_created_at.toString()), from: fns.addMinutes(fns.startOfMonth(new Date(project_created_at.toString())), -new Date().getTimezoneOffset()),
to: new Date(Date.now()), to: fns.addMilliseconds(fns.endOfDay(Date.now()), 1),
color: '#9362FF', color: '#9362FF',
default: true default: true
} }
const last30Days: DefaultSnapshot = {
project_id,
_id: '___last30days' as any,
name: 'Last 30 days',
from: fns.startOfDay(fns.subDays(Date.now(), 30)),
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
color: '#606c38',
default: true
}
const snapshotList = [lastDay, today, lastMonth, currentMonth, lastWeek, currentWeek, allTime] const last60Days: DefaultSnapshot = {
project_id,
_id: '___last60days' as any,
name: 'Last 60 days',
from: fns.startOfDay(fns.subDays(Date.now(), 60)),
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
color: '#bc6c25',
default: true
}
const last90Days: DefaultSnapshot = {
project_id,
_id: '___last90days' as any,
name: 'Last 90 days',
from: fns.startOfDay(fns.subDays(Date.now(), 90)),
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
color: '#fefae0',
default: true
}
const snapshotList = [
allTime,
lastDay, today,
lastWeek, currentWeek,
lastMonth, currentMonth,
last30Days,
last60Days, last90Days,
]
return snapshotList; return snapshotList;

View File

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

View File

@@ -34,10 +34,19 @@ function createAlert(title: string, text: string, icon: string, ms: number) {
}, 250) }, 250)
} }
function createSuccessAlert(title: string, text: string, ms?: number) {
return createAlert(title, text, 'far fa-circle-check', ms ?? 5000);
}
function createErrorAlert(title: string, text: string, ms?: number) {
return createAlert(title, text, 'far fa-triangle-exclamation', ms ?? 5000);
}
function closeAlert(id: number) { function closeAlert(id: number) {
alerts.value = alerts.value.filter(e => e.id != id); alerts.value = alerts.value.filter(e => e.id != id);
} }
export function useAlert() { export function useAlert() {
return { alerts, createAlert, closeAlert } return { alerts, createAlert, closeAlert, createSuccessAlert, createErrorAlert }
} }

View File

@@ -3,16 +3,18 @@ type RefOrPrimitive<T> = T | Ref<T> | ComputedRef<T>
export type CustomOptions = { export type CustomOptions = {
useSnapshotDates?: boolean, useSnapshotDates?: boolean,
useActiveDomain?: boolean,
useActivePid?: boolean, useActivePid?: boolean,
useTimeOffset?: boolean, useTimeOffset?: boolean,
slice?: RefOrPrimitive<string>, slice?: RefOrPrimitive<string>,
limit?: RefOrPrimitive<number | string>, limit?: RefOrPrimitive<number | string>,
custom?: Record<string, RefOrPrimitive<string>> custom?: Record<string, RefOrPrimitive<string>>,
} }
const { token } = useAccessToken(); const { token } = useAccessToken();
const { projectId } = useProject(); const { projectId } = useProject();
const { safeSnapshotDates } = useSnapshot() const { safeSnapshotDates } = useSnapshot()
const { domain } = useDomain();
function getValueFromRefOrPrimitive<T>(data?: T | Ref<T> | ComputedRef<T>) { function getValueFromRefOrPrimitive<T>(data?: T | Ref<T> | ComputedRef<T>) {
if (!data) return; if (!data) return;
@@ -24,6 +26,7 @@ export function useComputedHeaders(customOptions?: CustomOptions) {
const useSnapshotDates = customOptions?.useSnapshotDates || true; const useSnapshotDates = customOptions?.useSnapshotDates || true;
const useActivePid = customOptions?.useActivePid || true; const useActivePid = customOptions?.useActivePid || true;
const useTimeOffset = customOptions?.useTimeOffset || true; const useTimeOffset = customOptions?.useTimeOffset || true;
const useActiveDomain = customOptions?.useActiveDomain || true;
const headers = computed<Record<string, string>>(() => { const headers = computed<Record<string, string>>(() => {
// console.trace('Computed recalculated'); // console.trace('Computed recalculated');
@@ -41,6 +44,7 @@ export function useComputedHeaders(customOptions?: CustomOptions) {
'x-time-offset': useTimeOffset ? (new Date().getTimezoneOffset().toString()) : '', 'x-time-offset': useTimeOffset ? (new Date().getTimezoneOffset().toString()) : '',
'x-slice': getValueFromRefOrPrimitive(customOptions?.slice) ?? '', 'x-slice': getValueFromRefOrPrimitive(customOptions?.slice) ?? '',
'x-limit': getValueFromRefOrPrimitive(customOptions?.limit)?.toString() ?? '', 'x-limit': getValueFromRefOrPrimitive(customOptions?.limit)?.toString() ?? '',
'x-domain': useActiveDomain ? (domain.value ?? '') : '',
...parsedCustom ...parsedCustom
} }
}) })

View File

@@ -0,0 +1,43 @@
const { token } = useAccessToken();
const { projectId } = useProject();
const domainsRequest = useFetch<{ _id: string, visits: number }[]>('/api/domains/list', {
headers: computed(() => {
return {
'Authorization': `Bearer ${token.value}`,
'x-pid': projectId.value || ''
}
})
});
function refreshDomains() {
domainsRequest.refresh();
}
watch(domainsRequest.data, () => {
if (!domainsRequest.data.value) return;
setActiveDomain(domainList.value[0]._id);
});
const refreshingDomains = computed(() => domainsRequest.pending.value);
const domainList = computed(() => {
return (domainsRequest.data.value?.sort((a, b) => b.visits - a.visits) || []);
})
const activeDomain = ref<string>();
const domain = computed(() => {
return activeDomain.value;
})
function setActiveDomain(domain: string) {
activeDomain.value = domain;
}
export function useDomain() {
return { domainList, domain, setActiveDomain, refreshDomains, refreshingDomains }
}

View File

@@ -0,0 +1,14 @@
const { data: permission } = useFetch('/api/project/members/me', {
headers: useComputedHeaders({})
});
const canSeeWeb = computed(() => permission.value?.webAnalytics || false);
const canSeeEvents = computed(() => permission.value?.events || false);
const canSeeAi = computed(() => permission.value?.ai || false);
export function usePermission() {
return { permission, canSeeWeb, canSeeEvents, canSeeAi };
}

View File

@@ -33,7 +33,10 @@ const guestProjectList = computed(() => {
return guestProjectsRequest.data.value; return guestProjectsRequest.data.value;
}) })
const refreshProjectsList = () => projectsRequest.refresh(); const refreshProjectsList = async () => {
await projectsRequest.refresh();
await guestProjectsRequest.refresh();
}
const activeProjectId = ref<string | undefined>(); const activeProjectId = ref<string | undefined>();

View File

@@ -3,5 +3,5 @@
const app = useRuntimeConfig(); const app = useRuntimeConfig();
export function useSelfhosted() { export function useSelfhosted() {
return app.public.SELFHOSTED === 'TRUE'; return app.public.SELFHOSTED.toString() === 'TRUE' || app.public.SELFHOSTED.toString() === 'true';
} }

View File

@@ -15,7 +15,7 @@ const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
watch(project, async () => { watch(project, async () => {
await remoteSnapshots.refresh(); await remoteSnapshots.refresh();
snapshot.value = isLiveDemo.value ? snapshots.value[3] : snapshots.value[3]; snapshot.value = isLiveDemo.value ? snapshots.value[7] : snapshots.value[7];
}); });
const snapshots = computed<GenericSnapshot[]>(() => { const snapshots = computed<GenericSnapshot[]>(() => {

View File

@@ -1,10 +0,0 @@
module.exports = {
apps: [
{
name: 'Dashboard',
port: '3010',
exec_mode: 'fork',
script: './.output/server/index.mjs',
}
]
}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Section } from '~/components/CVerticalNavigation.vue'; import type { Section } from '~/components/layout/VerticalNavigation.vue';
import { Lit } from 'litlyx-js'; import { Lit } from 'litlyx-js';
import { DialogFeedback } from '#components'; import { DialogFeedback } from '#components';
@@ -18,30 +18,35 @@ const sections: Section[] = [
entries: [ entries: [
{ label: 'Web Analytics', to: '/', icon: 'fal fa-table-layout' }, { label: 'Web Analytics', to: '/', icon: 'fal fa-table-layout' },
{ label: 'Custom Events', to: '/events', icon: 'fal fa-square-bolt' }, { label: 'Custom Events', to: '/events', icon: 'fal fa-square-bolt' },
{ label: 'Members', to: '/members', icon: 'fal fa-users' },
{ label: 'Shields', to: '/shields', icon: 'fal fa-shield' },
{ label: 'Ask AI', to: '/analyst', icon: 'fal fa-sparkles' }, { label: 'Ask AI', to: '/analyst', icon: 'fal fa-sparkles' },
{ label: 'Security', to: '/security', icon: 'fal fa-shield', disabled: selfhosted },
// { label: 'Security', to: '/security', icon: 'fal fa-shield', disabled: selfhosted },
// { label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true }, // { label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
// { label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true }, // { label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
// { label: 'Integrations (soon)', to: '/integrations', icon: 'fal fa-cube', disabled: true }, // { label: 'Integrations (soon)', to: '/integrations', icon: 'fal fa-cube', disabled: true },
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' }, { grow: true, label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
{ // {
grow: true, // grow: true,
label: 'Leave a Feedback', icon: 'fal fa-message', // label: 'Leave a Feedback', icon: 'fal fa-message',
action() { // action() {
modal.open(DialogFeedback, {}); // modal.open(DialogFeedback, {});
}, // },
}, // disabled: selfhosted
{ // },
label: 'Documentation', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true, // {
action() { Lit.event('docs_clicked') }, // grow: true,
}, // label: 'Documentation', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
{ // action() { Lit.event('docs_clicked') },
label: 'Discord support', icon: 'fab fa-discord', // },
to: 'https://discord.gg/9cQykjsmWX', // {
external: true, // grow: true,
}, // label: 'Discord support', icon: 'fab fa-discord',
// to: 'https://discord.gg/9cQykjsmWX',
// external: true,
// },
// { // {
// label: 'Slack support', icon: 'fab fa-slack', // label: 'Slack support', icon: 'fab fa-slack',
// to: '#', // to: '#',
@@ -73,14 +78,14 @@ const { isOpen, close, open } = useMenu();
<div <div
class="px-6 py-3 flex items-center justify-center shadow-[0_0_10px_#000000CC] z-[20] rounded-xl mx-2 my-2 lg:hidden"> class="px-6 py-3 flex items-center justify-center dark:bg-lyx-background-light z-[20] rounded-xl mx-2 my-2 lg:hidden">
<i @click="open()" class="fas fa-bars text-[1.2rem] absolute left-6"></i> <i @click="open()" class="fas fa-bars text-[1.2rem] absolute left-6"></i>
<div class="nunito font-semibold text-[1.2rem]"> <!-- <div class="nunito font-semibold text-[1.2rem]">
Litlyx Litlyx
</div> </div> -->
</div> </div>
<div class="flex h-full"> <div class="flex h-full overflow-y-hidden">
<div v-if="isOpen" @click="close()" <div v-if="isOpen" @click="close()"
@@ -88,15 +93,15 @@ const { isOpen, close, open } = useMenu();
</div> </div>
<CVerticalNavigation :sections="sections"> <LayoutVerticalNavigation :sections="sections">
</CVerticalNavigation> </LayoutVerticalNavigation>
<div class="overflow-hidden w-full bg-lyx-background relative h-full"> <div class="flex flex-col overflow-hidden w-full bg-lyx-lightmode-background dark:bg-lyx-background relative h-full">
<div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]"> <div v-if="showDialog" class="barrier w-full h-full z-[34] absolute bg-black/50 backdrop-blur-[2px]">
<i <i
class="z-[40] absolute right-12 top-8 fas fa-times text-text-sub text-[1.8rem] lg:text-[3rem]"></i> class="z-[40] absolute right-12 top-8 fas fa-times text-lyx-lightmode-text-dark dark:text-text-sub text-[1.8rem] lg:text-[3rem]"></i>
</div> </div>
<div @click="closeDialog()" class="w-full h-full z-[35] absolute top-0 left-0 px-4 lg:px-60 py-20" <div @click="closeDialog()" class="w-full h-full z-[35] absolute top-0 left-0 px-4 lg:px-60 py-20"
@@ -104,8 +109,13 @@ const { isOpen, close, open } = useMenu();
<DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard> <DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard>
</div> </div>
<LayoutTopNavigation class="flex shrink-0"></LayoutTopNavigation>
<div class="flex-1 overflow-auto">
<slot></slot> <slot></slot>
</div> </div>
</div>
</div> </div>
</div> </div>

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