mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7658dbe85c | ||
|
|
1f9ef5d18c | ||
|
|
94a28b31d3 | ||
|
|
87c9aca5c4 | ||
|
|
afda29997d | ||
|
|
d1b3e997c1 | ||
|
|
be82f7046f | ||
|
|
45e9a9c6a7 | ||
|
|
942d074f99 | ||
|
|
63fa3995c5 | ||
|
|
76e5e07f79 | ||
|
|
b8f9e598a7 | ||
|
|
0ee4895e1a | ||
|
|
72d6b97383 | ||
|
|
3f22c655a5 | ||
|
|
4fea549a5a | ||
|
|
4c1d10f8b7 | ||
|
|
50d275e0ff | ||
|
|
98238fa180 | ||
|
|
c07bccd0dd | ||
|
|
7192c31136 | ||
|
|
56d7e71d90 | ||
|
|
30229d4b97 | ||
|
|
b2303468a4 | ||
|
|
af6dff57ed | ||
|
|
dd8b089c46 | ||
|
|
a5750d556a | ||
|
|
f5882bff9f | ||
|
|
f18cdc8278 | ||
|
|
a7ebbc22c0 | ||
|
|
346eecc928 | ||
|
|
abc485a9ef | ||
|
|
0292829805 | ||
|
|
4e2c8468f8 | ||
|
|
38cfd4315d | ||
|
|
b592695a49 | ||
|
|
0963201a32 | ||
|
|
4da840f2ec | ||
|
|
a1718875d9 | ||
|
|
e931235533 | ||
|
|
881a7800ce | ||
|
|
487c3ac7b4 | ||
|
|
0dd94be6e6 | ||
|
|
29a220b21e | ||
|
|
8cc2f07b95 | ||
|
|
88cec21df1 | ||
|
|
8183ae1e68 | ||
|
|
0f39cab26a | ||
|
|
a2e4ed9ee0 | ||
|
|
30b5db4200 | ||
|
|
bfeee8673c | ||
|
|
39b8dd84f1 | ||
|
|
19b7c7664a | ||
|
|
a3e74adf9c | ||
|
|
ad9aabcbf6 | ||
|
|
510bc2545a | ||
|
|
65c682c75d | ||
|
|
04acc0b18e | ||
|
|
852fea45a5 | ||
|
|
6f3e59e72e | ||
|
|
3960eaa8ad | ||
|
|
e4bdf7e4c3 | ||
|
|
afeaac1b0d | ||
|
|
8922507a64 | ||
|
|
13e94cb0f0 | ||
|
|
3923a06e9b | ||
|
|
6b5d23566c | ||
|
|
dbcda95823 | ||
|
|
fb89c87489 | ||
|
|
b59eea47e9 | ||
|
|
473331047d | ||
|
|
5af77ff63e | ||
|
|
e6e2340432 | ||
|
|
0b90c2fe3c | ||
|
|
a6d1797a4f | ||
|
|
d1abe1a91f | ||
|
|
b733cd2a68 | ||
|
|
88ebfc188c | ||
|
|
ab95772dd4 | ||
|
|
0d5dbc69ad | ||
|
|
8a359936d1 | ||
|
|
ffd2e96138 | ||
|
|
b8e434be9a |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,4 +4,8 @@ PROCESS_EVENT
|
||||
docker
|
||||
dev
|
||||
docker-compose.admin.yml
|
||||
full_reload.sh
|
||||
full_reload.sh
|
||||
build-all.sh
|
||||
tmp
|
||||
ecosystem.config.js
|
||||
todo
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/node_modules": true
|
||||
}
|
||||
}
|
||||
30
Dockerfile
30
Dockerfile
@@ -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" ]
|
||||
54
README.md
54
README.md
@@ -4,13 +4,14 @@
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
#
|
||||
<p align="center">
|
||||
The freshest, developer-friendly analytics tool.<br>
|
||||
Litlyx is an open-source, self-hostable analytics solution for modern frameworks. Setup takes less than 30 seconds!
|
||||
Litlys is a modern, developer-friendly, cookie-free analytics tool.<br>
|
||||
Setup takes less than 30 seconds! Completely self-hostable with docker.<br>
|
||||
Alternative to Google Analytics, Matomo, Umami, Plausible & Simple Analytics.
|
||||
</p>
|
||||
|
||||
#
|
||||
@@ -25,7 +26,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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">
|
||||
<img src="assets/tech.png" />
|
||||
</p>
|
||||
|
||||
# Import
|
||||
# Import using a package manager
|
||||
|
||||
Import litlyx-js library into your code:
|
||||
First, Import litlyx-js library into your code:
|
||||
|
||||
```js
|
||||
import { Lit } from 'litlyx-js';
|
||||
@@ -63,9 +64,9 @@ Once imported, you need to initialize Litlyx:
|
||||
Lit.init('your_project_id');
|
||||
```
|
||||
|
||||
After initialization, Litlyx will automatically track analytics such as `Page visits`, `Browsers`, `Devices`, `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.
|
||||
|
||||
@@ -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:
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
after the build finishes, run:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
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
|
||||
<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>
|
||||
```
|
||||
|
||||
# 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!
|
||||
<a href="https://github.com/litlyx/litlyx/graphs/contributors">
|
||||
|
||||
BIN
assets/.DS_Store
vendored
Normal file
BIN
assets/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
assets/agent.png
BIN
assets/agent.png
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 |
BIN
assets/tech.png
BIN
assets/tech.png
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.7 KiB |
12
consumer/.gitignore
vendored
12
consumer/.gitignore
vendored
@@ -1,8 +1,10 @@
|
||||
|
||||
node_modules
|
||||
static
|
||||
|
||||
ecosystem.config.cjs
|
||||
dist
|
||||
ecosystem.config.js
|
||||
|
||||
scripts/start_dev.js
|
||||
package-lock.json
|
||||
build_all.bat
|
||||
tests
|
||||
scripts/start_dev_prod.js
|
||||
dist
|
||||
src/shared
|
||||
@@ -5,24 +5,13 @@ RUN npm i -g pnpm
|
||||
|
||||
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/
|
||||
|
||||
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
|
||||
RUN pnpm install
|
||||
|
||||
COPY --link ../consumer ./
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
CMD ["node", "/home/app/consumer/dist/consumer/src/index.js"]
|
||||
CMD ["node", "/home/app/consumer/dist/index.js"]
|
||||
@@ -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: ''
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"express": "^4.19.2",
|
||||
"mongoose": "^8.9.5",
|
||||
"redis": "^4.7.0",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -14,12 +18,14 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start_dev.js",
|
||||
"dev_prod": "node scripts/start_dev_prod.js",
|
||||
"compile": "tsc",
|
||||
"build_project": "node ../scripts/build.js",
|
||||
"build": "npm run compile && npm run build_project && npm run create_db",
|
||||
"build": "npm run compile && npm run create_db",
|
||||
"create_db": "cd scripts && ts-node create_database.ts",
|
||||
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-consumer sh"
|
||||
"docker-inspect": "docker run -it litlyx-consumer sh",
|
||||
"workspace:shared": "ts-node ../scripts/consumer/shared.ts",
|
||||
"workspace:deploy": "ts-node ../scripts/consumer/deploy.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
|
||||
1493
consumer/pnpm-lock.yaml
generated
1493
consumer/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,10 @@
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
||||
import EmailService from '@services/EmailService';
|
||||
import { requireEnv } from "@utils/requireEnv";
|
||||
import { TProjectLimit } from "@schema/project/ProjectsLimits";
|
||||
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
|
||||
import { UserModel } from "./shared/schema/UserSchema";
|
||||
import { LimitNotifyModel } from "./shared/schema/broker/LimitNotifySchema";
|
||||
import { EmailService } from './shared/services/EmailService';
|
||||
import { TProjectLimit } from "./shared/schema/project/ProjectsLimits";
|
||||
import { EmailServiceHelper } from "./EmailServiceHelper";
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
EmailService.init(requireEnv('BREVO_API_KEY'));
|
||||
}
|
||||
|
||||
export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
@@ -27,7 +24,14 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
const owner = await UserModel.findById(project.owner);
|
||||
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 });
|
||||
|
||||
} 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);
|
||||
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 });
|
||||
|
||||
} 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);
|
||||
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 });
|
||||
|
||||
}
|
||||
|
||||
19
consumer/src/EmailServiceHelper.ts
Normal file
19
consumer/src/EmailServiceHelper.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
import { EmailServerInfo } from './shared/services/EmailService'
|
||||
import axios from 'axios';
|
||||
|
||||
const EMAIL_SECRET = process.env.EMAIL_SECRET;
|
||||
|
||||
export class EmailServiceHelper {
|
||||
static async sendEmail(data: EmailServerInfo) {
|
||||
try {
|
||||
await axios(data.url, {
|
||||
method: 'POST',
|
||||
data: data.body,
|
||||
headers: { ...data.headers, 'x-litlyx-token': EMAIL_SECRET }
|
||||
})
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
|
||||
import { MAX_LOG_LIMIT_PERCENT } from './shared/data/broker/Limits';
|
||||
import { checkLimitsForEmail } from './EmailController';
|
||||
|
||||
export async function checkLimits(project_id: string) {
|
||||
|
||||
28
consumer/src/Metrics.ts
Normal file
28
consumer/src/Metrics.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { RedisStreamService } from './shared/services/RedisStreamService';
|
||||
import { requireEnv } from './shared/utils/requireEnv';
|
||||
|
||||
const stream_name = requireEnv('STREAM_NAME');
|
||||
|
||||
export const metricsRouter = Router();
|
||||
|
||||
metricsRouter.get('/queue', async (req, res) => {
|
||||
try {
|
||||
const size = await RedisStreamService.getQueueInfo(stream_name);
|
||||
res.json({ size });
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
res.status(500).json({ error: ex.message });
|
||||
}
|
||||
})
|
||||
|
||||
metricsRouter.get('/durations', async (req, res) => {
|
||||
try {
|
||||
const durations = await RedisStreamService.METRICS_get()
|
||||
res.json({ durations });
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
res.status(500).json({ error: ex.message });
|
||||
}
|
||||
})
|
||||
@@ -1,39 +1,31 @@
|
||||
|
||||
import { requireEnv } from '@utils/requireEnv';
|
||||
import { connectDatabase } from '@services/DatabaseService';
|
||||
import { RedisStreamService } from '@services/RedisStreamService';
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { requireEnv } from './shared/utils/requireEnv';
|
||||
import { connectDatabase } from './shared/services/DatabaseService';
|
||||
import { RedisStreamService } from './shared/services/RedisStreamService';
|
||||
import { ProjectModel } from "./shared/schema/project/ProjectSchema";
|
||||
import { VisitModel } from "./shared/schema/metrics/VisitSchema";
|
||||
import { SessionModel } from "./shared/schema/metrics/SessionSchema";
|
||||
import { EventModel } from "./shared/schema/metrics/EventSchema";
|
||||
import { lookup } from './lookup';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { checkLimits } from './LimitChecker';
|
||||
import express from 'express';
|
||||
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import { ProjectCountModel } from '@schema/project/ProjectsCounts';
|
||||
import { ProjectLimitModel } from './shared/schema/project/ProjectsLimits';
|
||||
import { ProjectCountModel } from './shared/schema/project/ProjectsCounts';
|
||||
import { metricsRouter } from './Metrics';
|
||||
|
||||
|
||||
const app = express();
|
||||
|
||||
let durations: number[] = [];
|
||||
app.use('/metrics', metricsRouter);
|
||||
|
||||
app.get('/status', async (req, res) => {
|
||||
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);
|
||||
app.listen(process.env.PORT, () => console.log(`Listening on port ${process.env.PORT}`));
|
||||
|
||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||
main();
|
||||
|
||||
|
||||
const CONSUMER_NAME = `CONSUMER_${process.env.NODE_APP_INSTANCE || 'DEFAULT'}`
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -43,7 +35,7 @@ async function main() {
|
||||
const group_name = requireEnv('GROUP_NAME') as any; // Checks are inside "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);
|
||||
|
||||
}
|
||||
@@ -55,7 +47,7 @@ async function processStreamEntry(data: Record<string, string>) {
|
||||
try {
|
||||
|
||||
const eventType = data._type;
|
||||
if (!eventType) return;
|
||||
if (!eventType) return console.log('No type');
|
||||
|
||||
const { pid, sessionHash } = data;
|
||||
|
||||
@@ -73,18 +65,13 @@ async function processStreamEntry(data: Record<string, string>) {
|
||||
await process_visit(data, sessionHash);
|
||||
}
|
||||
|
||||
// console.log('Entry processed in', duration, 'ms');
|
||||
|
||||
} catch (ex: any) {
|
||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
durations.push(duration);
|
||||
if (durations.length > 1000) {
|
||||
durations = durations.splice(500);
|
||||
}
|
||||
RedisStreamService.METRICS_onProcess(CONSUMER_NAME, duration);
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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 });
|
||||
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, }, {
|
||||
$inc: { duration: 0 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
website,
|
||||
updated_at: new Date(parseInt(timestamp))
|
||||
}, { upsert: true });
|
||||
} else {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 1 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
website,
|
||||
updated_at: new Date(parseInt(timestamp))
|
||||
}, { 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) {
|
||||
|
||||
const { name, metadata, pid, flowHash, timestamp } = data;
|
||||
const { name, metadata, pid, flowHash, timestamp, website } = data;
|
||||
|
||||
let metadataObject;
|
||||
try {
|
||||
@@ -162,6 +151,7 @@ async function process_event(data: Record<string, string>, sessionHash: string)
|
||||
await Promise.all([
|
||||
EventModel.create({
|
||||
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
|
||||
website,
|
||||
created_at: new Date(parseInt(timestamp))
|
||||
}),
|
||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
|
||||
8
dashboard/.gitignore
vendored
8
dashboard/.gitignore
vendored
@@ -24,7 +24,6 @@ winston-*.ndjson
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
|
||||
# Test reports
|
||||
*.report.txt
|
||||
|
||||
@@ -35,5 +34,10 @@ out.pdf
|
||||
tests
|
||||
|
||||
# EXPLAINS MONGODB
|
||||
explains
|
||||
|
||||
explains
|
||||
#Ecosystem
|
||||
ecosystem.config.cjs
|
||||
ecosystem.config.js
|
||||
|
||||
shared
|
||||
|
||||
@@ -7,20 +7,14 @@ RUN npm i -g pnpm
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
|
||||
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
|
||||
RUN pnpm install
|
||||
|
||||
RUN pnpm run build
|
||||
COPY --link ./dashboard ./
|
||||
|
||||
RUN pnpm run build:compose
|
||||
|
||||
FROM node:21-alpine AS production
|
||||
|
||||
|
||||
9
dashboard/app.config.ts
Normal file
9
dashboard/app.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
notifications: {
|
||||
position: 'top-0 bottom-[unset]'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -16,18 +16,18 @@ const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
||||
<div class="w-dvw h-dvh bg-lyx-lightmode-background-light dark:bg-lyx-background-light relative">
|
||||
|
||||
<Transition name="drawer">
|
||||
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
|
||||
class="bg-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>
|
||||
</Transition>
|
||||
|
||||
|
||||
<div class="fixed top-4 right-8 z-[999] flex flex-col gap-2" v-if="alerts.length > 0">
|
||||
<div v-for="alert of alerts"
|
||||
class="w-[30vw] min-w-[20rem] relative bg-[#151515] overflow-hidden border-solid border-[2px] border-[#262626] rounded-lg p-6 drop-shadow-lg">
|
||||
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> <i :class="alert.icon"></i> </div>
|
||||
<div class="grow">
|
||||
@@ -56,8 +56,8 @@ const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div :style="dialogStyle" class="bg-lyx-widget rounded-xl relative outline outline-1 outline-lyx-widget-lighter">
|
||||
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-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">
|
||||
<i @click="closeDialog()" class="fas fa-close text-[1.6rem] hover:text-gray-500 cursor-pointer"></i>
|
||||
</div>
|
||||
@@ -69,6 +69,7 @@ const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
|
||||
|
||||
|
||||
<UModals />
|
||||
<UNotifications />
|
||||
|
||||
<LazyOnboarding> </LazyOnboarding>
|
||||
|
||||
|
||||
14
dashboard/assets/main.css
Normal file
14
dashboard/assets/main.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@import './font-awesome/css/all.css';
|
||||
/* Are these many fonts required? For the time being switching to privacy friendly bunny.net for Google fonts. NOTE: No variable font support in bunnet.net yet. */
|
||||
|
||||
@import url('https://fonts.bunny.net/css?family=nunito:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
||||
@import url('https://fonts.bunny.net/css?family=inter:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||
|
||||
|
||||
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
||||
@import url('https://fonts.bunny.net/css?family=manrope:300,400,500,600,700,800');
|
||||
@import url('https://fonts.bunny.net/css?family=lato:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||
@import url('https://fonts.bunny.net/css?family=poppins:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
|
||||
|
||||
@import url('https://cdn.jsdelivr.net/npm/material-symbols@0.28.2/index.css');
|
||||
@@ -1,18 +1,9 @@
|
||||
@use './utilities.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');
|
||||
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
||||
@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');
|
||||
:root{
|
||||
--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"
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
@@ -20,7 +11,6 @@
|
||||
src: url("../fonts/GeistVF.ttf");
|
||||
}
|
||||
|
||||
|
||||
.actionable-visits-color-checkbox {
|
||||
color: #5655d7;
|
||||
}
|
||||
@@ -33,7 +23,7 @@
|
||||
}
|
||||
|
||||
.geist {
|
||||
font-family: "Geist";
|
||||
font-family: "Geist", var(--font-sans);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,38 +38,38 @@
|
||||
}
|
||||
|
||||
.brockmann {
|
||||
font-family: "Brockmann" !important;
|
||||
font-family: "Brockmann", var(--font-sans)!important;
|
||||
}
|
||||
|
||||
.nunito {
|
||||
font-family: "Nunito" !important;
|
||||
font-family: "Nunito",var(--font-sans)!important;
|
||||
}
|
||||
|
||||
.inter {
|
||||
font-family: "Inter" !important;
|
||||
font-family: "Inter", var(--font-sans)!important;
|
||||
}
|
||||
|
||||
.geometric {
|
||||
font-family: 'Geometric Sans Serif v1' !important;
|
||||
font-family: "Geometric Sans Serif v1", var(--font-sans)!important;
|
||||
}
|
||||
|
||||
.manrope {
|
||||
font-family: 'Manrope' !important;
|
||||
font-family: "Manrope", var(--font-sans)!important;
|
||||
}
|
||||
|
||||
.lato {
|
||||
font-family: 'Lato' !important;
|
||||
font-family: "Lato", var(--font-sans)!important;
|
||||
}
|
||||
|
||||
.poppins {
|
||||
font-family: 'Poppins' !important;
|
||||
font-family: "Poppins", var(--font-sans)!important;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ function openExternalLink(link: string) {
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<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 }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -63,7 +63,7 @@ function openExternalLink(link: string) {
|
||||
|
||||
</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 }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +81,8 @@ function openExternalLink(link: string) {
|
||||
</div>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
|
||||
<div
|
||||
class="flex justify-between font-bold lyx-text-lightmode-text-dark dark:text-text-sub/80 text-[1.1rem] mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="isDetailView" class="flex items-center justify-center">
|
||||
<i @click="$emit('showGeneral')"
|
||||
@@ -107,7 +108,7 @@ function openExternalLink(link: string) {
|
||||
<div class="flex gap-1 items-center" @click="showDetails(element._id)"
|
||||
: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>
|
||||
|
||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||
@@ -119,24 +120,28 @@ function openExternalLink(link: string) {
|
||||
|
||||
<i v-else :class="iconProvider(element)?.[1]"></i>
|
||||
</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 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-text font-semibold text-[.9rem] md:text-[1rem] manrope"> {{
|
||||
formatNumberK(element.count) }} </div>
|
||||
<div
|
||||
class="text-lyx-lightmode-text dark:text-lyx-text font-semibold text-[.9rem] md:text-[1rem] manrope">
|
||||
{{
|
||||
formatNumberK(element.count) }} </div>
|
||||
</div>
|
||||
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]">
|
||||
No data yet
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ async function showMore() {
|
||||
|
||||
|
||||
<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 || []"
|
||||
desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider"
|
||||
:loading="browsersData.pending.value" label="Browsers" sub-label="Browsers">
|
||||
|
||||
@@ -5,17 +5,17 @@ import type { IconProvider } from './Base.vue';
|
||||
|
||||
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||
if (e._id === 'desktop') return ['icon','far fa-desktop'];
|
||||
if (e._id === 'tablet') return ['icon','far fa-tablet'];
|
||||
if (e._id === 'mobile') return ['icon','far fa-mobile'];
|
||||
if (e._id === 'tablet') return ['icon','far fa-tablet ml-1'];
|
||||
if (e._id === 'mobile') return ['icon','far fa-mobile ml-1'];
|
||||
if (e._id === 'smarttv') return ['icon','far fa-tv'];
|
||||
if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
|
||||
return ['icon', 'far fa-question']
|
||||
return ['icon', 'far fa-question ml-1 mr-1']
|
||||
}
|
||||
|
||||
|
||||
function transform(data: { _id: string, count: number }[]) {
|
||||
console.log(data);
|
||||
return data.map(e => ({ ...e, _id: e._id == null ? 'unknown' : e._id }))
|
||||
return data.map(e => ({ ...e, _id: e._id == null ? 'others' : e._id }))
|
||||
}
|
||||
|
||||
const devicesData = useFetch('/api/data/devices', {
|
||||
|
||||
@@ -49,7 +49,7 @@ async function showMore() {
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
|
||||
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
|
||||
label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"
|
||||
|
||||
44
dashboard/components/BarCard/Pages.vue
Normal file
44
dashboard/components/BarCard/Pages.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const pagesData = useFetch('/api/data/pages', {
|
||||
headers: useComputedHeaders({
|
||||
limit: 10,
|
||||
}), lazy: true
|
||||
});
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
async function showMore() {
|
||||
|
||||
dialogBarData.value = [];
|
||||
|
||||
showDialog.value = true;
|
||||
isDataLoading.value = true;
|
||||
|
||||
const res = await $fetch('/api/data/pages', {
|
||||
headers: useComputedHeaders({ limit: 1000 }).value
|
||||
});
|
||||
|
||||
dialogBarData.value = (res || []);
|
||||
|
||||
isDataLoading.value = false;
|
||||
}
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/visits');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<BarCardBase @showRawData="goToView()" @showMore="showMore()" @dataReload="pagesData.refresh()" :showLink=true
|
||||
:data="pagesData.data.value || []" :interactive="false" desc="Most visited pages."
|
||||
:rawButton="!isLiveDemo"
|
||||
:dataIcons="true" :loading="pagesData.pending.value" label="Top Pages" sub-label="Referrers">
|
||||
</BarCardBase>
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,7 +43,7 @@ async function showMore() {
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
|
||||
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
|
||||
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
|
||||
|
||||
@@ -9,10 +9,10 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<div class="flex items-center">
|
||||
<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 }}
|
||||
</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 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,72 @@
|
||||
<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);
|
||||
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex">
|
||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
||||
class="px-6 pb-3 poppins font-medium text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
||||
'hover:border-lyx-text-dark hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
|
||||
}">
|
||||
{{ tab.label }}
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex overflow-x-auto hide-scrollbars">
|
||||
<div class="flex">
|
||||
<div v-for="(tab, index) of items" @click="onChangeTab(index)"
|
||||
class="px-6 whitespace-nowrap pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
|
||||
:class="{
|
||||
'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 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-b-[1px] border-lyx-text-darker w-full">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div :class="{ 'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
|
||||
<slot :name="props.items[activeTabIndex].slot"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ function reloadPage() {
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
<div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div>
|
||||
<div class="text-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
|
||||
</div>
|
||||
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
|
||||
@@ -79,13 +79,13 @@ function reloadPage() {
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center justify-center mt-10">
|
||||
<div class="flex flex-col-reverse gap-6">
|
||||
<div class="flex items-center justify-center mt-10 w-full px-10">
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="flex gap-6 xl:flex-row flex-col">
|
||||
|
||||
<div class="h-full w-full">
|
||||
<CardTitled class="h-full w-full xl:min-w-[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!">
|
||||
|
||||
<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"
|
||||
sub="This is the identifier for this project, used to forward data">
|
||||
<div class="flex items-center justify-between gap-4 mt-6">
|
||||
<div class="p-2 bg-[#1c1b1b] rounded-md w-full">
|
||||
<div class="w-full text-[.9rem] text-[#acacac]"> {{ project?._id }} </div>
|
||||
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
|
||||
<div class="w-full text-[.9rem] dark:text-[#acacac]"> {{ project?._id }} </div>
|
||||
</div>
|
||||
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
|
||||
</div>
|
||||
@@ -135,8 +135,30 @@ function reloadPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<CardTitled class="w-full h-full" title="Documentation"
|
||||
sub="Learn how to use Litlyx in every tech stack">
|
||||
<CardTitled class="w-full h-full" title="Wordpress + Elementor"
|
||||
sub="Our WordPress plugin is coming soon!.">
|
||||
<template #header>
|
||||
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||
to="https://docs.litlyx.com">
|
||||
Visit documentation
|
||||
</LyxUiButton>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-end">
|
||||
<div class="justify-center w-full hidden xl:flex gap-3">
|
||||
<a href="#">
|
||||
<img class="cursor-pointer" :src="'tech-icons/wpel.png'" alt="Litlyx-Wordpress-Elementor">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<CardTitled class="w-full h-full" title="Modules"
|
||||
sub="Get started with your favorite framework.">
|
||||
<template #header>
|
||||
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
|
||||
to="https://docs.litlyx.com">
|
||||
|
||||
@@ -8,12 +8,18 @@ const props = defineProps<{ type: ButtonType, link?: string, target?: string, di
|
||||
|
||||
<template>
|
||||
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
|
||||
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-text" :class="{
|
||||
'bg-lyx-primary-dark outline-lyx-primary hover:bg-lyx-primary-hover': type === 'primary',
|
||||
'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-lyx-danger-dark outline-lyx-danger hover:bg-lyx-danger': type === 'danger',
|
||||
'!bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
|
||||
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-lyx-lightmode-text dark:text-lyx-text"
|
||||
:class="{
|
||||
|
||||
'bg-[#85a3ff] hover:bg-[#9db5fc] outline-lyx-lightmode-widget-light dark:bg-lyx-primary-dark dark:outline-lyx-primary dark:hover:bg-lyx-primary-hover': type === 'primary',
|
||||
|
||||
'bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget-lighter hover:bg-lyx-lightmode-widget dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': type === 'secondary',
|
||||
|
||||
'bg-lyx-transparent outline-lyx-lightmode-widget hover:bg-lyx-lightmode-widget-light dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
|
||||
|
||||
'bg-[#fcd1cb] hover:bg-[#f8c5be] dark:bg-lyx-danger-dark outline-lyx-danger dark:hover:bg-lyx-danger': type === 'danger',
|
||||
|
||||
'text-lyx-text !bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
|
||||
}">
|
||||
<slot></slot>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,6 +19,6 @@ const handleChange = (event: Event) => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
</template>
|
||||
15
dashboard/components/LyxUi/Separator.vue
Normal file
15
dashboard/components/LyxUi/Separator.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{ size?: string }>();
|
||||
|
||||
const widgetStyle = computed(() => {
|
||||
return `height: ${props.size ?? '1px'}`;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="widgetStyle" class="dark:bg-lyx-widget-light bg-lyx-lightmode-widget"></div>
|
||||
</template>
|
||||
@@ -98,7 +98,8 @@ async function saveJobTitle() {
|
||||
const showOnboarding = computed(() => {
|
||||
if (route.path === '/login') 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>
|
||||
@@ -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
|
||||
going to be your first/main analytics?
|
||||
</div>
|
||||
@@ -122,7 +123,7 @@ const showOnboarding = computed(() => {
|
||||
<div v-for="(e, i) of analyticsList">
|
||||
<div @click="selectIndex(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 }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,11 +141,11 @@ const showOnboarding = computed(() => {
|
||||
</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 ?
|
||||
</div>
|
||||
|
||||
@@ -152,7 +153,7 @@ const showOnboarding = computed(() => {
|
||||
<div v-for="(e, i) of jobsList">
|
||||
<div @click="selectIndex2(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 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,12 +16,12 @@ const emits = defineEmits<{
|
||||
|
||||
<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"
|
||||
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="{
|
||||
'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
|
||||
'hover:!bg-lyx-widget !cursor-not-allowed text-lyx-widget-lighter': 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-lightmode-widget-light text-lyx-lightmode-widget dark:hover:!bg-lyx-widget !cursor-not-allowed dark:!text-lyx-widget-lighter': opt.disabled
|
||||
}">
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
|
||||
88
dashboard/components/admin/Backend.vue
Normal file
88
dashboard/components/admin/Backend.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
|
||||
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
|
||||
|
||||
const avgDuration = computed(() => {
|
||||
if (!backendData?.value?.durations) return -1;
|
||||
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
|
||||
})
|
||||
|
||||
const labels = computed(() => {
|
||||
if (!backendData?.value?.durations) return [];
|
||||
|
||||
const sizes = new Map<string, number>();
|
||||
|
||||
for (const e of backendData.value.durations.durations) {
|
||||
if (!sizes.has(e[0])) {
|
||||
sizes.set(e[0], 0);
|
||||
} else {
|
||||
const data = sizes.get(e[0]) ?? 0;
|
||||
sizes.set(e[0], data + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const max = Array.from(sizes.values()).reduce((a, e) => a > e ? a : e, 0);
|
||||
return new Array(max).fill('-');
|
||||
});
|
||||
|
||||
const durationsDatasets = computed(() => {
|
||||
if (!backendData?.value?.durations) return [];
|
||||
|
||||
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
|
||||
|
||||
const datasets = [];
|
||||
|
||||
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
|
||||
|
||||
for (let i = 0; i < uniqueConsumers.length; i++) {
|
||||
|
||||
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
|
||||
|
||||
datasets.push({
|
||||
points: consumerDurations.map((e: any) => {
|
||||
return 1000 / parseInt(e[1])
|
||||
}),
|
||||
color: colors[i],
|
||||
chartType: 'line',
|
||||
name: uniqueConsumers[i]
|
||||
})
|
||||
}
|
||||
|
||||
return datasets;
|
||||
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
<div class="cursor-default flex justify-center w-full">
|
||||
|
||||
<div v-if="backendData && !backendPending" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
|
||||
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
|
||||
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
|
||||
</AdminBackendLineChart>
|
||||
</div>
|
||||
|
||||
<div @click="refreshBackend()"> Refresh </div>
|
||||
</div>
|
||||
|
||||
<div v-if="backendPending">
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
31
dashboard/components/admin/Feedbacks.vue
Normal file
31
dashboard/components/admin/Feedbacks.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: feedbacks, pending: pendingFeedbacks } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
<div
|
||||
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
|
||||
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
|
||||
v-for="feedback of feedbacks">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
|
||||
<div class="text-lyx-text-dark"> {{ feedback.project[0]?.name || 'DELETED PROJECT' }} </div>
|
||||
</div>
|
||||
{{ feedback.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="pendingFeedbacks"> Loading...</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
271
dashboard/components/admin/MiniChart.vue
Normal file
271
dashboard/components/admin/MiniChart.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script lang="ts" setup>
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
import * as fns from 'date-fns';
|
||||
|
||||
const props = defineProps<{ pid: string }>();
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: (false as any),
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
stacked: false,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: { enabled: false }
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Visits',
|
||||
data: [],
|
||||
backgroundColor: ['#5655d7'],
|
||||
borderColor: '#5655d7',
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#5655d7',
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return '#5655d7'
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||
return undefined;
|
||||
},
|
||||
backgroundColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return createGradient('#5655d7');
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unique visitors',
|
||||
data: [],
|
||||
backgroundColor: ['#4abde8'],
|
||||
borderColor: '#4abde8',
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#4abde8',
|
||||
hoverBorderColor: '#4abde8',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bar',
|
||||
// barThickness: 20,
|
||||
borderSkipped: ['bottom'],
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
data: [],
|
||||
backgroundColor: ['#fbbf24'],
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#fbbf24',
|
||||
hoverBorderColor: '#fbbf24',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bubble',
|
||||
stack: 'combined',
|
||||
borderColor: ["#fbbf24"]
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||
|
||||
const selectedSlice: Slice = 'day'
|
||||
|
||||
const allDatesFull = ref<string[]>([]);
|
||||
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
|
||||
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||
|
||||
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
|
||||
return { data, labels, todayIndex }
|
||||
}
|
||||
|
||||
function onResponseError(e: any) {
|
||||
let message = e.response._data.message ?? 'Generic error';
|
||||
if (message == 'internal server error') message = 'Please change slice';
|
||||
errorData.value = { errored: true, text: message }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||
}
|
||||
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||
'x-pid': props.pid
|
||||
}
|
||||
});
|
||||
|
||||
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}),
|
||||
lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
|
||||
|
||||
watch(readyToDisplay, () => {
|
||||
if (readyToDisplay.value === true) onDataReady();
|
||||
})
|
||||
|
||||
|
||||
function onDataReady() {
|
||||
if (!visitsData.data.value) return;
|
||||
if (!eventsData.data.value) return;
|
||||
if (!sessionsData.data.value) return;
|
||||
|
||||
chartData.value.labels = visitsData.data.value.labels;
|
||||
|
||||
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
|
||||
const maxEventSize = Math.max(...eventsData.data.value.data)
|
||||
|
||||
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||
|
||||
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||
const rValue = 20 / maxEventSize * e;
|
||||
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
});
|
||||
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||
|
||||
|
||||
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return true;
|
||||
return 'bottom';
|
||||
});
|
||||
|
||||
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return '#fbbf2400';
|
||||
return '#fbbf24';
|
||||
});
|
||||
|
||||
updateChart();
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="h-[10rem] w-full flex">
|
||||
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
|
||||
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||
</div>
|
||||
|
||||
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
|
||||
{{ errorData.text }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#external-tooltip {
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translate(-50%, 0);
|
||||
transition: all .1s ease;
|
||||
}
|
||||
</style>
|
||||
45
dashboard/components/admin/Onboardings.vue
Normal file
45
dashboard/components/admin/Onboardings.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<div v-if="onboardings" class="flex gap-40 px-20">
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lyx-primary"> Anaytics </div>
|
||||
<div class="flex items-center gap-2"
|
||||
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
|
||||
<div>{{ e._id }}</div>
|
||||
<div>{{ e.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lyx-primary"> Jobs </div>
|
||||
<div class="flex items-center gap-2"
|
||||
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
|
||||
<div>{{ e._id }}</div>
|
||||
<div>{{ e.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="onboardings" class="flex flex-col gap-8">
|
||||
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
|
||||
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="pendingOnboardings"> Loading...</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
204
dashboard/components/admin/Overview.vue
Normal file
204
dashboard/components/admin/Overview.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
|
||||
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
||||
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
|
||||
const page = ref<number>(1);
|
||||
|
||||
const ordersList = [
|
||||
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||
|
||||
{ label: 'active -->', id: '{ "last_log_at": 1 }' },
|
||||
{ label: 'active <--', id: '{ "last_log_at": -1 }' },
|
||||
|
||||
{ label: 'visits -->', id: '{ "visits": 1 }' },
|
||||
{ label: 'visits <--', id: '{ "visits": -1 }' },
|
||||
|
||||
{ label: 'events -->', id: '{ "events": 1 }' },
|
||||
{ label: 'events <--', id: '{ "events": -1 }' },
|
||||
|
||||
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
|
||||
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
|
||||
|
||||
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
|
||||
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
|
||||
|
||||
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
|
||||
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
|
||||
|
||||
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
|
||||
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
|
||||
|
||||
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
|
||||
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
|
||||
|
||||
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
|
||||
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
|
||||
|
||||
]
|
||||
|
||||
const order = ref<string>('{ "created_at": -1 }');
|
||||
|
||||
const limitList = [
|
||||
{ label: '10', id: 10 },
|
||||
{ label: '20', id: 20 },
|
||||
{ label: '50', id: 50 },
|
||||
{ label: '100', id: 100 },
|
||||
]
|
||||
|
||||
const limit = ref<number>(20);
|
||||
|
||||
const filterList = [
|
||||
{ label: 'ALL', id: '{}' },
|
||||
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
|
||||
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
|
||||
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
|
||||
]
|
||||
|
||||
|
||||
function isRangeSelected(duration: Duration) {
|
||||
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||
}
|
||||
|
||||
function selectRange(duration: Duration) {
|
||||
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||
}
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||
{ label: 'Last year', duration: { years: 1 } }
|
||||
]
|
||||
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
for (const key in PREMIUM_PLAN) {
|
||||
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
|
||||
}
|
||||
})
|
||||
|
||||
const filter = ref<string>('{}');
|
||||
|
||||
const { data: projectsInfo, pending: pendingProjects } = useFetch<{ count: number, projects: TAdminProject[] }>(
|
||||
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||
signHeaders()
|
||||
);
|
||||
|
||||
const { data: metrics, pending: pendingMetrics } = useFetch(
|
||||
() => `/api/admin/metrics?filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||
signHeaders()
|
||||
);
|
||||
|
||||
const { uiMenu } = useSelectMenuStyle();
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
|
||||
<div class="flex items-center gap-10 px-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Order:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||
value-attribute="id" option-attribute="label" v-model="order">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Limit:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||
value-attribute="id" option-attribute="label" v-model="limit">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Filter:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
|
||||
value-attribute="id" option-attribute="label" v-model="filter">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-10 justify-center px-10 w-full">
|
||||
|
||||
<div class="flex gap-2 items-center shrink-0">
|
||||
<div>Page {{ page }} </div>
|
||||
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
|
||||
}}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
|
||||
</div>
|
||||
|
||||
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||
<UButton class="w-full" color="primary" variant="solid">
|
||||
<div class="flex items-center justify-center w-full gap-2">
|
||||
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||
</div>
|
||||
</UButton>
|
||||
<template #panel="{ close }">
|
||||
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||
<div class="hidden sm:flex flex-col py-4">
|
||||
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||
variant="ghost" class="rounded-none px-6"
|
||||
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||
truncate @click="selectRange(range.duration)" />
|
||||
</div>
|
||||
|
||||
<DatePicker v-model="selected" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-[80%]">
|
||||
<div v-if="pendingMetrics"> Loading... </div>
|
||||
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
|
||||
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
|
||||
<div>
|
||||
Total visits: {{ formatNumberK(metrics.totalVisits) }}
|
||||
</div>
|
||||
<div>
|
||||
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
|
||||
Dead: {{ metrics.deadProjects }}
|
||||
</div>
|
||||
<div>
|
||||
Total events: {{ formatNumberK(metrics.totalEvents) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div
|
||||
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
|
||||
class="w-[26rem]" v-for="project of projectsInfo?.projects" />
|
||||
|
||||
<div v-if="pendingProjects"> Loading...</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
151
dashboard/components/admin/Users.vue
Normal file
151
dashboard/components/admin/Users.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||
import type { TAdminUser } from '~/server/api/admin/users';
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
|
||||
|
||||
const filterText = ref<string>('');
|
||||
|
||||
watch(filterText, () => {
|
||||
page.value = 1;
|
||||
})
|
||||
|
||||
function isRangeSelected(duration: Duration) {
|
||||
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||
}
|
||||
|
||||
function selectRange(duration: Duration) {
|
||||
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||
}
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||
{ label: 'Last year', duration: { years: 1 } }
|
||||
]
|
||||
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||
|
||||
const filter = computed(() => {
|
||||
return JSON.stringify({
|
||||
$or: [
|
||||
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
|
||||
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const page = ref<number>(1);
|
||||
|
||||
const ordersList = [
|
||||
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||
]
|
||||
|
||||
const order = ref<string>('{ "created_at": -1 }');
|
||||
|
||||
|
||||
const limitList = [
|
||||
{ label: '10', id: 10 },
|
||||
{ label: '20', id: 20 },
|
||||
{ label: '50', id: 50 },
|
||||
{ label: '100', id: 100 },
|
||||
]
|
||||
|
||||
const limit = ref<number>(20);
|
||||
|
||||
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
|
||||
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||
signHeaders()
|
||||
);
|
||||
|
||||
const { uiMenu } = useSelectMenuStyle();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
|
||||
<div class="flex items-center gap-10 px-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Order:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||
value-attribute="id" option-attribute="label" v-model="order">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Limit:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||
value-attribute="id" option-attribute="label" v-model="limit">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-centet gap-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Page {{ page }} </div>
|
||||
<div>
|
||||
{{ Math.min(limit, usersInfo?.count || 0) }}
|
||||
of
|
||||
{{ usersInfo?.count || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
|
||||
</div>
|
||||
|
||||
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||
<UButton class="w-full" color="primary" variant="solid">
|
||||
<div class="flex items-center justify-center w-full gap-2">
|
||||
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||
</div>
|
||||
</UButton>
|
||||
<template #panel="{ close }">
|
||||
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||
<div class="hidden sm:flex flex-col py-4">
|
||||
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||
variant="ghost" class="rounded-none px-6"
|
||||
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||
truncate @click="selectRange(range.duration)" />
|
||||
</div>
|
||||
|
||||
<DatePicker v-model="selected" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div
|
||||
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
|
||||
v-for="user of usersInfo?.users" />
|
||||
|
||||
<div v-if="pendingUsers"> Loading...</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
132
dashboard/components/admin/backend/LineChart.vue
Normal file
132
dashboard/components/admin/backend/LineChart.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
import * as datefns from 'date-fns';
|
||||
|
||||
const errored = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{
|
||||
labels: string[],
|
||||
title: string,
|
||||
datasets: {
|
||||
points: number[],
|
||||
color: string,
|
||||
name: string
|
||||
}[]
|
||||
}>();
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
title: {
|
||||
display: true,
|
||||
text: props.title
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleFont: { size: 16, weight: 'bold' },
|
||||
bodyFont: { size: 14 },
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
boxPadding: 10,
|
||||
caretPadding: 20,
|
||||
yAlign: 'bottom',
|
||||
xAlign: 'center',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = ref<ChartData<'line'>>({
|
||||
labels: props.labels.map(e => {
|
||||
try {
|
||||
return datefns.format(new Date(e), 'dd/MM');
|
||||
} catch (ex) {
|
||||
return e;
|
||||
}
|
||||
}),
|
||||
datasets: props.datasets.map(e => ({
|
||||
data: e.points,
|
||||
label: e.name,
|
||||
backgroundColor: [e.color + '00'],
|
||||
borderColor: e.color,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
type: 'line'
|
||||
} as any))
|
||||
});
|
||||
|
||||
|
||||
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// chartData.value.datasets.forEach(dataset => {
|
||||
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
|
||||
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
|
||||
// } else {
|
||||
// dataset.backgroundColor = [createGradient('#3d59a4')]
|
||||
// }
|
||||
// });
|
||||
} catch (ex) {
|
||||
errored.value = true;
|
||||
console.error(ex);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="errored"> ERROR CREATING CHART </div>
|
||||
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
</div>
|
||||
</template>
|
||||
48
dashboard/components/admin/dialog/ProjectDetails.vue
Normal file
48
dashboard/components/admin/dialog/ProjectDetails.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
|
||||
const props = defineProps<{ pid: string }>();
|
||||
|
||||
const { data: projectInfo, refresh, pending } = useFetch<{ domains: { _id: string }[], project: TAdminProject }>(
|
||||
() => `/api/admin/project_info?pid=${props.pid}`,
|
||||
signHeaders(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="mt-6 h-full flex flex-col gap-10 w-full" v-if="!pending">
|
||||
|
||||
<div>
|
||||
<LyxUiButton type="secondary" @click="refresh"> Refresh </LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-10" v-if="projectInfo">
|
||||
|
||||
<AdminOverviewProjectCard :project="projectInfo.project" class="w-[30rem] shrink-0" />
|
||||
|
||||
<AdminMiniChart class="max-w-[40rem]" :pid="pid"></AdminMiniChart>
|
||||
</div>
|
||||
|
||||
<div v-if="projectInfo" class="flex flex-col">
|
||||
|
||||
<div>Domains:</div>
|
||||
|
||||
<div class="flex flex-wrap gap-8 mt-8">
|
||||
|
||||
<div v-for="domain of projectInfo.domains">
|
||||
{{ domain._id }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="pending">
|
||||
Loading...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
62
dashboard/components/admin/onboarding/PieChart.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { PieChart, usePieChart } from 'vue-chart-3';
|
||||
|
||||
const props = defineProps<{ data: { _id: string, count: number }[], title:string }>();
|
||||
|
||||
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: props.title,
|
||||
color: '#EEECF6',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
|
||||
labels: props.data.map(e => e._id),
|
||||
datasets: [
|
||||
{
|
||||
data: props.data.map(e => e.count),
|
||||
backgroundColor: [
|
||||
"#295270",
|
||||
"#304F71",
|
||||
"#374C72",
|
||||
"#3E4A73",
|
||||
"#444773",
|
||||
"#4B4474",
|
||||
"#524175",
|
||||
],
|
||||
borderColor: '#222222'
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
|
||||
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div>
|
||||
|
||||
|
||||
<div class="graph">
|
||||
<PieChart v-bind="pieChartProps">
|
||||
</PieChart>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
134
dashboard/components/admin/overview/ProjectCard.vue
Normal file
134
dashboard/components/admin/overview/ProjectCard.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||
|
||||
|
||||
import { AdminDialogProjectDetails } from '#components';
|
||||
|
||||
const { openDialogEx } = useCustomDialog();
|
||||
|
||||
function showProjectDetails(pid: string) {
|
||||
openDialogEx(AdminDialogProjectDetails, {
|
||||
params: { pid }
|
||||
})
|
||||
}
|
||||
|
||||
const props = defineProps<{ project: TAdminProject }>();
|
||||
|
||||
|
||||
const logBg = computed(() => {
|
||||
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const week = 1000 * 60 * 60 * 24 * 7;
|
||||
|
||||
const lastLoggedAtDate = new Date(props.project.last_log_at || 0);
|
||||
|
||||
if (lastLoggedAtDate.getTime() > Date.now() - day) {
|
||||
return 'bg-green-500'
|
||||
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
|
||||
return 'bg-yellow-500'
|
||||
} else {
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
const dateDiffDays = computed(() => {
|
||||
const res = (Date.now() - new Date(props.project.last_log_at || 0).getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (res > -1 && res < 1) return 0;
|
||||
return res;
|
||||
});
|
||||
|
||||
const usageLabel = computed(() => {
|
||||
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
|
||||
});
|
||||
|
||||
const usagePercentLabel = computed(() => {
|
||||
const percent = 100 / props.project.limit_max * props.project.limit_total;
|
||||
return `~ ${percent.toFixed(1)}%`;
|
||||
});
|
||||
|
||||
const usageAiLabel = computed(() => {
|
||||
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
|
||||
}
|
||||
|
||||
); const usageAiPercentLabel = computed(() => {
|
||||
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
|
||||
return `~ ${percent.toFixed(1)}%`
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative h-fit">
|
||||
|
||||
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||
<div class="font-medium text-lyx-text-dark">
|
||||
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||
</div>
|
||||
</UTooltip>
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-5 justify-center">
|
||||
<div @click="showProjectDetails(project._id.toString())" class="font-medium hover:text-lyx-primary cursor-pointer">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-2">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Visits:</div>
|
||||
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Events:</div>
|
||||
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Sessions:</div>
|
||||
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
<div class="mb-2">
|
||||
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 justify-around">
|
||||
<div class="flex gap-1">
|
||||
<div>
|
||||
{{ usageLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usagePercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
{{ usageAiLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usageAiPercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
135
dashboard/components/admin/users/UserCard.vue
Normal file
135
dashboard/components/admin/users/UserCard.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
import type { TAdminUser } from '~/server/api/admin/users';
|
||||
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||
|
||||
import { AdminDialogProjectDetails } from '#components';
|
||||
|
||||
const { openDialogEx } = useCustomDialog();
|
||||
|
||||
function showProjectDetails(pid: string) {
|
||||
openDialogEx(AdminDialogProjectDetails, {
|
||||
params: { pid }
|
||||
})
|
||||
}
|
||||
|
||||
const props = defineProps<{ user: TAdminUser }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative max-h-[15rem]">
|
||||
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||
<div class="font-medium text-lyx-text-dark">
|
||||
{{ user.name ?? user.given_name }}
|
||||
</div>
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-5 justify-center">
|
||||
<div class="font-medium">
|
||||
{{ user.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
<div class="flex flex-col text-[.9rem]">
|
||||
<div class="flex gap-2" v-for="project of user.projects">
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||
<div class="font-medium text-lyx-text-dark">
|
||||
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||
</div>
|
||||
</UTooltip>
|
||||
|
||||
<div @click="showProjectDetails(project._id.toString())"
|
||||
class="ml-1 hover:text-lyx-primary cursor-pointer">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- <div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative">
|
||||
|
||||
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||
<div class="font-medium text-lyx-text-dark">
|
||||
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||
</div>
|
||||
</UTooltip>
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-5 justify-center">
|
||||
<div class="font-medium">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-2">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Visits:</div>
|
||||
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Events:</div>
|
||||
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Sessions:</div>
|
||||
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
<div class="mb-2">
|
||||
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 justify-around">
|
||||
<div class="flex gap-1">
|
||||
<div>
|
||||
{{ usageLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usagePercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
{{ usageAiLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usageAiPercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div> -->
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
@@ -113,7 +112,7 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unique sessions',
|
||||
label: 'Unique visitors',
|
||||
data: [],
|
||||
backgroundColor: ['#4abde8'],
|
||||
borderColor: '#4abde8',
|
||||
@@ -136,7 +135,7 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
type: 'bubble',
|
||||
stack: 'combined',
|
||||
borderColor: ["#fbbf24"]
|
||||
},
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
@@ -199,11 +198,16 @@ const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled:
|
||||
})
|
||||
|
||||
const selectedSlice = computed<Slice>(() => {
|
||||
console.log({ available: selectLabelsAvailable.value })
|
||||
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
|
||||
if (!targetValue) return 'day';
|
||||
if (targetValue.disabled) {
|
||||
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
|
||||
}
|
||||
if (selectedLabelIndex.value === -1) {
|
||||
console.error('ERROR CANNOT FIND CORRECT SLICE')
|
||||
return 'day';
|
||||
}
|
||||
return selectLabelsAvailable.value[selectedLabelIndex.value].value
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
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) {
|
||||
@@ -254,8 +263,6 @@ watch(readyToDisplay, () => {
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
function onDataReady() {
|
||||
if (!visitsData.data.value) return;
|
||||
if (!eventsData.data.value) return;
|
||||
@@ -279,7 +286,6 @@ function onDataReady() {
|
||||
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;
|
||||
@@ -333,8 +339,8 @@ const legendClasses = ref<string[]>([
|
||||
<div class="flex gap-6 w-full justify-between lg:flex-row flex-col">
|
||||
<LyxUiButton type="secondary" :to="isLiveDemo ? '#' : '/analyst'" :disabled="isLiveDemo">
|
||||
<div class="flex items-center gap-2 px-10">
|
||||
<i class="far fa-sparkles text-yellow-400"></i>
|
||||
<div class="poppins text-lyx-text"> Ask AI </div>
|
||||
<i class="far fa-sparkles text-yellow-600 dark:text-yellow-400"></i>
|
||||
<div class="poppins text-lyx-lightmode-text dark:text-lyx-text"> Ask AI </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
<div class="flex gap-6">
|
||||
@@ -352,7 +358,7 @@ const legendClasses = ref<string[]>([
|
||||
|
||||
|
||||
<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> Date: </div>
|
||||
<div v-if="currentTooltipData"> {{ currentTooltipData.date }}</div>
|
||||
@@ -365,6 +371,12 @@ const legendClasses = ref<string[]>([
|
||||
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 font-normal flex flex-col text-[.9rem] dark:text-lyx-text-dark text-lyx-lightmode-text-dark"
|
||||
v-if="(currentTooltipData as any).sessions > (currentTooltipData as any).visits">
|
||||
<div> Unique visitors are higher than total visits </div>
|
||||
<div> which often means bots (automated scripts or crawlers)</div>
|
||||
<div> are inflating the numbers.</div>
|
||||
</div>
|
||||
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
|
||||
</LyxUiCard>
|
||||
</div>
|
||||
|
||||
@@ -29,12 +29,12 @@ const { showDrawer } = useDrawer();
|
||||
</div>
|
||||
<div class="flex flex-col grow">
|
||||
<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 }}
|
||||
</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 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 class="flex flex-col items-center gap-1">
|
||||
@@ -52,7 +52,7 @@ const { showDrawer } = useDrawer();
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</LyxUiCard>
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ const columns = [
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-full h-full bg-bg rounded-xl p-8">
|
||||
<div class="full h-full overflow-y-auto">
|
||||
<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 text-lyx-lightmode-text dark:text-lyx-text">
|
||||
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
||||
<template #count-data="{ row }">
|
||||
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
||||
|
||||
@@ -27,7 +27,6 @@ const chartOptions = ref<ChartOptions<'doughnut'>>({
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
labels: {
|
||||
color: 'white',
|
||||
font: {
|
||||
family: 'Poppins',
|
||||
size: 16
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import DateService from '@services/DateService';
|
||||
import type { Slice } from '@services/DateService';
|
||||
import DateService, { type Slice } from '../../shared/services/DateService';
|
||||
|
||||
|
||||
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
|
||||
|
||||
|
||||
const chartSlice = computed(() => {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -68,27 +68,53 @@ const avgBouncingRate = computed(() => {
|
||||
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(() => {
|
||||
if (!sessionsDurationData.data.value) return '0.00 %'
|
||||
|
||||
const counts = sessionsDurationData.data.value.data
|
||||
.filter(e => e > 0)
|
||||
// .filter(e => e > 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 minutes = 0;
|
||||
let seconds = 0;
|
||||
seconds += avg * 60;
|
||||
while (seconds > 60) { seconds -= 60; minutes += 1; }
|
||||
while (minutes > 60) { minutes -= 60; hours += 1; }
|
||||
while (seconds >= 60) { seconds -= 60; minutes += 1; }
|
||||
while (minutes >= 60) { minutes -= 60; hours += 1; }
|
||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||
});
|
||||
|
||||
const todayIndex = computed(() => {
|
||||
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>
|
||||
<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"
|
||||
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data"
|
||||
tooltipText="Sum of all page views on your website."
|
||||
:labels="visitsData.data.value?.labels" color="#5655d7">
|
||||
tooltipText="Sum of all page views on your website." :labels="visitsData.data.value?.labels"
|
||||
color="#5655d7">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :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"
|
||||
text="Unique visitors"
|
||||
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
tooltipText="Count of distinct users visiting your website."
|
||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :data="sessionsData.data.value?.data"
|
||||
:labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
tooltipText="Count of distinct users visiting your website." :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
</DashboardCountCard>
|
||||
|
||||
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||
text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data"
|
||||
tooltipText="Average time users spend on your website."
|
||||
:labels="sessionsDurationData.data.value?.labels" color="#f56523">
|
||||
tooltipText="Average time users spend on your website." :labels="sessionsDurationData.data.value?.labels"
|
||||
color="#f56523">
|
||||
</DashboardCountCard>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -18,27 +18,15 @@ function copyProjectId() {
|
||||
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>
|
||||
|
||||
|
||||
<template>
|
||||
<div
|
||||
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="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">
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div
|
||||
class="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>
|
||||
|
||||
@@ -62,15 +50,16 @@ function showAnomalyInfoAlert() {
|
||||
</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="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
|
||||
@click="showAnomalyInfoAlert"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
</template>
|
||||
38
dashboard/components/dialog/ConfirmLogout.vue
Normal file
38
dashboard/components/dialog/ConfirmLogout.vue
Normal 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>
|
||||
@@ -36,7 +36,7 @@ function onColorChange() {
|
||||
|
||||
const snapshotName = ref<string>("");
|
||||
|
||||
const { updateSnapshots } = useSnapshot();
|
||||
const { updateSnapshots, snapshot, snapshots } = useSnapshot();
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function confirmSnapshot() {
|
||||
@@ -53,7 +53,10 @@ async function confirmSnapshot() {
|
||||
|
||||
await updateSnapshots();
|
||||
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>
|
||||
@@ -61,8 +64,8 @@ async function confirmSnapshot() {
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
|
||||
<div class="poppins text-center">
|
||||
Create a snapshot
|
||||
<div class="poppins text-center text-lyx-lightmode-text dark:text-lyx-text">
|
||||
Create a timeframe
|
||||
</div>
|
||||
|
||||
<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">
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -104,7 +107,7 @@ async function confirmSnapshot() {
|
||||
Cancel
|
||||
</LyxUiButton>
|
||||
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
|
||||
:disabled="snapshotName.length == 0">
|
||||
:disabled="snapshotName.trim().length == 0">
|
||||
Confirm
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ButtonType } from '../LyxUi/Button.vue';
|
||||
|
||||
const emit = defineEmits(['success', 'cancel'])
|
||||
|
||||
const props = defineProps<{
|
||||
buttonType: string,
|
||||
buttonType: ButtonType,
|
||||
message: string,
|
||||
deleteData: { isAll: boolean, visits: boolean, sessions: boolean, events: boolean, domain: string }
|
||||
}>();
|
||||
@@ -47,7 +48,7 @@ async function deleteData() {
|
||||
overlay: {
|
||||
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]'
|
||||
}">
|
||||
<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">
|
||||
<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 v-if="isDone" class="flex justify-end w-full">
|
||||
|
||||
@@ -35,7 +35,7 @@ async function sendFeedback() {
|
||||
overlay: {
|
||||
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]'
|
||||
}">
|
||||
<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> Share everything with us. </div>
|
||||
<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>Need help ? Check the docs <a href="https://docs.litlyx.com" target="_blank"
|
||||
class="text-blue-500">here</a> </div>
|
||||
|
||||
58
dashboard/components/dialog/Help.vue
Normal file
58
dashboard/components/dialog/Help.vue
Normal 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>
|
||||
86
dashboard/components/dialog/InviteManager.vue
Normal file
86
dashboard/components/dialog/InviteManager.vue
Normal 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>
|
||||
118
dashboard/components/dialog/PermissionManager.vue
Normal file
118
dashboard/components/dialog/PermissionManager.vue
Normal 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>
|
||||
80
dashboard/components/dialog/shields/AddAddress.vue
Normal file
80
dashboard/components/dialog/shields/AddAddress.vue
Normal 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>
|
||||
67
dashboard/components/dialog/shields/AddDomain.vue
Normal file
67
dashboard/components/dialog/shields/AddDomain.vue
Normal 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>
|
||||
56
dashboard/components/dialog/shields/DeleteAddress.vue
Normal file
56
dashboard/components/dialog/shields/DeleteAddress.vue
Normal 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>
|
||||
56
dashboard/components/dialog/shields/DeleteDomain.vue
Normal file
56
dashboard/components/dialog/shields/DeleteDomain.vue
Normal 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>
|
||||
@@ -10,7 +10,7 @@ const { drawerComponent } = useDrawer();
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -195,19 +195,27 @@ function getPricingsData() {
|
||||
|
||||
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="poppins text-[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">
|
||||
Do you need help ?
|
||||
</div>
|
||||
<div class="poppins text-[1.2rem] text-text/90">
|
||||
<div class="poppins text-[1.2rem]">
|
||||
We respond in max. 1-2 days
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="rounded-lg px-10 py-3 bg-[#151515]">
|
||||
<a href="mailto:help@litlyx.com" class="poppins text-[1.3rem]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<LyxUiButton type="secondary">
|
||||
<a href="mailto:help@litlyx.com" class="poppins text-[1.1rem]">
|
||||
help@litlyx.com
|
||||
</a>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
<LyxUiButton type="secondary">
|
||||
<a href="https://discord.com/invite/9cQykjsmWX" class="poppins text-[1.1rem]">
|
||||
Discord support
|
||||
</a>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,11 +81,11 @@ const canSearch = computed(() => {
|
||||
<div class="flex-[2]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
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-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
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'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search an event..." class="w-full"
|
||||
placeholder="Select an event" :options="eventNames.data.value || []"
|
||||
@@ -93,11 +93,11 @@ const canSearch = computed(() => {
|
||||
</USelectMenu>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
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-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
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'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search a field..." class="w-full"
|
||||
placeholder="Select a field" :options="metadataFields" v-model="selectedMetadataField">
|
||||
@@ -110,7 +110,7 @@ const canSearch = computed(() => {
|
||||
</div>
|
||||
<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>
|
||||
<input class="bg-transparent px-4 py-2 text-[1rem] outline-none" type="text"
|
||||
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="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">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> {{ item._id || 'OLD_EVENTS' }} </div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
import { type Slice } from '@services/DateService';
|
||||
|
||||
const props = defineProps<{ slice: Slice }>();
|
||||
const slice = computed(() => props.slice);
|
||||
@@ -10,45 +10,23 @@ const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, name: string, count: number }[]) {
|
||||
|
||||
const fixed = fixMetrics({
|
||||
data: input,
|
||||
from: input[0]._id,
|
||||
to: safeSnapshotDates.value.to
|
||||
},
|
||||
const fixed = fixMetrics(
|
||||
{ data: input, from: input[0]._id, to: safeSnapshotDates.value.to },
|
||||
slice.value,
|
||||
{ advanced: true, advancedGroupKey: 'name' });
|
||||
{ advanced: true, advancedGroupKey: 'name' }
|
||||
);
|
||||
|
||||
const parsedDatasets: any[] = [];
|
||||
|
||||
const colors = [
|
||||
"#5655d0",
|
||||
"#6bbbe3",
|
||||
"#a6d5cb",
|
||||
"#fae0b9",
|
||||
"#f28e8e",
|
||||
"#e3a7e4",
|
||||
"#c4a8e1",
|
||||
"#8cc1d8",
|
||||
"#f9c2cd",
|
||||
"#b4e3b2",
|
||||
"#ffdfba",
|
||||
"#e9c3b5",
|
||||
"#d5b8d6",
|
||||
"#add7f6",
|
||||
"#ffd1dc",
|
||||
"#ffe7a1",
|
||||
"#a8e6cf",
|
||||
"#d4a5a5",
|
||||
"#f3d6e4",
|
||||
"#c3aed6"
|
||||
"#5655d0", "#6bbbe3", "#a6d5cb", "#fae0b9", "#f28e8e",
|
||||
"#e3a7e4", "#c4a8e1", "#8cc1d8", "#f9c2cd", "#b4e3b2",
|
||||
"#ffdfba", "#e9c3b5", "#d5b8d6", "#add7f6", "#ffd1dc",
|
||||
"#ffe7a1", "#a8e6cf", "#d4a5a5", "#f3d6e4", "#c3aed6"
|
||||
];
|
||||
|
||||
for (let i = 0; i < fixed.allKeys.length; i++) {
|
||||
const line: any = {
|
||||
data: [],
|
||||
color: colors[i] || '#FF0000',
|
||||
label: fixed.allKeys[i]
|
||||
};
|
||||
const line: any = { data: [], color: colors[i] || '#FF0000', label: fixed.allKeys[i] };
|
||||
parsedDatasets.push(line)
|
||||
fixed.data.forEach((e: { key: string, value: number }[]) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
datasets: parsedDatasets,
|
||||
labels: fixed.labels
|
||||
}
|
||||
|
||||
return { datasets: parsedDatasets, labels: fixed.labels }
|
||||
}
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({
|
||||
@@ -88,7 +61,6 @@ const eventsStackedData = useFetch(`/api/timeline/events_stacked`, {
|
||||
onResponse
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
eventsStackedData.execute();
|
||||
});
|
||||
|
||||
@@ -46,12 +46,12 @@ async function analyzeEvent() {
|
||||
|
||||
<div class="py-2 flex items-center gap-3">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
}
|
||||
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'
|
||||
}
|
||||
}" searchable searchable-placeholder="Search an event..." class="w-full" placeholder="Select an event"
|
||||
:options="eventNames.data.value || []" v-model="selectedEventName">
|
||||
</USelectMenu>
|
||||
@@ -71,7 +71,7 @@ async function analyzeEvent() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="w-5 h-5 flex items-center justify-center">
|
||||
<img :src="`https://s2.googleusercontent.com/s2/favicons?domain=${referrer}&sz=64`"
|
||||
|
||||
@@ -22,86 +22,27 @@ const widthHeight = computed(() => {
|
||||
return 9 + props.size * props.spacing;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const colorMode = useColorMode();
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="w-fit h-fit">
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`"
|
||||
fill="none">
|
||||
|
||||
<template v-for="(p, x) 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)" />
|
||||
</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>
|
||||
|
||||
</div>
|
||||
|
||||
69
dashboard/components/layout/TopNavigation.vue
Normal file
69
dashboard/components/layout/TopNavigation.vue
Normal 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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import CreateSnapshot from './dialog/CreateSnapshot.vue';
|
||||
import { DialogConfirmLogout, DialogInviteManager } from '#components';
|
||||
import CreateSnapshot from '../dialog/CreateSnapshot.vue';
|
||||
|
||||
export type Entry = {
|
||||
label: string,
|
||||
@@ -23,9 +24,14 @@ type Props = {
|
||||
sections: Section[]
|
||||
}
|
||||
|
||||
|
||||
const route = useRoute();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { data: pendingInvites, refresh: refreshInvites } = useFetch('/api/project/members/pending', {
|
||||
headers: useComputedHeaders({})
|
||||
});
|
||||
|
||||
const { userRoles, setLoggedUser } = useLoggedUser();
|
||||
const { projectList } = useProject();
|
||||
|
||||
@@ -88,12 +94,25 @@ async function generatePDF() {
|
||||
|
||||
const { setToken } = useAccessToken();
|
||||
const router = useRouter();
|
||||
const { actions } = useProject();
|
||||
|
||||
|
||||
const modal = useModal();
|
||||
|
||||
|
||||
function onLogout() {
|
||||
console.log('LOGOUT')
|
||||
setToken('');
|
||||
setLoggedUser(undefined);
|
||||
router.push('/login');
|
||||
modal.open(DialogConfirmLogout, {
|
||||
onSuccess() {
|
||||
modal.close();
|
||||
console.log('LOGOUT');
|
||||
setToken('');
|
||||
setLoggedUser(undefined);
|
||||
router.push('/login');
|
||||
},
|
||||
onCancel() {
|
||||
modal.close();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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="{
|
||||
'absolute top-0 w-full md:w-[20rem] z-[45] open': 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">
|
||||
|
||||
<ProjectSelector></ProjectSelector>
|
||||
<SelectorProjectSelector></SelectorProjectSelector>
|
||||
|
||||
<div class="grow flex justify-end text-[1.4rem] mr-2 lg:hidden">
|
||||
<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="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]">
|
||||
Snapshots
|
||||
Timeframes
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</LyxUiButton>
|
||||
</UTooltip> -->
|
||||
<UTooltip text="Create new snapshot">
|
||||
<UTooltip text="Create new timeframe">
|
||||
<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>
|
||||
</UTooltip>
|
||||
</div>
|
||||
@@ -179,11 +220,11 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
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-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
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'
|
||||
}
|
||||
}" class="w-full" v-model="snapshot" :options="snapshotsItems">
|
||||
<template #label>
|
||||
@@ -204,7 +245,8 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
|
||||
</div>
|
||||
@@ -237,27 +279,26 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</LyxUiButton>
|
||||
</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 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-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="{
|
||||
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
||||
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
||||
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
|
||||
'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-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' : ''"
|
||||
@@ -281,36 +322,33 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
|
||||
<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="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"
|
||||
class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<i class="fas fa-cat"></i>
|
||||
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-cat"></i>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
||||
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-text text-lyx-text-dark">
|
||||
<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>
|
||||
</div>
|
||||
</UTooltip>
|
||||
@@ -41,7 +41,7 @@ async function onUpgradeClick() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative bg-[#151515] outline outline-[1px] outline-[#262626] py-8 px-10 rounded-lg w-full max-w-[30rem]">
|
||||
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 v-if="data.active"
|
||||
@@ -56,7 +56,7 @@ async function onUpgradeClick() {
|
||||
<div class="poppins text-4xl font-medium"> {{ data.price }} </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 v-if="datas.length > 1">
|
||||
@@ -76,7 +76,7 @@ async function onUpgradeClick() {
|
||||
</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 gap-2" v-for="feature of data.features">
|
||||
|
||||
51
dashboard/components/selector/DomainSelector.vue
Normal file
51
dashboard/components/selector/DomainSelector.vue
Normal 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>
|
||||
@@ -4,8 +4,8 @@ import type { TProject } from '@schema/project/ProjectSchema';
|
||||
|
||||
const { user } = useLoggedUser()
|
||||
|
||||
const { projectList, guestProjectList,allProjectList, actions, project } = useProject();
|
||||
|
||||
const { projectList, guestProjectList, allProjectList, actions, project } = useProject();
|
||||
const { setActiveDomain } = useDomain();
|
||||
|
||||
function isProjectMine(owner?: string) {
|
||||
if (!owner) return false;
|
||||
@@ -22,11 +22,11 @@ function onChange(e: TProject) {
|
||||
<template>
|
||||
|
||||
<USelectMenu :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
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-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
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'
|
||||
}
|
||||
}" class="w-full" v-if="allProjectList" @change="onChange" :value="project" :options="allProjectList">
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'change_pass', title: 'Change password', text: 'Change your password' },
|
||||
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
|
||||
]
|
||||
|
||||
|
||||
const { user } = useLoggedUser();
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
const canChangePassword = useFetch('/api/user/password/can_change', {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
async function deleteAccount() {
|
||||
const sure = confirm("Are you sure you want to delete this account ?");
|
||||
if (!sure) return;
|
||||
@@ -20,17 +26,63 @@ async function deleteAccount() {
|
||||
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>
|
||||
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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 @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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { TApiSettings } from '@schema/ApiSettingsSchema';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const { project } = useProject();
|
||||
const { project, isGuest } = useProject();
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'acodes', title: 'Appsumo codes', text: 'Redeem appsumo codes' },
|
||||
@@ -39,7 +39,7 @@ async function redeemCode() {
|
||||
|
||||
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
|
||||
<SettingsTemplate v-if="!isGuest" :entries="entries" :key="project?.name || 'NONE'">
|
||||
<template #acodes>
|
||||
<div class="flex items-center gap-4">
|
||||
<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">
|
||||
Redeemed codes: {{ valid_codes.data.value?.count || '0' }}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import DeleteDomainData from '../dialog/DeleteDomainData.vue';
|
||||
import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
|
||||
const { isGuest } = useProject();
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ 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' },
|
||||
@@ -105,15 +108,17 @@ const sessionsLabel = computed(() => {
|
||||
<div class="flex flex-col">
|
||||
|
||||
<!-- <div class="text-[.9rem] text-lyx-text-darker"> Select a domain </div> -->
|
||||
<USelectMenu placeholder="Select a domain" :uiMenu="{
|
||||
select: '!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter !ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-widget',
|
||||
<USelectMenu v-if="!isGuest" placeholder="Select a domain" :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-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-widget-lighter'
|
||||
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'
|
||||
}
|
||||
}" :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 class="text-[.9rem] text-lyx-text-dark"> Select data to delete </div>
|
||||
|
||||
@@ -140,15 +145,18 @@ const sessionsLabel = computed(() => {
|
||||
</div>
|
||||
</template>
|
||||
<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
|
||||
visits 0 events 0 sessions)</div>
|
||||
visits 0 events 0 sessions) </div>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isGuest" class="text-lyx-text-darker"> Guests cannot delete data</div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { SettingsTemplateEntry } from './Template.vue';
|
||||
|
||||
const { project, actions, projectList, isGuest, projectId } = useProject();
|
||||
|
||||
const { createErrorAlert, createAlert } = useAlert();
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
{ id: 'pname', title: 'Name', text: 'Project name' },
|
||||
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
|
||||
@@ -37,7 +39,7 @@ async function createApiKey() {
|
||||
apiKeys.value.push(res);
|
||||
newApiKeyName.value = '';
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
createErrorAlert('Error', ex.message, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ async function deleteApiKey(api_id: string) {
|
||||
newApiKeyName.value = '';
|
||||
await updateApiKeys();
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
createErrorAlert('Error', ex.message, 10000);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -116,14 +118,12 @@ async function deleteProject() {
|
||||
|
||||
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
createErrorAlert('Error', ex.message);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
|
||||
@@ -156,20 +156,28 @@ function copyProjectId() {
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
|
||||
<template #pname>
|
||||
<div class="flex items-center gap-4">
|
||||
<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>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="isGuest" class="text-lyx-text-darker"> *Guests cannot change project name </div>
|
||||
</div>
|
||||
</template>
|
||||
<template #api>
|
||||
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
|
||||
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName" v-model="newApiKeyName">
|
||||
</LyxUiInput>
|
||||
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3"
|
||||
type="primary">
|
||||
<i class="far fa-plus"></i>
|
||||
</LyxUiButton>
|
||||
<div class="flex flex-col gap-2" v-if="apiKeys && apiKeys.length < 5">
|
||||
<div class="flex items-center gap-4">
|
||||
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName"
|
||||
v-model="newApiKeyName">
|
||||
</LyxUiInput>
|
||||
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.trim().length < 3"
|
||||
type="primary">
|
||||
<i class="far fa-plus"></i>
|
||||
</LyxUiButton>
|
||||
</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">
|
||||
<div v-for="apiKey of apiKeys" class="flex flex-col w-full">
|
||||
@@ -201,10 +209,10 @@ function copyProjectId() {
|
||||
<div class="hidden lg:flex"><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||
</LyxUiCard>
|
||||
<div class="flex justify-end w-full">
|
||||
<LyxUiButton type="outline" class="flex lg:hidden mt-4">
|
||||
Copy script
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
<LyxUiButton type="outline" class="flex lg:hidden mt-4">
|
||||
Copy script
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #pdelete>
|
||||
<div class="flex lg:justify-end" v-if="!isGuest">
|
||||
@@ -212,6 +220,7 @@ function copyProjectId() {
|
||||
Delete project
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
<div v-if="isGuest"> *Guests cannot delete project </div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
|
||||
@@ -20,10 +20,10 @@ const props = defineProps<SettingsTemplateProp>();
|
||||
<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="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 }}
|
||||
</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 }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ const props = defineProps<SettingsTemplateProp>();
|
||||
<slot :name="entry.id"></slot>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -68,7 +68,7 @@ function getPremiumPrice(type: number) {
|
||||
}
|
||||
|
||||
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: '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' },
|
||||
@@ -116,36 +116,36 @@ const { showDrawer } = useDrawer();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="relative pb-[6rem]">
|
||||
|
||||
<div v-if="invoicesPending || planPending"
|
||||
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
<SettingsTemplate v-if="!invoicesPending && !planPending" :entries="entries">
|
||||
<SettingsTemplate v-if="!invoicesPending && !planPending && !isGuest" :entries="entries">
|
||||
<template #info>
|
||||
<div v-if="!isGuest">
|
||||
<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">
|
||||
</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">
|
||||
</LyxUiInput>
|
||||
<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">
|
||||
</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">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
<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">
|
||||
</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">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
@@ -175,7 +175,8 @@ const { showDrawer } = useDrawer();
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="poppins font-semibold text-[2rem]"> €
|
||||
{{ getPremiumPrice(planData.premium_type) }} </div>
|
||||
<div class="poppins text-text-sub mt-2"> per month </div>
|
||||
<div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub mt-2"> per month
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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> {{ prettyExpireDate }}</div>
|
||||
</div>
|
||||
@@ -212,7 +213,7 @@ const { showDrawer } = useDrawer();
|
||||
<div class="poppins font-semibold text-[1.1rem]">
|
||||
Usage
|
||||
</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.
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,7 +241,7 @@ const { showDrawer } = useDrawer();
|
||||
|
||||
<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">
|
||||
|
||||
<div> <i class="fal fa-file-invoice"></i> </div>
|
||||
@@ -266,6 +267,10 @@ const { showDrawer } = useDrawer();
|
||||
</CardTitled>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
101
dashboard/components/shields/Addresses.vue
Normal file
101
dashboard/components/shields/Addresses.vue
Normal 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>
|
||||
47
dashboard/components/shields/Bots.vue
Normal file
47
dashboard/components/shields/Bots.vue
Normal 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>
|
||||
99
dashboard/components/shields/Domains.vue
Normal file
99
dashboard/components/shields/Domains.vue
Normal 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>
|
||||
@@ -27,7 +27,7 @@ export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'],
|
||||
color: '#FF8531',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
|
||||
const lastMonth: DefaultSnapshot = {
|
||||
project_id,
|
||||
@@ -71,20 +71,56 @@ export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'],
|
||||
}
|
||||
|
||||
|
||||
|
||||
const allTime: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___allTime' as any,
|
||||
name: 'All Time',
|
||||
from: new Date(project_created_at.toString()),
|
||||
to: new Date(Date.now()),
|
||||
from: fns.addMinutes(fns.startOfMonth(new Date(project_created_at.toString())), -new Date().getTimezoneOffset()),
|
||||
to: fns.addMilliseconds(fns.endOfDay(Date.now()), 1),
|
||||
color: '#9362FF',
|
||||
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;
|
||||
|
||||
|
||||
13
dashboard/composables/ui/useSelectMenuStyle.ts
Normal file
13
dashboard/composables/ui/useSelectMenuStyle.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,19 @@ function createAlert(title: string, text: string, icon: string, ms: number) {
|
||||
}, 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) {
|
||||
alerts.value = alerts.value.filter(e => e.id != id);
|
||||
}
|
||||
|
||||
export function useAlert() {
|
||||
return { alerts, createAlert, closeAlert }
|
||||
return { alerts, createAlert, closeAlert, createSuccessAlert, createErrorAlert }
|
||||
}
|
||||
@@ -3,16 +3,18 @@ type RefOrPrimitive<T> = T | Ref<T> | ComputedRef<T>
|
||||
|
||||
export type CustomOptions = {
|
||||
useSnapshotDates?: boolean,
|
||||
useActiveDomain?: boolean,
|
||||
useActivePid?: boolean,
|
||||
useTimeOffset?: boolean,
|
||||
slice?: RefOrPrimitive<string>,
|
||||
limit?: RefOrPrimitive<number | string>,
|
||||
custom?: Record<string, RefOrPrimitive<string>>
|
||||
custom?: Record<string, RefOrPrimitive<string>>,
|
||||
}
|
||||
|
||||
const { token } = useAccessToken();
|
||||
const { projectId } = useProject();
|
||||
const { safeSnapshotDates } = useSnapshot()
|
||||
const { domain } = useDomain();
|
||||
|
||||
function getValueFromRefOrPrimitive<T>(data?: T | Ref<T> | ComputedRef<T>) {
|
||||
if (!data) return;
|
||||
@@ -24,6 +26,7 @@ export function useComputedHeaders(customOptions?: CustomOptions) {
|
||||
const useSnapshotDates = customOptions?.useSnapshotDates || true;
|
||||
const useActivePid = customOptions?.useActivePid || true;
|
||||
const useTimeOffset = customOptions?.useTimeOffset || true;
|
||||
const useActiveDomain = customOptions?.useActiveDomain || true;
|
||||
|
||||
const headers = computed<Record<string, string>>(() => {
|
||||
// console.trace('Computed recalculated');
|
||||
@@ -41,6 +44,7 @@ export function useComputedHeaders(customOptions?: CustomOptions) {
|
||||
'x-time-offset': useTimeOffset ? (new Date().getTimezoneOffset().toString()) : '',
|
||||
'x-slice': getValueFromRefOrPrimitive(customOptions?.slice) ?? '',
|
||||
'x-limit': getValueFromRefOrPrimitive(customOptions?.limit)?.toString() ?? '',
|
||||
'x-domain': useActiveDomain ? (domain.value ?? '') : '',
|
||||
...parsedCustom
|
||||
}
|
||||
})
|
||||
|
||||
43
dashboard/composables/useDomain.ts
Normal file
43
dashboard/composables/useDomain.ts
Normal 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 }
|
||||
}
|
||||
14
dashboard/composables/usePermission.ts
Normal file
14
dashboard/composables/usePermission.ts
Normal 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 };
|
||||
}
|
||||
@@ -33,7 +33,10 @@ const guestProjectList = computed(() => {
|
||||
return guestProjectsRequest.data.value;
|
||||
})
|
||||
|
||||
const refreshProjectsList = () => projectsRequest.refresh();
|
||||
const refreshProjectsList = async () => {
|
||||
await projectsRequest.refresh();
|
||||
await guestProjectsRequest.refresh();
|
||||
}
|
||||
|
||||
const activeProjectId = ref<string | undefined>();
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
const app = useRuntimeConfig();
|
||||
|
||||
export function useSelfhosted() {
|
||||
return app.public.SELFHOSTED === 'TRUE';
|
||||
return app.public.SELFHOSTED.toString() === 'TRUE' || app.public.SELFHOSTED.toString() === 'true';
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
|
||||
|
||||
watch(project, async () => {
|
||||
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[]>(() => {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
port: '3010',
|
||||
exec_mode: 'fork',
|
||||
script: './.output/server/index.mjs',
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<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 { DialogFeedback } from '#components';
|
||||
@@ -18,30 +18,35 @@ const sections: Section[] = [
|
||||
entries: [
|
||||
{ label: 'Web Analytics', to: '/', icon: 'fal fa-table-layout' },
|
||||
{ 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: '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: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
|
||||
// { label: 'Integrations (soon)', to: '/integrations', icon: 'fal fa-cube', disabled: true },
|
||||
|
||||
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||
{
|
||||
grow: true,
|
||||
label: 'Leave a Feedback', icon: 'fal fa-message',
|
||||
action() {
|
||||
modal.open(DialogFeedback, {});
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||
// {
|
||||
// grow: true,
|
||||
// label: 'Leave a Feedback', icon: 'fal fa-message',
|
||||
// action() {
|
||||
// modal.open(DialogFeedback, {});
|
||||
// },
|
||||
// disabled: selfhosted
|
||||
// },
|
||||
// {
|
||||
// grow: true,
|
||||
// label: 'Documentation', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
|
||||
// action() { Lit.event('docs_clicked') },
|
||||
// },
|
||||
// {
|
||||
// grow: true,
|
||||
// label: 'Discord support', icon: 'fab fa-discord',
|
||||
// to: 'https://discord.gg/9cQykjsmWX',
|
||||
// external: true,
|
||||
// },
|
||||
// {
|
||||
// label: 'Slack support', icon: 'fab fa-slack',
|
||||
// to: '#',
|
||||
@@ -73,14 +78,14 @@ const { isOpen, close, open } = useMenu();
|
||||
|
||||
|
||||
<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>
|
||||
<div class="nunito font-semibold text-[1.2rem]">
|
||||
<!-- <div class="nunito font-semibold text-[1.2rem]">
|
||||
Litlyx
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div class="flex h-full">
|
||||
<div class="flex h-full overflow-y-hidden">
|
||||
|
||||
|
||||
<div v-if="isOpen" @click="close()"
|
||||
@@ -88,15 +93,15 @@ const { isOpen, close, open } = useMenu();
|
||||
</div>
|
||||
|
||||
|
||||
<CVerticalNavigation :sections="sections">
|
||||
</CVerticalNavigation>
|
||||
<LayoutVerticalNavigation :sections="sections">
|
||||
</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]">
|
||||
<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 @click="closeDialog()" class="w-full h-full z-[35] absolute top-0 left-0 px-4 lg:px-60 py-20"
|
||||
@@ -104,7 +109,12 @@ const { isOpen, close, open } = useMenu();
|
||||
<DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard>
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
<LayoutTopNavigation class="flex shrink-0"></LayoutTopNavigation>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user