mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
Compare commits
66 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 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ docker
|
||||
dev
|
||||
docker-compose.admin.yml
|
||||
full_reload.sh
|
||||
build-all.sh
|
||||
tmp
|
||||
ecosystem.config.js
|
||||
todo
|
||||
51
README.md
51
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,11 +105,9 @@ 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.
|
||||
|
||||
You can find our Docker images on DockerHub for more.
|
||||
To self-host the Litlyx dashboard, first **clone** this repository. (Litlyx's Docker images are hosted on DockerHub).
|
||||
|
||||
Then run the following command:
|
||||
```bash
|
||||
@@ -117,9 +116,9 @@ 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"
|
||||
@@ -130,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
|
||||
|
||||
#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]'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ 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-lyx-lightmode-text-dark dark:text-text/70">
|
||||
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>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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="h-full flex flex-col">
|
||||
<div class="flex overflow-x-auto hide-scrollbars">
|
||||
<div class="flex">
|
||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
||||
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker" :class="{
|
||||
'!border-[#88A7FF] !text-[#88A7FF]': activeTabIndex === index,
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
@@ -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">
|
||||
|
||||
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>
|
||||
|
||||
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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,7 +122,7 @@ 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) || '...')"
|
||||
|
||||
@@ -18,18 +18,6 @@ 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>
|
||||
|
||||
|
||||
@@ -38,7 +26,7 @@ function showAnomalyInfoAlert() {
|
||||
|
||||
<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-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="animate-pulse w-[.8rem] h-[.8rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="poppins font-medium text-[.9rem]"> {{ onlineUsers.data }} Online users</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +50,7 @@ function showAnomalyInfoAlert() {
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!--
|
||||
<div v-if="!selfhosted"
|
||||
class="flex gap-2 items-center text-lyx-lightmode-text/90 dark:text-lyx-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
@@ -71,7 +59,7 @@ function showAnomalyInfoAlert() {
|
||||
<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>
|
||||
@@ -53,7 +53,7 @@ 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;
|
||||
|
||||
@@ -65,7 +65,7 @@ async function confirmSnapshot() {
|
||||
<div class="w-full h-full flex flex-col">
|
||||
|
||||
<div class="poppins text-center text-lyx-lightmode-text dark:text-lyx-text">
|
||||
Create a snapshot
|
||||
Create a timeframe
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex items-center gap-2">
|
||||
@@ -74,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>
|
||||
|
||||
@@ -107,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">
|
||||
|
||||
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>
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -24,21 +25,13 @@ type Props = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get() {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set() {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
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();
|
||||
|
||||
@@ -101,12 +94,25 @@ async function generatePDF() {
|
||||
|
||||
const { setToken } = useAccessToken();
|
||||
const router = useRouter();
|
||||
const { actions } = useProject();
|
||||
|
||||
|
||||
const modal = useModal();
|
||||
|
||||
|
||||
function onLogout() {
|
||||
console.log('LOGOUT')
|
||||
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", {
|
||||
@@ -118,6 +124,28 @@ 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>
|
||||
@@ -139,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>
|
||||
@@ -172,7 +200,7 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
|
||||
<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">
|
||||
@@ -181,7 +209,7 @@ 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-[.8rem]"></i></div>
|
||||
</LyxUiButton>
|
||||
@@ -251,7 +279,7 @@ 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-[.8rem]">
|
||||
<LyxUiButton @click="generatePDF()" type="outline" class="w-full text-center text-[.8rem]">
|
||||
Export report
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
@@ -261,7 +289,6 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
<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 }">
|
||||
@@ -295,17 +322,24 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
|
||||
<div class="grow"></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">
|
||||
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/admin" v-if="userRoles.isAdmin.value"
|
||||
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>
|
||||
@@ -313,7 +347,8 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
</div>
|
||||
|
||||
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
|
||||
<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">
|
||||
<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>
|
||||
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>
|
||||
@@ -5,7 +5,7 @@ import type { TProject } from '@schema/project/ProjectSchema';
|
||||
const { user } = useLoggedUser()
|
||||
|
||||
const { projectList, guestProjectList, allProjectList, actions, project } = useProject();
|
||||
|
||||
const { setActiveDomain } = useDomain();
|
||||
|
||||
function isProjectMine(owner?: string) {
|
||||
if (!owner) return false;
|
||||
@@ -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>
|
||||
@@ -58,4 +58,9 @@ async function redeemCode() {
|
||||
</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,7 +108,7 @@ 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="{
|
||||
<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: {
|
||||
@@ -114,6 +117,8 @@ const sessionsLabel = computed(() => {
|
||||
}
|
||||
}" :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>
|
||||
|
||||
@@ -141,7 +146,7 @@ const sessionsLabel = computed(() => {
|
||||
</template>
|
||||
<template #delete_data>
|
||||
|
||||
<div
|
||||
<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>
|
||||
@@ -151,6 +156,7 @@ const sessionsLabel = computed(() => {
|
||||
</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,21 +156,29 @@ function copyProjectId() {
|
||||
<template>
|
||||
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
|
||||
<template #pname>
|
||||
<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 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">
|
||||
<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.length < 3"
|
||||
<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">
|
||||
|
||||
@@ -212,6 +220,7 @@ function copyProjectId() {
|
||||
Delete project
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
<div v-if="isGuest"> *Guests cannot delete project </div>
|
||||
</template>
|
||||
</SettingsTemplate>
|
||||
</template>
|
||||
|
||||
@@ -116,14 +116,14 @@ 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">
|
||||
@@ -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-lyx-lightmode-text-dark dark: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">
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -12,39 +12,41 @@ const modal = useModal();
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
console.log({ selfhosted })
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: '',
|
||||
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, {});
|
||||
},
|
||||
disabled: selfhosted
|
||||
},
|
||||
{
|
||||
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: '#',
|
||||
@@ -76,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()"
|
||||
@@ -91,11 +93,11 @@ const { isOpen, close, open } = useMenu();
|
||||
</div>
|
||||
|
||||
|
||||
<CVerticalNavigation :sections="sections">
|
||||
</CVerticalNavigation>
|
||||
<LayoutVerticalNavigation :sections="sections">
|
||||
</LayoutVerticalNavigation>
|
||||
|
||||
|
||||
<div class="overflow-hidden w-full bg-lyx-lightmode-background dark: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
|
||||
@@ -107,8 +109,13 @@ const { isOpen, close, open } = useMenu();
|
||||
<DashboardDialogBarCard @click.stop="null" class="z-[36]"></DashboardDialogBarCard>
|
||||
</div>
|
||||
|
||||
<LayoutTopNavigation class="flex shrink-0"></LayoutTopNavigation>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const entries = [
|
||||
{
|
||||
label: 'Home',
|
||||
icon: 'i-heroicons-home',
|
||||
to: '/',
|
||||
},
|
||||
{
|
||||
label: 'Pricing',
|
||||
icon: 'i-heroicons-currency-dollar',
|
||||
to: '/pricing'
|
||||
},
|
||||
{
|
||||
label: 'FAQ',
|
||||
icon: 'i-heroicons-question-mark-circle',
|
||||
to: '/faq'
|
||||
}
|
||||
]
|
||||
|
||||
const loggedUser = useLoggedUser();
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="layout h-full flex flex-col pt-1 px-1">
|
||||
|
||||
<div class="text-white flex items-center py-4 pl-10 gap-2 mx-20">
|
||||
|
||||
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="bg-[#2969f1] h-[2.8rem] aspect-[1/1] flex items-center justify-center rounded-lg">
|
||||
<img class="h-[1.8rem]" :src="'/logo.png'">
|
||||
</div>
|
||||
<div class="font-bold text-[1.6rem] text-gray-300 poppins"> Litlyx </div>
|
||||
</div>
|
||||
<!-- <div class="flex items-center gap-4">
|
||||
<div class="w-8 h-8 bg-blue-400"></div>
|
||||
<div class="font-bold text-[1.2rem] poppins"> Litlyx </div>
|
||||
</div> -->
|
||||
|
||||
<div class="grow"></div>
|
||||
<div class="flex gap-8 text-[1rem] text-white font-[500] poppins">
|
||||
<div> Open metrics </div>
|
||||
<div> Docs </div>
|
||||
<div> Pricing </div>
|
||||
<div> GitHub </div>
|
||||
<div> FAQ </div>
|
||||
</div>
|
||||
<div class="px-10">
|
||||
<div class="poppins font-[500] px-4 py-[.3rem] bg-accent rounded-xl"> Open App </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto shrink h-full">
|
||||
<div>
|
||||
<slot></slot>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center text-[1.3rem] items-center poppins py-16">
|
||||
Made with ❤ in Italy
|
||||
</div>
|
||||
|
||||
<div class="border-t-[1px] border-accent/40 flex h-fit py-12 w-full justify-between px-[8rem] footer">
|
||||
|
||||
<div class="flex flex-col gap-7">
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- <div class="flex items-center justify-center">
|
||||
<img :src="'logo.png'" class="h-[1.5rem]">
|
||||
</div> -->
|
||||
<div class="poppins font-bold text-[1.6rem] text-text/90">
|
||||
Litlyx
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 text-[1.5rem] text-text-sub/80">
|
||||
<div> <i class="fab fa-x-twitter"></i> </div>
|
||||
<div> <i class="fab fa-linkedin"></i> </div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-[.9rem] text-text-sub/80"> © 2024 Epictech Development S.r.l. All right
|
||||
reserved.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex gap-20">
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-text-sub/60 font-semibold"> Product </div>
|
||||
<div class="hover:text-accent cursor-pointer"> Pricing </div>
|
||||
<div class="hover:text-accent cursor-pointer"> Docs </div>
|
||||
<div class="hover:text-accent cursor-pointer"> Github </div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-text-sub/60 font-semibold"> Company </div>
|
||||
<div class="hover:text-accent cursor-pointer"> About </div>
|
||||
<div class="hover:text-accent cursor-pointer"> Contact us </div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-text-sub/60 font-semibold"> Legal </div>
|
||||
<div class="hover:text-accent cursor-pointer"> Privacy policy </div>
|
||||
<div class="hover:text-accent cursor-pointer"> Terms and conditions </div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.banner * {
|
||||
font-family: "Nunito";
|
||||
}
|
||||
|
||||
.layout * {
|
||||
font-family: "Inter";
|
||||
}
|
||||
</style>
|
||||
@@ -26,15 +26,16 @@ export default defineNuxtConfig({
|
||||
|
||||
pages: true,
|
||||
ssr: false,
|
||||
css: ['~/assets/scss/main.scss'],
|
||||
|
||||
css: [
|
||||
'~/assets/main.css',
|
||||
'~/assets/scss/main.scss',
|
||||
],
|
||||
alias: {
|
||||
'@schema': fileURLToPath(new URL('../shared/schema', import.meta.url)),
|
||||
'@services': fileURLToPath(new URL('../shared/services', import.meta.url)),
|
||||
'@data': fileURLToPath(new URL('../shared/data', import.meta.url)),
|
||||
'@functions': fileURLToPath(new URL('../shared/functions', import.meta.url)),
|
||||
'@schema': fileURLToPath(new URL('./shared/schema', import.meta.url)),
|
||||
'@services': fileURLToPath(new URL('./shared/services', import.meta.url)),
|
||||
'@data': fileURLToPath(new URL('./shared/data', import.meta.url)),
|
||||
'@functions': fileURLToPath(new URL('./shared/functions', import.meta.url)),
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
MONGO_CONNECTION_STRING: process.env.MONGO_CONNECTION_STRING,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
@@ -43,8 +44,7 @@ export default defineNuxtConfig({
|
||||
AI_ORG: process.env.AI_ORG,
|
||||
AI_PROJECT: process.env.AI_PROJECT,
|
||||
AI_KEY: process.env.AI_KEY,
|
||||
EMAIL_SERVICE: process.env.EMAIL_SERVICE,
|
||||
BREVO_API_KEY: process.env.BREVO_API_KEY,
|
||||
EMAIL_SECRET: process.env.EMAIL_SECRET,
|
||||
AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET,
|
||||
GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID,
|
||||
GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET,
|
||||
@@ -55,7 +55,8 @@ export default defineNuxtConfig({
|
||||
STRIPE_SECRET_TEST: process.env.STRIPE_SECRET_TEST,
|
||||
STRIPE_WH_SECRET_TEST: process.env.STRIPE_WH_SECRET_TEST,
|
||||
NOAUTH_USER_EMAIL: process.env.NOAUTH_USER_EMAIL,
|
||||
NOAUTH_USER_NAME: process.env.NOAUTH_USER_NAME,
|
||||
NOAUTH_USER_PASS: process.env.NOAUTH_USER_PASS,
|
||||
MODE: process.env.MODE || 'NONE',
|
||||
SELFHOSTED: process.env.SELFHOSTED || 'FALSE',
|
||||
public: {
|
||||
AUTH_MODE: process.env.AUTH_MODE,
|
||||
|
||||
@@ -3,32 +3,41 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"build": "npm run workspace:shared && nuxt build --dotenv .env.testmode",
|
||||
"build:prod": "npm run workspace:shared && nuxt build --dotenv .env.prod",
|
||||
"build:compose": "nuxt build",
|
||||
"dev": "npm run workspace:shared && nuxt dev --dotenv .env.testmode",
|
||||
"dev:prod": "npm run workspace:shared && nuxi dev --dotenv .env.prod",
|
||||
"dev:docker": "npm run workspace:shared && nuxi dev --dotenv .env.docker",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest",
|
||||
"docker-build": "docker build -t litlyx-dashboard -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-dashboard sh",
|
||||
"docker-run": "docker run -p 3000:3000 litlyx-dashboard"
|
||||
"docker-run": "docker run -p 3000:3000 litlyx-dashboard",
|
||||
"workspace:shared": "ts-node ../scripts/dashboard/shared.ts",
|
||||
"workspace:deploy": "ts-node ../scripts/dashboard/deploy.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||
"chart.js": "^3.9.1",
|
||||
"chartjs-chart-funnel": "^4.2.1",
|
||||
"chartjs-plugin-annotation": "^2.2.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"google-auth-library": "^9.10.0",
|
||||
"googleapis": "^144.0.0",
|
||||
"highlight.js": "^11.10.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"litlyx-js": "^1.0.3",
|
||||
"mongoose": "^8.9.5",
|
||||
"nuxt": "^3.11.2",
|
||||
"nuxt-vue3-google-signin": "^0.0.11",
|
||||
"openai": "^4.61.0",
|
||||
"pdfkit": "^0.15.0",
|
||||
"primevue": "^3.52.0",
|
||||
"sass": "^1.81.0",
|
||||
"redis": "^4.7.0",
|
||||
"sass": "^1.83.4",
|
||||
"stripe": "^17.3.1",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vue": "^3.4.21",
|
||||
|
||||
34
dashboard/pages/accept_invite.vue
Normal file
34
dashboard/pages/accept_invite.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const project_id = route.query.project_id;
|
||||
if (!project_id) throw Error('project_id is required');
|
||||
const res = await $fetch('/api/project/members/accept', {
|
||||
headers: useComputedHeaders({
|
||||
custom: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ project_id })
|
||||
});
|
||||
router.push('/');
|
||||
} catch (ex) {
|
||||
console.error('ERROR');
|
||||
console.error(ex);
|
||||
alert('An error occurred');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
You will be redirected soon.
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,285 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import type { AdminProjectsList } from '~/server/api/admin/projects';
|
||||
import type { CItem } from '~/components/CustomTab.vue';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const filterPremium = ref<boolean>(false);
|
||||
const filterAppsumo = ref<boolean>(false);
|
||||
|
||||
|
||||
|
||||
const timeRange = ref<number>(9);
|
||||
|
||||
function setTimeRange(n: number) {
|
||||
timeRange.value = n;
|
||||
}
|
||||
|
||||
const timeRangeTimestamp = computed(() => {
|
||||
if (timeRange.value == 1) return Date.now() - 1000 * 60 * 60 * 24;
|
||||
if (timeRange.value == 2) return Date.now() - 1000 * 60 * 60 * 24 * 7;
|
||||
if (timeRange.value == 3) return Date.now() - 1000 * 60 * 60 * 24 * 30;
|
||||
return 0;
|
||||
})
|
||||
|
||||
|
||||
const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
|
||||
const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
|
||||
|
||||
|
||||
|
||||
function onHideClicked() {
|
||||
isAdminHidden.value = true;
|
||||
}
|
||||
|
||||
|
||||
function isAppsumoType(type: number) {
|
||||
return type > 6000 && type < 6004
|
||||
}
|
||||
|
||||
const projectsAggregated = computed(() => {
|
||||
|
||||
let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : [];
|
||||
|
||||
let shownPool: AdminProjectsList[] = [];
|
||||
|
||||
|
||||
for (const element of pool) {
|
||||
|
||||
shownPool.push({ ...element, projects: [...element.projects] });
|
||||
|
||||
if (filterAppsumo.value === true) {
|
||||
shownPool.forEach(e => {
|
||||
e.projects = e.projects.filter(project => {
|
||||
return isAppsumoType(project.premium_type)
|
||||
})
|
||||
})
|
||||
|
||||
shownPool = shownPool.filter(e => {
|
||||
return e.projects.length > 0;
|
||||
})
|
||||
|
||||
} else if (filterPremium.value === true) {
|
||||
shownPool.forEach(e => {
|
||||
e.projects = e.projects.filter(project => {
|
||||
return project.premium === true;
|
||||
})
|
||||
})
|
||||
|
||||
shownPool = shownPool.filter(e => {
|
||||
return e.projects.length > 0;
|
||||
})
|
||||
|
||||
} else {
|
||||
console.log('NO DATA')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return shownPool.sort((a, b) => {
|
||||
const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
|
||||
const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
|
||||
return sumVisitsB - sumVisitsA;
|
||||
}).filter(e => {
|
||||
return new Date(e.created_at).getTime() >= timeRangeTimestamp.value
|
||||
});
|
||||
})
|
||||
|
||||
const premiumCount = computed(() => {
|
||||
let premiums = 0;
|
||||
projectsAggregated.value?.forEach(e => {
|
||||
e.projects.forEach(p => {
|
||||
if (p.premium) premiums++;
|
||||
});
|
||||
|
||||
})
|
||||
return premiums;
|
||||
})
|
||||
|
||||
|
||||
const activeProjects = computed(() => {
|
||||
let actives = 0;
|
||||
|
||||
projectsAggregated.value?.forEach(e => {
|
||||
e.projects.forEach(p => {
|
||||
if (!p.counts) return;
|
||||
if (!p.counts.updated_at) return;
|
||||
const updated_at = new Date(p.counts.updated_at).getTime();
|
||||
if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
actives++;
|
||||
});
|
||||
})
|
||||
return actives;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const totalVisits = computed(() => {
|
||||
return projectsAggregated.value?.reduce((a, e) => {
|
||||
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
|
||||
}, 0) || 0;
|
||||
});
|
||||
|
||||
const totalEvents = computed(() => {
|
||||
return projectsAggregated.value?.reduce((a, e) => {
|
||||
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0);
|
||||
}, 0) || 0;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const details = ref<any>();
|
||||
const showDetails = ref<boolean>(false);
|
||||
async function getProjectDetails(project_id: string) {
|
||||
details.value = await $fetch(`/api/admin/details?project_id=${project_id}`, signHeaders());
|
||||
showDetails.value = true;
|
||||
}
|
||||
|
||||
async function resetCount(project_id: string) {
|
||||
await $fetch(`/api/admin/reset_count?project_id=${project_id}`, signHeaders());
|
||||
}
|
||||
|
||||
|
||||
function dateDiffDays(a: string) {
|
||||
return (Date.now() - new Date(a).getTime()) / (1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
function getLogBg(last_logged_at?: string) {
|
||||
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const week = 1000 * 60 * 60 * 24 * 7;
|
||||
|
||||
const lastLoggedAtDate = new Date(last_logged_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 tabs: CItem[] = [
|
||||
{ label: 'Overview', slot: 'overview' },
|
||||
{ label: 'Users', slot: 'users' },
|
||||
{ label: 'Feedbacks', slot: 'feedbacks' },
|
||||
{ label: 'OnBoarding', slot: 'onboarding' },
|
||||
{ label: 'Backend', slot: 'backend' }
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="bg-bg overflow-y-auto w-full h-dvh p-6 gap-6 flex flex-col">
|
||||
<div class="bg-bg overflow-y-hidden w-full p-6 gap-6 flex flex-col h-full">
|
||||
|
||||
<div v-if="showDetails"
|
||||
class="w-full md:px-40 h-full fixed top-0 left-0 bg-black/90 backdrop-blur-[2px] z-[20] overflow-y-auto">
|
||||
<div class="cursor-pointer bg-red-400 w-fit px-10 py-2 rounded-lg font-semibold my-3"
|
||||
@click="showDetails = false">
|
||||
Close
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap poppins">
|
||||
{{ JSON.stringify(details, null, 3) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div @click="onHideClicked()" v-if="!isAdminHidden"
|
||||
class="bg-menu hover:bg-menu/70 cursor-pointer flex gap-2 rounded-lg w-fit px-6 py-4 text-text-sub">
|
||||
<div class="text-text-sub/90"> <i class="far fa-eye"></i> </div>
|
||||
<div> Hide from the bar </div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<Card class="p-2 flex gap-10 items-center justify-center">
|
||||
<div :class="{ 'text-red-200': timeRange == 1 }" @click="setTimeRange(1)"> Last day </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 2 }" @click="setTimeRange(2)"> Last week </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 3 }" @click="setTimeRange(3)"> Last month </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 9 }" @click="setTimeRange(9)"> All </div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-2 flex gap-10 items-center justify-center">
|
||||
<UCheckbox v-model="filterPremium" label="Filter Premium"></UCheckbox>
|
||||
<UCheckbox v-model="filterAppsumo" label="Filter Appsumo"></UCheckbox>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<div>
|
||||
Users: {{ counts?.users }}
|
||||
</div>
|
||||
<div>
|
||||
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
|
||||
</div>
|
||||
<div>
|
||||
Total visits: {{ formatNumberK(totalVisits) }}
|
||||
</div>
|
||||
<div>
|
||||
Active: {{ activeProjects }} |
|
||||
Dead: {{ (counts?.projects || 0) - activeProjects }}
|
||||
</div>
|
||||
<div>
|
||||
Total events: {{ formatNumberK(totalEvents) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
|
||||
|
||||
<div v-for="item of projectsAggregated || []"
|
||||
class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full relative">
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div> {{ item.email }} </div>
|
||||
<div> {{ item.name }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-evenly flex-col lg:grid lg:grid-cols-3 gap-2 lg:gap-4">
|
||||
|
||||
<div v-for="project of item.projects" :class="{
|
||||
'outline outline-[2px] outline-yellow-400': isAppsumoType(project.premium_type)
|
||||
}" class="flex relative flex-col items-center bg-bg p-6 rounded-xl">
|
||||
|
||||
<div class="absolute left-2 top-2 flex items-center gap-2">
|
||||
<div :class="getLogBg(project?.counts?.updated_at)" class="h-3 w-3 rounded-full"> </div>
|
||||
<div> {{ dateDiffDays(project?.counts?.updated_at || '0').toFixed(0) }} days </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="font-bold">
|
||||
{{ project.premium ? 'PREMIUM' : 'FREE' }}
|
||||
</div>
|
||||
<div class="text-text-sub/90">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-ellipsis line-clamp-1"> {{ project.name }} </div>
|
||||
<div class="flex gap-2">
|
||||
<div> Visits: </div>
|
||||
<div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
|
||||
<div> Events: </div>
|
||||
<div> {{ formatNumberK(project.counts?.events || 0) }} </div>
|
||||
<div> Sessions: </div>
|
||||
<div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-center mt-4">
|
||||
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
|
||||
Payment details
|
||||
</LyxUiButton>
|
||||
<LyxUiButton type="danger" @click="resetCount(project._id)">
|
||||
Refresh counts
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<CustomTab :items="tabs" :manualScroll="true">
|
||||
<template #overview>
|
||||
<AdminOverview></AdminOverview>
|
||||
</template>
|
||||
<template #users>
|
||||
<AdminUsers></AdminUsers>
|
||||
</template>
|
||||
<template #feedbacks>
|
||||
<AdminFeedbacks></AdminFeedbacks>
|
||||
</template>
|
||||
<template #onboarding>
|
||||
<AdminOnboardings></AdminOnboardings>
|
||||
</template>
|
||||
<template #backend>
|
||||
<AdminBackend></AdminBackend>
|
||||
</template>
|
||||
</CustomTab>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
287
dashboard/pages/admin/old.vue
Normal file
287
dashboard/pages/admin/old.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import type { AdminProjectsList } from '~/server/api/admin/projects';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const filterPremium = ref<boolean>(false);
|
||||
const filterAppsumo = ref<boolean>(false);
|
||||
|
||||
|
||||
|
||||
const timeRange = ref<number>(9);
|
||||
|
||||
function setTimeRange(n: number) {
|
||||
timeRange.value = n;
|
||||
}
|
||||
|
||||
const timeRangeTimestamp = computed(() => {
|
||||
if (timeRange.value == 1) return Date.now() - 1000 * 60 * 60 * 24;
|
||||
if (timeRange.value == 2) return Date.now() - 1000 * 60 * 60 * 24 * 7;
|
||||
if (timeRange.value == 3) return Date.now() - 1000 * 60 * 60 * 24 * 30;
|
||||
return 0;
|
||||
})
|
||||
|
||||
|
||||
const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
|
||||
const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
|
||||
|
||||
|
||||
|
||||
function onHideClicked() {
|
||||
isAdminHidden.value = true;
|
||||
}
|
||||
|
||||
|
||||
function isAppsumoType(type: number) {
|
||||
return type > 6000 && type < 6004
|
||||
}
|
||||
|
||||
const projectsAggregated = computed(() => {
|
||||
|
||||
let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : [];
|
||||
|
||||
let shownPool: AdminProjectsList[] = [];
|
||||
|
||||
|
||||
for (const element of pool) {
|
||||
|
||||
shownPool.push({ ...element, projects: [...element.projects] });
|
||||
|
||||
if (filterAppsumo.value === true) {
|
||||
shownPool.forEach(e => {
|
||||
e.projects = e.projects.filter(project => {
|
||||
return isAppsumoType(project.premium_type)
|
||||
})
|
||||
})
|
||||
|
||||
shownPool = shownPool.filter(e => {
|
||||
return e.projects.length > 0;
|
||||
})
|
||||
|
||||
} else if (filterPremium.value === true) {
|
||||
shownPool.forEach(e => {
|
||||
e.projects = e.projects.filter(project => {
|
||||
return project.premium === true;
|
||||
})
|
||||
})
|
||||
|
||||
shownPool = shownPool.filter(e => {
|
||||
return e.projects.length > 0;
|
||||
})
|
||||
|
||||
} else {
|
||||
console.log('NO DATA')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return shownPool.sort((a, b) => {
|
||||
const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
|
||||
const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
|
||||
return sumVisitsB - sumVisitsA;
|
||||
}).filter(e => {
|
||||
return new Date(e.created_at).getTime() >= timeRangeTimestamp.value
|
||||
});
|
||||
})
|
||||
|
||||
const premiumCount = computed(() => {
|
||||
let premiums = 0;
|
||||
projectsAggregated.value?.forEach(e => {
|
||||
e.projects.forEach(p => {
|
||||
if (p.premium) premiums++;
|
||||
});
|
||||
|
||||
})
|
||||
return premiums;
|
||||
})
|
||||
|
||||
|
||||
const activeProjects = computed(() => {
|
||||
let actives = 0;
|
||||
|
||||
projectsAggregated.value?.forEach(e => {
|
||||
e.projects.forEach(p => {
|
||||
if (!p.counts) return;
|
||||
if (!p.counts.updated_at) return;
|
||||
const updated_at = new Date(p.counts.updated_at).getTime();
|
||||
if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
actives++;
|
||||
});
|
||||
})
|
||||
return actives;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const totalVisits = computed(() => {
|
||||
return projectsAggregated.value?.reduce((a, e) => {
|
||||
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
|
||||
}, 0) || 0;
|
||||
});
|
||||
|
||||
const totalEvents = computed(() => {
|
||||
return projectsAggregated.value?.reduce((a, e) => {
|
||||
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0);
|
||||
}, 0) || 0;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const details = ref<any>();
|
||||
const showDetails = ref<boolean>(false);
|
||||
async function getProjectDetails(project_id: string) {
|
||||
details.value = await $fetch(`/api/admin/details?project_id=${project_id}`, signHeaders());
|
||||
showDetails.value = true;
|
||||
}
|
||||
|
||||
async function resetCount(project_id: string) {
|
||||
await $fetch(`/api/admin/reset_count?project_id=${project_id}`, signHeaders());
|
||||
}
|
||||
|
||||
|
||||
function dateDiffDays(a: string) {
|
||||
return (Date.now() - new Date(a).getTime()) / (1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
function getLogBg(last_logged_at?: string) {
|
||||
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const week = 1000 * 60 * 60 * 24 * 7;
|
||||
|
||||
const lastLoggedAtDate = new Date(last_logged_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'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="bg-bg overflow-y-auto w-full h-dvh p-6 gap-6 flex flex-col">
|
||||
|
||||
<div v-if="showDetails"
|
||||
class="w-full md:px-40 h-full fixed top-0 left-0 bg-black/90 backdrop-blur-[2px] z-[20] overflow-y-auto">
|
||||
<div class="cursor-pointer bg-red-400 w-fit px-10 py-2 rounded-lg font-semibold my-3"
|
||||
@click="showDetails = false">
|
||||
Close
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap poppins">
|
||||
{{ JSON.stringify(details, null, 3) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div @click="onHideClicked()" v-if="!isAdminHidden"
|
||||
class="bg-menu hover:bg-menu/70 cursor-pointer flex gap-2 rounded-lg w-fit px-6 py-4 text-text-sub">
|
||||
<div class="text-text-sub/90"> <i class="far fa-eye"></i> </div>
|
||||
<div> Hide from the bar </div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<Card class="p-2 flex gap-10 items-center justify-center">
|
||||
<div :class="{ 'text-red-200': timeRange == 1 }" @click="setTimeRange(1)"> Last day </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 2 }" @click="setTimeRange(2)"> Last week </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 3 }" @click="setTimeRange(3)"> Last month </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 9 }" @click="setTimeRange(9)"> All </div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-2 flex gap-10 items-center justify-center">
|
||||
<UCheckbox v-model="filterPremium" label="Filter Premium"></UCheckbox>
|
||||
<UCheckbox v-model="filterAppsumo" label="Filter Appsumo"></UCheckbox>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<div>
|
||||
Users: {{ counts?.users }}
|
||||
</div>
|
||||
<div>
|
||||
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
|
||||
</div>
|
||||
<div>
|
||||
Total visits: {{ formatNumberK(totalVisits) }}
|
||||
</div>
|
||||
<div>
|
||||
Active: {{ activeProjects }} |
|
||||
Dead: {{ (counts?.projects || 0) - activeProjects }}
|
||||
</div>
|
||||
<div>
|
||||
Total events: {{ formatNumberK(totalEvents) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
|
||||
|
||||
<div v-for="item of projectsAggregated || []"
|
||||
class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full relative">
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div> {{ item.email }} </div>
|
||||
<div> {{ item.name }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-evenly flex-col lg:grid lg:grid-cols-3 gap-2 lg:gap-4">
|
||||
|
||||
<div v-for="project of item.projects" :class="{
|
||||
'outline outline-[2px] outline-yellow-400': isAppsumoType(project.premium_type)
|
||||
}" class="flex relative flex-col items-center bg-bg p-6 rounded-xl">
|
||||
|
||||
<div class="absolute left-2 top-2 flex items-center gap-2">
|
||||
<div :class="getLogBg(project?.counts?.updated_at)" class="h-3 w-3 rounded-full"> </div>
|
||||
<div> {{ dateDiffDays(project?.counts?.updated_at || '0').toFixed(0) }} days </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="font-bold">
|
||||
{{ project.premium ? 'PREMIUM' : 'FREE' }}
|
||||
</div>
|
||||
<div class="text-text-sub/90">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-ellipsis line-clamp-1"> {{ project.name }} </div>
|
||||
<div class="flex gap-2">
|
||||
<div> Visits: </div>
|
||||
<div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
|
||||
<div> Events: </div>
|
||||
<div> {{ formatNumberK(project.counts?.events || 0) }} </div>
|
||||
<div> Sessions: </div>
|
||||
<div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-center mt-4">
|
||||
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
|
||||
Payment details
|
||||
</LyxUiButton>
|
||||
<LyxUiButton type="danger" @click="resetCount(project._id)">
|
||||
Refresh counts
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const { permission, canSeeAi } = usePermission();
|
||||
|
||||
const debugModeAi = ref<boolean>(false);
|
||||
|
||||
const { userRoles } = useLoggedUser();
|
||||
@@ -253,7 +255,12 @@ async function clearAllChats() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-hidden">
|
||||
|
||||
<div v-if="!canSeeAi" class="h-full w-full flex mt-[20vh] justify-center">
|
||||
<div> You need AI permission to view this page </div>
|
||||
</div>
|
||||
|
||||
<div v-if="canSeeAi" class="w-full h-full overflow-y-hidden">
|
||||
|
||||
<div class="flex flex-row h-full overflow-y-hidden">
|
||||
|
||||
@@ -398,24 +405,29 @@ async function clearAllChats() {
|
||||
<div :class="{ '!text-green-500': debugModeAi }" class="cursor-pointer text-red-500 w-fit"
|
||||
v-if="userRoles.isAdmin.value" @click="debugModeAi = !debugModeAi"> Debug mode </div>
|
||||
|
||||
<div class="flex justify-between items-center pt-3">
|
||||
<div class="flex pt-3 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-accent w-5 h-5 rounded-full animate-pulse">
|
||||
</div>
|
||||
<div class="manrope font-semibold text-lyx-lightmode-text dark:text-text-dirty"> {{
|
||||
chatsRemaining }} remaining requests
|
||||
<!-- <div class="bg-accent w-4 h-4 rounded-full animate-pulse">
|
||||
</div> -->
|
||||
<div class="manrope font-semibold text-lyx-lightmode-text dark:text-text-dirty">
|
||||
{{ chatsRemaining }} messages left
|
||||
</div>
|
||||
</div>
|
||||
<LyxUiButton v-if="!selfhosted" type="primary" class="text-[.9rem] text-center " @click="showDrawer('PRICING')">
|
||||
<div class="grow"></div>
|
||||
<LyxUiButton v-if="!selfhosted" type="primary" class="text-[.9rem] text-center "
|
||||
@click="showDrawer('PRICING')">
|
||||
Upgrade
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="dark:bg-lyx-widget-light bg-lyx-lightmode-widget-light h-[1px]"></div>
|
||||
|
||||
<div class="flex items-center gap-4 px-4 mt-4">
|
||||
<div class="poppins font-semibold text-[1.1rem]"> History </div>
|
||||
<div class="grow"></div>
|
||||
<LyxUiButton v-if="chatsList && chatsList.length > 0" @click="clearAllChats()" type="secondary"
|
||||
class="text-center text-[.8rem]">
|
||||
Clear all
|
||||
Clear all chats
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const canDownload = computed(() => {
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
const columns = [
|
||||
{ key: 'website', label: 'Website', sortable: true },
|
||||
{ key: 'website', label: 'Domain', sortable: true },
|
||||
{ key: 'page', label: 'Page', sortable: true },
|
||||
{ key: 'referrer', label: 'Referrer', sortable: true },
|
||||
{ key: 'browser', label: 'Browser', sortable: true },
|
||||
|
||||
@@ -1,22 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import EventsFunnelChart from '~/components/events/EventsFunnelChart.vue';
|
||||
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const selectLabelsEvents = [
|
||||
const { permission, canSeeEvents } = usePermission();
|
||||
|
||||
const { snapshotDuration } = useSnapshot();
|
||||
|
||||
const selectedLabelIndex = ref<number>(1);
|
||||
|
||||
const selectLabels: { label: string, value: Slice }[] = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
const eventsStackedSelectIndex = ref<number>(0);
|
||||
|
||||
const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeaders({ custom: { 'x-schema': 'events' } }), lazy: true });
|
||||
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
|
||||
return selectLabels.map(e => {
|
||||
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
|
||||
});
|
||||
})
|
||||
|
||||
const eventsData = await useFetch(`/api/data/count`, {
|
||||
headers: useComputedHeaders({ custom: { 'x-schema': 'events' } }),
|
||||
lazy: true
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
|
||||
|
||||
<div v-if="!canSeeEvents" class="h-full w-full flex mt-[20vh] justify-center">
|
||||
<div> You need events permission to view this page </div>
|
||||
</div>
|
||||
|
||||
<div v-if="canSeeEvents" class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
|
||||
|
||||
|
||||
<LyxUiCard class="w-full flex justify-between items-center lg:flex-row flex-col gap-6 lg:gap-0">
|
||||
@@ -24,9 +44,6 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
|
||||
<div>
|
||||
Total events: {{ eventsData.data.value?.[0]?.count || '0' }}
|
||||
</div>
|
||||
<div v-if="(eventsData.data.value?.[0]?.count || 0) === 0">
|
||||
Waiting for your first event...
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<LyxUiButton type="secondary" target="_blank" to="https://docs.litlyx.com/custom-events">
|
||||
@@ -45,12 +62,14 @@ const eventsData = await useFetch(`/api/data/count`, { headers: useComputedHeade
|
||||
<CardTitled :key="refreshKey" class="p-4 xl:flex-[4] w-full h-full" title="Events"
|
||||
sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||
|
||||
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event"
|
||||
:currentIndex="selectedLabelIndex" :options="selectLabelsAvailable">
|
||||
</SelectButton>
|
||||
|
||||
</template>
|
||||
<div class="h-full">
|
||||
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
||||
<EventsStackedBarChart :slice="(selectLabelsAvailable[selectedLabelIndex].value as any)">
|
||||
</EventsStackedBarChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
@@ -11,6 +11,9 @@ const jwtLogin = computed(() => route.query.jwt_login as string);
|
||||
|
||||
const { token, setToken } = useAccessToken();
|
||||
|
||||
const { refreshingDomains } = useDomain();
|
||||
const { permission, canSeeWeb, canSeeEvents } = usePermission();
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
if (jwtLogin.value) {
|
||||
@@ -18,7 +21,6 @@ onMounted(async () => {
|
||||
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
const loggedUser = useLoggedUser();
|
||||
loggedUser.user = user;
|
||||
// setTimeout(() => { location.reload(); }, 100);
|
||||
}
|
||||
|
||||
if (justLogged.value) { setTimeout(() => { location.href = '/' }, 500) }
|
||||
@@ -37,13 +39,22 @@ const selfhosted = useSelfhosted();
|
||||
|
||||
<template>
|
||||
|
||||
<div class="dashboard w-full h-full overflow-y-auto overflow-x-hidden pb-[7rem] md:pt-4 lg:pt-0">
|
||||
<div v-if="!canSeeWeb" class="h-full w-full flex mt-[20vh] justify-center">
|
||||
<div> You need webAnalytics permission to view this page </div>
|
||||
</div>
|
||||
|
||||
<div v-if="canSeeWeb && refreshingDomains">
|
||||
<div class="w-full flex justify-center items-center mt-[20vh]">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canSeeWeb && !refreshingDomains" class="dashboard w-full h-full overflow-y-auto overflow-x-hidden pb-[7rem] md:pt-4 lg:pt-0">
|
||||
|
||||
<div v-if="showDashboard">
|
||||
|
||||
<div class="w-full px-4 py-2 gap-2 flex flex-col">
|
||||
<BannerLimitsInfo v-if="!selfhosted" :key="refreshKey"></BannerLimitsInfo>
|
||||
<BannerOffer v-if="!selfhosted" :key="refreshKey"></BannerOffer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -51,17 +62,22 @@ const selfhosted = useSelfhosted();
|
||||
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full">
|
||||
<DashboardActionableChart :key="refreshKey"></DashboardActionableChart>
|
||||
<DashboardActionableChart v-if="canSeeWeb && canSeeEvents" :key="refreshKey"></DashboardActionableChart>
|
||||
<LyxUiCard v-else class="flex justify-center w-full py-4">
|
||||
You need events permission to view this widget
|
||||
</LyxUiCard>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
<BarCardReferrers :key="refreshKey"></BarCardReferrers>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<BarCardWebsites :key="refreshKey"></BarCardWebsites>
|
||||
<BarCardPages :key="refreshKey"></BarCardPages>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,6 +93,7 @@ const selfhosted = useSelfhosted();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex w-full justify-center mt-6 px-6">
|
||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||
<div class="flex-1">
|
||||
@@ -90,7 +107,6 @@ const selfhosted = useSelfhosted();
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<FirstInteraction v-if="!justLogged" :refresh-interaction="firstInteraction.refresh"
|
||||
:first-interaction="(firstInteraction.data.value || false)"></FirstInteraction>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ const selectLabelsEvents = [
|
||||
Litlyx open metrics
|
||||
</div>
|
||||
<div v-if="project" class="flex gap-2 items-center text-text/90">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="animate-pulse w-[.8rem] h-[.8rem] bg-green-400 rounded-full"> </div>
|
||||
<div> {{ onlineUsers }} Online users</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,38 +13,14 @@ const useCodeClientWrapper = isNoAuth.value === false ?
|
||||
return { isReady: false, login: () => { } }
|
||||
}
|
||||
|
||||
async function loginWithoutAuth() {
|
||||
try {
|
||||
const result = await $fetch('/api/auth/no_auth');
|
||||
if (result.error) return alert('Error during login, please try again');
|
||||
|
||||
setToken(result.access_token);
|
||||
|
||||
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
const loggedUser = useLoggedUser();
|
||||
loggedUser.user = user;
|
||||
|
||||
console.log('LOGIN DONE - USER', loggedUser.user);
|
||||
|
||||
const isFirstTime = await $fetch<boolean>('/api/user/is_first_time', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
|
||||
if (isFirstTime === true) {
|
||||
router.push('/project_creation?just_logged=true');
|
||||
} else {
|
||||
router.push('/?just_logged=true');
|
||||
}
|
||||
|
||||
} catch (ex: any) {
|
||||
alert('Error during login.' + ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { isReady, login } = useCodeClientWrapper({ onSuccess: handleOnSuccess, onError: handleOnError, });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { token, setToken } = useAccessToken();
|
||||
|
||||
const { createErrorAlert } = useAlert();
|
||||
|
||||
async function handleOnSuccess(response: any) {
|
||||
|
||||
try {
|
||||
@@ -121,6 +97,39 @@ function goBackToEmailLogin() {
|
||||
password.value = '';
|
||||
}
|
||||
|
||||
async function signInSelfhosted() {
|
||||
try {
|
||||
const result: any = await $fetch(`/api/auth/no_auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
});
|
||||
if (result.error) {
|
||||
if (result.errorMessage) return alert(result.errorMessage);
|
||||
return alert('Error during login, please try again');
|
||||
}
|
||||
|
||||
setToken(result.access_token);
|
||||
|
||||
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
const loggedUser = useLoggedUser();
|
||||
loggedUser.user = user;
|
||||
|
||||
console.log('LOGIN DONE - USER', loggedUser.user);
|
||||
|
||||
const isFirstTime = await $fetch<boolean>('/api/user/is_first_time', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
|
||||
if (isFirstTime === true) {
|
||||
router.push('/project_creation?just_logged=true');
|
||||
} else {
|
||||
router.push('/?just_logged=true');
|
||||
}
|
||||
|
||||
} catch (ex: any) {
|
||||
createErrorAlert('Error', 'Error during login.' + ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithCredentials() {
|
||||
|
||||
try {
|
||||
@@ -130,7 +139,7 @@ async function signInWithCredentials() {
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
})
|
||||
|
||||
if (result.error) return alert(result.message);
|
||||
if (result.error) return createErrorAlert('Error', result.message);
|
||||
|
||||
setToken(result.access_token);
|
||||
|
||||
@@ -149,8 +158,8 @@ async function signInWithCredentials() {
|
||||
}
|
||||
|
||||
|
||||
} catch (ex) {
|
||||
alert('Something went wrong.');
|
||||
} catch (ex: any) {
|
||||
createErrorAlert('Error', 'Something went wrong.' + ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +185,8 @@ async function signInWithCredentials() {
|
||||
Sign in
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-lightmode-text/80 dark:text-lyx-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2">
|
||||
<div
|
||||
class="text-lyx-lightmode-text/80 dark:text-lyx-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2">
|
||||
Track web analytics and custom events
|
||||
with extreme simplicity in under 30 sec.
|
||||
<br>
|
||||
@@ -217,7 +227,8 @@ async function signInWithCredentials() {
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="!isNoAuth && !isEmailLogin" class="flex flex-col text-lyx-lightmode-text dark:text-lyx-text gap-2">
|
||||
<div v-if="!isNoAuth && !isEmailLogin"
|
||||
class="flex flex-col text-lyx-lightmode-text dark:text-lyx-text gap-2">
|
||||
|
||||
<div @click="login"
|
||||
class="hover:bg-lyx-primary bg-white dark:bg-transparent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
|
||||
@@ -247,17 +258,30 @@ async function signInWithCredentials() {
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="isNoAuth" @click="loginWithoutAuth"
|
||||
class="hover:bg-accent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-crown"></i>
|
||||
|
||||
|
||||
<div v-if="isNoAuth"
|
||||
class="flex text-[1.3rem] flex-col gap-4 items-center px-8 py-3 relative z-[2]">
|
||||
<div class="flex flex-col gap-4 z-[100] w-[20vw] min-w-[20rem]">
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Email" v-model="email"></LyxUiInput>
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Password" v-model="password" type="password">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
Continue as Admin
|
||||
<div class="flex justify-center mt-4 z-[100]">
|
||||
<LyxUiButton @click="signInSelfhosted()" class="text-center" type="primary">
|
||||
Sign in
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="text-[.9rem] poppins mt-20 text-lyx-lightmode-text-dark dark:text-lyx-text-dark text-center relative z-[2]">
|
||||
<div
|
||||
class="text-[.9rem] poppins mt-20 text-lyx-lightmode-text-dark dark:text-lyx-text-dark text-center relative z-[2]">
|
||||
By continuing you are accepting
|
||||
<br>
|
||||
our
|
||||
|
||||
212
dashboard/pages/members.vue
Normal file
212
dashboard/pages/members.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogPermissionManager } from '#components';
|
||||
import type { TPermission } from '~/shared/schema/TeamMemberSchema';
|
||||
|
||||
const { projectId, isGuest } = useProject();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const columns = [
|
||||
{ key: 'me', label: '' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'permission', label: 'Permission' },
|
||||
{ key: 'pending', label: 'Status' },
|
||||
{ key: 'action', label: 'Actions' },
|
||||
]
|
||||
|
||||
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
const showAddMember = ref<boolean>(false);
|
||||
|
||||
const addMemberEmail = ref<string>("");
|
||||
|
||||
const { createErrorAlert } = useAlert();
|
||||
|
||||
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 }) {
|
||||
createErrorAlert('Error', 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 }) {
|
||||
createErrorAlert('Error', response.statusText);
|
||||
}
|
||||
});
|
||||
|
||||
addMemberEmail.value = '';
|
||||
|
||||
refreshMembers();
|
||||
|
||||
} catch (ex: any) { }
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
const modal = useModal();
|
||||
|
||||
function openPermissionManagerDialog(member_id: string) {
|
||||
modal.open(DialogPermissionManager, {
|
||||
preventClose: true,
|
||||
member_id,
|
||||
onSuccess: () => {
|
||||
modal.close();
|
||||
refreshMembers();
|
||||
},
|
||||
onCancel: () => {
|
||||
modal.close();
|
||||
refreshMembers();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function permissionToString(permission: TPermission) {
|
||||
const result: string[] = [];
|
||||
if (permission.webAnalytics) result.push('w');
|
||||
if (permission.events) result.push('e');
|
||||
if (permission.ai) result.push('a');
|
||||
if (permission.domains.includes('All domains')) {
|
||||
result.push('+');
|
||||
} else {
|
||||
result.push(permission.domains.length.toString());
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
async function leaveProject() {
|
||||
try {
|
||||
await $fetch('/api/project/members/leave', {
|
||||
headers: useComputedHeaders({}).value
|
||||
});
|
||||
location.reload();
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 pt-10">
|
||||
|
||||
<div v-if="!isGuest" class="flex flex-col gap-8">
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-4 items-center">
|
||||
<LyxUiInput class="px-4 py-1 w-full" placeholder="Add a new member" v-model="addMemberEmail">
|
||||
</LyxUiInput>
|
||||
<LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
|
||||
</div>
|
||||
<div class="poppins text-[.8rem] mt-2 dark:text-lyx-text-dark">
|
||||
We will send an invitation email to the user you wish to add to this project.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UTable :rows="members || []" :columns="columns">
|
||||
|
||||
<template #me-data="e">
|
||||
<i v-if="e.row.me" class="far fa-user text-lyx-lightmode-text dark:text-lyx-text"></i>
|
||||
<i v-if="!e.row.me"></i>
|
||||
</template>
|
||||
|
||||
<template #email-data="e">
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text">
|
||||
{{ e.row.email }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #pending-data="e">
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text">
|
||||
{{ e.row.pending ? 'Pending' : 'Accepted' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #permission-data="e">
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text flex gap-2">
|
||||
<div v-if="e.row.role !== 'OWNER' && !isGuest">
|
||||
<LyxUiButton class="!px-2" type="secondary"
|
||||
@click="openPermissionManagerDialog(e.row.id.toString())">
|
||||
<UTooltip text="Manage permissions">
|
||||
<i class="far fa-gear"></i>
|
||||
</UTooltip>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<UBadge variant="outline" size="sm" color="yellow"
|
||||
v-if="!e.row.permission.webAnalytics && !e.row.permission.events && !e.row.permission.ai && e.row.permission.domains.length == 0">
|
||||
No permission given
|
||||
</UBadge>
|
||||
<UBadge variant="outline" size="sm" v-if="e.row.permission.webAnalytics"
|
||||
label="Analytics"> </UBadge>
|
||||
<UBadge variant="outline" size="sm" v-if="e.row.permission.events" label="Events">
|
||||
</UBadge>
|
||||
<UBadge variant="outline" size="sm" v-if="e.row.permission.ai" label="AI"> </UBadge>
|
||||
<UBadge variant="outline" color="blue" size="sm"
|
||||
v-if="e.row.permission.domains.includes('All domains')" label="All domains">
|
||||
</UBadge>
|
||||
|
||||
<UBadge variant="outline" size="sm" color="blue"
|
||||
v-if="!e.row.permission.domains.includes('All domains')"
|
||||
v-for="domain of e.row.permission.domains" :label="domain"> </UBadge>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
Remove
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</UTable>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="isGuest" class="flex flex-col gap-8 mt-[10vh]">
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
<div class="text-[1.2rem]"> Leave this project </div>
|
||||
<LyxUiButton @click="leaveProject()" type="primary"> Leave </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,7 +23,7 @@ onMounted(() => {
|
||||
|
||||
|
||||
async function createProject() {
|
||||
if (projectName.value.length < 2) return;
|
||||
if (projectName.value.trim().length < 2) return;
|
||||
|
||||
Lit.event('create_project');
|
||||
|
||||
@@ -34,14 +34,16 @@ async function createProject() {
|
||||
await $fetch('/api/project/create', {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ name: projectName.value })
|
||||
body: JSON.stringify({ name: projectName.value.trim() })
|
||||
});
|
||||
|
||||
await actions.refreshProjectsList();
|
||||
|
||||
const newActiveProjectId = projectList.value?.[projectList.value?.length - 1]._id.toString();
|
||||
|
||||
if (newActiveProjectId) {
|
||||
await actions.setActiveProject(newActiveProjectId);
|
||||
console.log('Set active project', newActiveProjectId);
|
||||
}
|
||||
|
||||
setPageLayout('dashboard');
|
||||
@@ -71,7 +73,7 @@ async function createProject() {
|
||||
<div class="flex flex-col items-center justify-center pt-[12rem] gap-12 relative z-[10]">
|
||||
|
||||
<div class="text-[3rem] font-semibold text-center text-lyx-lightmode-text dark:text-lyx-text">
|
||||
Create your {{ isFirstProject ? 'first' : '' }} project
|
||||
Create {{ isFirstProject ? '' : 'a new' }} {{ isFirstProject ? 'your first' : '' }} project
|
||||
</div>
|
||||
|
||||
<div v-if="isFirstProject" class="text-[1.5rem]">
|
||||
@@ -89,7 +91,7 @@ async function createProject() {
|
||||
|
||||
<div>
|
||||
|
||||
<LyxUiButton type="primary" @click="createProject()" :disabled="projectName.length < 2">
|
||||
<LyxUiButton type="primary" @click="createProject()" :disabled="projectName.trim().length < 2">
|
||||
Create
|
||||
</LyxUiButton>
|
||||
|
||||
|
||||
@@ -6,18 +6,6 @@ const reportList = useFetch(`/api/security/list`, { headers: useComputedHeaders(
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const rows = computed(() => reportList.data.value || [])
|
||||
|
||||
const columns = [
|
||||
@@ -33,14 +21,14 @@ const columns = [
|
||||
|
||||
<div class="home w-full h-full px-10 pt-6 overflow-y-auto">
|
||||
|
||||
<div class="flex gap-2 items-center text-lyx-lightmode-text dark:text-text/90 justify-end">
|
||||
<!-- <div class="flex gap-2 items-center text-lyx-lightmode-text dark:text-text/90 justify-end">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="poppins font-regular text-[1rem]"> AI Anomaly Detector </div>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
|
||||
@click="showAnomalyInfoAlert"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="pb-[10rem]">
|
||||
<UTable :rows="rows" :columns="columns">
|
||||
|
||||
@@ -5,31 +5,27 @@ definePageMeta({ layout: 'dashboard' });
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const items = [
|
||||
{ label: 'General', slot: 'general' },
|
||||
{ label: 'Data', slot: 'data' },
|
||||
{ label: 'Members', slot: 'members' },
|
||||
{ label: 'Billing', slot: 'billing' },
|
||||
{ label: 'Codes', slot: 'codes' },
|
||||
{ label: 'Account', slot: 'account' }
|
||||
{ label: 'General', slot: 'general', tab: 'general' },
|
||||
{ label: 'Domains', slot: 'domains', tab: 'domains' },
|
||||
{ label: 'Billing', slot: 'billing', tab: 'billing' },
|
||||
{ label: 'Codes', slot: 'codes', tab: 'codes' },
|
||||
{ label: 'Account', slot: 'account', tab: 'account' }
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lg:px-10 lg:py-8 h-dvh overflow-y-auto overflow-x-hidden hide-scrollbars">
|
||||
<div class="lg:px-10 h-full lg:py-8 overflow-hidden hide-scrollbars">
|
||||
|
||||
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Settings </div>
|
||||
|
||||
<CustomTab :items="items" class="mt-8">
|
||||
<CustomTab :items="items" :route="true" class="mt-8">
|
||||
<template #general>
|
||||
<SettingsGeneral :key="refreshKey"></SettingsGeneral>
|
||||
</template>
|
||||
<template #data>
|
||||
<template #domains>
|
||||
<SettingsData :key="refreshKey"></SettingsData>
|
||||
</template>
|
||||
<template #members>
|
||||
<SettingsMembers :key="refreshKey"></SettingsMembers>
|
||||
</template>
|
||||
<template #billing>
|
||||
<SettingsBilling v-if="!selfhosted" :key="refreshKey"></SettingsBilling>
|
||||
<div class="flex popping text-[1.2rem] font-semibold justify-center mt-[20vh] text-lyx-lightmode-text dark:text-lyx-text"
|
||||
|
||||
35
dashboard/pages/shields.vue
Normal file
35
dashboard/pages/shields.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const items = [
|
||||
{ label: 'Domains', slot: 'domains', tab: 'domains' },
|
||||
{ label: 'IP Addresses', slot: 'ipaddresses', tab: 'ipaddresses' },
|
||||
{ label: 'Bot traffic', slot: 'bots', tab: 'bots' },
|
||||
// { label: 'Countries', slot: 'countries', tab: 'countries' },
|
||||
// { label: 'Pages', slot: 'pages', tab: 'pages' },
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lg:px-10 h-full lg:py-8 overflow-hidden hide-scrollbars">
|
||||
|
||||
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Shields </div>
|
||||
|
||||
<CustomTab :items="items" :route="true" class="mt-8">
|
||||
<template #domains>
|
||||
<ShieldsDomains></ShieldsDomains>
|
||||
</template>
|
||||
<template #ipaddresses>
|
||||
<ShieldsAddresses></ShieldsAddresses>
|
||||
</template>
|
||||
<template #bots>
|
||||
<ShieldsBots></ShieldsBots>
|
||||
</template>
|
||||
</CustomTab>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
15
dashboard/pages/test_links.vue
Normal file
15
dashboard/pages/test_links.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: links } = useFetch('/api/project/links/list', {
|
||||
headers: useComputedHeaders()
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="link of links">
|
||||
{{ link }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
2325
dashboard/pnpm-lock.yaml
generated
2325
dashboard/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user