mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c39dbba1 | ||
|
|
9403aebbb9 | ||
|
|
69bb6fb03c | ||
|
|
33b730e66b | ||
|
|
0ba44a406d | ||
|
|
3c77a727cd | ||
|
|
8e3ad2920f | ||
|
|
f4401d74a2 | ||
|
|
375330bac4 | ||
|
|
3b1ee0fd13 | ||
|
|
f5edf187fd | ||
|
|
5b7e93bcbb | ||
|
|
3b6a202538 | ||
|
|
cf1aa103e4 | ||
|
|
4eeebaa0c3 | ||
|
|
f285e92132 | ||
|
|
ac7ba7abd3 | ||
|
|
3c59551f88 | ||
|
|
628e471cec | ||
|
|
0be3dbecbf |
@@ -1,51 +1,26 @@
|
||||
shared/node_modules
|
||||
shared/.output
|
||||
|
||||
# Broker
|
||||
broker/node_modules
|
||||
broker/scripts/start_dev.js
|
||||
broker/ecosystem.config.cjs
|
||||
broker/ecosystem.config.example.cjs
|
||||
broker/Dockerfile
|
||||
broker/.gitignore
|
||||
broker/dist
|
||||
scripts/node_modules
|
||||
|
||||
lyx-ui/node_modules
|
||||
lyx-ui/.nuxt
|
||||
lyx-ui/.output
|
||||
|
||||
# Producer
|
||||
producer/node_modules
|
||||
producer/scripts/start_dev.js
|
||||
producer/ecosystem.config.cjs
|
||||
producer/ecosystem.config.example.cjs
|
||||
producer/Dockerfile
|
||||
producer/.gitignore
|
||||
producer/dist
|
||||
|
||||
# Dashboard
|
||||
consumer/node_modules
|
||||
consumer/scripts/start_dev.js
|
||||
consumer/ecosystem.config.cjs
|
||||
|
||||
dashboard/node_modules
|
||||
dashboard/.nuxt
|
||||
dashboard/.output
|
||||
dashboard/explains
|
||||
dashboard/tests
|
||||
dashboard/.env.example
|
||||
dashboard/.env
|
||||
dashboard/.gitignore
|
||||
dashboard/winston-*.ndjson
|
||||
dashboard/ecosystem.config.cjs
|
||||
dashboard/out.pdf
|
||||
dashboard/timeline.report.txt
|
||||
dashboard/Dockerfile
|
||||
dashboard/vitest.config.ts
|
||||
dashboard/vitest.setup.ts
|
||||
|
||||
# Shared
|
||||
shared/node_modules
|
||||
shared/.gitignore
|
||||
|
||||
# Others
|
||||
docs/*
|
||||
landing/*
|
||||
docker/*
|
||||
dev/*
|
||||
assets/*
|
||||
CODE_OF_CONDUCT.md
|
||||
LICENSE
|
||||
readme.md
|
||||
SECURITY.md
|
||||
steps
|
||||
docker-compose.yml
|
||||
docker-compose.admin.yml
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM node:21-alpine as base
|
||||
|
||||
FROM base as build
|
||||
|
||||
RUN npm i -g pnpm
|
||||
RUN npm i -g pm2
|
||||
|
||||
# COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
|
||||
# RUN npm install --production=false
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link dashboard ./dashboard
|
||||
COPY --link lyx-ui ./lyx-ui
|
||||
COPY --link consumer ./consumer
|
||||
COPY --link producer ./producer
|
||||
COPY --link shared ./shared
|
||||
|
||||
WORKDIR /home/app/producer
|
||||
RUN pnpm install
|
||||
|
||||
WORKDIR /home/app/consumer
|
||||
RUN pnpm install
|
||||
|
||||
WORKDIR /home/app/dashboard
|
||||
RUN pnpm install
|
||||
RUN pnpm run dev
|
||||
|
||||
|
||||
# CMD [ "node", "/home/app/.output/server/index.mjs" ]
|
||||
@@ -1,43 +0,0 @@
|
||||
ARG NODE_VERSION=21
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine as base
|
||||
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Build stage
|
||||
|
||||
FROM base as build
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY --link broker/package.json broker/pnpm-lock.yaml home/app/
|
||||
|
||||
COPY --link shared/package.json shared/pnpm-lock.yaml /home/shared/
|
||||
|
||||
WORKDIR /home/app
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
WORKDIR /home/shared
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --link ../broker /home/app
|
||||
|
||||
COPY --link ../shared /home/shared
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
RUN pnpm run build_all
|
||||
|
||||
RUN pnpm prune
|
||||
|
||||
# Final stage
|
||||
|
||||
FROM base
|
||||
|
||||
COPY --from=build /home/app /home/app
|
||||
|
||||
WORKDIR /home/app
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["node", "dist/app/src/index.js"]
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+.tsx?$": ["ts-jest",{}],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'@services/(.*)': '<rootDir>/../shared/services/$1',
|
||||
'@data/(.*)': '<rootDir>/../shared/data/$1',
|
||||
'@functions/(.*)': '<rootDir>/../shared/functions/$1',
|
||||
'@schema/(.*)': '<rootDir>/../shared/schema/$1',
|
||||
}
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"mongoose": "^8.3.2",
|
||||
"nodemailer": "^6.9.13",
|
||||
"redis": "^4.6.14",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"glob": "^10.4.1",
|
||||
"jest": "^29.7.0",
|
||||
"node-ssh": "^13.2.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"name": "litlyx-queue-broker",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start_dev.js",
|
||||
"compile": "tsc",
|
||||
"build": "ts-node scripts/build.ts",
|
||||
"create_db": "cd scripts && ts-node create_database.ts",
|
||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
||||
"docker-build": "docker build -t litlyx-broker -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-broker sh",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
"license": "MIT",
|
||||
"description": "Queue broker for Litlyx - Saves events to database."
|
||||
}
|
||||
4685
broker/pnpm-lock.yaml
generated
4685
broker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import { globSync } from 'glob';
|
||||
const tsconfigContent = fs.readFileSync('tsconfig.json', 'utf8');
|
||||
const tsconfigObject = JSON.parse(tsconfigContent);
|
||||
const paths = tsconfigObject.compilerOptions.paths;
|
||||
const filesList = globSync('dist/**/*.js');
|
||||
filesList.forEach(file => {
|
||||
let raw = fs.readFileSync(file, 'utf8');
|
||||
for (const path in paths) {
|
||||
const deep = (file.match(/\\|\//g) || []).length;
|
||||
const pathText = path.replace('*', '');
|
||||
const toReplaceText = new RegExp(`"${pathText}(.*?)"`, 'g');
|
||||
raw = raw.replace(toReplaceText, `"${new Array(deep - 2).fill('../').join('')}${paths[path][0].replace('*', '')}${'$1'}"`);
|
||||
}
|
||||
fs.writeFileSync(file, raw);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
|
||||
|
||||
export function getDeviceFromScreenSize(width: number, height: number) {
|
||||
const totalArea = width * height;
|
||||
|
||||
const mobileArea = 375 * 667;
|
||||
const tabletMinArea = 768 * 1366
|
||||
const tabletMaxArea = 1024 * 1366
|
||||
|
||||
const isMobile = totalArea <= mobileArea;
|
||||
const isTablet = totalArea >= tabletMinArea && totalArea <= tabletMaxArea;
|
||||
|
||||
if (isMobile) return 'mobile';
|
||||
if (isTablet) return 'tablet'
|
||||
return 'desktop';
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
|
||||
import { RedisStreamService } from '@services/RedisStreamService';
|
||||
import { requireEnv } from '../../shared/utilts/requireEnv';
|
||||
import { EventModel } from '@schema/metrics/EventSchema';
|
||||
import { SessionModel } from '@schema/metrics/SessionSchema';
|
||||
import { ProjectModel } from '@schema/ProjectSchema';
|
||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
||||
import { EVENT_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
import { checkLimitsForEmail } from './Controller';
|
||||
import { lookup } from './lookup';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||
|
||||
|
||||
export async function startStreamLoop() {
|
||||
|
||||
await RedisStreamService.connect();
|
||||
|
||||
await RedisStreamService.startReadingLoop({
|
||||
streamName: requireEnv('STREAM_NAME'),
|
||||
delay: { base: 10, empty: 5000 },
|
||||
readBlock: 2000,
|
||||
consumer: 'consumer_' + process.env.NODE_APP_INSTANCE
|
||||
}, processStreamEvent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
export async function processStreamEvent(data: Record<string, string>) {
|
||||
try {
|
||||
const eventType = data._type;
|
||||
if (!eventType) return;
|
||||
|
||||
const { pid, sessionHash } = data;
|
||||
|
||||
const project = await ProjectModel.exists({ _id: pid });
|
||||
if (!project) return;
|
||||
|
||||
|
||||
if (eventType === 'event') return await process_event(data, sessionHash);
|
||||
if (eventType === 'keep_alive') return await process_keep_alive(data, sessionHash);
|
||||
if (eventType === 'visit') return await process_visit(data, sessionHash);
|
||||
|
||||
} catch (ex: any) {
|
||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function checkLimits(project_id: string) {
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
if (!projectLimits) return false;
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
if ((TOTAL_COUNT) > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT) return false;
|
||||
await checkLimitsForEmail(projectLimits);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
||||
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
let referrerParsed;
|
||||
try {
|
||||
referrerParsed = new URL(referrer);
|
||||
} catch (ex) {
|
||||
referrerParsed = { hostname: referrer };
|
||||
}
|
||||
|
||||
const geoLocation = lookup(ip);
|
||||
|
||||
const userAgentParsed = UAParser(userAgent);
|
||||
|
||||
const device = userAgentParsed.device.type;
|
||||
|
||||
const visit = new VisitModel({
|
||||
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
||||
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
||||
os: userAgentParsed.os.name || 'NO_OS',
|
||||
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||
session: sessionHash,
|
||||
flowHash,
|
||||
continent: geoLocation[0],
|
||||
country: geoLocation[1],
|
||||
});
|
||||
|
||||
await visit.save();
|
||||
|
||||
|
||||
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true });
|
||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } });
|
||||
|
||||
}
|
||||
|
||||
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, instant, flowHash } = data;
|
||||
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||
if (!existingSession) {
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
||||
}
|
||||
|
||||
if (instant == "true") {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 0 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
}, { upsert: true });
|
||||
} else {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 1 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
}, { upsert: true });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
async function process_event(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { name, metadata, pid, flowHash } = data;
|
||||
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
let metadataObject;
|
||||
try {
|
||||
if (metadata) metadataObject = JSON.parse(metadata);
|
||||
} catch (ex) {
|
||||
metadataObject = { error: 'Error parsing metadata' }
|
||||
}
|
||||
|
||||
const event = new EventModel({ project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash });
|
||||
await event.save();
|
||||
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true });
|
||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } });
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
import { requireEnv } from '../../shared/utilts/requireEnv';
|
||||
import { connectDatabase } from '@services/DatabaseService';
|
||||
import { startStreamLoop } from './StreamLoopController';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
|
||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||
|
||||
import HealthRouter from './routes/HealthRouter';
|
||||
app.use('/health', HealthRouter);
|
||||
|
||||
app.listen(requireEnv('PORT'), () => console.log(`Listening on port ${requireEnv('PORT')}`));
|
||||
|
||||
startStreamLoop();
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
return res.json({ alive: true });
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
return res.status(500).json({ error: 'ERROR' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Request } from "express";
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function getIPFromRequest(req: Request) {
|
||||
const ip = req.header('X-Real-IP') || req.header('X-Forwarded-For') || '0.0.0.0';
|
||||
return ip;
|
||||
}
|
||||
|
||||
|
||||
export function createSessionHash(website: string, ip: string, userAgent: string) {
|
||||
const dailySalt = new Date().toLocaleDateString('it-IT');
|
||||
const sessionClean = dailySalt + website + ip + userAgent;
|
||||
const sessionHash = crypto.createHash('md5').update(sessionClean).digest("hex");
|
||||
return sessionHash;
|
||||
}
|
||||
0
broker/.gitignore → consumer/.gitignore
vendored
0
broker/.gitignore → consumer/.gitignore
vendored
38
consumer/Dockerfile
Normal file
38
consumer/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Start with a minimal Node.js base image
|
||||
FROM node:21-alpine as base
|
||||
|
||||
# Install pnpm globally with caching to avoid reinstalling if nothing has changed
|
||||
RUN npm i -g pnpm
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /home/app
|
||||
|
||||
# Copy only package-related files to leverage caching
|
||||
COPY --link ./scripts/package.json ./scripts/pnpm-lock.yaml ./scripts/
|
||||
COPY --link ./shared/package.json ./shared/pnpm-lock.yaml ./shared/
|
||||
COPY --link ./consumer/package.json ./consumer/pnpm-lock.yaml ./consumer/
|
||||
|
||||
# Install dependencies for each package
|
||||
WORKDIR /home/app/scripts
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
WORKDIR /home/app/shared
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
WORKDIR /home/app/consumer
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Now copy the rest of the source files
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link ../scripts ./scripts
|
||||
COPY --link ../shared ./shared
|
||||
COPY --link ../consumer ./consumer
|
||||
|
||||
# Build the consumer
|
||||
WORKDIR /home/app/consumer
|
||||
|
||||
RUN pnpm run build_all
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "/home/app/consumer/dist/consumer/src/index.js"]
|
||||
@@ -1,19 +1,16 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'QueueBroker',
|
||||
port: '3999',
|
||||
name: 'consumer',
|
||||
exec_mode: 'fork',
|
||||
script: './dist/producer/src/index.js',
|
||||
script: './dist/consumer/src/index.js',
|
||||
env: {
|
||||
EMAIL_SERVICE: "",
|
||||
BREVO_API_KEY: "",
|
||||
PORT: "",
|
||||
MONGO_CONNECTION_STRING: "",
|
||||
REDIS_URL: "",
|
||||
REDIS_USERNAME: "",
|
||||
REDIS_PASSWORD: "",
|
||||
STREAM_NAME: ""
|
||||
STREAM_NAME: "",
|
||||
GROUP_NAME: ""
|
||||
}
|
||||
}
|
||||
]
|
||||
30
consumer/package.json
Normal file
30
consumer/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"mongoose": "^8.3.2",
|
||||
"redis": "^4.6.14",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"name": "consumer-database",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start_dev.js",
|
||||
"compile": "tsc",
|
||||
"build": "node ../scripts/build.js",
|
||||
"create_db": "cd scripts && ts-node create_database.ts",
|
||||
"build_all": "npm run compile && npm run build && npm run create_db",
|
||||
"docker-build": "docker build -t litlyx-consumer -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-consumer sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
"license": "MIT",
|
||||
"description": "Database Consumer - Saves events to database."
|
||||
}
|
||||
1498
consumer/pnpm-lock.yaml
generated
Normal file
1498
consumer/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
Can't render this file because it is too large.
|
@@ -2,7 +2,7 @@ import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
||||
import EmailService from '@services/EmailService';
|
||||
import { requireEnv } from "../../shared/utilts/requireEnv";
|
||||
import { requireEnv } from "@utils/requireEnv";
|
||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
@@ -19,8 +19,7 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit)) {
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit3 === true) return;
|
||||
if (hasNotifyEntry.limit3 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
@@ -33,8 +32,7 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.9)) {
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit2 === true) return;
|
||||
if (hasNotifyEntry.limit2 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
@@ -47,8 +45,7 @@ export async function checkLimitsForEmail(projectCounts: TProjectLimit) {
|
||||
|
||||
} else if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit * 0.5)) {
|
||||
|
||||
const notify = await LimitNotifyModel.findOne({ project_id });
|
||||
if (notify && notify.limit1 === true) return;
|
||||
if (hasNotifyEntry.limit1 === true) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
15
consumer/src/LimitChecker.ts
Normal file
15
consumer/src/LimitChecker.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
|
||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
import { checkLimitsForEmail } from './EmailController';
|
||||
|
||||
export async function checkLimits(project_id: string) {
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id });
|
||||
if (!projectLimits) return false;
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
if ((TOTAL_COUNT) > COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT) return false;
|
||||
await checkLimitsForEmail(projectLimits);
|
||||
return true;
|
||||
}
|
||||
142
consumer/src/index.ts
Normal file
142
consumer/src/index.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
|
||||
import { requireEnv } from '@utils/requireEnv';
|
||||
import { connectDatabase } from '@services/DatabaseService';
|
||||
import { RedisStreamService } from '@services/RedisStreamService';
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { lookup } from './lookup';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { checkLimits } from './LimitChecker';
|
||||
|
||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
||||
|
||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
|
||||
await RedisStreamService.connect();
|
||||
|
||||
const stream_name = requireEnv('STREAM_NAME');
|
||||
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'}`
|
||||
}, processStreamEntry);
|
||||
|
||||
}
|
||||
|
||||
async function processStreamEntry(data: Record<string, string>) {
|
||||
try {
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const eventType = data._type;
|
||||
if (!eventType) return;
|
||||
|
||||
const { pid, sessionHash } = data;
|
||||
|
||||
const project = await ProjectModel.exists({ _id: pid });
|
||||
if (!project) return;
|
||||
|
||||
const canLog = await checkLimits(pid);
|
||||
if (!canLog) return;
|
||||
|
||||
if (eventType === 'event') {
|
||||
await process_event(data, sessionHash);
|
||||
} else if (eventType === 'keep_alive') {
|
||||
await process_keep_alive(data, sessionHash);
|
||||
} else if (eventType === 'visit') {
|
||||
await process_visit(data, sessionHash);
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// console.log('Entry processed in', duration, 'ms');
|
||||
|
||||
} catch (ex: any) {
|
||||
console.error('ERROR PROCESSING STREAM EVENT', ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
||||
|
||||
let referrerParsed;
|
||||
try {
|
||||
referrerParsed = new URL(referrer);
|
||||
} catch (ex) {
|
||||
referrerParsed = { hostname: referrer };
|
||||
}
|
||||
|
||||
const geoLocation = lookup(ip);
|
||||
|
||||
const userAgentParsed = UAParser(userAgent);
|
||||
|
||||
const device = userAgentParsed.device.type;
|
||||
|
||||
await Promise.all([
|
||||
VisitModel.create({
|
||||
project_id: pid, website, page, referrer: referrerParsed.hostname,
|
||||
browser: userAgentParsed.browser.name || 'NO_BROWSER',
|
||||
os: userAgentParsed.os.name || 'NO_OS',
|
||||
device: device ? device : (userAgentParsed.browser.name ? 'desktop' : undefined),
|
||||
session: sessionHash,
|
||||
flowHash,
|
||||
continent: geoLocation[0],
|
||||
country: geoLocation[1],
|
||||
}),
|
||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true }),
|
||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } })
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, instant, flowHash } = data;
|
||||
|
||||
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||
if (!existingSession) {
|
||||
await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'sessions': 1 } }, { upsert: true });
|
||||
}
|
||||
|
||||
if (instant == "true") {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 0 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
}, { upsert: true });
|
||||
} else {
|
||||
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
|
||||
$inc: { duration: 1 },
|
||||
flowHash,
|
||||
updated_at: Date.now()
|
||||
}, { upsert: true });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function process_event(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { name, metadata, pid, flowHash } = data;
|
||||
|
||||
let metadataObject;
|
||||
try {
|
||||
if (metadata) metadataObject = JSON.parse(metadata);
|
||||
} catch (ex) {
|
||||
metadataObject = { error: 'Error parsing metadata' }
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
EventModel.create({ project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash }),
|
||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
@@ -5,10 +5,6 @@
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node",
|
||||
"jest"
|
||||
],
|
||||
"paths": {
|
||||
"@schema/*": [
|
||||
"../shared/schema/*"
|
||||
@@ -21,14 +17,14 @@
|
||||
],
|
||||
"@functions/*": [
|
||||
"../shared/functions/*"
|
||||
],
|
||||
"@utils/*": [
|
||||
"../shared/utils/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"scripts/**/*.ts",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/utils.ts"
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
@@ -1,34 +1,50 @@
|
||||
ARG NODE_VERSION=21
|
||||
# Start with a minimal Node.js base image
|
||||
FROM node:21-alpine AS base
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine as base
|
||||
# Create a distinct build environment
|
||||
FROM base AS build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Build stage
|
||||
# Install pnpm globally with caching to avoid reinstalling if nothing has changed
|
||||
RUN npm i -g pnpm
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /home/app
|
||||
|
||||
FROM base as build
|
||||
# Copy only package-related files to leverage caching
|
||||
COPY --link ./dashboard/package.json ./dashboard/pnpm-lock.yaml ./dashboard/
|
||||
COPY --link ./lyx-ui/package.json ./lyx-ui/pnpm-lock.yaml ./lyx-ui/
|
||||
COPY --link ./shared/package.json ./shared/pnpm-lock.yaml ./shared/
|
||||
|
||||
COPY --link dashboard/package.json dashboard/pnpm-lock.yaml ./
|
||||
RUN npm install --production=false
|
||||
# Install dependencies for each package
|
||||
WORKDIR /home/app/lyx-ui
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --link dashboard/ ./
|
||||
# WORKDIR /home/app/shared
|
||||
# RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --link shared/ /home/shared
|
||||
WORKDIR /home/app/dashboard
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
ARG GOOGLE_AUTH_CLIENT_ID
|
||||
ENV GOOGLE_AUTH_CLIENT_ID=$GOOGLE_AUTH_CLIENT_ID
|
||||
# Now copy the rest of the source files
|
||||
WORKDIR /home/app
|
||||
|
||||
RUN npm run build
|
||||
RUN npm prune
|
||||
COPY --link ./dashboard ./dashboard
|
||||
COPY --link ./lyx-ui ./lyx-ui
|
||||
COPY --link ./shared ./shared
|
||||
|
||||
# Final stage
|
||||
# Build the dashboard
|
||||
WORKDIR /home/app/dashboard
|
||||
|
||||
FROM base
|
||||
RUN pnpm run build
|
||||
|
||||
COPY --from=build /home/app /home/app
|
||||
# Use a smaller base image for the final production build
|
||||
FROM node:21-alpine AS production
|
||||
|
||||
EXPOSE ${PORT}
|
||||
# Set the working directory for the production container
|
||||
WORKDIR /home/app
|
||||
|
||||
CMD [ "node", "/home/app/.output/server/index.mjs" ]
|
||||
# Copy the built application from the build stage
|
||||
COPY --from=build /home/app/dashboard/.output /home/app/.output
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "/home/app/.output/server/index.mjs"]
|
||||
@@ -6,7 +6,7 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
|
||||
<template>
|
||||
<LyxUiCard>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-text">
|
||||
@@ -18,8 +18,7 @@ const props = defineProps<{ title: string, sub?: string }>();
|
||||
</div>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div class="h-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
292
dashboard/components/FirstInteraction.vue
Normal file
292
dashboard/components/FirstInteraction.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
import 'highlight.js/styles/stackoverflow-dark.css';
|
||||
import hljs from 'highlight.js';
|
||||
import CardTitled from './CardTitled.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
firstInteraction: boolean,
|
||||
refreshInteraction: () => any
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
hljs.highlightAll();
|
||||
})
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
|
||||
|
||||
const createScriptText = () => {
|
||||
return [
|
||||
'<script defer ',
|
||||
`data-project="${activeProject.value?._id}" `,
|
||||
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||
'script>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(createScriptText());
|
||||
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
const scriptText = computed(() => {
|
||||
return [
|
||||
`<script defer data-project="${activeProject.value?._id.toString()}"`,
|
||||
`\nsrc="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">\n<`,
|
||||
`/script>`
|
||||
].join('');
|
||||
})
|
||||
|
||||
|
||||
function reloadPage() {
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div v-if="!firstInteraction && activeProject" class="mt-[5vh] flex flex-col">
|
||||
|
||||
<div class="flex gap-4 items-center justify-center">
|
||||
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
|
||||
<div class="text-text/90 poppins text-[1.3rem] font-semibold">
|
||||
Waiting for your first Visit or Event
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mt-4">
|
||||
<LyxUiButton type="primary" @click="reloadPage()">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="far fa-refresh"></i>
|
||||
<div> Reload </div>
|
||||
</div>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center mt-10">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex gap-6">
|
||||
<div>
|
||||
<CardTitled class="h-full" title="Tutorial" sub="Coming soon. For now enjoy our launch video.">
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<iframe width="560" height="315"
|
||||
src="https://www.youtube.com/embed/GntyWMR7jsY?si=YGGkQwrk6-Iqmn8w" title="Litlyx"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div>
|
||||
<CardTitled title="Quick Integration"
|
||||
sub="Start tracking web analytics in one line. (works everywhere js is supported)">
|
||||
<div class="flex flex-col items-end gap-4">
|
||||
<div class="w-full">
|
||||
<pre><code class="language-html">{{ scriptText }}</code></pre>
|
||||
</div>
|
||||
<LyxUiButton type="secondary" @click="copyScript()">
|
||||
Copy
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full">
|
||||
<CardTitled class="h-full w-full" title="Project id"
|
||||
sub="This is the identifier for this project, used to forward data">
|
||||
<div class="flex flex-col items-end">
|
||||
<div class="w-full text-[.9rem] text-[#acacac]"> {{ activeProject?._id }} </div>
|
||||
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<CardTitled class="w-full h-full" title="Documentation"
|
||||
sub="Learn how to use Litlyx in every tech stack">
|
||||
<div class="flex flex-col items-end">
|
||||
<div class="flex justify-center w-full">
|
||||
<svg width="680" height="100" viewBox="0 0 680 100" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="path-1-inside-1_473_1361" fill="white">
|
||||
<path
|
||||
d="M0 12C0 5.37258 5.37258 0 12 0H88C94.6274 0 100 5.37258 100 12V88C100 94.6274 94.6274 100 88 100H12C5.37258 100 0 94.6274 0 88V12Z" />
|
||||
</mask>
|
||||
<path
|
||||
d="M0 12C0 5.37258 5.37258 0 12 0H88C94.6274 0 100 5.37258 100 12V88C100 94.6274 94.6274 100 88 100H12C5.37258 100 0 94.6274 0 88V12Z"
|
||||
fill="#0A0A0A" />
|
||||
<path
|
||||
d="M0 12C0 4.8203 5.8203 -1 13 -1H87C94.1797 -1 100 4.8203 100 12C100 5.92487 94.6274 1 88 1H12C5.37258 1 0 5.92487 0 12ZM100 100H0H100ZM0 100V0V100ZM100 0V100V0Z"
|
||||
fill="#303246" mask="url(#path-1-inside-1_473_1361)" />
|
||||
<mask id="path-3-inside-2_473_1361" fill="white">
|
||||
<path
|
||||
d="M348 12C348 5.37258 353.373 0 360 0H436C442.627 0 448 5.37258 448 12V88C448 94.6274 442.627 100 436 100H360C353.373 100 348 94.6274 348 88V12Z" />
|
||||
</mask>
|
||||
<path
|
||||
d="M348 12C348 5.37258 353.373 0 360 0H436C442.627 0 448 5.37258 448 12V88C448 94.6274 442.627 100 436 100H360C353.373 100 348 94.6274 348 88V12Z"
|
||||
fill="#0A0A0A" />
|
||||
<path
|
||||
d="M348 12C348 4.8203 353.82 -1 361 -1H435C442.18 -1 448 4.8203 448 12C448 5.92487 442.627 1 436 1H360C353.373 1 348 5.92487 348 12ZM448 100H348H448ZM348 100V0V100ZM448 0V100V0Z"
|
||||
fill="#303246" mask="url(#path-3-inside-2_473_1361)" />
|
||||
<path
|
||||
d="M398 80C414.569 80 428 66.5685 428 50C428 33.4315 414.569 20 398 20C381.431 20 368 33.4315 368 50C368 66.5685 381.431 80 398 80Z"
|
||||
fill="white" />
|
||||
<path
|
||||
d="M417.836 72.5068L391.047 38H386V61.99H390.038V43.1278L414.666 74.9484C415.778 74.2045 416.836 73.3884 417.836 72.5068Z"
|
||||
fill="url(#paint0_linear_473_1361)" />
|
||||
<path d="M410.333 38H406.333V62H410.333V38Z"
|
||||
fill="url(#paint1_linear_473_1361)" />
|
||||
<mask id="path-8-inside-3_473_1361" fill="white">
|
||||
<path
|
||||
d="M116 12C116 5.37258 121.373 0 128 0H204C210.627 0 216 5.37258 216 12V88C216 94.6274 210.627 100 204 100H128C121.373 100 116 94.6274 116 88V12Z" />
|
||||
</mask>
|
||||
<path
|
||||
d="M116 12C116 5.37258 121.373 0 128 0H204C210.627 0 216 5.37258 216 12V88C216 94.6274 210.627 100 204 100H128C121.373 100 116 94.6274 116 88V12Z"
|
||||
fill="#0A0A0A" />
|
||||
<path
|
||||
d="M116 12C116 4.8203 121.82 -1 129 -1H203C210.18 -1 216 4.8203 216 12C216 5.92487 210.627 1 204 1H128C121.373 1 116 5.92487 116 12ZM216 100H116H216ZM116 100V0V100ZM216 0V100V0Z"
|
||||
fill="#303246" mask="url(#path-8-inside-3_473_1361)" />
|
||||
<path d="M182.2 27H193L166 73.575L139 27H159.655L166 37.8L172.21 27H182.2Z"
|
||||
fill="#41B883" />
|
||||
<path d="M139 27L166 73.575L193 27H182.2L166 54.945L149.665 27H139Z"
|
||||
fill="#41B883" />
|
||||
<path d="M149.665 27L166 55.08L182.2 27H172.21L166 37.8L159.655 27H149.665Z"
|
||||
fill="#35495E" />
|
||||
<path
|
||||
d="M53.6605 70H75.9651C76.6735 70.0001 77.3695 69.8153 77.983 69.4642C78.5965 69.1131 79.1059 68.6081 79.46 67.9999C79.8141 67.3918 80.0003 66.7019 80 65.9998C79.9997 65.2977 79.8128 64.608 79.4582 64.0002L64.4791 38.2859C64.1251 37.6779 63.6158 37.173 63.0024 36.8219C62.389 36.4709 61.6932 36.2861 60.9849 36.2861C60.2766 36.2861 59.5808 36.4709 58.9674 36.8219C58.354 37.173 57.8447 37.6779 57.4906 38.2859L53.6605 44.8653L46.1721 31.9995C45.8177 31.3916 45.3082 30.8867 44.6946 30.5358C44.0811 30.1848 43.3852 30 42.6767 30C41.9683 30 41.2724 30.1848 40.6588 30.5358C40.0453 30.8867 39.5357 31.3916 39.1814 31.9995L20.5418 64.0002C20.1872 64.608 20.0003 65.2977 20 65.9998C19.9997 66.7019 20.1859 67.3918 20.54 67.9999C20.8941 68.6081 21.4035 69.1131 22.017 69.4642C22.6305 69.8153 23.3265 70.0001 24.0349 70H38.0359C43.5832 70 47.6741 67.585 50.4891 62.8734L57.3233 51.143L60.9838 44.8653L71.9698 63.7222H57.3233L53.6605 70ZM37.8076 63.7158L28.0367 63.7136L42.6833 38.5724L49.9913 51.143L45.0983 59.545C43.2289 62.602 41.1051 63.7158 37.8076 63.7158Z"
|
||||
fill="#00DC82" />
|
||||
<mask id="path-14-inside-4_473_1361" fill="white">
|
||||
<path
|
||||
d="M464 12C464 5.37258 469.373 0 476 0H552C558.627 0 564 5.37258 564 12V88C564 94.6274 558.627 100 552 100H476C469.373 100 464 94.6274 464 88V12Z" />
|
||||
</mask>
|
||||
<path
|
||||
d="M464 12C464 5.37258 469.373 0 476 0H552C558.627 0 564 5.37258 564 12V88C564 94.6274 558.627 100 552 100H476C469.373 100 464 94.6274 464 88V12Z"
|
||||
fill="#0A0A0A" />
|
||||
<path
|
||||
d="M464 12C464 4.8203 469.82 -1 477 -1H551C558.18 -1 564 4.8203 564 12C564 5.92487 558.627 1 552 1H476C469.373 1 464 5.92487 464 12ZM564 100H464H564ZM464 100V0V100ZM564 0V100V0Z"
|
||||
fill="#303246" mask="url(#path-14-inside-4_473_1361)" />
|
||||
<path
|
||||
d="M514 55.299C517.088 55.299 519.591 52.7959 519.591 49.7081C519.591 46.6203 517.088 44.1172 514 44.1172C510.912 44.1172 508.409 46.6203 508.409 49.7081C508.409 52.7959 510.912 55.299 514 55.299Z"
|
||||
fill="#61DAFB" />
|
||||
<path
|
||||
d="M514 61.1625C530.569 61.1625 544 56.0341 544 49.708C544 43.3818 530.569 38.2534 514 38.2534C497.431 38.2534 484 43.3818 484 49.708C484 56.0341 497.431 61.1625 514 61.1625Z"
|
||||
stroke="#61DAFB" stroke-width="5" />
|
||||
<path
|
||||
d="M504.08 55.4353C512.364 69.7841 523.521 78.8519 529 75.6888C534.479 72.5257 532.204 58.3295 523.92 43.9808C515.636 29.632 504.479 20.5642 499 23.7273C493.521 26.8904 495.796 41.0865 504.08 55.4353Z"
|
||||
stroke="#61DAFB" stroke-width="5" />
|
||||
<path
|
||||
d="M504.08 43.9808C495.796 58.3296 493.521 72.5258 499 75.6888C504.479 78.8519 515.636 69.7841 523.92 55.4354C532.204 41.0866 534.479 26.8904 529 23.7273C523.521 20.5642 512.364 29.632 504.08 43.9808Z"
|
||||
stroke="#61DAFB" stroke-width="5" />
|
||||
<mask id="path-20-inside-5_473_1361" fill="white">
|
||||
<path
|
||||
d="M232 12C232 5.37258 237.373 0 244 0H320C326.627 0 332 5.37258 332 12V88C332 94.6274 326.627 100 320 100H244C237.373 100 232 94.6274 232 88V12Z" />
|
||||
</mask>
|
||||
<path
|
||||
d="M232 12C232 5.37258 237.373 0 244 0H320C326.627 0 332 5.37258 332 12V88C332 94.6274 326.627 100 320 100H244C237.373 100 232 94.6274 232 88V12Z"
|
||||
fill="#0A0A0A" />
|
||||
<path
|
||||
d="M232 12C232 4.8203 237.82 -1 245 -1H319C326.18 -1 332 4.8203 332 12C332 5.92487 326.627 1 320 1H244C237.373 1 232 5.92487 232 12ZM332 100H232H332ZM232 100V0V100ZM332 0V100V0Z"
|
||||
fill="#303246" mask="url(#path-20-inside-5_473_1361)" />
|
||||
<path
|
||||
d="M282 20C298.569 20 312 33.4314 312 50C312 66.5686 298.569 80 282 80C265.431 80 252 66.5686 252 50C252 33.4314 265.431 20 282 20Z"
|
||||
fill="black" />
|
||||
<path
|
||||
d="M281.327 64.6787C280.558 64.4713 279.766 64.9167 279.541 65.6761L279.531 65.7115L277.539 73.0943L277.53 73.1299C277.342 73.8995 277.802 74.6827 278.572 74.8901C279.341 75.0979 280.132 74.6525 280.357 73.8929L280.367 73.8577L282.359 66.4749L282.369 66.4391C282.382 66.3837 282.392 66.3279 282.399 66.2723L282.405 66.2167L282.358 65.9775L282.289 65.6331L282.245 65.4181C282.152 65.2379 282.022 65.0791 281.864 64.9517C281.706 64.8245 281.523 64.7315 281.327 64.6787ZM267.445 57.0757C267.408 57.1481 267.378 57.2245 267.353 57.3043L267.339 57.3525L265.347 64.7353L265.338 64.7711C265.15 65.5407 265.61 66.3237 266.38 66.5313C267.149 66.7389 267.941 66.2937 268.166 65.5341L268.176 65.4987L269.982 58.8045C269.036 58.3035 268.187 57.7255 267.445 57.0757ZM262.694 48.5857C261.925 48.3781 261.133 48.8233 260.908 49.5829L260.898 49.6183L258.906 57.0011L258.897 57.0367C258.709 57.8063 259.169 58.5893 259.939 58.7969C260.708 59.0045 261.499 58.5593 261.725 57.7997L261.734 57.7643L263.727 50.3815L263.736 50.3459C263.923 49.5763 263.463 48.7933 262.694 48.5857ZM307.364 46.9091C306.595 46.7015 305.803 47.1467 305.578 47.9063L305.568 47.9417L303.576 55.3245L303.567 55.3601C303.379 56.1297 303.839 56.9127 304.608 57.1203C305.378 57.3279 306.169 56.8827 306.394 56.1231L306.404 56.0877L308.396 48.7049L308.406 48.6693C308.593 47.8997 308.133 47.1167 307.364 46.9091ZM258.356 37.0504C256.687 40.0887 255.625 43.4223 255.228 46.8657C255.418 47.0823 255.668 47.2379 255.946 47.3125C256.715 47.5203 257.507 47.0749 257.732 46.3153L257.742 46.2801L259.734 38.8972L259.743 38.8616C259.931 38.0919 259.471 37.3088 258.701 37.1013C258.589 37.0708 258.472 37.0538 258.356 37.0504ZM302.318 37.1013C301.549 36.8936 300.757 37.3389 300.532 38.0985L300.522 38.1338L298.53 45.5167L298.521 45.5523C298.333 46.3219 298.793 47.1051 299.563 47.3125C300.332 47.5203 301.123 47.0749 301.349 46.3153L301.358 46.2801L303.351 38.8972L303.36 38.8616C303.547 38.0919 303.087 37.3088 302.318 37.1013Z"
|
||||
fill="white" />
|
||||
<path
|
||||
d="M267.026 30.0813C266.256 29.8736 265.465 30.319 265.24 31.0786L265.23 31.1138L263.238 38.4967L263.229 38.5323C263.041 39.302 263.501 40.085 264.27 40.2926C265.04 40.5002 265.831 40.0548 266.056 39.2953L266.066 39.26L268.058 31.8772L268.067 31.8416C268.255 31.0719 267.795 30.2888 267.026 30.0813ZM292.623 31.4769C291.854 31.2692 291.062 31.7145 290.837 32.4742L290.827 32.5094L289.489 37.47C290.356 37.8983 291.183 38.4025 291.962 38.9768L292.091 39.0729L293.656 33.2728L293.665 33.2372C293.852 32.4675 293.393 31.6844 292.623 31.4769ZM279.594 23.1528C278.659 23.2354 277.729 23.3668 276.809 23.5463L276.613 23.5853L274.756 30.4684L274.747 30.504C274.56 31.2737 275.02 32.0567 275.789 32.2643C276.558 32.4719 277.35 32.0266 277.575 31.267L277.585 31.2317L279.577 23.8489L279.586 23.8133C279.639 23.5966 279.642 23.3707 279.594 23.1528ZM297.925 28.2526L297.534 29.7034L297.525 29.7389C297.337 30.5086 297.797 31.2916 298.566 31.4992C299.336 31.7068 300.127 31.2615 300.352 30.5019L300.362 30.4666L300.405 30.3092C299.672 29.6241 298.902 28.9802 298.098 28.3804L297.925 28.2526ZM286.334 23.3935L285.628 26.0119L285.619 26.0475C285.431 26.8172 285.891 27.6002 286.661 27.8078C287.43 28.0154 288.221 27.5701 288.447 26.8105L288.456 26.7752L289.2 24.0193C288.325 23.7773 287.438 23.58 286.543 23.4281L286.334 23.3935Z"
|
||||
fill="white" />
|
||||
<path
|
||||
d="M271.382 69.2504C271.607 68.4908 272.398 68.0456 273.168 68.253C273.937 68.4604 274.397 69.2436 274.209 70.0134L274.2 70.049L272.774 75.3326L272.575 75.2592C271.717 74.9386 270.875 74.5744 270.054 74.1676L271.372 69.2856L271.382 69.2504Z"
|
||||
fill="white" />
|
||||
<path
|
||||
d="M280.828 36.9814C272.104 36.9814 265.318 42.4734 265.318 49.3032C265.318 55.7536 271.562 59.8722 281.242 59.666C282.065 59.6484 282.303 60.2014 282.571 60.9466C282.839 61.6918 283.559 65.6192 284.133 68.6232C284.647 71.3118 285.168 74.0102 285.567 76.719C291.888 75.8834 297.733 72.7612 302.015 68.052L297.447 51.0174C296.309 46.9034 294.978 43.1126 291.457 40.3586C288.624 38.1431 285.024 36.9814 280.828 36.9814Z"
|
||||
fill="white" />
|
||||
<path
|
||||
d="M282.703 41.9141C283.739 41.9141 284.578 42.7535 284.578 43.7891C284.578 44.8247 283.739 45.6641 282.703 45.6641C281.668 45.6641 280.828 44.8247 280.828 43.7891C280.828 42.7535 281.668 41.9141 282.703 41.9141Z"
|
||||
fill="black" />
|
||||
<mask id="path-28-inside-6_473_1361" fill="white">
|
||||
<path
|
||||
d="M580 12C580 5.37258 585.373 0 592 0H668C674.627 0 680 5.37258 680 12V88C680 94.6274 674.627 100 668 100H592C585.373 100 580 94.6274 580 88V12Z" />
|
||||
</mask>
|
||||
<path
|
||||
d="M580 12C580 5.37258 585.373 0 592 0H668C674.627 0 680 5.37258 680 12V88C680 94.6274 674.627 100 668 100H592C585.373 100 580 94.6274 580 88V12Z"
|
||||
fill="#0A0A0A" />
|
||||
<path
|
||||
d="M580 12C580 4.8203 585.82 -1 593 -1H667C674.18 -1 680 4.8203 680 12C680 5.92487 674.627 1 668 1H592C585.373 1 580 5.92487 580 12ZM680 100H580H680ZM580 100V0V100ZM680 0V100V0Z"
|
||||
fill="#303246" mask="url(#path-28-inside-6_473_1361)" />
|
||||
<path d="M655 25H605V75H655V25Z" fill="#F7DF1E" />
|
||||
<path
|
||||
d="M638.587 64.0625C639.594 65.7069 640.905 66.9156 643.222 66.9156C645.169 66.9156 646.413 65.9426 646.413 64.5982C646.413 62.9871 645.135 62.4164 642.992 61.4791L641.817 60.9752C638.427 59.5307 636.175 57.7212 636.175 53.8958C636.175 50.372 638.859 47.6895 643.056 47.6895C646.043 47.6895 648.19 48.7291 649.738 51.4514L646.079 53.8006C645.274 52.3561 644.405 51.7871 643.056 51.7871C641.679 51.7871 640.807 52.6601 640.807 53.8006C640.807 55.2101 641.68 55.7807 643.696 56.6537L644.871 57.1569C648.863 58.8688 651.117 60.6141 651.117 64.5379C651.117 68.768 647.794 71.0855 643.331 71.0855C638.967 71.0855 636.148 69.0061 634.769 66.2807L638.587 64.0625ZM621.99 64.4696C622.728 65.7791 623.399 66.8863 625.013 66.8863C626.557 66.8863 627.531 66.2823 627.531 63.9339V47.9577H632.229V63.9974C632.229 68.8625 629.377 71.0768 625.213 71.0768C621.452 71.0768 619.273 69.1299 618.165 66.7851L621.99 64.4696Z"
|
||||
fill="black" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_473_1361" x1="404.333" y1="58.8334"
|
||||
x2="416.167" y2="73.5" gradientUnits="userSpaceOnUse">
|
||||
<stop />
|
||||
<stop offset="1" stop-color="white" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_473_1361" x1="408.334" y1="38"
|
||||
x2="408.267" y2="55.6251" gradientUnits="userSpaceOnUse">
|
||||
<stop />
|
||||
<stop offset="1" stop-color="white" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<LyxUiButton type="secondary" to="https://docs.litlyx.com"> Visit documentation
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- <div class="flex justify-center gap-10 flex-col lg:flex-row items-center lg:items-stretch px-10">
|
||||
|
||||
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
|
||||
<div class="poppins font-semibold"> Copy your project_id: </div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div> <i @click="copyProjectId()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||
<div class="text-[.9rem] text-[#acacac]"> {{ activeProject?._id }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full lg:max-w-[40vw]">
|
||||
<div class="poppins font-semibold">
|
||||
Start logging visits in 1 click | Plug anywhere !
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div> <i @click="copyScript()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||
|
||||
<pre><code class="language-html">{{ scriptText }}</code></pre>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -5,6 +5,10 @@ import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
registerChartComponents();
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({
|
||||
errored: false,
|
||||
text: ''
|
||||
})
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
@@ -130,6 +134,7 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
||||
const selectLabels: { label: string, value: Slice }[] = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
|
||||
const selectedLabelIndex = ref<number>(1);
|
||||
@@ -157,19 +162,35 @@ const body = computed(() => {
|
||||
});
|
||||
|
||||
|
||||
function onResponseError(e: any) {
|
||||
console.log('ON RESPONSE ERROR')
|
||||
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
console.log('ON RESPONSE')
|
||||
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||
}
|
||||
|
||||
const visitsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/visits`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
lazy: true, immediate: false,
|
||||
onResponseError,
|
||||
onResponse
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
lazy: true, immediate: false,
|
||||
onResponseError,
|
||||
onResponse
|
||||
});
|
||||
|
||||
const sessionsData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/sessions`, {
|
||||
method: 'POST', ...signHeaders({ v2: 'true' }), body, transform: transformResponse,
|
||||
lazy: true, immediate: false
|
||||
lazy: true, immediate: false,
|
||||
onResponseError,
|
||||
onResponse
|
||||
});
|
||||
|
||||
|
||||
@@ -255,20 +276,20 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
|
||||
|
||||
const inLiveDemo = isLiveDemo();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardTitled title="Trend chart" sub="Easily match Visits, Unique sessions and Events trends." class="w-full">
|
||||
<template #header>
|
||||
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event"
|
||||
:currentIndex="selectedLabelIndex" :options="selectLabels">
|
||||
</SelectButton>
|
||||
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event" :currentIndex="selectedLabelIndex"
|
||||
:options="selectLabels">
|
||||
</SelectButton>
|
||||
</template>
|
||||
|
||||
<div class="flex gap-6 w-full justify-between">
|
||||
<LyxUiButton type="secondary" to="/analyst">
|
||||
<LyxUiButton type="secondary" :to="inLiveDemo ? '#' : '/analyst'" :disabled="inLiveDemo">
|
||||
<div class="flex items-center gap-2 px-10">
|
||||
<i class="far fa-sparkles text-yellow-400"></i>
|
||||
<div class="poppins text-lyx-text"> Ask AI </div>
|
||||
@@ -310,9 +331,14 @@ onMounted(async () => {
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end" v-if="readyToDisplay">
|
||||
<div class="flex flex-col items-end" 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>
|
||||
|
||||
</CardTitled>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ const props = defineProps<{
|
||||
ready?: boolean
|
||||
}>();
|
||||
|
||||
const { snapshotDuration } = useSnapshot()
|
||||
|
||||
const uTooltipText = computed(() => {
|
||||
const duration = snapshotDuration.value;
|
||||
if (!duration) return '';
|
||||
if (duration > 25) return 'Monthly trend';
|
||||
if (duration > 7) return 'Weekly trend';
|
||||
return 'Daily trend';
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,14 +39,17 @@ const props = defineProps<{
|
||||
<div class="poppins text-text-sub text-[.9rem] 2xl:text-base"> {{ text }} </div>
|
||||
</div>
|
||||
<div v-if="trend" class="flex flex-col items-center gap-1">
|
||||
<div class="flex items-center gap-3 rounded-xl px-2 py-1" :style="`background-color: ${props.color}33`">
|
||||
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'"
|
||||
class="far text-[.9rem] 2xl:text-[1rem]" :style="`color: ${props.color}`"></i>
|
||||
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
|
||||
{{ trend.toFixed(0) }} %
|
||||
<UTooltip :text="uTooltipText">
|
||||
<div class="flex items-center gap-3 rounded-xl px-2 py-1"
|
||||
:style="`background-color: ${props.color}33`">
|
||||
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'"
|
||||
class="far text-[.9rem] 2xl:text-[1rem]" :style="`color: ${props.color}`"></i>
|
||||
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
|
||||
{{ trend.toFixed(0) }} %
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.7rem]"> Trend </div>
|
||||
</UTooltip>
|
||||
<!-- <div class="poppins text-text-sub text-[.7rem]"> Trend </div> -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@ const chartData = ref<ChartData<'doughnut'>>({
|
||||
|
||||
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const activeProjectId = useActiveProjectId();
|
||||
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
@@ -102,12 +102,14 @@ const headers = computed(() => {
|
||||
return {
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
Authorization: authorizationHeaderComputed.value,
|
||||
limit: "10"
|
||||
'Authorization': authorizationHeaderComputed.value,
|
||||
'x-schema': 'events',
|
||||
'x-limit': "6",
|
||||
'x-pid': activeProjectId.data.value || ''
|
||||
}
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/metrics/${activeProject.value?._id}/data/events`, {
|
||||
const eventsData = useFetch(`/api/data/query`, {
|
||||
method: 'POST', headers, lazy: true, immediate: false, transform: transformResponse
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ function showAnomalyInfoAlert() {
|
||||
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
|
||||
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
|
||||
Litlyx will notify you via email with actionable advices`,
|
||||
'far fa-bug',
|
||||
'far fa-shield',
|
||||
10000
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ function showAnomalyInfoAlert() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full px-6 py-2 lg:py-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg lg:text-2xl gap-2 xl:gap-12">
|
||||
class="w-full px-6 py-2 lg:py-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg gap-2 xl:gap-12 lg:text-2xl">
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
|
||||
@@ -76,7 +76,6 @@ async function confirmSnapshot() {
|
||||
</div>
|
||||
|
||||
<div class="mt-4 justify-center flex w-full">
|
||||
|
||||
<UPopover class="w-full" :popper="{ placement: 'bottom' }">
|
||||
<UButton class="w-full" color="primary" variant="solid">
|
||||
<div class="flex items-center justify-center w-full gap-2">
|
||||
@@ -97,8 +96,6 @@ async function confirmSnapshot() {
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
148
dashboard/components/events/EventsFunnelChart.vue
Normal file
148
dashboard/components/events/EventsFunnelChart.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { defineChartComponent } from 'vue-chart-3';
|
||||
registerChartComponents();
|
||||
|
||||
const FunnelChart = defineChartComponent('funnel', 'funnel');
|
||||
|
||||
const chartOptions = ref<ChartOptions<'funnel'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleFont: { size: 16, weight: 'bold' },
|
||||
bodyFont: { size: 14 },
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
boxPadding: 10,
|
||||
caretPadding: 20,
|
||||
yAlign: 'bottom',
|
||||
xAlign: 'center',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const chartData = ref<ChartData<'funnel'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [],
|
||||
backgroundColor: ['#5680F8' + '77'],
|
||||
// borderColor: '#0000CC',
|
||||
// borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#5680F8',
|
||||
// hoverBorderColor: 'white',
|
||||
// hoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
// const c = document.createElement('canvas');
|
||||
// const ctx = c.getContext("2d");
|
||||
// let gradient: any = `${'#0000CC'}22`;
|
||||
// if (ctx) {
|
||||
// gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
// gradient.addColorStop(0, `${'#0000CC'}99`);
|
||||
// gradient.addColorStop(0.35, `${'#0000CC'}66`);
|
||||
// gradient.addColorStop(1, `${'#0000CC'}22`);
|
||||
// } else {
|
||||
// console.warn('Cannot get context for gradient');
|
||||
// }
|
||||
|
||||
// chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
});
|
||||
|
||||
const activeProjectId = useActiveProjectId();
|
||||
const { safeSnapshotDates } = useSnapshot();
|
||||
|
||||
const eventsCount = await useFetch<{ _id: string, count: number }[]>(`/api/data/query`, {
|
||||
...signHeaders({
|
||||
'x-pid': activeProjectId.data.value || '',
|
||||
'x-schema': 'events',
|
||||
'x-from': safeSnapshotDates.value.from,
|
||||
'x-to': safeSnapshotDates.value.to,
|
||||
'x-query-limit': '1000'
|
||||
}), lazy: true
|
||||
});
|
||||
|
||||
|
||||
const enabledEvents = ref<string[]>([]);
|
||||
|
||||
async function onEventCheck(eventName: string) {
|
||||
const index = enabledEvents.value.indexOf(eventName);
|
||||
if (index == -1) {
|
||||
enabledEvents.value.push(eventName);
|
||||
} else {
|
||||
enabledEvents.value.splice(index, 1);
|
||||
}
|
||||
|
||||
|
||||
chartData.value.labels = enabledEvents.value;
|
||||
chartData.value.datasets[0].data = [];
|
||||
|
||||
for (const enabledEvent of enabledEvents.value) {
|
||||
const target = (eventsCount.data.value ?? []).find(e => e._id == enabledEvent);
|
||||
chartData.value.datasets[0].data.push(target?.count || 0);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<CardTitled title="Funnel" sub="Funnel events">
|
||||
<div class="flex gap-2 justify-between">
|
||||
<div>
|
||||
<div class="min-w-[20rem]">
|
||||
Select two or more events
|
||||
</div>
|
||||
<div v-for="event of eventsCount.data.value">
|
||||
<UCheckbox @change="onEventCheck(event._id)" :value="enabledEvents.includes(event._id)"
|
||||
:label="event._id">
|
||||
</UCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<FunnelChart :chart-data="chartData" :options="chartOptions"> </FunnelChart>
|
||||
</div>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</template>
|
||||
@@ -32,26 +32,26 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
|
||||
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++) {
|
||||
@@ -74,8 +74,26 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
|
||||
}
|
||||
}
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({
|
||||
errored: false,
|
||||
text: ''
|
||||
})
|
||||
|
||||
|
||||
function onResponseError(e: any) {
|
||||
console.log('ON RESPONSE ERROR')
|
||||
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
console.log('ON RESPONSE')
|
||||
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||
}
|
||||
|
||||
const eventsStackedData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events_stacked`, {
|
||||
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse, ...signHeaders()
|
||||
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse, ...signHeaders(),
|
||||
onResponseError,
|
||||
onResponse
|
||||
});
|
||||
|
||||
|
||||
@@ -86,13 +104,17 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-full">
|
||||
<div v-if="eventsStackedData.pending.value" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value"
|
||||
<AdvancedStackedBarChart v-if="!eventsStackedData.pending.value && !errorData.errored"
|
||||
:datasets="eventsStackedData.data.value?.datasets || []"
|
||||
:labels="eventsStackedData.data.value?.labels || []">
|
||||
</AdvancedStackedBarChart>
|
||||
<div v-if="errorData.errored" class="flex items-center justify-center py-8 h-full">
|
||||
{{ errorData.text }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
157
dashboard/components/integrations/SupabaseChartDialog.vue
Normal file
157
dashboard/components/integrations/SupabaseChartDialog.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { sub, isSameDay, type Duration } from 'date-fns'
|
||||
|
||||
type ChartType = 'bar' | 'line';
|
||||
const chartTypeOptions: { value: ChartType, label: string }[] = [
|
||||
{ value: 'bar', label: 'Bar chart' },
|
||||
{ value: 'line', label: 'Line chart' },
|
||||
]
|
||||
|
||||
type yAxisMode = 'count';
|
||||
const yAxisModeOptions: { value: yAxisMode, label: string }[] = [
|
||||
{ value: 'count', label: 'Count fields' },
|
||||
]
|
||||
|
||||
type Slice = 'day' | 'month';
|
||||
const sliceOptions: Slice[] = ['day', 'month'];
|
||||
|
||||
const chartType = ref<ChartType>('line');
|
||||
const tableName = ref<string>('');
|
||||
const xAxis = ref<string>('');
|
||||
const yAxisMode = ref<yAxisMode>('count');
|
||||
const slice = ref<Slice>('day');
|
||||
const visualizationName = ref<string>('');
|
||||
|
||||
|
||||
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 timeframe = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||
|
||||
function isRangeSelected(duration: Duration) {
|
||||
return isSameDay(timeframe.value.start, sub(new Date(), duration)) && isSameDay(timeframe.value.end, new Date())
|
||||
}
|
||||
|
||||
function selectRange(duration: Duration) {
|
||||
timeframe.value = { start: sub(new Date(), duration), end: new Date() }
|
||||
}
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
const { closeDialog } = useCustomDialog();
|
||||
const activeProjectId = useActiveProjectId();
|
||||
|
||||
const { integrationsCredentials,testConnection } = useSupabase();
|
||||
|
||||
async function generate() {
|
||||
const credentials = integrationsCredentials.data.value;
|
||||
if (!credentials?.supabase) return createAlert('Credentials not found', 'Please add supabase credentials on the integration page', 'far fa-error', 5000);
|
||||
const connectionStatus = await testConnection();
|
||||
if (!connectionStatus) return createAlert('Invalid supabase credentials', 'Please check your supabase credentials on the integration page', 'far fa-error', 5000);
|
||||
|
||||
try {
|
||||
const creation = await $fetch('/api/integrations/supabase/add', {
|
||||
...signHeaders({
|
||||
'x-pid': activeProjectId.data.value || '',
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: visualizationName.value,
|
||||
chart_type: chartType.value,
|
||||
table_name: tableName.value,
|
||||
xField: xAxis.value,
|
||||
yMode: yAxisMode.value,
|
||||
from: timeframe.value.start,
|
||||
to: timeframe.value.end,
|
||||
slice: slice.value
|
||||
})
|
||||
})
|
||||
|
||||
createAlert('Integration generated', 'Integration generated successfully', 'far fa-check-circle', 5000);
|
||||
closeDialog();
|
||||
} catch (ex: any) {
|
||||
createAlert('Error generating integrations', ex.response._data.message.toString(), 'far fa-error', 5000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div> Visualization name </div>
|
||||
<div>
|
||||
<LyxUiInput class="w-full px-2 py-1" v-model="visualizationName"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div> Chart type </div>
|
||||
<USelect v-model="chartType" :options="chartTypeOptions" />
|
||||
</div>
|
||||
<div>
|
||||
<div> Table name </div>
|
||||
<div>
|
||||
<LyxUiInput class="w-full px-2 py-1" v-model="tableName"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div> X axis field </div>
|
||||
<div>
|
||||
<LyxUiInput class="w-full px-2 py-1" v-model="xAxis"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div> Y axis mode </div>
|
||||
<div>
|
||||
<USelect v-model="yAxisMode" :options="yAxisModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div> Timeframe </div>
|
||||
<div>
|
||||
<UPopover class="w-full" :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>
|
||||
{{ timeframe.start.toLocaleDateString() }} - {{ timeframe.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="timeframe" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div> View mode </div>
|
||||
<div>
|
||||
<USelect v-model="slice" :options="sliceOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiButton type="primary" @click="generate()">
|
||||
Generate
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
170
dashboard/components/integrations/SupabaseLineChart.vue
Normal file
170
dashboard/components/integrations/SupabaseLineChart.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import type { TSupabaseIntegration } from '@schema/integrations/SupabaseIntegrationSchema';
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
|
||||
const props = defineProps<{ integration_id: string }>();
|
||||
|
||||
const activeProjectId = useActiveProjectId();
|
||||
|
||||
const supabaseData = ref<{ labels: string[], data: number[] }>();
|
||||
const supabaseError = ref<string | undefined>(undefined);
|
||||
const supabaseFetching = ref<boolean>(false);
|
||||
|
||||
const { getRemoteData } = useSupabase();
|
||||
|
||||
function createGradient() {
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `#34B67C22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `#34B67C99`);
|
||||
gradient.addColorStop(0.35, `#34B67C66`);
|
||||
gradient.addColorStop(1, `#34B67C22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
}
|
||||
|
||||
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleFont: { size: 16, weight: 'bold' },
|
||||
bodyFont: { size: 14 },
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
boxPadding: 10,
|
||||
caretPadding: 20,
|
||||
yAlign: 'bottom',
|
||||
xAlign: 'center',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const chartData = ref<ChartData<'line'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [],
|
||||
backgroundColor: ['#34B67C' + '77'],
|
||||
borderColor: '#34B67C',
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#34B67C',
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
supabaseFetching.value = true;
|
||||
supabaseError.value = undefined;
|
||||
|
||||
const integrationData = await $fetch<TSupabaseIntegration>('/api/integrations/supabase/get', {
|
||||
...signHeaders({
|
||||
'x-pid': activeProjectId.data.value || '',
|
||||
'x-integration': props.integration_id
|
||||
})
|
||||
});
|
||||
|
||||
if (!integrationData) {
|
||||
supabaseError.value = 'Cannot get integration data';
|
||||
supabaseFetching.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await getRemoteData(
|
||||
integrationData.table_name,
|
||||
integrationData.xField,
|
||||
integrationData.yMode,
|
||||
integrationData.from.toString(),
|
||||
integrationData.to.toString(),
|
||||
integrationData.slice,
|
||||
);
|
||||
if (data.error) {
|
||||
supabaseError.value = data.error;
|
||||
supabaseFetching.value = false;
|
||||
return;
|
||||
}
|
||||
supabaseFetching.value = false;
|
||||
supabaseData.value = data.result;
|
||||
|
||||
chartData.value.labels = data.result?.labels || [];
|
||||
chartData.value.datasets[0].data = data.result?.data || [];
|
||||
|
||||
console.log(data.result);
|
||||
createGradient();
|
||||
} catch (ex: any) {
|
||||
if (!ex.response._data) {
|
||||
supabaseError.value = ex.message.toString();
|
||||
supabaseFetching.value = false;
|
||||
} else {
|
||||
supabaseError.value = ex.response._data.message.toString();
|
||||
supabaseFetching.value = false;
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div v-if="!supabaseFetching">
|
||||
<div v-if="!supabaseError">
|
||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
</div>
|
||||
<div v-if="supabaseError"> {{ supabaseError }} </div>
|
||||
</div>
|
||||
<div v-if="supabaseFetching">
|
||||
Getting remote data...
|
||||
</div>
|
||||
</template>
|
||||
@@ -110,6 +110,8 @@ async function deleteProject() {
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
const activeProjectId = useActiveProjectId()
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
|
||||
@@ -117,7 +119,7 @@ function copyScript() {
|
||||
const createScriptText = () => {
|
||||
return [
|
||||
'<script defer ',
|
||||
`data-project="${activeProject.value?._id}" `,
|
||||
`data-project="${activeProjectId.data.value}" `,
|
||||
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||
'script>'
|
||||
].join('')
|
||||
@@ -130,7 +132,7 @@ function copyScript() {
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||
navigator.clipboard.writeText(activeProjectId.data.value || '');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
@@ -186,7 +188,7 @@ function copyProjectId() {
|
||||
<div><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||
</LyxUiCard>
|
||||
</template>
|
||||
<template #pdelete >
|
||||
<template #pdelete>
|
||||
<div class="flex justify-end" v-if="!isGuest">
|
||||
<LyxUiButton type="danger" @click="deleteProject()">
|
||||
Delete project
|
||||
|
||||
@@ -23,7 +23,7 @@ const props = defineProps<SettingsTemplateProp>();
|
||||
<div class="poppins font-medium text-lyx-text">
|
||||
{{ entry.title }}
|
||||
</div>
|
||||
<div class="poppins font-regular text-lyx-text-dark">
|
||||
<div class="poppins font-regular text-lyx-text-dark whitespace-pre-wrap">
|
||||
{{ entry.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,11 @@ const activeProject = useActiveProject();
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { data: planData, refresh: planRefresh, pending: planPending } = useFetch('/api/project/plan', {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
...signHeaders(), lazy: true
|
||||
});
|
||||
|
||||
const { data: customerAddress, refresh: refreshCustomerAddress } = useFetch(`/api/pay/${activeProject.value?._id.toString()}/customer_info`, {
|
||||
...signHeaders(), lazy: true
|
||||
});
|
||||
|
||||
const percent = computed(() => {
|
||||
@@ -43,8 +46,7 @@ const prettyExpireDate = computed(() => {
|
||||
|
||||
|
||||
const { data: invoices, refresh: invoicesRefresh, pending: invoicesPending } = useFetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, {
|
||||
...signHeaders(),
|
||||
lazy: true
|
||||
...signHeaders(), lazy: true
|
||||
})
|
||||
|
||||
function openInvoice(link: string) {
|
||||
@@ -65,25 +67,50 @@ function getPremiumPrice(type: number) {
|
||||
return (PLAN.COST / 100).toFixed(2).replace('.', ',')
|
||||
}
|
||||
|
||||
|
||||
watch(activeProject, () => {
|
||||
invoicesRefresh();
|
||||
planRefresh();
|
||||
})
|
||||
|
||||
|
||||
const entries: SettingsTemplateEntry[] = [
|
||||
// { id: 'info', title: 'Billing informations', text: 'Manage billing informations for this project' },
|
||||
{ id: 'plan', title: 'Current plan', text: 'Manage current plat for this project' },
|
||||
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
|
||||
{ id: 'info', title: 'Billing address', text: 'This will be reflected in every upcoming invoice,\npast invoices are not affected' },
|
||||
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
|
||||
]
|
||||
|
||||
watch(customerAddress, () => {
|
||||
console.log('UPDATE',customerAddress.value)
|
||||
if (!customerAddress.value) return;
|
||||
currentBillingInfo.value = customerAddress.value;
|
||||
});
|
||||
|
||||
const currentBillingInfo = ref<any>({
|
||||
address: ''
|
||||
line1: '',
|
||||
line2: '',
|
||||
city: '',
|
||||
country: '',
|
||||
postal_code: '',
|
||||
state: ''
|
||||
});
|
||||
|
||||
const { createAlert } = useAlert()
|
||||
|
||||
async function saveBillingInfo() {
|
||||
|
||||
try {
|
||||
const res = await $fetch(`/api/pay/${activeProject.value?._id.toString()}/update_customer`, {
|
||||
method: 'POST',
|
||||
...signHeaders({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify(currentBillingInfo.value)
|
||||
});
|
||||
|
||||
createAlert('Customer updated','Customer updated successfully', 'far fa-check', 5000);
|
||||
|
||||
} catch(ex) {
|
||||
createAlert('Error updating customer','An error occurred while updating the customer', 'far fa-error', 8000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
|
||||
</script>
|
||||
@@ -97,6 +124,35 @@ const { visible } = usePricingDrawer();
|
||||
</div>
|
||||
|
||||
<SettingsTemplate v-if="!invoicesPending && !planPending" :entries="entries">
|
||||
<template #info>
|
||||
<div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<LyxUiInput class="px-2 py-1" placeholder="Address line 1" v-model="currentBillingInfo.line1">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-1" placeholder="Address line 2" v-model="currentBillingInfo.line2">
|
||||
</LyxUiInput>
|
||||
<div class="flex gap-2 w-full">
|
||||
<LyxUiInput class="px-2 py-1 w-full" placeholder="Country"
|
||||
v-model="currentBillingInfo.country">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-1 w-full" placeholder="Postal code"
|
||||
v-model="currentBillingInfo.postal_code">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
<div class="flex gap-2 w-full">
|
||||
<LyxUiInput class="px-2 py-1 w-full" placeholder="City" v-model="currentBillingInfo.city">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-1 w-full" placeholder="State" v-model="currentBillingInfo.state">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<LyxUiButton type="primary" @click="saveBillingInfo">
|
||||
Save
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #plan>
|
||||
<LyxUiCard v-if="planData" class="flex flex-col w-full">
|
||||
<div class="flex flex-col gap-6 px-8 grow">
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import annotaionPlugin from 'chartjs-plugin-annotation';
|
||||
|
||||
let registered = false;
|
||||
export async function registerChartComponents() {
|
||||
if (registered) return;
|
||||
if (process.client) {
|
||||
Chart.register(...registerables, annotaionPlugin);
|
||||
registered = true;
|
||||
}
|
||||
console.log('registerChartComponents is deprecated. Plugin is now used');
|
||||
registered = true;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
const ACCESS_TOKEN_STATE_KEY = 'access_token';
|
||||
const ACCESS_TOKEN_COOKIE_KEY = 'access_token';
|
||||
|
||||
|
||||
export function signHeaders(headers?: Record<string, string>) {
|
||||
const { token } = useAccessToken()
|
||||
return { headers: { ...(headers || {}), 'Authorization': 'Bearer ' + token.value } }
|
||||
|
||||
@@ -5,9 +5,9 @@ export function isLiveDemo() {
|
||||
return route.path == '/live_demo';
|
||||
}
|
||||
|
||||
const liveDemoData = useFetch('/api/live_demo');
|
||||
|
||||
export function useLiveDemo() {
|
||||
return useFetch('/api/live_demo', {
|
||||
key: 'live_demo_project'
|
||||
});
|
||||
return liveDemoData;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ function startWatching(instant: boolean = true) {
|
||||
if (instant) getOnlineUsers();
|
||||
watching = setInterval(async () => {
|
||||
await getOnlineUsers();
|
||||
}, 5000);
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
function stopWatching() {
|
||||
|
||||
@@ -68,9 +68,15 @@ async function updateSnapshots() {
|
||||
await remoteSnapshots.refresh();
|
||||
}
|
||||
|
||||
const snapshotDuration = computed(() => {
|
||||
const from = new Date(snapshot.value?.from || 0).getTime();
|
||||
const to = new Date(snapshot.value?.to || 0).getTime();
|
||||
return (to - from) / (1000 * 60 * 60 * 24);
|
||||
});
|
||||
|
||||
export function useSnapshot() {
|
||||
if (remoteSnapshots.status.value === 'idle') {
|
||||
remoteSnapshots.execute();
|
||||
}
|
||||
return { snapshot, snapshots, safeSnapshotDates, updateSnapshots }
|
||||
return { snapshot, snapshots, safeSnapshotDates, updateSnapshots, snapshotDuration }
|
||||
}
|
||||
125
dashboard/composables/useSupabase.ts
Normal file
125
dashboard/composables/useSupabase.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
|
||||
|
||||
import type { TSupabaseIntegration } from "@schema/integrations/SupabaseIntegrationSchema";
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const activeProjectId = useActiveProjectId();
|
||||
|
||||
|
||||
const computedHeaders = computed<Record<string, string>>(() => {
|
||||
const signedHeaders = signHeaders();
|
||||
return {
|
||||
'x-pid': activeProjectId.data.value || '',
|
||||
'Authorization': signedHeaders.headers.Authorization
|
||||
}
|
||||
})
|
||||
|
||||
const integrationsCredentials = useFetch(`/api/integrations/credentials/get`, {
|
||||
headers: computedHeaders,
|
||||
onResponse: (e) => {
|
||||
supabaseUrl.value = e.response._data.supabase.url || '';
|
||||
supabaseAnonKey.value = e.response._data.supabase.anon_key || '';
|
||||
supabaseServiceRoleKey.value = e.response._data.supabase.service_role_key || '';
|
||||
}
|
||||
});
|
||||
|
||||
const supabaseUrl = ref<string>('');
|
||||
const supabaseAnonKey = ref<string>('');
|
||||
const supabaseServiceRoleKey = ref<string>('');
|
||||
|
||||
const supabaseIntegrations = useFetch<TSupabaseIntegration[]>('/api/integrations/supabase/list', { headers: computedHeaders })
|
||||
|
||||
|
||||
const subabaseClientData: { client: SupabaseClient | undefined } = {
|
||||
client: undefined
|
||||
}
|
||||
|
||||
async function updateIntegrationsCredentails(data: { supabase_url: string, supabase_anon_key: string, supabase_service_role_key: string }) {
|
||||
try {
|
||||
await $fetch(`/api/integrations/credentials/${activeProjectId.data.value}/update`, {
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
supabase_url: data.supabase_url,
|
||||
supabase_anon_key: data.supabase_anon_key,
|
||||
supabase_service_role_key: data.supabase_service_role_key
|
||||
}),
|
||||
});
|
||||
integrationsCredentials.refresh();
|
||||
return { ok: true, error: '' }
|
||||
} catch (ex: any) {
|
||||
return { ok: false, error: ex.message.toString() };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function createSupabaseUrl(supabaseUrl: string) {
|
||||
let result = supabaseUrl;
|
||||
if (!result.includes('https://')) result = `https://${result}`;
|
||||
if (!result.endsWith('.supabase.co')) result = `${result}.supabase.co`;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
async function testConnection() {
|
||||
const url = createSupabaseUrl(supabaseUrl.value);
|
||||
subabaseClientData.client = createClient(url, supabaseAnonKey.value);
|
||||
const res = await subabaseClientData.client.from('_t_e_s_t_').select('*').limit(1);
|
||||
if (res.error?.message.startsWith('TypeError')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
type GroupBy = 'day' | 'month';
|
||||
|
||||
const groupByDate = (data: string[], groupBy: GroupBy) => {
|
||||
return data.reduce((acc, item) => {
|
||||
const date = new Date(item);
|
||||
const dateKey = groupBy === 'day'
|
||||
? format(date, 'yyyy-MM-dd') // Group by day
|
||||
: format(date, 'yyyy-MM'); // Group by month
|
||||
|
||||
if (!acc[dateKey]) { acc[dateKey] = []; }
|
||||
|
||||
acc[dateKey].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
}
|
||||
|
||||
async function getRemoteData(table: string, xField: string, yMode: string, from: string, to: string, slice: string) {
|
||||
const url = createSupabaseUrl(supabaseUrl.value);
|
||||
subabaseClientData.client = createClient(url, supabaseAnonKey.value);
|
||||
const res = await subabaseClientData.client.from(table).select(xField)
|
||||
.filter(xField, 'gte', from)
|
||||
.filter(xField, 'lte', to);
|
||||
|
||||
if (res.error) return { error: res.error.message };
|
||||
|
||||
const grouped = groupByDate(res.data.map((e: any) => e.created_at) || [], slice as any);
|
||||
|
||||
const result: { labels: string[], data: number[] } = { labels: [], data: [] }
|
||||
|
||||
for (const key in grouped) {
|
||||
result.labels.push(key);
|
||||
result.data.push(grouped[key].length);
|
||||
}
|
||||
|
||||
|
||||
return { result };
|
||||
}
|
||||
|
||||
export function useSupabase() {
|
||||
|
||||
return {
|
||||
getRemoteData,
|
||||
testConnection,
|
||||
supabaseIntegrations, integrationsCredentials,
|
||||
supabaseUrl, supabaseAnonKey,
|
||||
supabaseServiceRoleKey,
|
||||
updateIntegrationsCredentails
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,9 +18,10 @@ const sections: Section[] = [
|
||||
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
|
||||
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
|
||||
{ label: 'AI Analyst', to: '/analyst', icon: 'fal fa-sparkles' },
|
||||
{ label: 'Security', to: '/security', icon: 'fal fa-shield' },
|
||||
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
|
||||
{ label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
|
||||
{ label: 'Integrations (soon)', to: '#', icon: 'fal fa-cube', disabled: true },
|
||||
{ label: 'Integrations (soon)', to: '/integrations', icon: 'fal fa-cube', disabled: true },
|
||||
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||
{
|
||||
grow: true,
|
||||
@@ -29,7 +30,7 @@ const sections: Section[] = [
|
||||
},
|
||||
{
|
||||
label: 'Slack support', icon: 'fab fa-slack',
|
||||
to:'#',
|
||||
to: '#',
|
||||
premiumOnly: true,
|
||||
action() {
|
||||
if (isGuest.value === true) return;
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineNuxtConfig({
|
||||
postcss: {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
autoprefixer: {},
|
||||
}
|
||||
},
|
||||
colorMode: {
|
||||
@@ -60,6 +60,9 @@ export default defineNuxtConfig({
|
||||
nitro: {
|
||||
plugins: ['~/server/init.ts']
|
||||
},
|
||||
plugins: [
|
||||
{ src: '~/plugins/chartjs.ts', mode: 'client' }
|
||||
],
|
||||
...gooleSignInConfig,
|
||||
modules: ['@nuxt/ui', 'nuxt-vue3-google-signin'],
|
||||
devServer: {
|
||||
|
||||
@@ -10,16 +10,20 @@
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest",
|
||||
"docker-build": "docker build -t litlyx-dashboard -f Dockerfile ../",
|
||||
"docker-inspect": "docker run -it litlyx-dashboard sh"
|
||||
"docker-inspect": "docker run -it litlyx-dashboard sh",
|
||||
"docker-run": "docker run -p 3000:3000 litlyx-dashboard"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||
"@supabase/supabase-js": "^2.45.4",
|
||||
"chart.js": "^3.9.1",
|
||||
"chartjs-chart-funnel": "^4.2.1",
|
||||
"chartjs-plugin-annotation": "^2.2.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"google-auth-library": "^9.9.0",
|
||||
"highlight.js": "^11.10.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"litlyx-js": "^1.0.2",
|
||||
"mongoose": "^8.3.2",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import EventsFunnelChart from '~/components/events/EventsFunnelChart.vue';
|
||||
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
@@ -20,15 +22,16 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row">
|
||||
<div class="flex gap-6 flex-col xl:flex-row h-full">
|
||||
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-[4] w-full" title="Events" sub="Events stacked bar chart.">
|
||||
<CardTitled :key="refreshKey" class="p-4 flex-[4] w-full h-full" title="Events"
|
||||
sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||
</SelectButton>
|
||||
</template>
|
||||
<div>
|
||||
<div class="h-full">
|
||||
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
||||
</EventsStackedBarChart>
|
||||
</div>
|
||||
@@ -41,6 +44,10 @@ const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProje
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<EventsFunnelChart :key="refreshKey" class="w-full"></EventsFunnelChart>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<EventsUserFlow :key="refreshKey"></EventsUserFlow>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,10 @@ const limitsInfo = ref<{
|
||||
percent: number
|
||||
}>();
|
||||
|
||||
const justLogged = computed(() => {
|
||||
return route.query.just_logged;
|
||||
})
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
if (route.query.just_logged) return location.href = '/';
|
||||
@@ -30,33 +34,6 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||
|
||||
|
||||
const createScriptText = () => {
|
||||
return [
|
||||
'<script defer ',
|
||||
`data-project="${activeProject.value?._id}" `,
|
||||
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||
'script>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(createScriptText());
|
||||
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
const firstInteractionUrl = computed(() => {
|
||||
return `/api/metrics/${activeProject.value?._id}/first_interaction`
|
||||
});
|
||||
@@ -95,7 +72,8 @@ function goToUpgrade() {
|
||||
|
||||
<div class="dashboard w-full h-full overflow-y-auto pb-20 md:pt-4 lg:pt-0">
|
||||
|
||||
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value">
|
||||
<div :key="'home-' + isLiveDemo()"
|
||||
v-if="projects && activeProject && (firstInteraction.data.value === true) && !justLogged">
|
||||
|
||||
<div class="w-full px-4 py-2 gap-2 flex flex-col">
|
||||
<div v-if="limitsInfo && limitsInfo.limited"
|
||||
@@ -215,50 +193,15 @@ function goToUpgrade() {
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="!firstInteraction.data.value && activeProject" class="mt-[20vh] lg:mt-[36vh] flex flex-col gap-6">
|
||||
<div class="flex gap-4 items-center justify-center">
|
||||
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
|
||||
<div class="text-text/90 poppins text-[1.3rem] font-semibold">
|
||||
Waiting for your first Visit or Event
|
||||
</div>
|
||||
</div>
|
||||
<FirstInteraction v-if="!justLogged" :refresh-interaction="firstInteraction.refresh"
|
||||
:first-interaction="(firstInteraction.data.value || false)"></FirstInteraction>
|
||||
|
||||
<div class="flex justify-center gap-10 flex-col lg:flex-row items-center lg:items-stretch px-10">
|
||||
|
||||
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
|
||||
<div class="poppins font-semibold"> Copy your project_id: </div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div> <i @click="copyProjectId()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||
<div class="text-[.9rem] text-[#acacac]"> {{ activeProject?._id }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full lg:max-w-[40vw]">
|
||||
<div class="poppins font-semibold">
|
||||
Start logging visits in 1 click | Plug anywhere !
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div> <i @click="copyScript()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
|
||||
<div class="text-[.9rem] text-[#acacac] lg:w-min">
|
||||
{{ `
|
||||
<script defer data-project="${activeProject?._id}"
|
||||
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<NuxtLink to="https://docs.litlyx.com" target="_blank"
|
||||
class="flex justify-center poppins text-[1.2rem] text-accent gap-2 items-center">
|
||||
<div> <i class="far fa-book"></i> </div>
|
||||
<div class="poppins"> Go to docs </div>
|
||||
</NuxtLink>
|
||||
<div class="text-text/85 mt-8 ml-8 poppis text-[1.2rem]" v-if="projects && projects.length == 0 && !justLogged">
|
||||
Create your first project...
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-text/85 mt-8 ml-8 poppis text-[1.2rem]" v-if="projects && projects.length == 0">
|
||||
Create your first project...
|
||||
<div v-if="justLogged" class="text-[2rem]">
|
||||
The page will refresh soon
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
105
dashboard/pages/integrations.vue
Normal file
105
dashboard/pages/integrations.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import SupabaseChartDialog from '~/components/integrations/SupabaseChartDialog.vue';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
const activeProjectId = useActiveProjectId();
|
||||
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
const {
|
||||
supabaseUrl, supabaseAnonKey, supabaseServiceRoleKey, integrationsCredentials,
|
||||
supabaseIntegrations, updateIntegrationsCredentails
|
||||
} = useSupabase()
|
||||
|
||||
async function updateCredentials() {
|
||||
|
||||
const res = await updateIntegrationsCredentails({
|
||||
supabase_url: supabaseUrl.value,
|
||||
supabase_anon_key: supabaseAnonKey.value,
|
||||
supabase_service_role_key: supabaseServiceRoleKey.value
|
||||
});
|
||||
|
||||
if (res.ok === true) {
|
||||
integrationsCredentials.refresh();
|
||||
createAlert('Credentials updated', 'Credentials updated successfully', 'far fa-error', 4000);
|
||||
} else {
|
||||
createAlert('Error updating credentials', res.error, 'far fa-error', 4000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const { openDialogEx } = useCustomDialog()
|
||||
|
||||
function showChartDialog() {
|
||||
openDialogEx(SupabaseChartDialog, {
|
||||
closable: true,
|
||||
width: '55vw',
|
||||
height: '65vh'
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home w-full h-full px-10 pt-6 overflow-y-auto">
|
||||
|
||||
<CardTitled title="Supabase integration" class="w-full">
|
||||
<template #header>
|
||||
<img class="h-10 w-10" :src="'supabase.svg'" alt="Supabase logo">
|
||||
</template>
|
||||
<div class="flex gap-6 flex-col w-full">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-lyx-text"> Supabase url </div>
|
||||
<div class="text-lyx-text-dark"> Required to fetch data from supabase </div>
|
||||
<LyxUiInput v-if="!integrationsCredentials.pending.value" class="w-full mt-2 px-4 py-1"
|
||||
v-model="supabaseUrl" type="text"></LyxUiInput>
|
||||
<div v-if="integrationsCredentials.pending.value"> Loading... </div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-lyx-text"> Supabase anon key </div>
|
||||
<div class="text-lyx-text-dark"> Required to fetch data from supabase </div>
|
||||
<LyxUiInput v-if="!integrationsCredentials.pending.value" class="w-full mt-2 px-4 py-1"
|
||||
v-model="supabaseAnonKey" type="password"></LyxUiInput>
|
||||
<div v-if="integrationsCredentials.pending.value"> Loading... </div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-lyx-text"> Supabase service role key </div>
|
||||
<div class="text-lyx-text-dark"> Only used if you need to bypass RLS </div>
|
||||
<LyxUiInput v-if="!integrationsCredentials.pending.value" class="w-full mt-2 px-4 py-1"
|
||||
v-model="supabaseServiceRoleKey" type="password"></LyxUiInput>
|
||||
<div v-if="integrationsCredentials.pending.value"> Loading... </div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<LyxUiButton v-if="!integrationsCredentials.pending.value" @click="updateCredentials()"
|
||||
type="primary"> Save
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
|
||||
<LyxUiCard class="mt-6 w-full">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex gap-2 items-center" v-for="supabaseIntegration of supabaseIntegrations.data.value">
|
||||
<div> {{ supabaseIntegration.name }} </div>
|
||||
<div> <i class="far fa-edit"></i> </div>
|
||||
<div> <i class="far fa-trash"></i> </div>
|
||||
</div>
|
||||
<div>
|
||||
<LyxUiButton type="primary" @click="showChartDialog()"> Add supabase chart </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
|
||||
<div class="mt-10">
|
||||
<IntegrationsSupabaseLineChart integration_id="66f6c558d97e4abd408feee0"></IntegrationsSupabaseLineChart>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -6,6 +6,8 @@ const { snapshot, snapshots } = useSnapshot();
|
||||
|
||||
const { data: project } = useLiveDemo();
|
||||
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
let interval: any;
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -13,8 +15,11 @@ onMounted(async () => {
|
||||
snapshot.value = snapshots.value[0];
|
||||
interval = setInterval(async () => {
|
||||
await getOnlineUsers();
|
||||
}, 5000);
|
||||
}, 20000);
|
||||
|
||||
setTimeout(() => {
|
||||
ready.value = true;
|
||||
}, 2000);
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -54,8 +59,7 @@ const selectLabelsEvents = [
|
||||
<template>
|
||||
|
||||
<div class="dashboard w-full h-full overflow-y-auto pb-20">
|
||||
|
||||
<div v-if="project">
|
||||
<div v-if="project && ready">
|
||||
|
||||
<div
|
||||
class="bg-bg w-full px-6 py-6 text-text/90 flex flex-collg:flex-row text-lg lg:text-2xl gap-2 lg:gap-12">
|
||||
@@ -85,35 +89,10 @@ const selectLabelsEvents = [
|
||||
<DashboardTopCards></DashboardTopCards>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
|
||||
|
||||
<CardTitled class="p-4 flex-1 w-full" title="Visits trends" sub="Shows trends in page visits.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="mainChartSelectIndex = $event" :currentIndex="mainChartSelectIndex"
|
||||
:options="selectLabels">
|
||||
</SelectButton>
|
||||
</template>
|
||||
<div>
|
||||
<DashboardVisitsLineChart :slice="(selectLabels[mainChartSelectIndex].value as any)">
|
||||
</DashboardVisitsLineChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<!-- <CardTitled class="p-4 flex-1" title="Sessions" sub="Shows trends in sessions.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="sessionsChartSelectIndex = $event"
|
||||
:currentIndex="sessionsChartSelectIndex" :options="selectLabels">
|
||||
</SelectButton>
|
||||
</template>
|
||||
<div>
|
||||
<DashboardSessionsLineChart :slice="(selectLabels[sessionsChartSelectIndex].value as any)">
|
||||
</DashboardSessionsLineChart>
|
||||
</div>
|
||||
</CardTitled> -->
|
||||
|
||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full">
|
||||
<DashboardActionableChart></DashboardActionableChart>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
||||
|
||||
<CardTitled class="p-4 flex-[4] w-full h-full" title="Events" sub="Events stacked bar chart.">
|
||||
@@ -213,6 +192,9 @@ const selectLabelsEvents = [
|
||||
|
||||
|
||||
</div>
|
||||
<div v-if="!ready || !project" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
122
dashboard/pages/security.vue
Normal file
122
dashboard/pages/security.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
const activeProjectId = useActiveProjectId();
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'Authorization': authorizationHeaderComputed.value,
|
||||
'x-pid': activeProjectId.data.value || ''
|
||||
}
|
||||
});
|
||||
|
||||
const reportList = useFetch(`/api/security/list`, { headers });
|
||||
|
||||
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 = [
|
||||
{ key: 'scan', label: 'Scan date' },
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'data', label: 'Data' },
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home w-full h-full px-10 pt-6 overflow-y-auto">
|
||||
|
||||
<div class="flex gap-2 items-center 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 class="pb-[10rem]">
|
||||
<UTable :rows="rows" :columns="columns">
|
||||
|
||||
|
||||
<template #scan-data="{ row }">
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ new Date(row.data.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #type-data="{ row }">
|
||||
<UBadge color="white" class="w-[4rem] flex justify-center">
|
||||
{{ row.type }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<template #data-data="{ row }">
|
||||
<div class="text-lyx-text-dark">
|
||||
<div v-if="row.type === 'domain'">
|
||||
{{ row.data.domain }}
|
||||
</div>
|
||||
<div v-if="row.type === 'visit'">
|
||||
{{ row.data.visit }}
|
||||
</div>
|
||||
<div v-if="row.type === 'event'">
|
||||
{{ row.data.event }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- <template #actions-data="{ row }">
|
||||
<UDropdown :items="items(row)">
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-ellipsis-horizontal-20-solid" />
|
||||
</UDropdown>
|
||||
</template> -->
|
||||
|
||||
</UTable>
|
||||
</div>
|
||||
|
||||
<!-- <div class="w-full py-8 px-12 pb-[10rem]">
|
||||
<div v-if="reportList.data.value" class="flex flex-col gap-2">
|
||||
<div v-for="entry of reportList.data.value" class="flex flex-col gap-4">
|
||||
<div v-if="entry.type === 'event'" class="flex gap-2 flex-col lg:flex-row items-center lg:items-start">
|
||||
<div class="text-lyx-text-darker">{{ new Date(entry.data.created_at).toLocaleString() }}</div>
|
||||
<UBadge class="w-[4rem] flex justify-center"> {{ entry.type }} </UBadge>
|
||||
<div class="text-lyx-text-dark">
|
||||
Event date: {{ new Date(entry.data.eventDate).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="entry.type === 'visit'" class="flex gap-2 flex-col lg:flex-row items-center lg:items-start">
|
||||
<div class="text-lyx-text-darker">{{ new Date(entry.data.created_at).toLocaleString() }}</div>
|
||||
<UBadge class="w-[4rem] flex justify-center"> {{ entry.type }} </UBadge>
|
||||
<div class="text-lyx-text-dark">
|
||||
Visit date: {{ new Date(entry.data.visitDate).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="entry.type === 'domain'" class="flex gap-2 flex-col py-2 lg:flex-row items-center lg:items-start">
|
||||
<div class="text-lyx-text-darker">{{ new Date(entry.data.created_at).toLocaleString() }}</div>
|
||||
<UBadge class="w-[4rem] flex justify-center"> {{ entry.type }} </UBadge>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ entry.data.domain }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -3,6 +3,8 @@
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
const items = [
|
||||
{ label: 'General', slot: 'general' },
|
||||
{ label: 'Members', slot: 'members' },
|
||||
@@ -18,32 +20,18 @@ const items = [
|
||||
|
||||
<CustomTab :items="items" class="mt-8">
|
||||
<template #general>
|
||||
<SettingsGeneral></SettingsGeneral>
|
||||
<SettingsGeneral :key="activeProject?._id.toString()"></SettingsGeneral>
|
||||
</template>
|
||||
<template #members>
|
||||
<SettingsMembers></SettingsMembers>
|
||||
<SettingsMembers :key="activeProject?._id.toString()"></SettingsMembers>
|
||||
</template>
|
||||
<template #billing>
|
||||
<SettingsBilling></SettingsBilling>
|
||||
<SettingsBilling :key="activeProject?._id.toString()"></SettingsBilling>
|
||||
</template>
|
||||
<template #account>
|
||||
<SettingsAccount></SettingsAccount>
|
||||
<SettingsAccount :key="activeProject?._id.toString()"></SettingsAccount>
|
||||
</template>
|
||||
</CustomTab>
|
||||
|
||||
<!-- <UTabs :items="items" class="mt-8">
|
||||
<template #general>
|
||||
<SettingsGeneral></SettingsGeneral>
|
||||
</template>
|
||||
<template #members>
|
||||
<SettingsMembers></SettingsMembers>
|
||||
</template>
|
||||
<template #billing>
|
||||
<SettingsBilling></SettingsBilling>
|
||||
</template>
|
||||
<template #account>
|
||||
<SettingsAccount></SettingsAccount>
|
||||
</template>
|
||||
</UTabs> -->
|
||||
</div>
|
||||
</template>
|
||||
10
dashboard/plugins/chartjs.ts
Normal file
10
dashboard/plugins/chartjs.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import annotaionPlugin from 'chartjs-plugin-annotation';
|
||||
import 'chartjs-chart-funnel';
|
||||
|
||||
import { FunnelController, FunnelChart, TrapezoidElement } from 'chartjs-chart-funnel';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
Chart.register(...registerables, annotaionPlugin, FunnelController, FunnelChart, TrapezoidElement);
|
||||
})
|
||||
154
dashboard/pnpm-lock.yaml
generated
154
dashboard/pnpm-lock.yaml
generated
@@ -14,9 +14,15 @@ importers:
|
||||
'@nuxtjs/tailwindcss':
|
||||
specifier: ^6.12.0
|
||||
version: 6.12.0(rollup@4.18.0)
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.45.4
|
||||
version: 2.45.4
|
||||
chart.js:
|
||||
specifier: ^3.9.1
|
||||
version: 3.9.1
|
||||
chartjs-chart-funnel:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(chart.js@3.9.1)
|
||||
chartjs-plugin-annotation:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1(chart.js@3.9.1)
|
||||
@@ -29,6 +35,9 @@ importers:
|
||||
google-auth-library:
|
||||
specifier: ^9.9.0
|
||||
version: 9.10.0(encoding@0.1.13)
|
||||
highlight.js:
|
||||
specifier: ^11.10.0
|
||||
version: 11.10.0
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
@@ -1132,6 +1141,28 @@ packages:
|
||||
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@supabase/auth-js@2.65.0':
|
||||
resolution: {integrity: sha512-+wboHfZufAE2Y612OsKeVP4rVOeGZzzMLD/Ac3HrTQkkY4qXNjI6Af9gtmxwccE5nFvTiF114FEbIQ1hRq5uUw==}
|
||||
|
||||
'@supabase/functions-js@2.4.1':
|
||||
resolution: {integrity: sha512-8sZ2ibwHlf+WkHDUZJUXqqmPvWQ3UHN0W30behOJngVh/qHHekhJLCFbh0AjkE9/FqqXtf9eoVvmYgfCLk5tNA==}
|
||||
|
||||
'@supabase/node-fetch@2.6.15':
|
||||
resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
|
||||
'@supabase/postgrest-js@1.16.1':
|
||||
resolution: {integrity: sha512-EOSEZFm5pPuCPGCmLF1VOCS78DfkSz600PBuvBND/IZmMciJ1pmsS3ss6TkB6UkuvTybYiBh7gKOYyxoEO3USA==}
|
||||
|
||||
'@supabase/realtime-js@2.10.2':
|
||||
resolution: {integrity: sha512-qyCQaNg90HmJstsvr2aJNxK2zgoKh9ZZA8oqb7UT2LCh3mj9zpa3Iwu167AuyNxsxrUE8eEJ2yH6wLCij4EApA==}
|
||||
|
||||
'@supabase/storage-js@2.7.0':
|
||||
resolution: {integrity: sha512-iZenEdO6Mx9iTR6T7wC7sk6KKsoDPLq8rdu5VRy7+JiT1i8fnqfcOr6mfF2Eaqky9VQzhP8zZKQYjzozB65Rig==}
|
||||
|
||||
'@supabase/supabase-js@2.45.4':
|
||||
resolution: {integrity: sha512-E5p8/zOLaQ3a462MZnmnz03CrduA5ySH9hZyL03Y+QZLIOO4/Gs8Rdy4ZCKDHsN7x0xdanVEWWFN3pJFQr9/hg==}
|
||||
|
||||
'@swc/helpers@0.3.17':
|
||||
resolution: {integrity: sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==}
|
||||
|
||||
@@ -1178,6 +1209,9 @@ packages:
|
||||
'@types/argparse@1.0.38':
|
||||
resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==}
|
||||
|
||||
'@types/chroma-js@2.4.4':
|
||||
resolution: {integrity: sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==}
|
||||
|
||||
'@types/estree@1.0.5':
|
||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
||||
|
||||
@@ -1214,6 +1248,9 @@ packages:
|
||||
'@types/pdfkit@0.13.4':
|
||||
resolution: {integrity: sha512-ixGNDHYJCCKuamY305wbfYSphZ2WPe8FPkjn8oF4fHV+PgPV4V+hecPh2VOS2h4RNtpSB3zQcR4sCpNvvrEb1A==}
|
||||
|
||||
'@types/phoenix@1.6.5':
|
||||
resolution: {integrity: sha512-xegpDuR+z0UqG9fwHqNoy3rI7JDlvaPh2TY47Fl80oq6g+hXT+c/LEuE43X48clZ6lOfANl5WrPur9fYO1RJ/w==}
|
||||
|
||||
'@types/qs@6.9.16':
|
||||
resolution: {integrity: sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==}
|
||||
|
||||
@@ -1235,6 +1272,9 @@ packages:
|
||||
'@types/whatwg-url@11.0.5':
|
||||
resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
|
||||
|
||||
'@types/ws@8.5.12':
|
||||
resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==}
|
||||
|
||||
'@ungap/structured-clone@1.2.0':
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
|
||||
@@ -1459,20 +1499,20 @@ packages:
|
||||
'@vue/reactivity@3.4.27':
|
||||
resolution: {integrity: sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==}
|
||||
|
||||
'@vue/reactivity@3.5.6':
|
||||
resolution: {integrity: sha512-shZ+KtBoHna5GyUxWfoFVBCVd7k56m6lGhk5e+J9AKjheHF6yob5eukssHRI+rzvHBiU1sWs/1ZhNbLExc5oYQ==}
|
||||
'@vue/reactivity@3.5.8':
|
||||
resolution: {integrity: sha512-mlgUyFHLCUZcAYkqvzYnlBRCh0t5ZQfLYit7nukn1GR96gc48Bp4B7OIcSfVSvlG1k3BPfD+p22gi1t2n9tsXg==}
|
||||
|
||||
'@vue/runtime-core@3.4.27':
|
||||
resolution: {integrity: sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==}
|
||||
|
||||
'@vue/runtime-core@3.5.6':
|
||||
resolution: {integrity: sha512-FpFULR6+c2lI+m1fIGONLDqPQO34jxV8g6A4wBOgne8eSRHP6PQL27+kWFIx5wNhhjkO7B4rgtsHAmWv7qKvbg==}
|
||||
'@vue/runtime-core@3.5.8':
|
||||
resolution: {integrity: sha512-fJuPelh64agZ8vKkZgp5iCkPaEqFJsYzxLk9vSC0X3G8ppknclNDr61gDc45yBGTaN5Xqc1qZWU3/NoaBMHcjQ==}
|
||||
|
||||
'@vue/runtime-dom@3.4.27':
|
||||
resolution: {integrity: sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==}
|
||||
|
||||
'@vue/runtime-dom@3.5.6':
|
||||
resolution: {integrity: sha512-SDPseWre45G38ENH2zXRAHL1dw/rr5qp91lS4lt/nHvMr0MhsbCbihGAWLXNB/6VfFOJe2O+RBRkXU+CJF7/sw==}
|
||||
'@vue/runtime-dom@3.5.8':
|
||||
resolution: {integrity: sha512-DpAUz+PKjTZPUOB6zJgkxVI3GuYc2iWZiNeeHQUw53kdrparSTG6HeXUrYDjaam8dVsCdvQxDz6ZWxnyjccUjQ==}
|
||||
|
||||
'@vue/server-renderer@3.4.27':
|
||||
resolution: {integrity: sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==}
|
||||
@@ -1482,8 +1522,8 @@ packages:
|
||||
'@vue/shared@3.4.27':
|
||||
resolution: {integrity: sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==}
|
||||
|
||||
'@vue/shared@3.5.6':
|
||||
resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==}
|
||||
'@vue/shared@3.5.8':
|
||||
resolution: {integrity: sha512-mJleSWbAGySd2RJdX1RBtcrUBX6snyOc0qHpgk3lGi4l9/P/3ny3ELqFWqYdkXIwwNN/kdm8nD9ky8o6l/Lx2A==}
|
||||
|
||||
'@vueuse/components@10.10.0':
|
||||
resolution: {integrity: sha512-HiA10NQ9HJAGnju+8ZK4TyA8LIc0a6BnJmVWDa/k+TRhaYCVacSDU04k0BQ2otV+gghUDdwu98upf6TDRXpoeg==}
|
||||
@@ -1857,6 +1897,11 @@ packages:
|
||||
chart.js@3.9.1:
|
||||
resolution: {integrity: sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==}
|
||||
|
||||
chartjs-chart-funnel@4.2.1:
|
||||
resolution: {integrity: sha512-S1eqYMDXefl7k7uuQc5MA83ZS9zjclt4bbYXbmPJ5GEvB6HMBb7tt892R62AtzoKXbt/VfDNy9Sq3L785sWvdQ==}
|
||||
peerDependencies:
|
||||
chart.js: '>=3.7.0'
|
||||
|
||||
chartjs-plugin-annotation@2.2.1:
|
||||
resolution: {integrity: sha512-RL9UtrFr2SXd7C47zD0MZqn6ZLgrcRt3ySC6cYal2amBdANcYB1QcwFXcpKWAYnO4SGJYRok7P5rKDDNgJMA/w==}
|
||||
peerDependencies:
|
||||
@@ -1873,6 +1918,9 @@ packages:
|
||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
chroma-js@2.6.0:
|
||||
resolution: {integrity: sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==}
|
||||
|
||||
ci-info@4.0.0:
|
||||
resolution: {integrity: sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2759,6 +2807,10 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
highlight.js@11.10.0:
|
||||
resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
@@ -6608,6 +6660,48 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@2.3.0': {}
|
||||
|
||||
'@supabase/auth-js@2.65.0':
|
||||
dependencies:
|
||||
'@supabase/node-fetch': 2.6.15
|
||||
|
||||
'@supabase/functions-js@2.4.1':
|
||||
dependencies:
|
||||
'@supabase/node-fetch': 2.6.15
|
||||
|
||||
'@supabase/node-fetch@2.6.15':
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
'@supabase/postgrest-js@1.16.1':
|
||||
dependencies:
|
||||
'@supabase/node-fetch': 2.6.15
|
||||
|
||||
'@supabase/realtime-js@2.10.2':
|
||||
dependencies:
|
||||
'@supabase/node-fetch': 2.6.15
|
||||
'@types/phoenix': 1.6.5
|
||||
'@types/ws': 8.5.12
|
||||
ws: 8.17.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@supabase/storage-js@2.7.0':
|
||||
dependencies:
|
||||
'@supabase/node-fetch': 2.6.15
|
||||
|
||||
'@supabase/supabase-js@2.45.4':
|
||||
dependencies:
|
||||
'@supabase/auth-js': 2.65.0
|
||||
'@supabase/functions-js': 2.4.1
|
||||
'@supabase/node-fetch': 2.6.15
|
||||
'@supabase/postgrest-js': 1.16.1
|
||||
'@supabase/realtime-js': 2.10.2
|
||||
'@supabase/storage-js': 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@swc/helpers@0.3.17':
|
||||
dependencies:
|
||||
tslib: 2.6.2
|
||||
@@ -6651,6 +6745,8 @@ snapshots:
|
||||
|
||||
'@types/argparse@1.0.38': {}
|
||||
|
||||
'@types/chroma-js@2.4.4': {}
|
||||
|
||||
'@types/estree@1.0.5': {}
|
||||
|
||||
'@types/http-proxy@1.17.14':
|
||||
@@ -6693,6 +6789,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.12.12
|
||||
|
||||
'@types/phoenix@1.6.5': {}
|
||||
|
||||
'@types/qs@6.9.16': {}
|
||||
|
||||
'@types/resize-observer-browser@0.1.11': {}
|
||||
@@ -6709,6 +6807,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/webidl-conversions': 7.0.3
|
||||
|
||||
'@types/ws@8.5.12':
|
||||
dependencies:
|
||||
'@types/node': 20.12.12
|
||||
|
||||
'@ungap/structured-clone@1.2.0': {}
|
||||
|
||||
'@unhead/dom@1.9.11':
|
||||
@@ -7126,7 +7228,7 @@ snapshots:
|
||||
'@volar/language-core': 1.11.1
|
||||
'@volar/source-map': 1.11.1
|
||||
'@vue/compiler-dom': 3.4.27
|
||||
'@vue/shared': 3.5.6
|
||||
'@vue/shared': 3.5.8
|
||||
computeds: 0.0.1
|
||||
minimatch: 9.0.4
|
||||
muggle-string: 0.3.1
|
||||
@@ -7139,19 +7241,19 @@ snapshots:
|
||||
dependencies:
|
||||
'@vue/shared': 3.4.27
|
||||
|
||||
'@vue/reactivity@3.5.6':
|
||||
'@vue/reactivity@3.5.8':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.6
|
||||
'@vue/shared': 3.5.8
|
||||
|
||||
'@vue/runtime-core@3.4.27':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.4.27
|
||||
'@vue/shared': 3.4.27
|
||||
|
||||
'@vue/runtime-core@3.5.6':
|
||||
'@vue/runtime-core@3.5.8':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.6
|
||||
'@vue/shared': 3.5.6
|
||||
'@vue/reactivity': 3.5.8
|
||||
'@vue/shared': 3.5.8
|
||||
|
||||
'@vue/runtime-dom@3.4.27':
|
||||
dependencies:
|
||||
@@ -7159,11 +7261,11 @@ snapshots:
|
||||
'@vue/shared': 3.4.27
|
||||
csstype: 3.1.3
|
||||
|
||||
'@vue/runtime-dom@3.5.6':
|
||||
'@vue/runtime-dom@3.5.8':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.6
|
||||
'@vue/runtime-core': 3.5.6
|
||||
'@vue/shared': 3.5.6
|
||||
'@vue/reactivity': 3.5.8
|
||||
'@vue/runtime-core': 3.5.8
|
||||
'@vue/shared': 3.5.8
|
||||
csstype: 3.1.3
|
||||
|
||||
'@vue/server-renderer@3.4.27(vue@3.4.27(typescript@5.4.2))':
|
||||
@@ -7174,7 +7276,7 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.4.27': {}
|
||||
|
||||
'@vue/shared@3.5.6': {}
|
||||
'@vue/shared@3.5.8': {}
|
||||
|
||||
'@vueuse/components@10.10.0(vue@3.4.27(typescript@5.4.2))':
|
||||
dependencies:
|
||||
@@ -7563,6 +7665,12 @@ snapshots:
|
||||
|
||||
chart.js@3.9.1: {}
|
||||
|
||||
chartjs-chart-funnel@4.2.1(chart.js@3.9.1):
|
||||
dependencies:
|
||||
'@types/chroma-js': 2.4.4
|
||||
chart.js: 3.9.1
|
||||
chroma-js: 2.6.0
|
||||
|
||||
chartjs-plugin-annotation@2.2.1(chart.js@3.9.1):
|
||||
dependencies:
|
||||
chart.js: 3.9.1
|
||||
@@ -7585,6 +7693,8 @@ snapshots:
|
||||
|
||||
chownr@2.0.0: {}
|
||||
|
||||
chroma-js@2.6.0: {}
|
||||
|
||||
ci-info@4.0.0: {}
|
||||
|
||||
citty@0.1.6:
|
||||
@@ -8535,6 +8645,8 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
highlight.js@11.10.0: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
hosted-git-info@7.0.2:
|
||||
@@ -11235,8 +11347,8 @@ snapshots:
|
||||
|
||||
vue-chart-3@3.1.8(chart.js@3.9.1)(vue@3.4.27(typescript@5.4.2)):
|
||||
dependencies:
|
||||
'@vue/runtime-core': 3.5.6
|
||||
'@vue/runtime-dom': 3.5.6
|
||||
'@vue/runtime-core': 3.5.8
|
||||
'@vue/runtime-dom': 3.5.8
|
||||
chart.js: 3.9.1
|
||||
csstype: 3.1.3
|
||||
lodash-es: 4.17.21
|
||||
|
||||
15
dashboard/public/supabase.svg
Normal file
15
dashboard/public/supabase.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="98" height="100" viewBox="0 0 98 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M56.8944 98.338C54.3397 101.555 49.1599 99.7924 49.0983 95.6846L48.1982 35.6025H88.5973C95.9147 35.6025 99.9957 44.0542 95.4457 49.7849L56.8944 98.338Z" fill="url(#paint0_linear_99_24683)"/>
|
||||
<path d="M56.8944 98.338C54.3397 101.555 49.1599 99.7924 49.0983 95.6846L48.1982 35.6025H88.5973C95.9147 35.6025 99.9957 44.0542 95.4457 49.7849L56.8944 98.338Z" fill="url(#paint1_linear_99_24683)" fill-opacity="0.2"/>
|
||||
<path d="M40.464 1.66109C43.0187 -1.55638 48.1986 0.206562 48.2601 4.31445L48.6546 64.3964H8.76106C1.44348 64.3964 -2.63767 55.9448 1.91262 50.214L40.464 1.66109Z" fill="#3ECF8E"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_99_24683" x1="48.1982" y1="48.9242" x2="84.1036" y2="63.9829" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#249361"/>
|
||||
<stop offset="1" stop-color="#3ECF8E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_99_24683" x1="32.2797" y1="27.1289" x2="48.6544" y2="57.9534" gradientUnits="userSpaceOnUse">
|
||||
<stop/>
|
||||
<stop offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -4,19 +4,22 @@ import { LITLYX_PROJECT_ID } from '@data/LITLYX'
|
||||
import { hasAccessToProject } from "./utils/hasAccessToProject";
|
||||
|
||||
export async function getUserProjectFromId(project_id: string, user: AuthContext | undefined, allowGuest: boolean = true) {
|
||||
if (project_id == LITLYX_PROJECT_ID) {
|
||||
const project = await ProjectModel.findOne({ _id: project_id });
|
||||
return project;
|
||||
} else {
|
||||
if (!user?.logged) return;
|
||||
if (!project_id) return;
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
const [hasAccess, role] = await hasAccessToProject(user.id, project_id, project);
|
||||
if (!hasAccess) return;
|
||||
if (role === 'GUEST' && !allowGuest) return false;
|
||||
return project;
|
||||
if (!project_id) return;
|
||||
|
||||
if (project_id === LITLYX_PROJECT_ID) {
|
||||
return await ProjectModel.findOne({ _id: project_id });
|
||||
}
|
||||
|
||||
if (!user || !user.logged) return;
|
||||
|
||||
const project = await ProjectModel.findById(project_id);
|
||||
if (!project) return;
|
||||
|
||||
const [hasAccess, role] = await hasAccessToProject(user.id, project_id, project);
|
||||
if (!hasAccess) return;
|
||||
|
||||
if (role === 'GUEST' && !allowGuest) return false;
|
||||
|
||||
return project;
|
||||
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import winston from 'winston';
|
||||
const { combine, timestamp, json, errors } = winston.format;
|
||||
|
||||
|
||||
const timestampFormat = () => { return new Date().toLocaleString('it-IT', { timeZone: 'Europe/Rome' }); }
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
format: combine(
|
||||
errors({ stack: true }),
|
||||
timestamp({
|
||||
format: 'DD-MM-YYYY hh:mm:ss'
|
||||
format: timestampFormat
|
||||
}),
|
||||
json()
|
||||
),
|
||||
@@ -27,7 +28,7 @@ export const logger = winston.createLogger({
|
||||
format: combine(
|
||||
winston.format.colorize({ all: true }),
|
||||
errors({ stack: true }),
|
||||
timestamp({ format: 'DD-MM-YYYY hh:mm:ss' }),
|
||||
timestamp({ format: timestampFormat }),
|
||||
winston.format.printf((info) => {
|
||||
if (info instanceof Error) {
|
||||
return `${info.timestamp} [${info.level}]: ${info.message}\n${info.stack}`;
|
||||
|
||||
65
dashboard/server/api/data/query.ts
Normal file
65
dashboard/server/api/data/query.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { Redis } from "~/server/services/CacheService";
|
||||
|
||||
import type { Model } from "mongoose";
|
||||
|
||||
|
||||
const allowedModels: Record<string, { model: Model<any>, field: string }> = {
|
||||
'events': {
|
||||
model: EventModel,
|
||||
field: 'name'
|
||||
}
|
||||
}
|
||||
|
||||
type TModelName = keyof typeof allowedModels;
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const project_id = getHeader(event, 'x-pid');
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const from = getRequestHeader(event, 'x-from');
|
||||
const to = getRequestHeader(event, 'x-to');
|
||||
|
||||
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to are required');
|
||||
|
||||
const schemaName = getRequestHeader(event, 'x-schema');
|
||||
if (!schemaName) return setResponseStatus(event, 400, 'x-schema is required');
|
||||
|
||||
if (!Object.keys(allowedModels).includes(schemaName)) return setResponseStatus(event, 400, 'x-schema value is not valid');
|
||||
|
||||
const limitHeader = getRequestHeader(event, 'x-query-limit');
|
||||
const limitNumber = parseInt(limitHeader || '10');
|
||||
const limit = isNaN(limitNumber) ? 10 : limitNumber;
|
||||
|
||||
const cacheKey = `${schemaName}:${project_id}:${from}:${to}`;
|
||||
const cacheExp = 60;
|
||||
|
||||
return await Redis.useCacheV2(cacheKey, cacheExp, async (noStore, updateExp) => {
|
||||
|
||||
const { model } = allowedModels[schemaName as TModelName];
|
||||
|
||||
const result = await model.aggregate([
|
||||
{
|
||||
$match: {
|
||||
project_id: project._id,
|
||||
created_at: {
|
||||
$gte: new Date(from),
|
||||
$lte: new Date(to)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $group: { _id: "$name", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: limit }
|
||||
]);
|
||||
|
||||
return result;
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
23
dashboard/server/api/integrations/credentials/get.ts
Normal file
23
dashboard/server/api/integrations/credentials/get.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { IntegrationsCredentialsModel } from '@schema/integrations/IntegrationsCredentialsSchema';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const project_id = getHeader(event, 'x-pid');
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const credentials = await IntegrationsCredentialsModel.findOne({ project_id });
|
||||
|
||||
return {
|
||||
supabase: {
|
||||
anon_key: credentials?.supabase_anon_key || '',
|
||||
service_role_key: credentials?.supabase_service_role_key || '',
|
||||
url: credentials?.supabase_url || ''
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
23
dashboard/server/api/integrations/credentials/update.post.ts
Normal file
23
dashboard/server/api/integrations/credentials/update.post.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IntegrationsCredentialsModel } from "@schema/integrations/IntegrationsCredentialsSchema";
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const project_id = getHeader(event, 'x-pid');
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const res = await IntegrationsCredentialsModel.updateOne({ project_id }, {
|
||||
supabase_anon_key: body.supabase_anon_key || '',
|
||||
supabase_service_role_key: body.supabase_service_role_key || '',
|
||||
supabase_url: body.supabase_url || '',
|
||||
}, { upsert: true });
|
||||
|
||||
return { ok: res.acknowledged };
|
||||
|
||||
});
|
||||
29
dashboard/server/api/integrations/supabase/add.post.ts
Normal file
29
dashboard/server/api/integrations/supabase/add.post.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { SupabaseIntegrationModel } from "@schema/integrations/SupabaseIntegrationSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const project_id = getHeader(event, 'x-pid');
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const { chart_type, table_name, xField, yMode, from, to, slice, name } = await readBody(event);
|
||||
|
||||
if (!project.premium) {
|
||||
const supabaseIntegrationsCount = await SupabaseIntegrationModel.countDocuments({ project_id });
|
||||
if (supabaseIntegrationsCount > 0) return setResponseStatus(event, 400, 'LIMIT_REACHED');
|
||||
}
|
||||
|
||||
await SupabaseIntegrationModel.create({
|
||||
name,
|
||||
project_id, chart_type,
|
||||
table_name, xField, yMode,
|
||||
from, to, slice,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
|
||||
});
|
||||
18
dashboard/server/api/integrations/supabase/get.ts
Normal file
18
dashboard/server/api/integrations/supabase/get.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { SupabaseIntegrationModel } from '@schema/integrations/SupabaseIntegrationSchema';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const project_id = getHeader(event, 'x-pid');
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const integration_id = getHeader(event, 'x-integration');
|
||||
|
||||
const integration = await SupabaseIntegrationModel.findOne({ _id: integration_id });
|
||||
return integration;
|
||||
|
||||
});
|
||||
16
dashboard/server/api/integrations/supabase/list.ts
Normal file
16
dashboard/server/api/integrations/supabase/list.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { SupabaseIntegrationModel } from '@schema/integrations/SupabaseIntegrationSchema';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const project_id = getHeader(event, 'x-pid');
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const integrations = await SupabaseIntegrationModel.find({ project_id });
|
||||
return integrations;
|
||||
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { getTimeline } from "./generic";
|
||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { executeAdvancedTimelineAggregation } from "~/server/services/TimelineService";
|
||||
import { executeAdvancedTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||
import DateService from '@services/DateService';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const project_id = getRequestProjectId(event);
|
||||
@@ -29,6 +30,9 @@ export default defineEventHandler(async event => {
|
||||
customIdGroup: { name: '$name' },
|
||||
})
|
||||
|
||||
// const filledDates = DateService.createBetweenDates(from, to, slice);
|
||||
// const merged = DateService.mergeFilledDates(filledDates.dates, timelineStackedEvents, '_id', slice, { count: 0, name: '' });
|
||||
|
||||
return timelineStackedEvents;
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async event => {
|
||||
return setResponseStatus(event, 400, 'Plan not exist');
|
||||
}
|
||||
|
||||
const checkout = await StripeService.cretePayment(
|
||||
const checkout = await StripeService.createPayment(
|
||||
StripeService.testMode ? PLAN.PRICE_TEST : PLAN.PRICE,
|
||||
'https://dashboard.litlyx.com/payment_ok',
|
||||
project_id,
|
||||
|
||||
21
dashboard/server/api/pay/[project_id]/customer_info.ts
Normal file
21
dashboard/server/api/pay/[project_id]/customer_info.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const project_id = getRequestProjectId(event);
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user, false);
|
||||
if (!project) return;
|
||||
|
||||
if (!project.customer_id) return;
|
||||
|
||||
const customer = await StripeService.getCustomer(project.customer_id);
|
||||
if (customer?.deleted) return;
|
||||
|
||||
return customer?.address;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const project_id = getRequestProjectId(event);
|
||||
if (!project_id) return setResponseStatus(event, 400, 'Cannot get project_id');
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user, false);
|
||||
if (!project) return setResponseStatus(event, 400, 'Cannot get user from project_id');
|
||||
|
||||
if (!project.customer_id) return setResponseStatus(event, 400, 'Project has no customer_id');
|
||||
|
||||
const body = await readBody(event);
|
||||
const res = await StripeService.setCustomerInfo(project.customer_id, body);
|
||||
|
||||
return { ok: true, data: res }
|
||||
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
||||
import { ProjectLimitModel } from "@schema/ProjectsLimits";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import { EVENT_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
@@ -25,8 +25,8 @@ export default defineEventHandler(async event => {
|
||||
return {
|
||||
total: TOTAL_COUNT,
|
||||
limit: COUNT_LIMIT,
|
||||
maxLimit: Math.round(COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT),
|
||||
limited: TOTAL_COUNT > COUNT_LIMIT * EVENT_LOG_LIMIT_PERCENT,
|
||||
maxLimit: Math.round(COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT),
|
||||
limited: TOTAL_COUNT > COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT,
|
||||
percent: Math.round(100 / COUNT_LIMIT * TOTAL_COUNT)
|
||||
}
|
||||
|
||||
|
||||
44
dashboard/server/api/security/list.ts
Normal file
44
dashboard/server/api/security/list.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
|
||||
import { AnomalyDomainModel } from '@schema/anomalies/AnomalyDomainSchema';
|
||||
import { AnomalyEventsModel } from '@schema/anomalies/AnomalyEventsSchema';
|
||||
import { AnomalyVisitModel } from '@schema/anomalies/AnomalyVisitSchema';
|
||||
|
||||
|
||||
type TSecurityDomainEntry = { type: 'domain', data: { domain: string, created_at: Date } }
|
||||
type TSecurityVisitEntry = { type: 'visit', data: { visitDate: Date, created_at: Date } }
|
||||
type TSecurityEventEntry = { type: 'event', data: { eventDate: Date, created_at: Date } }
|
||||
|
||||
|
||||
|
||||
export type SecutityReport = (TSecurityDomainEntry | TSecurityVisitEntry | TSecurityEventEntry)[];
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const project_id = getHeader(event, 'x-pid');
|
||||
if (!project_id) return;
|
||||
|
||||
const user = getRequestUser(event);
|
||||
const project = await getUserProjectFromId(project_id, user);
|
||||
if (!project) return;
|
||||
|
||||
const visits = await AnomalyVisitModel.find({ project_id }, { _id: 0, project_id: 0 });
|
||||
const events = await AnomalyEventsModel.find({ project_id }, { _id: 0, project_id: 0 });
|
||||
const domains = await AnomalyDomainModel.find({ project_id }, { _id: 0, project_id: 0 });
|
||||
|
||||
const report: SecutityReport = [];
|
||||
|
||||
for (const visit of visits) {
|
||||
report.push({ type: 'visit', data: visit });
|
||||
}
|
||||
for (const event of events) {
|
||||
report.push({ type: 'event', data: event });
|
||||
}
|
||||
for (const domain of domains) {
|
||||
report.push({ type: 'domain', data: domain });
|
||||
}
|
||||
|
||||
return report.toSorted((a, b) => b.data.created_at.getTime() - a.data.created_at.getTime());
|
||||
|
||||
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
|
||||
import { checkApiKey, checkAuthorization, eventsListApi } from '~/server/services/ApiService';
|
||||
import { useCors } from '~/server/utils/useCors';
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
if (useCors(event)) return '';
|
||||
|
||||
const { rows, from, to, limit } = await readBody(event);
|
||||
|
||||
const token = checkAuthorization(event);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
|
||||
import { checkApiKey, checkAuthorization, eventsListApi, } from '~/server/services/ApiService';
|
||||
import { useCors } from '~/server/utils/useCors';
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
if (useCors(event)) return '';
|
||||
|
||||
const { row, from, to, limit } = getQuery(event);
|
||||
|
||||
const token = checkAuthorization(event);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
|
||||
import { checkApiKey, checkAuthorization } from '~/server/services/ApiService';
|
||||
import { visitsListApi } from '../../services/ApiService';
|
||||
import { useCors } from '~/server/utils/useCors';
|
||||
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
if (useCors(event)) return '';
|
||||
|
||||
const { rows, from, to, limit } = await readBody(event);
|
||||
|
||||
const token = checkAuthorization(event);
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
import { ApiSettingsModel } from '@schema/ApiSettingsSchema';
|
||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||
import { checkApiKey, checkAuthorization, visitsListApi } from '~/server/services/ApiService';
|
||||
import { useCors } from '~/server/utils/useCors';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
if (useCors(event)) return '';
|
||||
|
||||
const { row, from, to, limit } = getQuery(event);
|
||||
|
||||
const token = checkAuthorization(event);
|
||||
|
||||
@@ -2,7 +2,6 @@ import mongoose from "mongoose";
|
||||
import { Redis } from "~/server/services/CacheService";
|
||||
import EmailService from '@services/EmailService';
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
import { anomalyLoop } from "./services/AnomalyService";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,21 @@ export default defineEventHandler(async (event) => {
|
||||
const ip = getRequestAddress(event);
|
||||
const user = getRequestUser(event);
|
||||
|
||||
event.node.res.on('finish', () => {
|
||||
let payload: any | undefined;
|
||||
|
||||
const headers = getHeaders(event);
|
||||
|
||||
const xHeaders = Object.keys(headers)
|
||||
.filter(e => e.startsWith('x-') && !e.startsWith("x-forwarded"))
|
||||
.map(e => ({ [e]: headers[e] }));
|
||||
|
||||
|
||||
if (event.method === 'POST' || event.method === 'DELETE') {
|
||||
payload = await readBody(event)
|
||||
}
|
||||
|
||||
|
||||
event.node.res.on('finish', async () => {
|
||||
if (!event.context['performance-start']) return;
|
||||
const start = parseInt(event.context['performance-start']);
|
||||
if (isNaN(start)) return;
|
||||
@@ -15,11 +29,11 @@ export default defineEventHandler(async (event) => {
|
||||
const duration = (end - start);
|
||||
|
||||
if (!user) {
|
||||
logger.debug('Request without user', { path: event.path, method: event.method, ip, duration });
|
||||
logger.debug('Request without user', { path: event.path, method: event.method, ip, duration, xHeaders, payload });
|
||||
} else if (!user.logged) {
|
||||
logger.debug('Request as guest', { path: event.path, method: event.method, ip, duration });
|
||||
logger.debug('Request as guest', { path: event.path, method: event.method, ip, duration, xHeaders, payload });
|
||||
} else {
|
||||
logger.debug(`(${duration}ms) [${event.method}] ${event.path} { ${user.user.email} }`, { ip });
|
||||
logger.debug(`(${duration}ms) [${event.method}] ${event.path} { ${user.user.email} }`, { ip, duration, xHeaders, payload });
|
||||
}
|
||||
|
||||
// event.node.res.setHeader('X-Total-Response-Time', `${duration.toFixed(2)} ms`);
|
||||
|
||||
@@ -13,6 +13,8 @@ export const EVENT_NAMES_EXPIRE_TIME = 60;
|
||||
|
||||
export const EVENT_METADATA_FIELDS_EXPIRE_TIME = 30;
|
||||
|
||||
type UseCacheV2Callback<T> = (noStore: () => void, updateExp: (value: number) => void) => Promise<T>
|
||||
|
||||
|
||||
export class Redis {
|
||||
|
||||
@@ -65,4 +67,17 @@ export class Redis {
|
||||
return result;
|
||||
}
|
||||
|
||||
static async useCacheV2<T extends any>(key: string, exp: number, callback: UseCacheV2Callback<T>) {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached) return cached;
|
||||
let expireValue = exp;
|
||||
let shouldStore = true;
|
||||
const noStore = () => shouldStore = false;
|
||||
const updateExp = (newExp: number) => expireValue = newExp;
|
||||
const result = await callback(noStore, updateExp);
|
||||
if (!shouldStore) return result;
|
||||
await this.set(key, result, expireValue);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
|
||||
|
||||
|
||||
class PerformanceThing {
|
||||
public min: number = Infinity;
|
||||
public max: number = -Infinity;
|
||||
private things: number[] = [];
|
||||
private slice: number = 0;
|
||||
constructor(public id: string, private maxThings: number) { }
|
||||
start() { this.slice = performance.now(); }
|
||||
stop() {
|
||||
const time = performance.now() - this.slice;
|
||||
if (time > this.max) this.max = time;
|
||||
if (time < this.min) this.min = time;
|
||||
this.things.push(time);
|
||||
if (this.things.length > this.maxThings) {
|
||||
this.things.shift();
|
||||
}
|
||||
return time;
|
||||
}
|
||||
avg() {
|
||||
return this.things.reduce((a, e) => a + e, 0) / this.things.length;
|
||||
}
|
||||
|
||||
print() {
|
||||
console.log(`${this.id} | Avg: ${this.avg().toFixed(0)} ms | Min: ${this.min.toFixed(0)} ms | Max: ${this.max.toFixed(0)} ms`)
|
||||
}
|
||||
get data() { return this.things; }
|
||||
}
|
||||
|
||||
export class PerformanceService {
|
||||
static create(id: string, maxThings: number = 100) {
|
||||
const thing = new PerformanceThing(id, maxThings);
|
||||
return thing;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class StripeService {
|
||||
return checkout;
|
||||
}
|
||||
|
||||
async cretePayment(price: string, success_url: string, pid: string, customer?: string) {
|
||||
async createPayment(price: string, success_url: string, pid: string, customer?: string) {
|
||||
if (this.disabledMode) return;
|
||||
if (!this.stripe) throw Error('Stripe not initialized');
|
||||
|
||||
@@ -126,6 +126,22 @@ class StripeService {
|
||||
return customer;
|
||||
}
|
||||
|
||||
async setCustomerInfo(customer_id: string, address: { line1: string, line2: string, city: string, country: string, postal_code: string, state: string }) {
|
||||
if (this.disabledMode) return;
|
||||
if (!this.stripe) throw Error('Stripe not initialized');
|
||||
const customer = await this.stripe.customers.update(customer_id, {
|
||||
address: {
|
||||
line1: address.line1,
|
||||
line2: address.line2,
|
||||
city: address.city,
|
||||
country: address.country,
|
||||
postal_code: address.postal_code,
|
||||
state: address.state
|
||||
}
|
||||
})
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async deleteCustomer(customer_id: string) {
|
||||
if (this.disabledMode) return;
|
||||
if (!this.stripe) throw Error('Stripe not initialized');
|
||||
|
||||
@@ -29,6 +29,17 @@ export async function executeAdvancedTimelineAggregation<T = {}>(options: Advanc
|
||||
|
||||
const { group, sort, fromParts } = DateService.getQueryDateRange(options.slice);
|
||||
|
||||
if (!sort) throw Error('Slice is probably not correct');
|
||||
|
||||
const dateDistDays = (new Date(options.to).getTime() - new Date(options.from).getTime()) / (1000 * 60 * 60 * 24)
|
||||
// 15 Days
|
||||
if (options.slice === 'hour' && (dateDistDays > 15)) throw Error('Date gap too big for this slice');
|
||||
// 1 Year
|
||||
if (options.slice === 'day' && (dateDistDays > 365)) throw Error('Date gap too big for this slice');
|
||||
// 3 Years
|
||||
if (options.slice === 'month' && (dateDistDays > 365 * 3)) throw Error('Date gap too big for this slice');
|
||||
|
||||
|
||||
const aggregation = [
|
||||
{
|
||||
$match: {
|
||||
@@ -46,7 +57,7 @@ export async function executeAdvancedTimelineAggregation<T = {}>(options: Advanc
|
||||
console.log(JSON.stringify(aggregation, null, 2));
|
||||
}
|
||||
|
||||
const timeline: { _id: string, count: number & T }[] = await options.model.aggregate(aggregation);
|
||||
const timeline: ({ _id: string, count: number } & T)[] = await options.model.aggregate(aggregation);
|
||||
|
||||
return timeline;
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
||||
import { TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
|
||||
export async function hasAccessToProject(user_id: string, project_id: string, project?: TProject) {
|
||||
const targetProject = project || await ProjectModel.findById(project_id, { owner: true });
|
||||
const targetProject = project ?? await ProjectModel.findById(project_id, { owner: true });
|
||||
if (!targetProject) return [false, 'NONE'];
|
||||
if (targetProject.owner.toString() === user_id) return [true, 'OWNER'];
|
||||
const members = await TeamMemberModel.find({ project_id });
|
||||
if (members.map(e => e.user_id.toString()).includes(user_id)) return [true, 'GUEST'];
|
||||
const isGuest = await TeamMemberModel.exists({ project_id, user_id });
|
||||
if (isGuest) return [true, 'GUEST'];
|
||||
return [false, 'NONE'];
|
||||
}
|
||||
12
dashboard/server/utils/useCors.ts
Normal file
12
dashboard/server/utils/useCors.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
|
||||
import type { H3Event, EventHandlerRequest } from 'h3';
|
||||
|
||||
|
||||
export function useCors(event: H3Event<EventHandlerRequest>) {
|
||||
setResponseHeader(event, 'Access-Control-Allow-Origin', '*');
|
||||
setResponseHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
|
||||
setResponseHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
if (event.method === 'OPTIONS') return true;
|
||||
return false;
|
||||
}
|
||||
@@ -2,18 +2,23 @@ services:
|
||||
mongo:
|
||||
image: mongo
|
||||
environment:
|
||||
# Change with your database username
|
||||
MONGO_INITDB_ROOT_USERNAME: litlyx
|
||||
# Change with your database password
|
||||
MONGO_INITDB_ROOT_PASSWORD: litlyx
|
||||
ports:
|
||||
- 27017:27017
|
||||
# Uncomment to expose database
|
||||
# ports:
|
||||
# - 27017:27017
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
||||
cache:
|
||||
image: redis:alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
# Uncomment to expose redis
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
# Change with "--requirepass YOUR_REDIS_PASSWORD"
|
||||
command: redis-server --save 20 1 --loglevel warning --requirepass litlyx
|
||||
|
||||
producer:
|
||||
@@ -25,32 +30,29 @@ services:
|
||||
PORT: "3099"
|
||||
REDIS_URL: "redis://cache"
|
||||
REDIS_USERNAME: "default"
|
||||
# Change with your redis password
|
||||
REDIS_PASSWORD: "litlyx"
|
||||
STREAM_NAME: "lib-events"
|
||||
STREAM_NAME: "LITLYX"
|
||||
build:
|
||||
dockerfile: ./producer/Dockerfile
|
||||
|
||||
|
||||
broker:
|
||||
image: litlyx-broker
|
||||
consumer:
|
||||
image: litlyx-consumer
|
||||
restart: always
|
||||
ports:
|
||||
- "3999:3999"
|
||||
environment:
|
||||
|
||||
# Optional - Used to send welcome and quota emails
|
||||
|
||||
# NUXT_EMAIL_SERVICE: "Brevo"
|
||||
# NUXT_BREVO_API_KEY: ""
|
||||
|
||||
PORT: "3999"
|
||||
# EMAIL_SERVICE: "Brevo"
|
||||
# BREVO_API_KEY: ""
|
||||
# Change "litlyx:litlyx" with "mongodb://YOUR_MONGO_USERNAME:YOUR_MONGO_PASSWORD"
|
||||
MONGO_CONNECTION_STRING: "mongodb://litlyx:litlyx@mongo:27017/SimpleMetrics?readPreference=primaryPreferred&authSource=admin"
|
||||
REDIS_URL: "redis://cache"
|
||||
REDIS_USERNAME: "default"
|
||||
# Change with your redis password
|
||||
REDIS_PASSWORD: "litlyx"
|
||||
STREAM_NAME: "lib-events"
|
||||
STREAM_NAME: "LITLYX"
|
||||
GROUP_NAME: "DATABASE"
|
||||
build:
|
||||
dockerfile: ./broker/Dockerfile
|
||||
dockerfile: ./consumer/Dockerfile
|
||||
|
||||
dashboard:
|
||||
image: litlyx-dashboard
|
||||
@@ -59,60 +61,48 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NUXT_PORT: "3000"
|
||||
# Change "litlyx:litlyx" with "mongodb://YOUR_MONGO_USERNAME:YOUR_MONGO_PASSWORD"
|
||||
NUXT_MONGO_CONNECTION_STRING: 'mongodb://litlyx:litlyx@mongo:27017/SimpleMetrics?readPreference=primaryPreferred&authSource=admin'
|
||||
|
||||
NUXT_REDIS_URL: "redis://cache"
|
||||
NUXT_REDIS_USERNAME: "default"
|
||||
# Change with your redis password
|
||||
NUXT_REDIS_PASSWORD: "litlyx"
|
||||
|
||||
|
||||
# Optional - Used to use Lit, the AI analyst
|
||||
|
||||
# Optional - Used for Lit, the AI analyst
|
||||
# NUXT_AI_ORG: 'OPEN_AI_ORGANIZATION'
|
||||
# NUXT_AI_PROJECT: 'OPEN_AI_PROJECT'
|
||||
# NUXT_AI_KEY: 'OPEN_AI_KEY'
|
||||
|
||||
|
||||
# Optional - Used to send welcome and quota emails
|
||||
|
||||
# NUXT_EMAIL_SERVICE: "Brevo"
|
||||
# NUXT_BREVO_API_KEY: ""
|
||||
|
||||
# Change with your jwt secret
|
||||
NUXT_AUTH_JWT_SECRET: "litlyx_jwt_secret"
|
||||
|
||||
|
||||
# Optional - Used to register / login via google
|
||||
|
||||
# NUXT_GOOGLE_AUTH_CLIENT_ID: ""
|
||||
# NUXT_GOOGLE_AUTH_CLIENT_SECRET: ""
|
||||
|
||||
# NO_AUTH or GOOGLE
|
||||
|
||||
NUXT_PUBLIC_AUTH_MODE: 'NO_AUTH'
|
||||
|
||||
# Default user created in NO_AUTH mode
|
||||
|
||||
NUXT_NOAUTH_USER_EMAIL: 'default@user.com'
|
||||
NUXT_NOAUTH_USER_NAME: "defaultuser"
|
||||
|
||||
|
||||
# Optional - Used for tests
|
||||
|
||||
# NUXT_STRIPE_SECRET_TEST: ""
|
||||
# NUXT_STRIPE_WH_SECRET_TEST: ""
|
||||
|
||||
|
||||
# Optional - Stripe secret - Used to change plans of the projects
|
||||
|
||||
# NUXT_STRIPE_SECRET: ""
|
||||
# NUXT_STRIPE_WH_SECRET: ""
|
||||
|
||||
build:
|
||||
dockerfile: ./dashboard/Dockerfile
|
||||
#args:
|
||||
|
||||
# Optional - Used to register / login via google
|
||||
|
||||
# GOOGLE_AUTH_CLIENT_ID: ""
|
||||
|
||||
volumes:
|
||||
|
||||
26
landing/.gitignore
vendored
26
landing/.gitignore
vendored
@@ -1,26 +0,0 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
package-lock.json
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -1,75 +0,0 @@
|
||||
# Nuxt 3 Minimal Starter
|
||||
|
||||
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm run dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm run build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm run preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Litlyx - One-Line Code Lightweight Analytics | AI Powered Analytics Dashboard',
|
||||
ogTitle: 'Litlyx - One-Line Code Lightweight Analytics | AI Powered Analytics Dashboard',
|
||||
description: 'Track over 10 KPIs effortlessly with Litlyx. One line of code, open-source, lightweight, custom events, AI Data-Analyst at your service and affordable. Start for free!',
|
||||
ogDescription: 'Track over 10 KPIs effortlessly with Litlyx. One line of code, open-source, lightweight, custom events, AI Data-Analyst at your service and affordable. Start for free!',
|
||||
keywords: 'Litlyx, analytics tracking, real-time analytics, open-source analytics, minimal setup, KPI tracking, one line of code, high customization, AI data analyst assistant, report generation, user-friendly analytics, business intelligence, data visualization, performance metrics, customizable dashboards, data insights, seamless integration, powerful analytics',
|
||||
author: 'Litlyx',
|
||||
ogImage: 'https://litlyx.com/ogimage.jpg',
|
||||
ogType: 'website',
|
||||
ogUrl: 'https://litlyx.com',
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: 'Litlyx - One-Line Code Lightweight Analytics | AI Powered Analytics Dashboard',
|
||||
twitterDescription: 'Track over 10 KPIs effortlessly with Litlyx. One line of code, open-source, lightweight, custom events, AI Data-Analyst at your service and affordable. Start for free!',
|
||||
twitterImage: 'https://litlyx.com/ogimage.jpg',
|
||||
ogSiteName: 'Litlyx',
|
||||
ogLocale: 'en_US',
|
||||
ogImageWidth: '1200',
|
||||
ogImageHeight: '630',
|
||||
themeColor: '#0A0A0A',
|
||||
appleMobileWebAppCapable: 'yes',
|
||||
appleMobileWebAppStatusBarStyle: 'black-translucent',
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-dvw h-dvh bg-lyx-background">
|
||||
<NuxtLayout>
|
||||
<NuxtPage></NuxtPage>
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
27846
landing/assets/font-awesome/css/all.css
vendored
27846
landing/assets/font-awesome/css/all.css
vendored
File diff suppressed because it is too large
Load Diff
12
landing/assets/font-awesome/css/all.min.css
vendored
12
landing/assets/font-awesome/css/all.min.css
vendored
File diff suppressed because one or more lines are too long
1573
landing/assets/font-awesome/css/brands.css
vendored
1573
landing/assets/font-awesome/css/brands.css
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
12841
landing/assets/font-awesome/css/duotone.css
vendored
12841
landing/assets/font-awesome/css/duotone.css
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
13336
landing/assets/font-awesome/css/fontawesome.css
vendored
13336
landing/assets/font-awesome/css/fontawesome.css
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
19
landing/assets/font-awesome/css/light.css
vendored
19
landing/assets/font-awesome/css/light.css
vendored
@@ -1,19 +0,0 @@
|
||||
/*!
|
||||
* Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license (Commercial License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:root, :host {
|
||||
--fa-style-family-classic: 'Font Awesome 6 Pro';
|
||||
--fa-font-light: normal 300 1em/1 'Font Awesome 6 Pro'; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 6 Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-light-300.woff2") format("woff2"), url("../webfonts/fa-light-300.ttf") format("truetype"); }
|
||||
|
||||
.fal,
|
||||
.fa-light {
|
||||
font-weight: 300; }
|
||||
@@ -1,6 +0,0 @@
|
||||
/*!
|
||||
* Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license (Commercial License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Pro";--fa-font-light:normal 300 1em/1 "Font Awesome 6 Pro"}@font-face{font-family:"Font Awesome 6 Pro";font-style:normal;font-weight:300;font-display:block;src:url(../webfonts/fa-light-300.woff2) format("woff2"),url(../webfonts/fa-light-300.ttf) format("truetype")}.fa-light,.fal{font-weight:300}
|
||||
19
landing/assets/font-awesome/css/regular.css
vendored
19
landing/assets/font-awesome/css/regular.css
vendored
@@ -1,19 +0,0 @@
|
||||
/*!
|
||||
* Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license (Commercial License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:root, :host {
|
||||
--fa-style-family-classic: 'Font Awesome 6 Pro';
|
||||
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Pro'; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 6 Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); }
|
||||
|
||||
.far,
|
||||
.fa-regular {
|
||||
font-weight: 400; }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user