mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-09 23:48:36 +01:00
Merge branch 'snapshot-rework'
This commit is contained in:
@@ -1,38 +1,28 @@
|
||||
# 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 ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
|
||||
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
|
||||
RUN pnpm install
|
||||
RUN pnpm install --filter consumer
|
||||
|
||||
WORKDIR /home/app/scripts
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install
|
||||
|
||||
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
|
||||
RUN pnpm run build
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "/home/app/consumer/dist/consumer/src/index.js"]
|
||||
@@ -2,15 +2,19 @@ module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'consumer',
|
||||
exec_mode: 'fork',
|
||||
port: '3031',
|
||||
exec_mode: 'cluster',
|
||||
instances: '2',
|
||||
script: './dist/consumer/src/index.js',
|
||||
env: {
|
||||
MONGO_CONNECTION_STRING: "",
|
||||
EMAIL_SERVICE: '',
|
||||
BREVO_API_KEY: '',
|
||||
MONGO_CONNECTION_STRING: '',
|
||||
REDIS_URL: "",
|
||||
REDIS_USERNAME: "",
|
||||
REDIS_PASSWORD: "",
|
||||
STREAM_NAME: "",
|
||||
GROUP_NAME: ""
|
||||
GROUP_NAME: ''
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^2.2.0",
|
||||
"mongoose": "^8.3.2",
|
||||
"redis": "^4.6.14",
|
||||
"express": "^4.19.2",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -11,17 +9,17 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"name": "consumer-database",
|
||||
"name": "consumer",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start_dev.js",
|
||||
"compile": "tsc",
|
||||
"build": "node ../scripts/build.js",
|
||||
"build_project": "node ../scripts/build.js",
|
||||
"build": "npm run compile && npm run build_project && npm run create_db",
|
||||
"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"
|
||||
"docker-inspect": "docker run -it litlyx-consumer sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Emily",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
||||
import EmailService from '@services/EmailService';
|
||||
import { requireEnv } from "@utils/requireEnv";
|
||||
import { TProjectLimit } from "@schema/ProjectsLimits";
|
||||
import { TProjectLimit } from "@schema/project/ProjectsLimits";
|
||||
|
||||
if (process.env.EMAIL_SERVICE) {
|
||||
EmailService.init(requireEnv('BREVO_API_KEY'));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
import { checkLimitsForEmail } from './EmailController';
|
||||
|
||||
|
||||
@@ -2,20 +2,39 @@
|
||||
import { requireEnv } from '@utils/requireEnv';
|
||||
import { connectDatabase } from '@services/DatabaseService';
|
||||
import { RedisStreamService } from '@services/RedisStreamService';
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { lookup } from './lookup';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { checkLimits } from './LimitChecker';
|
||||
import express from 'express';
|
||||
|
||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||
import { ProjectCountModel } from '@schema/ProjectsCounts';
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import { ProjectCountModel } from '@schema/project/ProjectsCounts';
|
||||
|
||||
|
||||
const app = express();
|
||||
|
||||
let durations: number[] = [];
|
||||
|
||||
app.get('/status', async (req, res) => {
|
||||
try {
|
||||
return res.json({ status: 'ALIVE', durations })
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
return res.setStatus(500).json({ error: ex.message });
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(process.env.PORT);
|
||||
|
||||
connectDatabase(requireEnv('MONGO_CONNECTION_STRING'));
|
||||
main();
|
||||
|
||||
|
||||
|
||||
async function main() {
|
||||
|
||||
await RedisStreamService.connect();
|
||||
@@ -30,9 +49,10 @@ async function main() {
|
||||
}
|
||||
|
||||
async function processStreamEntry(data: Record<string, string>) {
|
||||
try {
|
||||
|
||||
const start = Date.now();
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
|
||||
const eventType = data._type;
|
||||
if (!eventType) return;
|
||||
@@ -53,18 +73,24 @@ async function processStreamEntry(data: Record<string, string>) {
|
||||
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);
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
durations.push(duration);
|
||||
if (durations.length > 1000) {
|
||||
durations = durations.splice(500);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function process_visit(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, ip, website, page, referrer, userAgent, flowHash } = data;
|
||||
const { pid, ip, website, page, referrer, userAgent, flowHash, timestamp } = data;
|
||||
|
||||
let referrerParsed;
|
||||
try {
|
||||
@@ -89,6 +115,7 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
|
||||
flowHash,
|
||||
continent: geoLocation[0],
|
||||
country: geoLocation[1],
|
||||
created_at: new Date(parseInt(timestamp))
|
||||
}),
|
||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } }, { upsert: true }),
|
||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'visits': 1 } })
|
||||
@@ -98,7 +125,7 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
|
||||
|
||||
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { pid, instant, flowHash } = data;
|
||||
const { pid, instant, flowHash, timestamp } = data;
|
||||
|
||||
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
|
||||
if (!existingSession) {
|
||||
@@ -123,7 +150,7 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
|
||||
|
||||
async function process_event(data: Record<string, string>, sessionHash: string) {
|
||||
|
||||
const { name, metadata, pid, flowHash } = data;
|
||||
const { name, metadata, pid, flowHash, timestamp } = data;
|
||||
|
||||
let metadataObject;
|
||||
try {
|
||||
@@ -133,7 +160,10 @@ async function process_event(data: Record<string, string>, sessionHash: string)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
EventModel.create({ project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash }),
|
||||
EventModel.create({
|
||||
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
|
||||
created_at: new Date(parseInt(timestamp))
|
||||
}),
|
||||
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),
|
||||
ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } })
|
||||
]);
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
"@schema/*": [
|
||||
"../shared/schema/*"
|
||||
],
|
||||
"@services/*": [
|
||||
"../shared/services/*"
|
||||
],
|
||||
"@data/*": [
|
||||
"../shared/data/*"
|
||||
],
|
||||
"@functions/*": [
|
||||
"../shared/functions/*"
|
||||
],
|
||||
"@utils/*": [
|
||||
"../shared/utils/*"
|
||||
]
|
||||
}
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
|
||||
@@ -1,50 +1,31 @@
|
||||
# Start with a minimal Node.js base image
|
||||
|
||||
FROM node:21-alpine AS base
|
||||
|
||||
# Create a distinct build environment
|
||||
FROM base AS build
|
||||
|
||||
# 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 ./package.json ./tsconfig.json ./pnpm-lock.yaml ./
|
||||
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/
|
||||
|
||||
# Install dependencies for each package
|
||||
WORKDIR /home/app/lyx-ui
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install
|
||||
RUN pnpm install --filter dashboard
|
||||
|
||||
# WORKDIR /home/app/shared
|
||||
# RUN pnpm install --frozen-lockfile
|
||||
|
||||
WORKDIR /home/app/dashboard
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Now copy the rest of the source files
|
||||
WORKDIR /home/app
|
||||
|
||||
COPY --link ./dashboard ./dashboard
|
||||
COPY --link ./lyx-ui ./lyx-ui
|
||||
COPY --link ./shared ./shared
|
||||
|
||||
# Build the dashboard
|
||||
WORKDIR /home/app/dashboard
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
# Use a smaller base image for the final production build
|
||||
FROM node:21-alpine AS production
|
||||
|
||||
# Set the working directory for the production container
|
||||
WORKDIR /home/app
|
||||
|
||||
# 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"]
|
||||
@@ -10,7 +10,7 @@ const { alerts, closeAlert } = useAlert();
|
||||
|
||||
const { showDialog, closeDialog, dialogComponent, dialogParams, dialogStyle, dialogClosable } = useCustomDialog();
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -18,10 +18,10 @@ const { visible } = usePricingDrawer();
|
||||
|
||||
<div class="w-dvw h-dvh bg-lyx-background-light relative">
|
||||
|
||||
<Transition name="pdrawer">
|
||||
<LazyPricingDrawer @onCloseClick="visible = false"
|
||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="visible">
|
||||
</LazyPricingDrawer>
|
||||
<Transition name="drawer">
|
||||
<LazyDrawerGeneric @onCloseClick="hideDrawer()" :class="drawerClasses"
|
||||
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="drawerVisible">
|
||||
</LazyDrawerGeneric>
|
||||
</Transition>
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ const { visible } = usePricingDrawer();
|
||||
|
||||
<UModals />
|
||||
|
||||
<LazyOnboarding> </LazyOnboarding>
|
||||
|
||||
<NuxtLayout>
|
||||
<NuxtPage></NuxtPage>
|
||||
</NuxtLayout>
|
||||
@@ -78,18 +80,18 @@ const { visible } = usePricingDrawer();
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pdrawer-enter-active,
|
||||
.pdrawer-leave-active {
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: all .5s ease-in-out;
|
||||
}
|
||||
|
||||
.pdrawer-enter-from,
|
||||
.pdrawer-leave-to {
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
transform: translateX(100%)
|
||||
}
|
||||
|
||||
.pdrawer-enter-to,
|
||||
.pdrawer-leave-from {
|
||||
.drawer-enter-to,
|
||||
.drawer-leave-from {
|
||||
transform: translateX(0)
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
@use './utilities.scss';
|
||||
@use './colors.scss';
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap');
|
||||
@import url('https://fonts.cdnfonts.com/css/brockmann');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
|
||||
|
||||
@import '../font-awesome/css/all.css';
|
||||
@import './utilities.scss';
|
||||
@import './colors.scss';
|
||||
|
||||
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
|
||||
|
||||
@@ -105,8 +105,6 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
|
||||
});
|
||||
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -208,8 +206,7 @@ const pricingDrawer = usePricingDrawer();
|
||||
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
|
||||
<div class="flex gap-1 items-center justify-center text-lyx-text-dark">
|
||||
<div class="poppins">
|
||||
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim()
|
||||
}}
|
||||
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
|
||||
</div>
|
||||
<div class="poppins"> to </div>
|
||||
<div class="poppins">
|
||||
@@ -217,7 +214,7 @@ const pricingDrawer = usePricingDrawer();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
|
||||
<div class="mt-2" v-if="('default' in snapshot == false)">
|
||||
<UPopover placement="bottom">
|
||||
<LyxUiButton type="danger" class="w-full text-center">
|
||||
Delete current snapshot
|
||||
|
||||
175
dashboard/components/Onboarding.vue
Normal file
175
dashboard/components/Onboarding.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: needsOnboarding } = useFetch("/api/onboarding/exist", {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false, useTimeOffset: false })
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const analyticsList = [
|
||||
"I have no prior analytics tool",
|
||||
"Google Analytics 4",
|
||||
"Plausible",
|
||||
"Umami",
|
||||
"MixPanel",
|
||||
"Simple Analytics",
|
||||
"Matomo",
|
||||
"Fathom",
|
||||
"Adobe Analytics",
|
||||
"Other"
|
||||
]
|
||||
|
||||
const jobsList = [
|
||||
"Developer",
|
||||
"Marketing",
|
||||
"Product",
|
||||
"Startup founder",
|
||||
"Indie hacker",
|
||||
"Other",
|
||||
]
|
||||
|
||||
const selectedIndex = ref<number>(-1);
|
||||
const otherFieldVisisble = ref<boolean>(false);
|
||||
const otherText = ref<string>('');
|
||||
function selectIndex(index: number) {
|
||||
selectedIndex.value = index;
|
||||
otherFieldVisisble.value = index == analyticsList.length - 1;
|
||||
}
|
||||
|
||||
const selectedIndex2 = ref<number>(-1);
|
||||
const otherFieldVisisble2 = ref<boolean>(false);
|
||||
const otherText2 = ref<string>('');
|
||||
function selectIndex2(index: number) {
|
||||
selectedIndex2.value = index;
|
||||
otherFieldVisisble2.value = index == jobsList.length - 1;
|
||||
}
|
||||
|
||||
const page = ref<number>(0);
|
||||
|
||||
function onNextPage() {
|
||||
if (selectedIndex.value == -1) return;
|
||||
saveAnalyticsType();
|
||||
page.value = 1;
|
||||
}
|
||||
|
||||
function onFinish(skipped?: boolean) {
|
||||
if (skipped) return location.reload();
|
||||
if (selectedIndex2.value == -1) return;
|
||||
saveJobTitle();
|
||||
page.value = 2;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function saveAnalyticsType() {
|
||||
await $fetch('/api/onboarding/add', {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false, useTimeOffset: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
analytics:
|
||||
selectedIndex.value == analyticsList.length - 1 ?
|
||||
otherText.value :
|
||||
analyticsList[selectedIndex.value]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function saveJobTitle() {
|
||||
|
||||
await $fetch('/api/onboarding/add', {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false, useTimeOffset: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
job:
|
||||
selectedIndex2.value == jobsList.length - 1 ?
|
||||
otherText2.value :
|
||||
jobsList[selectedIndex2.value]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const showOnboarding = computed(() => {
|
||||
if (route.path === '/login') return false;
|
||||
if (route.path === '/register') return false;
|
||||
if (needsOnboarding.value?.exist === false) return true;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div v-if="showOnboarding" class="absolute top-0 left-0 w-full h-full z-[30] bg-black/80 flex justify-center">
|
||||
|
||||
|
||||
|
||||
<div v-if="page == 0" class="bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||
|
||||
<div class="text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||
|
||||
<div class="text-lyx-text mt-4">
|
||||
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
|
||||
going to be your first/main analytics?
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||
<div v-for="(e, i) of analyticsList">
|
||||
<div @click="selectIndex(i)"
|
||||
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
|
||||
class="bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||
{{ e }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||
v-model="otherText"></LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-center flex-col items-center">
|
||||
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
|
||||
type="primary"> Next </LyxUiButton>
|
||||
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-if="page == 1" class="bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
|
||||
|
||||
<div class="text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
|
||||
|
||||
<div class="text-lyx-text mt-4">
|
||||
What is your job title ?
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-8">
|
||||
<div v-for="(e, i) of jobsList">
|
||||
<div @click="selectIndex2(i)"
|
||||
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
|
||||
class="bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
|
||||
{{ e }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
|
||||
v-model="otherText2"></LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-center flex-col items-center">
|
||||
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
|
||||
Finish </LyxUiButton>
|
||||
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TProject } from '@schema/ProjectSchema';
|
||||
import type { TProject } from '@schema/project/ProjectSchema';
|
||||
|
||||
const { user } = useLoggedUser()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type Props = {
|
||||
options: { label: string }[],
|
||||
options: { label: string, disabled?: boolean }[],
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
@@ -17,9 +17,12 @@ const emits = defineEmits<{
|
||||
<template>
|
||||
|
||||
<div class="flex gap-2 border-[1px] border-lyx-widget-lighter p-1 md:p-2 rounded-xl bg-lyx-widget">
|
||||
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||
class="hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
||||
:class="{ 'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index }">
|
||||
:class="{
|
||||
'bg-lyx-widget-lighter hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
|
||||
'hover:!bg-lyx-widget !cursor-not-allowed text-lyx-widget-lighter': opt.disabled
|
||||
}">
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,10 @@ const limitsInfo = await useFetch("/api/project/limits_info", {
|
||||
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
|
||||
const { project } = useProject()
|
||||
@@ -20,7 +20,8 @@ const isPremium = computed(() => {
|
||||
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
<div class="poppins font-semibold text-lyx-primary">
|
||||
Launch offer: 25% off forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout
|
||||
Launch offer: 25% off forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at
|
||||
checkout
|
||||
from Acceleration Plan and beyond.
|
||||
</div>
|
||||
<!-- <div class="poppins text-lyx-primary">
|
||||
|
||||
@@ -6,6 +6,23 @@ import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -24,9 +41,12 @@ const chartOptions = ref<ChartOptions<'line'>>({
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
stacked: false,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
@@ -65,12 +85,32 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
borderColor: '#5655d7',
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#5655d7',
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return '#5655d7'
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||
return undefined;
|
||||
},
|
||||
backgroundColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return createGradient('#5655d7');
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unique sessions',
|
||||
@@ -81,19 +121,21 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
hoverBackgroundColor: '#4abde8',
|
||||
hoverBorderColor: '#4abde8',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bar'
|
||||
type: 'bar',
|
||||
// barThickness: 20,
|
||||
borderSkipped: ['bottom']
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
data: [],
|
||||
backgroundColor: ['#fbbf24'],
|
||||
borderColor: '#fbbf24',
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#fbbf24',
|
||||
hoverBorderColor: '#fbbf24',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bubble',
|
||||
stack: 'combined'
|
||||
stack: 'combined',
|
||||
borderColor: ["#fbbf24"]
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -106,6 +148,17 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
||||
const { chart, tooltip } = context;
|
||||
const tooltipEl = externalTooltipElement.value;
|
||||
|
||||
const currentIndex = tooltip.dataPoints[0].parsed.x;
|
||||
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (todayIndex && todayIndex >= 0) {
|
||||
if (currentIndex > todayIndex - 1) {
|
||||
if (!tooltipEl) return;
|
||||
return tooltipEl.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
currentTooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
|
||||
currentTooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
|
||||
currentTooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2 as number;
|
||||
@@ -130,13 +183,29 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
|
||||
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||
|
||||
}
|
||||
|
||||
const { snapshotDuration } = useSnapshot();
|
||||
|
||||
const selectLabels: { label: string, value: Slice }[] = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
|
||||
const selectedSlice = computed(() => selectLabels[selectedLabelIndex.value].value);
|
||||
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
|
||||
return selectLabels.map(e => {
|
||||
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
|
||||
});
|
||||
})
|
||||
|
||||
const selectedSlice = computed<Slice>(() => {
|
||||
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
|
||||
if (!targetValue) return 'day';
|
||||
if (targetValue.disabled) {
|
||||
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
|
||||
}
|
||||
return selectLabelsAvailable.value[selectedLabelIndex.value].value
|
||||
});
|
||||
|
||||
const selectedLabelIndex = ref<number>(1);
|
||||
const allDatesFull = ref<string[]>([]);
|
||||
@@ -144,13 +213,18 @@ const allDatesFull = ref<string[]>([]);
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, selectLabels[selectedLabelIndex.value].value));
|
||||
allDatesFull.value = input.map(e => e._id.toString());
|
||||
return { data, labels }
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value));
|
||||
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||
|
||||
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
|
||||
return { data, labels, todayIndex }
|
||||
}
|
||||
|
||||
function onResponseError(e: any) {
|
||||
errorData.value = { errored: true, text: e.response._data.message ?? 'Generic error' }
|
||||
let message = e.response._data.message ?? 'Generic error';
|
||||
if (message == 'internal server error') message = 'Please change slice';
|
||||
errorData.value = { errored: true, text: message }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
@@ -180,21 +254,7 @@ watch(readyToDisplay, () => {
|
||||
})
|
||||
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
function onDataReady() {
|
||||
if (!visitsData.data.value) return;
|
||||
@@ -208,15 +268,33 @@ function onDataReady() {
|
||||
|
||||
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||
|
||||
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||
const rValue = 25 / maxEventSize * e;
|
||||
return { x: 0, y: maxChartY + 70, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
const rValue = 20 / maxEventSize * e;
|
||||
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
});
|
||||
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||
|
||||
|
||||
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return true;
|
||||
return 'bottom';
|
||||
});
|
||||
|
||||
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return '#fbbf2400';
|
||||
return '#fbbf24';
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
updateChart();
|
||||
}
|
||||
|
||||
@@ -230,7 +308,8 @@ const currentTooltipData = ref<{ visits: number, events: number, sessions: numbe
|
||||
const tooltipNameIndex = ['visits', 'sessions', 'events'];
|
||||
|
||||
function onLegendChange(dataset: any, index: number, checked: any) {
|
||||
dataset.hidden = !checked;
|
||||
const newValue = !checked;
|
||||
dataset.hidden = newValue;
|
||||
}
|
||||
|
||||
const legendColors = ref<string[]>(['#5655d7', '#4abde8', '#fbbf24'])
|
||||
@@ -247,7 +326,7 @@ const legendClasses = ref<string[]>([
|
||||
<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">
|
||||
:options="selectLabelsAvailable">
|
||||
</SelectButton>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,23 +5,18 @@ const props = defineProps<{
|
||||
value: string,
|
||||
text: string,
|
||||
avg?: string,
|
||||
trend?: number,
|
||||
color: string,
|
||||
data?: number[],
|
||||
labels?: string[],
|
||||
ready?: boolean,
|
||||
slow?: boolean
|
||||
slow?: boolean,
|
||||
todayIndex: number,
|
||||
tooltipText: string
|
||||
}>();
|
||||
|
||||
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';
|
||||
})
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -36,30 +31,23 @@ const uTooltipText = computed(() => {
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="brockmann text-text-dirty text-[1.2rem] 2xl:text-[1.4rem]">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.65rem] 2xl:text-[.8rem]"> {{ avg }} </div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem] 2xl:text-[1rem]"> {{ text }} </div>
|
||||
</div>
|
||||
<div v-if="trend" class="flex flex-col items-center gap-1">
|
||||
<UTooltip :text="uTooltipText">
|
||||
<div class="flex items-center gap-3 rounded-md 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 class="flex flex-col items-center gap-1">
|
||||
<UTooltip :text="props.tooltipText">
|
||||
<i class="far fa-info-circle text-lyx-text-darker text-[1rem]"></i>
|
||||
</UTooltip>
|
||||
<!-- <div class="poppins text-text-sub text-[.7rem]"> Trend </div> -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end"
|
||||
v-if="((props.data?.length || 0) > 0) && ready">
|
||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
|
||||
:color="props.color">
|
||||
<DashboardEmbedChartCard v-if="ready" :todayIndex="todayIndex" :data="props.data || []"
|
||||
:labels="props.labels || []" :color="props.color">
|
||||
</DashboardEmbedChartCard>
|
||||
</div>
|
||||
<div v-if="!ready" class="flex justify-center items-center w-full h-full flex-col gap-2">
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string,
|
||||
title: string,
|
||||
text: string,
|
||||
sub: string,
|
||||
color: string
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
||||
|
||||
<!-- <div class="absolute flex items-center justify-center right-4 top-4 cursor-pointer hover:text-blue-400">
|
||||
<i class="fal fa-info-circle text-[.9rem] lg:text-[1.4rem]"></i>
|
||||
</div> -->
|
||||
|
||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
||||
:style="`background: ${props.color}`">
|
||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
||||
</div>
|
||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center lg:items-end">
|
||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -7,8 +7,10 @@ const props = defineProps<{
|
||||
data: any[],
|
||||
labels: string[]
|
||||
color: string,
|
||||
todayIndex: number
|
||||
}>();
|
||||
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -48,10 +50,22 @@ const chartData = ref<ChartData<'line'>>({
|
||||
data: props.data,
|
||||
backgroundColor: [props.color + '77'],
|
||||
borderColor: props.color,
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
if (!props.todayIndex || props.todayIndex == -1) return props.color;
|
||||
if (ctx.p1DataIndex >= props.todayIndex) return props.color + '00';
|
||||
return props.color;
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
if (!props.todayIndex || props.todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == props.todayIndex -1) return [2, 4];
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,68 +3,57 @@
|
||||
import DateService from '@services/DateService';
|
||||
import type { Slice } from '@services/DateService';
|
||||
|
||||
const { snapshot, safeSnapshotDates } = useSnapshot()
|
||||
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
|
||||
|
||||
const snapshotDays = computed(() => {
|
||||
const to = new Date(safeSnapshotDates.value.to).getTime();
|
||||
const from = new Date(safeSnapshotDates.value.from).getTime();
|
||||
return (to - from) / 1000 / 60 / 60 / 24;
|
||||
});
|
||||
|
||||
const chartSlice = computed(() => {
|
||||
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 30) return 'day' as Slice;
|
||||
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 90) return 'day' as Slice;
|
||||
if (snapshotDuration.value <= 3) return 'hour' as Slice;
|
||||
if (snapshotDuration.value <= 32) return 'day' as Slice;
|
||||
return 'month' as Slice;
|
||||
});
|
||||
|
||||
|
||||
function findFirstZeroOrNullIndex(arr: (number | null)[]) {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr.slice(i).every(val => val === 0 || val === null)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
|
||||
const data = input.map(e => e.count || 0);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), chartSlice.value));
|
||||
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, chartSlice.value));
|
||||
|
||||
const pool = [...input.map(e => e.count || 0)];
|
||||
|
||||
const avg = pool.reduce((a, e) => a + e, 0) / pool.length;
|
||||
|
||||
const targets = input.slice(Math.floor(input.length / 4 * 3));
|
||||
const targetAvg = targets.reduce((a, e) => a + e.count, 0) / targets.length;
|
||||
|
||||
const diffPercent: number = (100 / avg * (targetAvg)) - 100;
|
||||
|
||||
const trend = Math.max(Math.min(diffPercent, 99), -99);
|
||||
|
||||
return { data, labels, trend }
|
||||
return { data, labels, input }
|
||||
|
||||
}
|
||||
|
||||
const visitsData = useFetch('/api/timeline/visits', {
|
||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
|
||||
const sessionsData = useFetch('/api/timeline/sessions', {
|
||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
|
||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
|
||||
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
|
||||
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
|
||||
});
|
||||
|
||||
const avgVisitDay = computed(() => {
|
||||
if (!visitsData.data.value) return '0.00';
|
||||
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgSessionsDay = computed(() => {
|
||||
if (!sessionsData.data.value) return '0.00';
|
||||
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
|
||||
const avg = counts / Math.max(snapshotDays.value, 1);
|
||||
const avg = counts / Math.max(snapshotDuration.value, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
@@ -97,6 +86,11 @@ const avgSessionDuration = computed(() => {
|
||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||
});
|
||||
|
||||
const todayIndex = computed(() => {
|
||||
if (!visitsData.data.value) return -1;
|
||||
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -104,29 +98,33 @@ const avgSessionDuration = computed(() => {
|
||||
<template>
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
|
||||
|
||||
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total visits"
|
||||
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.data.value?.trend"
|
||||
:data="visitsData.data.value?.data" :labels="visitsData.data.value?.labels" color="#5655d7">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
|
||||
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgVisitDay) + '/day'" :data="visitsData.data.value?.data"
|
||||
tooltipText="Sum of all page views on your website."
|
||||
:labels="visitsData.data.value?.labels" color="#5655d7">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="!bouncingRateData.pending.value" icon="far fa-chart-user" text="Bouncing rate"
|
||||
:value="avgBouncingRate" :trend="bouncingRateData.data.value?.trend" :slow="true"
|
||||
:data="bouncingRateData.data.value?.data" :labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!bouncingRateData.pending.value" icon="far fa-chart-user"
|
||||
text="Bouncing rate" :value="avgBouncingRate" :slow="true" :data="bouncingRateData.data.value?.data"
|
||||
tooltipText="Percentage of users who leave quickly (lower is better)."
|
||||
:labels="bouncingRateData.data.value?.labels" color="#1e9b86">
|
||||
</DashboardCountCard>
|
||||
|
||||
|
||||
<DashboardCountCard :ready="!sessionsData.pending.value" icon="far fa-user" text="Unique visitors"
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsData.pending.value" icon="far fa-user"
|
||||
text="Unique visitors"
|
||||
:value="formatNumberK(sessionsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :trend="sessionsData.data.value?.trend"
|
||||
:data="sessionsData.data.value?.data" :labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
tooltipText="Count of distinct users visiting your website."
|
||||
:avg="formatNumberK(avgSessionsDay) + '/day'" :data="sessionsData.data.value?.data"
|
||||
:labels="sessionsData.data.value?.labels" color="#4abde8">
|
||||
</DashboardCountCard>
|
||||
|
||||
|
||||
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||
text="Visit duration" :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
|
||||
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
|
||||
color="#f56523">
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
|
||||
text="Visit duration" :value="avgSessionDuration" :data="sessionsDurationData.data.value?.data"
|
||||
tooltipText="Average time users spend on your website."
|
||||
:labels="sessionsDurationData.data.value?.labels" color="#f56523">
|
||||
</DashboardCountCard>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
||||
onMounted(() => startWatching());
|
||||
onUnmounted(() => stopWatching());
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
@@ -62,7 +63,7 @@ function showAnomalyInfoAlert() {
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div v-if="!selfhosted" class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="poppins font-regular text-[.9rem]"> AI Anomaly Detector </div>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -10,7 +10,7 @@ const { safeSnapshotDates } = useSnapshot()
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), props.slice));
|
||||
return { data, labels }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const { closeDialog } = useCustomDialog();
|
||||
|
||||
import { sub, format, isSameDay, type Duration } from 'date-fns'
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
@@ -46,14 +46,14 @@ async function confirmSnapshot() {
|
||||
body: JSON.stringify({
|
||||
name: snapshotName.value,
|
||||
color: currentColor.value,
|
||||
from: selected.value.start.toISOString(),
|
||||
to: selected.value.end.toISOString()
|
||||
from: startOfDay(selected.value.start),
|
||||
to: endOfDay(selected.value.end)
|
||||
})
|
||||
});
|
||||
|
||||
await updateSnapshots();
|
||||
closeDialog();
|
||||
createAlert('Snapshot created','Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
createAlert('Snapshot created', 'Snapshot created successfully', 'far fa-circle-check', 5000);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
13
dashboard/components/drawer/Docs.vue
Normal file
13
dashboard/components/drawer/Docs.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<iframe class="w-full h-full" src="https://docs.litlyx.com/introduction" frameborder="0"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
20
dashboard/components/drawer/Generic.vue
Normal file
20
dashboard/components/drawer/Generic.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const emits = defineEmits<{ (evt: 'onCloseClick'): void }>();
|
||||
|
||||
const { drawerComponent } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
<i class="fas fa-close text-[1.6rem]"></i>
|
||||
</div>
|
||||
|
||||
<Component v-if="drawerComponent" :is="drawerComponent"></Component>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PricingCardProp } from './PricingCardGeneric.vue';
|
||||
|
||||
import type { PricingCardProp } from '../pricing/PricingCardGeneric.vue';
|
||||
|
||||
|
||||
const { data: planData, refresh: refreshPlanData } = useFetch('/api/project/plan', {
|
||||
@@ -182,35 +181,11 @@ function getPricingsData() {
|
||||
return { freePricing, customPricing, slidePricings }
|
||||
}
|
||||
|
||||
const { projectId } = useProject();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'onCloseClick'): void
|
||||
}>();
|
||||
|
||||
async function onLifetimeUpgradeClick() {
|
||||
const res = await $fetch<string>(`/api/pay/create-onetime`, {
|
||||
...signHeaders({
|
||||
'content-type': 'application/json',
|
||||
'x-pid': projectId.value ?? ''
|
||||
}),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planId: 2001 })
|
||||
})
|
||||
if (!res) alert('Something went wrong');
|
||||
window.open(res);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 overflow-y-auto">
|
||||
|
||||
<div @click="$emit('onCloseClick')"
|
||||
class="cursor-pointer fixed top-4 right-4 rounded-full bg-menu drop-shadow-[0_0_2px_#CCCCCCCC] w-9 h-9 flex items-center justify-center">
|
||||
<i class="fas fa-close text-[1.6rem]"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8 mt-10 h-max xl:flex-row flex-col">
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().freePricing"></PricingCardGeneric>
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().slidePricings" :default-index="2">
|
||||
@@ -218,52 +193,6 @@ async function onLifetimeUpgradeClick() {
|
||||
<PricingCardGeneric class="flex-1" :datas="getPricingsData().customPricing"></PricingCardGeneric>
|
||||
</div>
|
||||
|
||||
<!-- <LyxUiCard class="w-full mt-6">
|
||||
<div class="flex">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<span class="text-lyx-primary font-semibold text-[1.4rem]">
|
||||
LIFETIME DEAL
|
||||
</span>
|
||||
<span class="text-lyx-text-dark text-[.8rem]"> (Growth plan) </span>
|
||||
</div>
|
||||
<div class="text-[2rem]"> € 2.399,00 </div>
|
||||
<div> Up to 500.000 visits/events per month </div>
|
||||
<LyxUiButton type="primary" @click="onLifetimeUpgradeClick()"> Purchase </LyxUiButton>
|
||||
</div>
|
||||
<div class="flex justify-evenly grow">
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Slack support </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Unlimited domanis </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Unlimited reports </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-evenly">
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> AI Tokens: 3.000 / month </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Server type: SHARED </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img class="h-6" :src="'/check.png'" alt="Check">
|
||||
<div> Data retention: 5 Years </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LyxUiCard> -->
|
||||
|
||||
<div class="flex justify-between items-center mt-10 flex-col xl:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="poppins text-[2rem] font-semibold">
|
||||
@@ -282,7 +211,5 @@ async function onLifetimeUpgradeClick() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,157 +0,0 @@
|
||||
<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>
|
||||
@@ -1,170 +0,0 @@
|
||||
<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>
|
||||
@@ -15,8 +15,6 @@ export type PricingCardProp = {
|
||||
|
||||
const props = defineProps<{ datas: PricingCardProp[], defaultIndex?: number }>();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const currentIndex = ref<number>(props.defaultIndex || 0);
|
||||
|
||||
const data = computed(() => {
|
||||
|
||||
@@ -111,8 +111,7 @@ async function saveBillingInfo() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
const { visible } = usePricingDrawer();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -128,9 +127,11 @@ const { visible } = usePricingDrawer();
|
||||
<template #info>
|
||||
<div v-if="!isGuest">
|
||||
<div class="flex flex-col gap-4">
|
||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 1" v-model="currentBillingInfo.line1">
|
||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 1"
|
||||
v-model="currentBillingInfo.line1">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 2" v-model="currentBillingInfo.line2">
|
||||
<LyxUiInput class="px-2 py-2 !bg-[#161616]" placeholder="Address line 2"
|
||||
v-model="currentBillingInfo.line2">
|
||||
</LyxUiInput>
|
||||
<div class="flex gap-4 w-full">
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="Country"
|
||||
@@ -141,9 +142,11 @@ const { visible } = usePricingDrawer();
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
<div class="flex gap-4 w-full">
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="City" v-model="currentBillingInfo.city">
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="City"
|
||||
v-model="currentBillingInfo.city">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="State" v-model="currentBillingInfo.state">
|
||||
<LyxUiInput class="px-2 py-2 w-full !bg-[#161616]" placeholder="State"
|
||||
v-model="currentBillingInfo.state">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,7 +198,7 @@ const { visible } = usePricingDrawer();
|
||||
<div class="poppins"> Expire date:</div>
|
||||
<div> {{ prettyExpireDate }}</div>
|
||||
</div>
|
||||
<LyxUiButton v-if="!isGuest" @click="visible = true" type="primary">
|
||||
<LyxUiButton v-if="!isGuest" @click="showDrawer('PRICING')" type="primary">
|
||||
Upgrade plan
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
91
dashboard/composables/snapshots/BaseSnapshots.ts
Normal file
91
dashboard/composables/snapshots/BaseSnapshots.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
|
||||
import type { TProjectSnapshot } from "@schema/project/ProjectSnapshot";
|
||||
|
||||
import * as fns from 'date-fns';
|
||||
|
||||
export type DefaultSnapshot = TProjectSnapshot & { default: true }
|
||||
export type GenericSnapshot = TProjectSnapshot | DefaultSnapshot;
|
||||
|
||||
export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'], project_created_at: Date | string) {
|
||||
|
||||
const today: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___today' as any,
|
||||
name: 'Today',
|
||||
from: fns.startOfDay(Date.now()),
|
||||
to: fns.endOfDay(Date.now()),
|
||||
color: '#FFA600',
|
||||
default: true
|
||||
}
|
||||
|
||||
const lastDay: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___lastDay' as any,
|
||||
name: 'Yesterday',
|
||||
from: fns.startOfDay(fns.subDays(Date.now(), 1)),
|
||||
to: fns.endOfDay(fns.subDays(Date.now(), 1)),
|
||||
color: '#FF8531',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
const lastMonth: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___lastMonth' as any,
|
||||
name: 'Last Month',
|
||||
from: fns.startOfMonth(fns.subMonths(Date.now(), 1)),
|
||||
to: fns.endOfMonth(fns.subMonths(Date.now(), 1)),
|
||||
color: '#BC5090',
|
||||
default: true
|
||||
}
|
||||
const currentMonth: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___currentMonth' as any,
|
||||
name: 'Current Month',
|
||||
from: fns.startOfMonth(Date.now()),
|
||||
to: fns.endOfMonth(Date.now()),
|
||||
color: '#58508D',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
const lastWeek: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___lastWeek' as any,
|
||||
name: 'Last Week',
|
||||
from: fns.startOfWeek(fns.subWeeks(Date.now(), 1)),
|
||||
to: fns.endOfWeek(fns.subWeeks(Date.now(), 1)),
|
||||
color: '#3E909D',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
const currentWeek: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___currentWeek' as any,
|
||||
name: 'Current Week',
|
||||
from: fns.startOfWeek(Date.now()),
|
||||
to: fns.endOfWeek(Date.now()),
|
||||
color: '#007896',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
|
||||
const allTime: DefaultSnapshot = {
|
||||
project_id,
|
||||
_id: '___allTime' as any,
|
||||
name: 'All Time',
|
||||
from: new Date(project_created_at.toString()),
|
||||
to: new Date(Date.now()),
|
||||
color: '#9362FF',
|
||||
default: true
|
||||
}
|
||||
|
||||
|
||||
|
||||
const snapshotList = [lastDay, today, lastMonth, currentMonth, lastWeek, currentWeek, allTime]
|
||||
|
||||
return snapshotList;
|
||||
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { StringExpressionOperator } from "mongoose";
|
||||
|
||||
type RefOrPrimitive<T> = T | Ref<T> | ComputedRef<T>
|
||||
|
||||
export type CustomOptions = {
|
||||
useSnapshotDates?: boolean,
|
||||
useActivePid?: boolean,
|
||||
useTimeOffset?: boolean,
|
||||
slice?: RefOrPrimitive<string>,
|
||||
limit?: RefOrPrimitive<number | string>,
|
||||
custom?: Record<string, RefOrPrimitive<string>>
|
||||
@@ -23,9 +23,10 @@ function getValueFromRefOrPrimitive<T>(data?: T | Ref<T> | ComputedRef<T>) {
|
||||
export function useComputedHeaders(customOptions?: CustomOptions) {
|
||||
const useSnapshotDates = customOptions?.useSnapshotDates || true;
|
||||
const useActivePid = customOptions?.useActivePid || true;
|
||||
const useTimeOffset = customOptions?.useTimeOffset || true;
|
||||
|
||||
const headers = computed<Record<string, string>>(() => {
|
||||
|
||||
// console.trace('Computed recalculated');
|
||||
const parsedCustom: Record<string, string> = {}
|
||||
const customKeys = Object.keys(customOptions?.custom || {});
|
||||
for (const key of customKeys) {
|
||||
@@ -37,11 +38,14 @@ export function useComputedHeaders(customOptions?: CustomOptions) {
|
||||
'x-pid': useActivePid ? (projectId.value ?? '') : '',
|
||||
'x-from': useSnapshotDates ? (safeSnapshotDates.value.from ?? '') : '',
|
||||
'x-to': useSnapshotDates ? (safeSnapshotDates.value.to ?? '') : '',
|
||||
'x-time-offset': useTimeOffset ? (new Date().getTimezoneOffset().toString()) : '',
|
||||
'x-slice': getValueFromRefOrPrimitive(customOptions?.slice) ?? '',
|
||||
'x-limit': getValueFromRefOrPrimitive(customOptions?.limit)?.toString() ?? '',
|
||||
...parsedCustom
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
return headers;
|
||||
}
|
||||
34
dashboard/composables/useDrawer.ts
Normal file
34
dashboard/composables/useDrawer.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
const drawerVisible = ref<boolean>(false);
|
||||
const drawerComponent = ref<Component>();
|
||||
|
||||
const drawerClasses = ref<string>('')
|
||||
|
||||
type ComponentType = "DOCS" | "PRICING";
|
||||
|
||||
async function loadComponent(component: ComponentType): Promise<Component> {
|
||||
switch (component) {
|
||||
case "DOCS":
|
||||
const DrawerDocs = await import("../components/drawer/Docs.vue");
|
||||
return DrawerDocs.default;
|
||||
case "PRICING":
|
||||
const DrawerPricing = await import("../components/drawer/Pricing.vue");
|
||||
return DrawerPricing.default;
|
||||
default:
|
||||
throw new Error("Unknown component type");
|
||||
}
|
||||
}
|
||||
|
||||
async function showDrawer(component: ComponentType, classes: string = "") {
|
||||
drawerComponent.value = await loadComponent(component);
|
||||
drawerVisible.value = true;
|
||||
drawerClasses.value = classes;
|
||||
}
|
||||
|
||||
function hideDrawer() {
|
||||
drawerVisible.value = false;
|
||||
}
|
||||
|
||||
export function useDrawer() {
|
||||
return { drawerClasses, drawerVisible, drawerComponent, showDrawer, hideDrawer };
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
|
||||
|
||||
|
||||
const pricingDrawerVisible = ref<boolean>(false);
|
||||
|
||||
|
||||
export function usePricingDrawer() {
|
||||
return { visible: pricingDrawerVisible };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
import type { TProject } from "@schema/ProjectSchema";
|
||||
import { ProjectSnapshotModel } from "@schema/ProjectSnapshot";
|
||||
import type { TProject } from "@schema/project/ProjectSchema";
|
||||
import { ProjectSnapshotModel } from "@schema/project/ProjectSnapshot";
|
||||
|
||||
const { token } = useAccessToken();
|
||||
|
||||
|
||||
7
dashboard/composables/useSelfhosted.ts
Normal file
7
dashboard/composables/useSelfhosted.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
|
||||
const app = useRuntimeConfig();
|
||||
|
||||
export function useSelfhosted() {
|
||||
return app.public.SELFHOSTED === 'TRUE';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TProjectSnapshot } from "@schema/ProjectSnapshot";
|
||||
|
||||
import type { TProjectSnapshot } from "@schema/project/ProjectSnapshot";
|
||||
import { getDefaultSnapshots, type GenericSnapshot } from "./snapshots/BaseSnapshots";
|
||||
import * as fns from 'date-fns';
|
||||
|
||||
const { projectId, project } = useProject();
|
||||
|
||||
@@ -9,59 +10,20 @@ const headers = computed(() => {
|
||||
'x-pid': projectId.value ?? ''
|
||||
}
|
||||
});
|
||||
const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
|
||||
headers
|
||||
});
|
||||
|
||||
const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', { headers });
|
||||
|
||||
watch(project, async () => {
|
||||
await remoteSnapshots.refresh();
|
||||
snapshot.value = isLiveDemo.value ? snapshots.value[0] : snapshots.value[1];
|
||||
snapshot.value = isLiveDemo.value ? snapshots.value[3] : snapshots.value[3];
|
||||
});
|
||||
|
||||
const snapshots = computed(() => {
|
||||
|
||||
const getDefaultSnapshots: () => TProjectSnapshot[] = () => [
|
||||
{
|
||||
project_id: project.value?._id as any,
|
||||
_id: 'default0' as any,
|
||||
name: 'All',
|
||||
from: new Date(project.value?.created_at || 0),
|
||||
to: new Date(Date.now()),
|
||||
color: '#CCCCCC'
|
||||
},
|
||||
{
|
||||
project_id: project.value?._id as any,
|
||||
_id: 'default1' as any,
|
||||
name: 'Last month',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
|
||||
to: new Date(Date.now()),
|
||||
color: '#00CC00'
|
||||
},
|
||||
{
|
||||
project_id: project.value?._id as any,
|
||||
_id: 'default2' as any,
|
||||
name: 'Last week',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
||||
to: new Date(Date.now()),
|
||||
color: '#0F02D2'
|
||||
},
|
||||
{
|
||||
project_id: project.value?._id as any,
|
||||
_id: 'default3' as any,
|
||||
name: 'Last day',
|
||||
from: new Date(Date.now() - 1000 * 60 * 60 * 24),
|
||||
to: new Date(Date.now()),
|
||||
color: '#CC11CC'
|
||||
}
|
||||
]
|
||||
|
||||
return [
|
||||
...getDefaultSnapshots(),
|
||||
...(remoteSnapshots.data.value || [])
|
||||
];
|
||||
const snapshots = computed<GenericSnapshot[]>(() => {
|
||||
const defaultSnapshots: GenericSnapshot[] = project.value?._id ? getDefaultSnapshots(project.value._id as any, project.value.created_at) : [];
|
||||
return [...defaultSnapshots, ...(remoteSnapshots.data.value || [])];
|
||||
})
|
||||
|
||||
const snapshot = ref<TProjectSnapshot>(isLiveDemo.value ? snapshots.value[0] : snapshots.value[1]);
|
||||
const snapshot = ref<GenericSnapshot>(snapshots.value[3]);
|
||||
|
||||
const safeSnapshotDates = computed(() => {
|
||||
const from = new Date(snapshot.value?.from || 0).toISOString();
|
||||
@@ -75,8 +37,8 @@ async function updateSnapshots() {
|
||||
|
||||
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);
|
||||
const to = new Date(snapshot.value?.to || 0).getTime() + 1000;
|
||||
return fns.differenceInDays(to, from);
|
||||
});
|
||||
|
||||
export function useSnapshot() {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
36
dashboard/composables/useTextType.ts
Normal file
36
dashboard/composables/useTextType.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
|
||||
|
||||
|
||||
export function useTextType(options: { ms: number, increase: number }, onTickAction?: () => any) {
|
||||
|
||||
let interval: any;
|
||||
const index = ref<number>(0);
|
||||
|
||||
function onTick() {
|
||||
index.value += options.increase;
|
||||
onTickAction?.();
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (interval) clearInterval(interval);
|
||||
interval = setInterval(() => onTick(), options.ms);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (interval) clearTimeout(interval);
|
||||
}
|
||||
|
||||
function start() {
|
||||
index.value = 0;
|
||||
if (interval) clearInterval(interval);
|
||||
interval = setInterval(() => onTick(), options.ms);
|
||||
}
|
||||
|
||||
return { start, stop, resume, pause, index, interval }
|
||||
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Lit } from 'litlyx-js';
|
||||
const { userRoles, isLogged } = useLoggedUser();
|
||||
const { project } = useProject();
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
@@ -16,10 +16,12 @@ const sections: Section[] = [
|
||||
{ label: 'Web Analytics', to: '/', icon: 'fal fa-table-layout' },
|
||||
{ label: 'Custom Events', to: '/events', icon: 'fal fa-square-bolt' },
|
||||
{ label: 'Ask AI', to: '/analyst', icon: 'fal fa-sparkles' },
|
||||
{ label: 'Security', to: '/security', icon: 'fal fa-shield' },
|
||||
{ label: 'Security', to: '/security', icon: 'fal fa-shield', disabled: selfhosted },
|
||||
|
||||
// { label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
|
||||
// { label: 'Links (soon)', to: '#', icon: 'fal fa-globe-pointer', disabled: true },
|
||||
// { label: 'Integrations (soon)', to: '/integrations', icon: 'fal fa-cube', disabled: true },
|
||||
|
||||
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||
{
|
||||
grow: true,
|
||||
|
||||
@@ -23,12 +23,6 @@ const entries = [
|
||||
const loggedUser = useLoggedUser();
|
||||
const { setToken } = useAccessToken();
|
||||
|
||||
function logout() {
|
||||
loggedUser.value = { logged: false }
|
||||
setToken('');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -15,21 +15,26 @@ export default defineNuxtConfig({
|
||||
autoprefixer: {},
|
||||
}
|
||||
},
|
||||
|
||||
colorMode: {
|
||||
preference: 'dark',
|
||||
},
|
||||
|
||||
devtools: {
|
||||
enabled: false
|
||||
},
|
||||
|
||||
pages: true,
|
||||
ssr: false,
|
||||
css: ['~/assets/scss/main.scss'],
|
||||
|
||||
alias: {
|
||||
'@schema': fileURLToPath(new URL('../shared/schema', import.meta.url)),
|
||||
'@services': fileURLToPath(new URL('../shared/services', import.meta.url)),
|
||||
'@data': fileURLToPath(new URL('../shared/data', import.meta.url)),
|
||||
'@functions': fileURLToPath(new URL('../shared/functions', import.meta.url)),
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
MONGO_CONNECTION_STRING: process.env.MONGO_CONNECTION_STRING,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
@@ -50,24 +55,31 @@ export default defineNuxtConfig({
|
||||
STRIPE_SECRET_TEST: process.env.STRIPE_SECRET_TEST,
|
||||
STRIPE_WH_SECRET_TEST: process.env.STRIPE_WH_SECRET_TEST,
|
||||
NOAUTH_USER_EMAIL: process.env.NOAUTH_USER_EMAIL,
|
||||
NOAUTH_USER_NAME: process.env.NOAUTH_USER_NAME,
|
||||
NOAUTH_USER_NAME: process.env.NOAUTH_USER_NAME || 'FALSE',
|
||||
SELFHOSTED: process.env.SELFHOSTED,
|
||||
public: {
|
||||
AUTH_MODE: process.env.AUTH_MODE,
|
||||
GITHUB_CLIENT_ID: process.env.GITHUB_AUTH_CLIENT_ID || 'NONE'
|
||||
GITHUB_CLIENT_ID: process.env.GITHUB_AUTH_CLIENT_ID || 'NONE',
|
||||
SELFHOSTED: process.env.SELFHOSTED || 'FALSE',
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
nitro: {
|
||||
plugins: ['~/server/init.ts']
|
||||
},
|
||||
|
||||
plugins: [
|
||||
{ src: '~/plugins/chartjs.ts', mode: 'client' }
|
||||
],
|
||||
|
||||
...gooleSignInConfig,
|
||||
modules: ['@nuxt/ui', 'nuxt-vue3-google-signin'],
|
||||
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
|
||||
components: true,
|
||||
extends: ['../lyx-ui']
|
||||
compatibilityDate: '2024-11-16'
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"name": "dashboard",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -14,45 +14,36 @@
|
||||
"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.10.0",
|
||||
"googleapis": "^144.0.0",
|
||||
"highlight.js": "^11.10.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"litlyx-js": "^1.0.2",
|
||||
"mongoose": "^8.3.2",
|
||||
"nodemailer": "^6.9.13",
|
||||
"litlyx-js": "^1.0.3",
|
||||
"nuxt": "^3.11.2",
|
||||
"nuxt-vue3-google-signin": "^0.0.11",
|
||||
"openai": "^4.61.0",
|
||||
"pdfkit": "^0.15.0",
|
||||
"primevue": "^3.52.0",
|
||||
"redis": "^4.6.13",
|
||||
"sass": "^1.75.0",
|
||||
"stripe": "^15.8.0",
|
||||
"sass": "^1.81.0",
|
||||
"stripe": "^17.3.1",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vue": "^3.4.21",
|
||||
"vue-chart-3": "^3.1.8",
|
||||
"vue-markdown-render": "^2.2.1",
|
||||
"vue-router": "^4.3.0",
|
||||
"winston": "^3.14.2"
|
||||
"winston": "^3.14.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/ui": "^2.15.2",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/pdfkit": "^0.13.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vitest": "^1.6.0"
|
||||
"tailwindcss": "^3.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,17 @@ import VueMarkdown from 'vue-markdown-render';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const debugModeAi = ref<boolean>(false);
|
||||
|
||||
const { userRoles } = useLoggedUser();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const { data: chatsList, refresh: reloadChatsList } = useFetch(`/api/ai/chats_list`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
|
||||
const viewChatsList = computed(() => (chatsList.value || []).toReversed());
|
||||
|
||||
const { data: chatsRemaining, refresh: reloadChatsRemaining } = useFetch(`/api/ai/chats_remaining`, {
|
||||
@@ -19,20 +23,82 @@ const { data: chatsRemaining, refresh: reloadChatsRemaining } = useFetch(`/api/a
|
||||
|
||||
const currentText = ref<string>("");
|
||||
const loading = ref<boolean>(false);
|
||||
const canSend = ref<boolean>(false);
|
||||
|
||||
const currentChatId = ref<string>("");
|
||||
const currentChatMessages = ref<{ role: string, content: string, charts?: any[] }[]>([]);
|
||||
const currentChatMessages = ref<{ role: string, content: string, charts?: any[], tool_calls?: any }[]>([]);
|
||||
const currentChatMessageDelta = ref<string>("");
|
||||
|
||||
|
||||
const typer = useTextType({ ms: 10, increase: 2 }, () => {
|
||||
const cleanMessage = currentChatMessageDelta.value.replace(/\[(data:(.*?))\]/g, '');
|
||||
if (typer.index.value >= cleanMessage.length) typer.pause();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
typer.stop();
|
||||
})
|
||||
|
||||
const currentChatMessageDeltaTextVisible = computed(() => {
|
||||
const cleanMessage = currentChatMessageDelta.value.replace(/\[(data:(.*?))\]/g, '');
|
||||
const textVisible = cleanMessage.substring(0, typer.index.value);
|
||||
setTimeout(() => scrollToBottom(), 1);
|
||||
return textVisible;
|
||||
});
|
||||
|
||||
const currentChatMessageDeltaShowLoader = computed(() => {
|
||||
const lastData = currentChatMessageDelta.value.match(/\[(data:(.*?))\]$/);
|
||||
return lastData != null;
|
||||
});
|
||||
|
||||
const scroller = ref<HTMLDivElement | null>(null);
|
||||
|
||||
|
||||
async function pollSendMessageStatus(chat_id: string, times: number, updateStatus: (status: string) => any) {
|
||||
|
||||
if (times > 100) return;
|
||||
|
||||
const res = await $fetch(`/api/ai/${chat_id}/status`, {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false,
|
||||
}).value
|
||||
});
|
||||
if (!res) throw Error('Error during status request');
|
||||
|
||||
updateStatus(res.status);
|
||||
|
||||
|
||||
typer.resume();
|
||||
|
||||
|
||||
if (res.completed === false) {
|
||||
setTimeout(() => pollSendMessageStatus(chat_id, times + 1, updateStatus), (times > 10 ? 2000 : 1000));
|
||||
} else {
|
||||
|
||||
typer.stop();
|
||||
|
||||
const messages = await $fetch(`/api/ai/${chat_id}/get_messages`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
if (!messages) return;
|
||||
|
||||
currentChatMessages.value = messages.map(e => ({ ...e, charts: e.charts.map(k => JSON.parse(k)) })) as any;
|
||||
currentChatMessageDelta.value = '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
|
||||
|
||||
if (loading.value) return;
|
||||
if (!project.value) return;
|
||||
|
||||
if (currentText.value.length == 0) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const body: any = { text: currentText.value }
|
||||
const body: any = { text: currentText.value, timeOffset: new Date().getTimezoneOffset() }
|
||||
if (currentChatId.value) body.chat_id = currentChatId.value
|
||||
|
||||
currentChatMessages.value.push({ role: 'user', content: currentText.value });
|
||||
@@ -43,45 +109,63 @@ async function sendMessage() {
|
||||
|
||||
try {
|
||||
|
||||
const res = await $fetch(`/api/ai/send_message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false,
|
||||
custom: { 'Content-Type': 'application/json' }
|
||||
}).value
|
||||
});
|
||||
canSend.value = false;
|
||||
|
||||
currentChatMessages.value.push({ role: 'assistant', content: res.content || 'nocontent', charts: res.charts.map(e => JSON.parse(e)) });
|
||||
const res = await $fetch<{ chat_id: string }>(`/api/ai/send_message`, { method: 'POST', body: JSON.stringify(body), headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'Content-Type': 'application/json' } }).value });
|
||||
currentChatId.value = res.chat_id;
|
||||
|
||||
await reloadChatsRemaining();
|
||||
await reloadChatsList();
|
||||
currentChatId.value = chatsList.value?.at(-1)?._id.toString() || '';
|
||||
|
||||
await new Promise(e => setTimeout(e, 200));
|
||||
|
||||
|
||||
typer.start();
|
||||
|
||||
await pollSendMessageStatus(res.chat_id, 0, status => {
|
||||
if (!status) return;
|
||||
if (status.length > 0) loading.value = false;
|
||||
currentChatMessageDelta.value = status;
|
||||
});
|
||||
|
||||
canSend.value = true;
|
||||
|
||||
} catch (ex: any) {
|
||||
|
||||
if (ex.message.includes('CHAT_LIMIT_REACHED')) {
|
||||
currentChatMessages.value.push({
|
||||
role: 'assistant',
|
||||
content: 'You have reached your current tier chat limit.\n Upgrade to an higher tier. <a style="color: blue; text-decoration: underline;" href="/plans"> Upgrade now. </a>',
|
||||
});
|
||||
}
|
||||
|
||||
if (ex.message.includes('Unauthorized')) {
|
||||
currentChatMessages.value.push({
|
||||
role: 'assistant',
|
||||
content: 'To use AI you need to provide AI_ORG, AI_PROJECT and AI_KEY in docker compose',
|
||||
});
|
||||
}
|
||||
|
||||
currentChatMessages.value.push({ role: 'assistant', content: ex.message, });
|
||||
|
||||
canSend.value = true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => scrollToBottom(), 1);
|
||||
|
||||
|
||||
loading.value = false;
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function openChat(chat_id?: string) {
|
||||
menuOpen.value = false;
|
||||
if (!project.value) return;
|
||||
|
||||
typer.stop();
|
||||
|
||||
canSend.value = true;
|
||||
currentChatMessages.value = [];
|
||||
currentChatMessageDelta.value = '';
|
||||
|
||||
if (!chat_id) {
|
||||
currentChatId.value = '';
|
||||
@@ -89,7 +173,7 @@ async function openChat(chat_id?: string) {
|
||||
}
|
||||
currentChatId.value = chat_id;
|
||||
const messages = await $fetch(`/api/ai/${chat_id}/get_messages`, {
|
||||
headers: useComputedHeaders({useSnapshotDates:false}).value
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
if (!messages) return;
|
||||
|
||||
@@ -117,10 +201,10 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
const menuOpen = ref<boolean>(false);
|
||||
|
||||
const defaultPrompts = [
|
||||
"Create a line chart with this data: \n[100, 200, 30, 300, 500, 40]",
|
||||
"Create a chart with Events (bar) and Visits (line) data from last week.",
|
||||
"What can you do and how can you help me ?",
|
||||
"Show me an example line chart with random data",
|
||||
"How many visits did I get last week?",
|
||||
"Create a line chart of last week's visits."
|
||||
"Create a line chart of last week's visits"
|
||||
]
|
||||
|
||||
async function deleteChat(chat_id: string) {
|
||||
@@ -130,14 +214,35 @@ async function deleteChat(chat_id: string) {
|
||||
if (currentChatId.value === chat_id) {
|
||||
currentChatId.value = "";
|
||||
currentChatMessages.value = [];
|
||||
currentChatMessageDelta.value = '';
|
||||
}
|
||||
await $fetch(`/api/ai/${chat_id}/delete`, {
|
||||
headers: useComputedHeaders({useSnapshotDates:false}).value
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
await reloadChatsList();
|
||||
}
|
||||
|
||||
const { visible: pricingDrawerVisible } = usePricingDrawer()
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
|
||||
async function clearAllChats() {
|
||||
const sure = confirm(`Are you sure to delete all ${(chatsList.value?.length || 0)} chats ?`);
|
||||
if (!sure) return;
|
||||
await $fetch(`/api/ai/delete_all_chats`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
await reloadChatsList();
|
||||
|
||||
menuOpen.value = false;
|
||||
typer.stop();
|
||||
canSend.value = true;
|
||||
currentChatMessages.value = [];
|
||||
currentChatMessageDelta.value = '';
|
||||
currentChatId.value = '';
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -148,6 +253,7 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
|
||||
|
||||
<div class="flex-[5] py-8 flex h-full flex-col items-center relative overflow-y-hidden">
|
||||
|
||||
|
||||
<div class="flex flex-col items-center xl:mt-[20vh] px-8 xl:px-28"
|
||||
v-if="currentChatMessages.length == 0">
|
||||
<div class="w-[7rem] xl:w-[10rem]">
|
||||
@@ -164,29 +270,47 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div ref="scroller" class="flex flex-col w-full gap-6 px-6 xl:px-28 overflow-y-auto pb-20">
|
||||
|
||||
<div class="flex w-full flex-col" v-for="message of currentChatMessages">
|
||||
<div class="flex w-full flex-col" v-for="(message, messageIndex) of currentChatMessages">
|
||||
|
||||
<div class="flex justify-end w-full poppins text-[1.1rem]" v-if="message.role === 'user'">
|
||||
<div v-if="message.role === 'user'" class="flex justify-end w-full poppins text-[1.1rem]">
|
||||
<div class="bg-lyx-widget-light px-5 py-3 rounded-lg">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]"
|
||||
v-if="message.role === 'assistant' && message.content">
|
||||
|
||||
<div v-if="message.role === 'assistant' && (debugModeAi ? true : message.content)"
|
||||
class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]">
|
||||
<div class="flex items-center justify-center shrink-0">
|
||||
<img class="h-[3.5rem] w-auto" :src="'analyst.png'">
|
||||
</div>
|
||||
<div class="max-w-[70%] text-text/90 ai-message">
|
||||
<vue-markdown :source="message.content" :options="{
|
||||
|
||||
<vue-markdown v-if="message.content" :source="message.content" :options="{
|
||||
html: true,
|
||||
breaks: true,
|
||||
}" />
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="debugModeAi && !message.content">
|
||||
<div class="flex flex-col"
|
||||
v-if="message.tool_calls && message.tool_calls.length > 0">
|
||||
<div> {{ message.tool_calls[0].function.name }}</div>
|
||||
<div> {{ message.tool_calls[0].function.arguments }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="debugModeAi && !message.content"
|
||||
class="text-[.8rem] flex gap-1 items-center w-fit hover:text-[#CCCCCC] cursor-pointer">
|
||||
<i class="fas fa-info text-[.7rem]"></i>
|
||||
<div class="mt-1">Debug</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="message.charts && message.charts.length > 0"
|
||||
class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem] flex-col mt-4">
|
||||
<div v-for="chart of message.charts" class="w-full">
|
||||
@@ -198,6 +322,32 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]"
|
||||
v-if="currentChatMessageDelta">
|
||||
|
||||
<div class="flex items-center justify-center shrink-0">
|
||||
<img class="h-[3.5rem] w-auto" :src="'analyst.png'">
|
||||
</div>
|
||||
|
||||
<div class="max-w-[70%] text-text/90 ai-message">
|
||||
<div v-if="currentChatMessageDeltaShowLoader" class="flex items-center gap-1">
|
||||
<i class="fas fa-loader animate-spin"></i>
|
||||
<div> Loading </div>
|
||||
</div>
|
||||
<vue-markdown :source="currentChatMessageDeltaTextVisible" :options="{
|
||||
html: true,
|
||||
breaks: true,
|
||||
}" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div v-if="loading"
|
||||
class="flex items-center mt-10 gap-3 justify-center w-full poppins text-[1.1rem]">
|
||||
<div class="flex items-center justify-center">
|
||||
@@ -235,6 +385,9 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="{ '!text-green-500': debugModeAi }" class="cursor-pointer text-red-500 w-fit"
|
||||
v-if="userRoles.isAdmin.value" @click="debugModeAi = !debugModeAi"> Debug mode </div>
|
||||
|
||||
<div class="flex justify-between items-center pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-accent w-5 h-5 rounded-full animate-pulse">
|
||||
@@ -242,12 +395,18 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
|
||||
<div class="manrope font-semibold text-text-dirty"> {{ chatsRemaining }} remaining requests
|
||||
</div>
|
||||
</div>
|
||||
<LyxUiButton type="primary" class="text-[.9rem] text-center " @click="pricingDrawerVisible = true">
|
||||
<LyxUiButton type="primary" class="text-[.9rem] text-center " @click="showDrawer('PRICING')">
|
||||
Upgrade
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="poppins font-semibold text-[1.1rem]"> History </div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="poppins font-semibold text-[1.1rem]"> History </div>
|
||||
<LyxUiButton v-if="chatsList && chatsList.length > 0" @click="clearAllChats()" type="secondary"
|
||||
class="text-center text-[.8rem]">
|
||||
Clear all
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="px-2">
|
||||
<div @click="openChat()"
|
||||
@@ -298,6 +457,9 @@ const { visible: pricingDrawerVisible } = usePricingDrawer()
|
||||
color: white;
|
||||
}
|
||||
|
||||
p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.8;
|
||||
|
||||
@@ -7,6 +7,11 @@ definePageMeta({ layout: 'dashboard' });
|
||||
const { project } = useProject();
|
||||
|
||||
const isPremium = computed(() => (project.value?.premium_type || 0) > 0);
|
||||
const selfhosted = useSelfhosted();
|
||||
const canDownload = computed(() => {
|
||||
if (selfhosted) return true;
|
||||
return isPremium.value;
|
||||
});
|
||||
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
@@ -65,10 +70,10 @@ const showWarning = computed(() => {
|
||||
})
|
||||
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -105,12 +110,12 @@ function goToUpgrade() {
|
||||
}" v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="isPremium" @click="downloadCSV()"
|
||||
<div v-if="canDownload" @click="downloadCSV()"
|
||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-1 rounded-lg">
|
||||
Download CSV
|
||||
</div>
|
||||
|
||||
<div v-if="!isPremium" @click="goToUpgrade()"
|
||||
<div v-if="!canDownload" @click="goToUpgrade()"
|
||||
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
<i class="far fa-lock"></i>
|
||||
Upgrade plan for CSV
|
||||
|
||||
@@ -7,6 +7,11 @@ definePageMeta({ layout: 'dashboard' });
|
||||
const { project } = useProject();
|
||||
|
||||
const isPremium = computed(() => (project.value?.premium_type || 0) > 0);
|
||||
const selfhosted = useSelfhosted();
|
||||
const canDownload = computed(() => {
|
||||
if (selfhosted) return true;
|
||||
return isPremium.value;
|
||||
});
|
||||
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
@@ -72,10 +77,10 @@ const showWarning = computed(() => {
|
||||
return options.indexOf(selectedTimeFrom.value) > 1
|
||||
})
|
||||
|
||||
const pricingDrawer = usePricingDrawer();
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
pricingDrawer.visible.value = true;
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -110,12 +115,12 @@ function goToUpgrade() {
|
||||
}" v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="isPremium" @click="downloadCSV()"
|
||||
<div v-if="canDownload" @click="downloadCSV()"
|
||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-1 rounded-lg">
|
||||
Download CSV
|
||||
</div>
|
||||
|
||||
<div v-if="!isPremium" @click="goToUpgrade()"
|
||||
<div v-if="!canDownload" @click="goToUpgrade()"
|
||||
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
<i class="far fa-lock"></i>
|
||||
Upgrade plan for CSV
|
||||
|
||||
@@ -31,17 +31,19 @@ const firstInteraction = useFetch<boolean>('/api/project/first_interaction', {
|
||||
|
||||
const showDashboard = computed(() => project.value && firstInteraction.data.value);
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="dashboard w-full h-full overflow-y-auto overflow-x-hidden pb-[7rem] md:pt-4 lg:pt-0">
|
||||
|
||||
<div v-if="showDashboard">
|
||||
|
||||
<div v-if="showDashboard">
|
||||
<div class="w-full px-4 py-2 gap-2 flex flex-col">
|
||||
<BannerLimitsInfo :key="refreshKey"></BannerLimitsInfo>
|
||||
<BannerOffer :key="refreshKey"></BannerOffer>
|
||||
<BannerLimitsInfo v-if="!selfhosted" :key="refreshKey"></BannerLimitsInfo>
|
||||
<BannerOffer v-if="!selfhosted" :key="refreshKey"></BannerOffer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<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>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const projectName = ref<string>("");
|
||||
const creating = ref<boolean>(false);
|
||||
@@ -8,15 +7,18 @@ const creating = ref<boolean>(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { projectList, actions } = useProject();
|
||||
|
||||
const isFirstProject = computed(() => { return projectList.value?.length == 0; })
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
|
||||
|
||||
import { Lit } from 'litlyx-js';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.just_logged) return location.href = '/project_creation';
|
||||
setPageLayout(isFirstProject.value ? 'none' : 'dashboard');
|
||||
})
|
||||
|
||||
|
||||
@@ -42,6 +44,7 @@ async function createProject() {
|
||||
await actions.setActiveProject(newActiveProjectId);
|
||||
}
|
||||
|
||||
setPageLayout('dashboard');
|
||||
router.push('/');
|
||||
|
||||
} catch (ex: any) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 9.4 KiB |
@@ -1,5 +1,5 @@
|
||||
import { AuthContext } from "./middleware/01-authorization";
|
||||
import { ProjectModel } from "~/../shared/schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { LITLYX_PROJECT_ID } from '@data/LITLYX'
|
||||
import { hasAccessToProject } from "./utils/hasAccessToProject";
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import type OpenAI from 'openai'
|
||||
|
||||
|
||||
export type AIPlugin_TTool<T extends string> = (OpenAI.Chat.Completions.ChatCompletionTool & { function: { name: T } });
|
||||
export type AIPlugin_TFunction<T extends string> = (...args: any[]) => any;
|
||||
|
||||
export type AIPlugin_TFunction = (...args: any[]) => any;
|
||||
|
||||
type AIPlugin_Constructor<Items extends string[]> = {
|
||||
[Key in Items[number]]: {
|
||||
tool: AIPlugin_TTool<Key>,
|
||||
handler: AIPlugin_TFunction<Key>
|
||||
handler: AIPlugin_TFunction
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
127
dashboard/server/ai/functions/AI_Billing.ts
Normal file
127
dashboard/server/ai/functions/AI_Billing.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import { AIPlugin } from "../Plugin";
|
||||
import { MAX_LOG_LIMIT_PERCENT } from "@data/broker/Limits";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import StripeService from "~/server/services/StripeService";
|
||||
import { InvoiceData } from "~/server/api/pay/invoices";
|
||||
|
||||
export class AiBilling extends AIPlugin<[
|
||||
'getBillingInfo',
|
||||
'getLimits',
|
||||
'getInvoices'
|
||||
]> {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
|
||||
'getInvoices': {
|
||||
handler: async (data: { project_id: string }) => {
|
||||
|
||||
const project = await ProjectModel.findOne({ _id: data.project_id });
|
||||
if (!project) return { error: 'Project not found' };
|
||||
const invoices = await StripeService.getInvoices(project.customer_id);
|
||||
if (!invoices) return [];
|
||||
|
||||
return invoices?.data.map(e => {
|
||||
const result: InvoiceData = {
|
||||
link: e.invoice_pdf || '',
|
||||
id: e.number || '',
|
||||
date: e.created * 1000,
|
||||
status: e.status || 'NO_STATUS',
|
||||
cost: e.amount_due
|
||||
}
|
||||
return result;
|
||||
});
|
||||
},
|
||||
tool: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getInvoices',
|
||||
description: 'Gets the invoices of the user project',
|
||||
parameters: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'getBillingInfo': {
|
||||
handler: async (data: { project_id: string }) => {
|
||||
|
||||
const project = await ProjectModel.findOne({ _id: data.project_id });
|
||||
if (!project) return { error: 'Project not found' };
|
||||
|
||||
if (project.subscription_id === 'onetime') {
|
||||
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id: data.project_id });
|
||||
if (!projectLimits) return { error: 'Limits not found' }
|
||||
|
||||
const result = {
|
||||
premium: project.premium,
|
||||
premium_type: project.premium_type,
|
||||
billing_start_at: projectLimits.billing_start_at,
|
||||
billing_expire_at: projectLimits.billing_expire_at,
|
||||
limit: projectLimits.limit,
|
||||
count: projectLimits.events + projectLimits.visits,
|
||||
subscription_status: StripeService.isDisabled() ? 'Disabled mode' : ('One time payment')
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const subscription = await StripeService.getSubscription(project.subscription_id);
|
||||
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id: data.project_id });
|
||||
if (!projectLimits) return { error: 'Limits not found' }
|
||||
|
||||
|
||||
const result = {
|
||||
premium: project.premium,
|
||||
premium_type: project.premium_type,
|
||||
billing_start_at: projectLimits.billing_start_at,
|
||||
billing_expire_at: projectLimits.billing_expire_at,
|
||||
limit: projectLimits.limit,
|
||||
count: projectLimits.events + projectLimits.visits,
|
||||
subscription_status: StripeService.isDisabled() ? 'Disabled mode' : (subscription?.status ?? '?')
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
tool: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getBillingInfo',
|
||||
description: 'Gets the informations about the billing of the user project, limits, count, subscription_status, is premium, premium type, billing start at, billing expire at',
|
||||
parameters: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'getLimits': {
|
||||
handler: async (data: { project_id: string }) => {
|
||||
const projectLimits = await ProjectLimitModel.findOne({ project_id: data.project_id });
|
||||
if (!projectLimits) return { error: 'Project limits not found' };
|
||||
const TOTAL_COUNT = projectLimits.events + projectLimits.visits;
|
||||
const COUNT_LIMIT = projectLimits.limit;
|
||||
return {
|
||||
total: TOTAL_COUNT,
|
||||
limit: COUNT_LIMIT,
|
||||
limited: TOTAL_COUNT > COUNT_LIMIT * MAX_LOG_LIMIT_PERCENT,
|
||||
percent: Math.round(100 / COUNT_LIMIT * TOTAL_COUNT)
|
||||
}
|
||||
},
|
||||
tool: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getLimits',
|
||||
description: 'Gets the informations about the limits of the user project',
|
||||
parameters: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const AiBillingInstance = new AiBilling();
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { AdvancedTimelineAggregationOptions, executeAdvancedTimelineAggregation, executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||
import { executeTimelineAggregation } from "~/server/services/TimelineService";
|
||||
import { Types } from "mongoose";
|
||||
import { AIPlugin, AIPlugin_TTool } from "../Plugin";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
|
||||
const getEventsCountTool: AIPlugin_TTool<'getEventsCount'> = {
|
||||
type: 'function',
|
||||
@@ -12,8 +12,8 @@ const getEventsCountTool: AIPlugin_TTool<'getEventsCount'> = {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'ISO string of start date including hours' },
|
||||
to: { type: 'string', description: 'ISO string of end date including hours' },
|
||||
from: { type: 'string', description: 'ISO string of start date' },
|
||||
to: { type: 'string', description: 'ISO string of end date' },
|
||||
name: { type: 'string', description: 'Name of the events to get' },
|
||||
metadata: { type: 'object', description: 'Metadata of events to get' },
|
||||
},
|
||||
@@ -30,8 +30,8 @@ const getEventsTimelineTool: AIPlugin_TTool<'getEventsTimeline'> = {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'ISO string of start date including hours' },
|
||||
to: { type: 'string', description: 'ISO string of end date including hours' },
|
||||
from: { type: 'string', description: 'ISO string of start date' },
|
||||
to: { type: 'string', description: 'ISO string of end date' },
|
||||
name: { type: 'string', description: 'Name of the events to get' },
|
||||
metadata: { type: 'object', description: 'Metadata of events to get' },
|
||||
},
|
||||
@@ -46,12 +46,12 @@ export class AiEvents extends AIPlugin<['getEventsCount', 'getEventsTimeline']>
|
||||
|
||||
super({
|
||||
'getEventsCount': {
|
||||
handler: async (data: { project_id: string, from?: string, to?: string, name?: string, metadata?: string }) => {
|
||||
handler: async (data: { project_id: string, from: string, to: string, name?: string, metadata?: string }) => {
|
||||
const query: any = {
|
||||
project_id: data.project_id,
|
||||
created_at: {
|
||||
$gt: data.from ? new Date(data.from).getTime() : new Date(2023).getTime(),
|
||||
$lt: data.to ? new Date(data.to).getTime() : new Date().getTime(),
|
||||
$gt: new Date(data.from),
|
||||
$lt: new Date(data.to),
|
||||
}
|
||||
}
|
||||
if (data.metadata) query.metadata = data.metadata;
|
||||
@@ -62,21 +62,17 @@ export class AiEvents extends AIPlugin<['getEventsCount', 'getEventsTimeline']>
|
||||
tool: getEventsCountTool
|
||||
},
|
||||
'getEventsTimeline': {
|
||||
handler: async (data: { project_id: string, from: string, to: string, name?: string, metadata?: string }) => {
|
||||
const query: AdvancedTimelineAggregationOptions & { customMatch: Record<string, any> } = {
|
||||
projectId: new Types.ObjectId(data.project_id) as any,
|
||||
model: EventModel,
|
||||
from: dayjs(data.from).startOf('day').toISOString(),
|
||||
to: dayjs(data.to).startOf('day').toISOString(),
|
||||
slice: 'day',
|
||||
customMatch: {}
|
||||
}
|
||||
if (data.metadata) query.customMatch.metadata = data.metadata;
|
||||
if (data.name) query.customMatch.name = data.name;
|
||||
handler: async (data: { project_id: string, from: string, to: string, time_offset: number, name?: string, metadata?: string }) => {
|
||||
|
||||
const timelineData = await executeAdvancedTimelineAggregation(query);
|
||||
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, 'day', data.from, data.to);
|
||||
return { data: timelineFilledMerged };
|
||||
const timelineData = await executeTimelineAggregation({
|
||||
projectId: new Types.ObjectId(data.project_id),
|
||||
model: EventModel,
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
slice: 'day',
|
||||
timeOffset: data.time_offset
|
||||
});
|
||||
return { data: timelineData };
|
||||
},
|
||||
tool: getEventsTimelineTool
|
||||
}
|
||||
|
||||
86
dashboard/server/ai/functions/AI_Sessions.ts
Normal file
86
dashboard/server/ai/functions/AI_Sessions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { executeTimelineAggregation } from "~/server/services/TimelineService";
|
||||
import { Types } from "mongoose";
|
||||
import { AIPlugin, AIPlugin_TTool } from "../Plugin";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
|
||||
const getSessionsCountsTool: AIPlugin_TTool<'getSessionsCount'> = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getSessionsCount',
|
||||
description: 'Gets the number of sessions (unique visitors) received on a date range',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'ISO string of start date' },
|
||||
to: { type: 'string', description: 'ISO string of end date' },
|
||||
min_duration: { type: 'number', description: 'Minimum duration of the session' },
|
||||
max_duration: { type: 'number', description: 'Maximum duration of the session' },
|
||||
},
|
||||
required: ['from', 'to']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getSessionsTimelineTool: AIPlugin_TTool<'getSessionsTimeline'> = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getSessionsTimeline',
|
||||
description: 'Gets an array of date and count for events received on a date range. Should be used to create charts.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'ISO string of start date' },
|
||||
to: { type: 'string', description: 'ISO string of end date' },
|
||||
},
|
||||
required: ['from', 'to']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AiSessions extends AIPlugin<['getSessionsCount', 'getSessionsTimeline']> {
|
||||
|
||||
constructor() {
|
||||
|
||||
super({
|
||||
'getSessionsCount': {
|
||||
handler: async (data: { project_id: string, from: string, to: string, min_duration?: number, max_duration?: number }) => {
|
||||
|
||||
const query: any = {
|
||||
project_id: data.project_id,
|
||||
created_at: {
|
||||
$gt: new Date(data.from),
|
||||
$lt: new Date(data.to),
|
||||
},
|
||||
duration: {
|
||||
$gte: data.min_duration || 0,
|
||||
$lte: data.max_duration || 999_999_999,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await VisitModel.countDocuments(query);
|
||||
return { count: result };
|
||||
},
|
||||
tool: getSessionsCountsTool
|
||||
},
|
||||
'getSessionsTimeline': {
|
||||
handler: async (data: { project_id: string, from: string, to: string, time_offset: number, website?: string, page?: string }) => {
|
||||
|
||||
const timelineData = await executeTimelineAggregation({
|
||||
projectId: new Types.ObjectId(data.project_id),
|
||||
model: SessionModel,
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
slice: 'day',
|
||||
timeOffset: data.time_offset
|
||||
});
|
||||
return { data: timelineData };
|
||||
},
|
||||
tool: getSessionsTimelineTool
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export const AiSessionsInstance = new AiSessions();
|
||||
78
dashboard/server/ai/functions/AI_Snapshots.ts
Normal file
78
dashboard/server/ai/functions/AI_Snapshots.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
import { AIPlugin } from "../Plugin";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { ProjectSnapshotModel } from "@schema/project/ProjectSnapshot";
|
||||
|
||||
export class AiSnapshot extends AIPlugin<[
|
||||
'getSnapshots',
|
||||
'createSnapshot',
|
||||
]> {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
|
||||
'getSnapshots': {
|
||||
handler: async (data: { project_id: string }) => {
|
||||
const snapshots = await ProjectSnapshotModel.find({ project_id: data.project_id });
|
||||
return snapshots.map(e => e.toJSON());
|
||||
},
|
||||
tool: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'getSnapshots',
|
||||
description: 'Gets the snapshots list',
|
||||
parameters: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'createSnapshot': {
|
||||
handler: async (data: { project_id: string, from: string, to: string, color: string, name: string }) => {
|
||||
|
||||
if (!data.name) return { error: 'SnapshotName too short' }
|
||||
if (data.name.length == 0) return { error: 'SnapshotName too short' }
|
||||
|
||||
if (!data.from) return { error: 'from is required' }
|
||||
if (!data.to) return { error: 'to is required' }
|
||||
if (!data.color) return { error: 'color is required' }
|
||||
|
||||
const project = await ProjectModel.findById(data.project_id);
|
||||
if (!project) return { error: 'Project not found' }
|
||||
|
||||
|
||||
const newSnapshot = await ProjectSnapshotModel.create({
|
||||
name: data.name,
|
||||
from: new Date(data.from),
|
||||
to: new Date(data.to),
|
||||
color: data.color,
|
||||
project_id: data.project_id
|
||||
});
|
||||
|
||||
return newSnapshot.id;
|
||||
|
||||
|
||||
},
|
||||
tool: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'createSnapshot',
|
||||
description: 'Create a snapshot',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'ISO string of start date' },
|
||||
to: { type: 'string', description: 'ISO string of end date' },
|
||||
color: { type: 'string', description: 'Color of the snapshot in HEX' },
|
||||
name: { type: 'string', description: 'Name of the snapshot' }
|
||||
},
|
||||
required: ['from', 'to', 'color', 'name']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const AiSnapshotInstance = new AiSnapshot();
|
||||
@@ -1,8 +1,7 @@
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { AdvancedTimelineAggregationOptions, executeAdvancedTimelineAggregation, executeTimelineAggregation, fillAndMergeTimelineAggregationV2 } from "~/server/services/TimelineService";
|
||||
import { executeTimelineAggregation } from "~/server/services/TimelineService";
|
||||
import { Types } from "mongoose";
|
||||
import { AIPlugin, AIPlugin_TTool } from "../Plugin";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const getVisitsCountsTool: AIPlugin_TTool<'getVisitsCount'> = {
|
||||
type: 'function',
|
||||
@@ -12,8 +11,8 @@ const getVisitsCountsTool: AIPlugin_TTool<'getVisitsCount'> = {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'ISO string of start date including hours' },
|
||||
to: { type: 'string', description: 'ISO string of end date including hours' },
|
||||
from: { type: 'string', description: 'ISO string of start date' },
|
||||
to: { type: 'string', description: 'ISO string of end date' },
|
||||
website: { type: 'string', description: 'The website of the visits' },
|
||||
page: { type: 'string', description: 'The page of the visit' }
|
||||
},
|
||||
@@ -30,10 +29,10 @@ const getVisitsTimelineTool: AIPlugin_TTool<'getVisitsTimeline'> = {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'ISO string of start date including hours' },
|
||||
to: { type: 'string', description: 'ISO string of end date including hours' },
|
||||
from: { type: 'string', description: 'ISO string of start date' },
|
||||
to: { type: 'string', description: 'ISO string of end date' },
|
||||
website: { type: 'string', description: 'The website of the visits' },
|
||||
page: { type: 'string', description: 'The page of the visit' }
|
||||
page: { type: 'string', description: 'The page of the visit' },
|
||||
},
|
||||
required: ['from', 'to']
|
||||
}
|
||||
@@ -46,39 +45,39 @@ export class AiVisits extends AIPlugin<['getVisitsCount', 'getVisitsTimeline']>
|
||||
|
||||
super({
|
||||
'getVisitsCount': {
|
||||
handler: async (data: { project_id: string, from?: string, to?: string, website?: string, page?: string }) => {
|
||||
handler: async (data: { project_id: string, from: string, to: string, website?: string, page?: string }) => {
|
||||
|
||||
const query: any = {
|
||||
project_id: data.project_id,
|
||||
created_at: {
|
||||
$gt: data.from ? new Date(data.from).getTime() : new Date(2023).getTime(),
|
||||
$lt: data.to ? new Date(data.to).getTime() : new Date().getTime(),
|
||||
$gt: new Date(data.from),
|
||||
$lt: new Date(data.to),
|
||||
}
|
||||
}
|
||||
|
||||
if (data.website) query.website = data.website;
|
||||
|
||||
if (data.page) query.page = data.page;
|
||||
|
||||
const result = await VisitModel.countDocuments(query);
|
||||
|
||||
return { count: result };
|
||||
|
||||
},
|
||||
tool: getVisitsCountsTool
|
||||
},
|
||||
'getVisitsTimeline': {
|
||||
handler: async (data: { project_id: string, from: string, to: string, website?: string, page?: string }) => {
|
||||
handler: async (data: { project_id: string, from: string, to: string, time_offset: number, website?: string, page?: string }) => {
|
||||
|
||||
const query: AdvancedTimelineAggregationOptions & { customMatch: Record<string, any> } = {
|
||||
projectId: new Types.ObjectId(data.project_id) as any,
|
||||
const timelineData = await executeTimelineAggregation({
|
||||
projectId: new Types.ObjectId(data.project_id),
|
||||
model: VisitModel,
|
||||
from: dayjs(data.from).startOf('day').toISOString(),
|
||||
to: dayjs(data.to).startOf('day').toISOString(),
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
slice: 'day',
|
||||
customMatch: {}
|
||||
}
|
||||
|
||||
if (data.website) query.customMatch.website = data.website;
|
||||
if (data.page) query.customMatch.page = data.page;
|
||||
|
||||
const timelineData = await executeAdvancedTimelineAggregation(query);
|
||||
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, 'day', data.from, data.to);
|
||||
return { data: timelineFilledMerged };
|
||||
timeOffset: data.time_offset
|
||||
});
|
||||
return { data: timelineData };
|
||||
},
|
||||
tool: getVisitsTimelineTool
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectCountModel } from "@schema/ProjectsCounts";
|
||||
import { ProjectLimitModel } from "@schema/ProjectsLimits";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { ProjectCountModel } from "@schema/project/ProjectsCounts";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { ProjectCountModel } from "@schema/ProjectsCounts";
|
||||
import { ProjectCountModel } from "@schema/project/ProjectsCounts";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
|
||||
import { AiChatModel } from "@schema/ai/AiChatSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
@@ -11,6 +11,6 @@ export default defineEventHandler(async event => {
|
||||
if (!event.context.params) return;
|
||||
const chat_id = event.context.params['chat_id'];
|
||||
|
||||
const result = await AiChatModel.deleteOne({ _id: chat_id, project_id });
|
||||
return result.deletedCount > 0;
|
||||
const result = await AiChatModel.updateOne({ _id: chat_id, project_id }, { deleted: true });
|
||||
return result.modifiedCount > 0;
|
||||
});
|
||||
@@ -7,6 +7,8 @@ export default defineEventHandler(async event => {
|
||||
const data = await getRequestData(event);
|
||||
if (!data) return;
|
||||
|
||||
const isAdmin = data.user.user.roles.includes('ADMIN');
|
||||
|
||||
const { project_id } = data;
|
||||
|
||||
if (!event.context.params) return;
|
||||
@@ -16,13 +18,13 @@ export default defineEventHandler(async event => {
|
||||
if (!chat) return;
|
||||
|
||||
return (chat.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[])
|
||||
.filter(e => e.role === 'assistant' || e.role === 'user')
|
||||
.filter(e => isAdmin ? true : (e.role === 'assistant' || e.role === 'user'))
|
||||
.map(e => {
|
||||
const charts = getChartsInMessage(e);
|
||||
const content = e.content;
|
||||
return { role: e.role, content, charts }
|
||||
return { ...e, charts }
|
||||
})
|
||||
.filter(e=>{
|
||||
return e.charts.length > 0 || e.content
|
||||
.filter(e => {
|
||||
return isAdmin ? true : (e.charts.length > 0 || e.content);
|
||||
})
|
||||
});
|
||||
17
dashboard/server/api/ai/[chat_id]/status.ts
Normal file
17
dashboard/server/api/ai/[chat_id]/status.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
import { AiChatModel } from "@schema/ai/AiChatSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const data = await getRequestData(event);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id } = data;
|
||||
|
||||
if (!event.context.params) return;
|
||||
const chat_id = event.context.params['chat_id'];
|
||||
|
||||
const chat = await AiChatModel.findOne({ _id: chat_id, project_id }, { status: 1, completed: 1 });
|
||||
if (!chat) return;
|
||||
|
||||
return { status: chat.status, completed: chat.completed || false }
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const { project_id } = data;
|
||||
|
||||
const chatList = await AiChatModel.find({ project_id }, { _id: 1, title: 1 }, { sort: { updated_at: 1 } });
|
||||
const chatList = await AiChatModel.find({ project_id, deleted: false }, { _id: 1, title: 1 }, { sort: { updated_at: 1 } });
|
||||
|
||||
return chatList.map(e => e.toJSON());
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectLimitModel } from "@schema/ProjectsLimits";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
|
||||
export async function getAiChatRemainings(project_id: string) {
|
||||
const limits = await ProjectLimitModel.findOne({ project_id })
|
||||
|
||||
13
dashboard/server/api/ai/delete_all_chats.ts
Normal file
13
dashboard/server/api/ai/delete_all_chats.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
import { AiChatModel } from "@schema/ai/AiChatSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event);
|
||||
if (!data) return;
|
||||
|
||||
const { project_id } = data;
|
||||
|
||||
const result = await AiChatModel.updateMany({ project_id }, { deleted: true });
|
||||
return result.modifiedCount > 0;
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sendMessageOnChat } from "~/server/services/AiService";
|
||||
import { sendMessageOnChat, updateChatStatus } from "~/server/services/AiService";
|
||||
import { getAiChatRemainings } from "./chats_remaining";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
|
||||
|
||||
|
||||
@@ -9,13 +10,52 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const { pid } = data;
|
||||
|
||||
const { text, chat_id } = await readBody(event);
|
||||
const { text, chat_id, timeOffset } = await readBody(event);
|
||||
if (!text) return setResponseStatus(event, 400, 'text parameter missing');
|
||||
|
||||
const chatsRemaining = await getAiChatRemainings(pid);
|
||||
if (chatsRemaining <= 0) return setResponseStatus(event, 400, 'CHAT_LIMIT_REACHED');
|
||||
|
||||
const response = await sendMessageOnChat(text, pid, chat_id);
|
||||
|
||||
return response;
|
||||
await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { ai_messages: 1 } });
|
||||
|
||||
const currentStatus: string[] = [];
|
||||
|
||||
let responseSent = false;
|
||||
|
||||
let targetChatId = '';
|
||||
|
||||
await sendMessageOnChat(text, pid, timeOffset, chat_id, {
|
||||
onChatId: async chat_id => {
|
||||
if (!responseSent) {
|
||||
event.node.res.setHeader('Content-Type', 'application/json');
|
||||
event.node.res.end(JSON.stringify({ chat_id }));
|
||||
targetChatId = chat_id;
|
||||
responseSent = true;
|
||||
}
|
||||
},
|
||||
onDelta: async text => {
|
||||
currentStatus.push(text);
|
||||
await updateChatStatus(targetChatId, currentStatus.join(''), false);
|
||||
},
|
||||
onFunctionName: async name => {
|
||||
currentStatus.push('[data:FunctionName]');
|
||||
await updateChatStatus(targetChatId, currentStatus.join(''), false);
|
||||
},
|
||||
onFunctionCall: async name => {
|
||||
currentStatus.push('[data:FunctionCall]');
|
||||
await updateChatStatus(targetChatId, currentStatus.join(''), false);
|
||||
},
|
||||
onFunctionResult: async (name, result) => {
|
||||
currentStatus.push('[data:FunctionResult]');
|
||||
await updateChatStatus(targetChatId, currentStatus.join(''), false);
|
||||
},
|
||||
onFinish: async calls => {
|
||||
// currentStatus.push('[data:FunctionFinish]');
|
||||
// await updateChatStatus(targetChatId, currentStatus.join(''), false);
|
||||
}
|
||||
});
|
||||
|
||||
await updateChatStatus(targetChatId, '', true);
|
||||
|
||||
});
|
||||
@@ -18,7 +18,12 @@ export default defineEventHandler(async event => {
|
||||
return await Redis.useCacheV2(cacheKey, cacheExp, async () => {
|
||||
|
||||
const result = await VisitModel.aggregate([
|
||||
{ $match: { project_id }, },
|
||||
{
|
||||
$match: {
|
||||
project_id,
|
||||
created_at: { $gte: new Date(from), $lte: new Date(to) }
|
||||
},
|
||||
},
|
||||
{ $match: { website: websiteName, }, },
|
||||
{ $group: { _id: "$page", count: { $sum: 1, } } },
|
||||
{ $sort: { count: -1 } },
|
||||
|
||||
18
dashboard/server/api/feedback/add.post.ts
Normal file
18
dashboard/server/api/feedback/add.post.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
import { FeedbackModel } from '@schema/FeedbackSchema';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const data = await getRequestData(event);
|
||||
if (!data) return;
|
||||
|
||||
const { text } = await readBody(event);
|
||||
|
||||
const save = await FeedbackModel.create({
|
||||
user_id: data.user.id,
|
||||
project_id: data.project_id,
|
||||
text
|
||||
});
|
||||
|
||||
return { ok: true }
|
||||
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
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 || ''
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
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 };
|
||||
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
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 };
|
||||
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
||||
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const liveDemoProject = await ProjectModel.findById('6643cd08a1854e3b81722ab5');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||
import { ProjectCountModel } from "@schema/ProjectsCounts";
|
||||
import { ProjectCountModel } from "@schema/project/ProjectsCounts";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { COUNTS_EXPIRE_TIME, COUNTS_SESSIONS_EXPIRE_TIME, Redis } from "~/server/services/CacheService";
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { Redis, TIMELINE_EXPIRE_TIME } from "~/server/services/CacheService";
|
||||
|
||||
export type EventsPie = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventModel } from "@schema/metrics/EventSchema";
|
||||
import { VisitModel } from "@schema/metrics/VisitSchema";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
|
||||
@@ -30,9 +30,6 @@ 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;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AggregateOptions, Model, Types } from "mongoose";
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
|
||||
|
||||
export type MetricsTimeline = {
|
||||
|
||||
16
dashboard/server/api/onboarding/add.post.ts
Normal file
16
dashboard/server/api/onboarding/add.post.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import { OnboardingModel } from '@schema/OnboardingSchema';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const data = await getRequestData(event);
|
||||
if (!data) return;
|
||||
|
||||
const { job, analytics } = await readBody(event);
|
||||
|
||||
|
||||
const save = await OnboardingModel.updateOne({
|
||||
user_id: data.user.id,
|
||||
}, { job, analytics }, { upsert: true });
|
||||
return { ok: true }
|
||||
|
||||
});
|
||||
11
dashboard/server/api/onboarding/exist.ts
Normal file
11
dashboard/server/api/onboarding/exist.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import { OnboardingModel } from '@schema/OnboardingSchema';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const data = await getRequestData(event);
|
||||
if (!data) return;
|
||||
|
||||
const exist = await OnboardingModel.exists({ user_id: data.user.id });
|
||||
|
||||
return { exist: exist != null }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppsumoCodeTryModel } from "@schema/AppsumoCodeTrySchema";
|
||||
import { AppsumoCodeTryModel } from "@schema/appsumo/AppsumoCodeTrySchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
import type Event from 'stripe';
|
||||
import { ProjectModel } from '@schema/ProjectSchema';
|
||||
import { ProjectModel } from '@schema/project/ProjectSchema';
|
||||
import { PREMIUM_DATA, PREMIUM_PLAN, getPlanFromId, getPlanFromPrice, getPlanFromTag } from '@data/PREMIUM';
|
||||
import { ProjectLimitModel } from '@schema/ProjectsLimits';
|
||||
import { ProjectLimitModel } from '@schema/project/ProjectsLimits';
|
||||
import EmailService from '@services/EmailService'
|
||||
import { UserModel } from '@schema/UserSchema';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
||||
import { ProjectCountModel } from "@schema/ProjectsCounts";
|
||||
import { ProjectLimitModel } from "@schema/ProjectsLimits";
|
||||
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||
import { ProjectCountModel } from "@schema/project/ProjectsCounts";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import { UserSettingsModel } from "@schema/UserSettings";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectCountModel } from "@schema/ProjectsCounts";
|
||||
import { ProjectLimitModel } from "@schema/ProjectsLimits";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { ProjectCountModel } from "@schema/project/ProjectsCounts";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import { SessionModel } from "@schema/metrics/SessionSchema";
|
||||
import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
|
||||
@@ -56,6 +56,8 @@ async function exportToGoogle(data: string, user_id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const { SELFHOSTED } = useRuntimeConfig();
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
const data = await getRequestData(event, { requireSchema: false });
|
||||
@@ -63,9 +65,12 @@ export default defineEventHandler(async event => {
|
||||
|
||||
const { project, project_id, user } = data;
|
||||
|
||||
const PREMIUM_TYPE = project.premium_type;
|
||||
|
||||
if (PREMIUM_TYPE === 0) return setResponseStatus(event, 400, 'Project not premium');
|
||||
if (SELFHOSTED !== 'TRUE') {
|
||||
const PREMIUM_TYPE = project.premium_type;
|
||||
if (PREMIUM_TYPE === 0) return setResponseStatus(event, 400, 'Project not premium');
|
||||
}
|
||||
|
||||
|
||||
const { mode, slice } = getQuery(event);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import pdfkit from 'pdfkit';
|
||||
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||
import { EventModel } from '@schema/metrics/EventSchema';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { ProjectLimitModel } from "@schema/ProjectsLimits";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import { MAX_LOG_LIMIT_PERCENT } from '@data/broker/Limits';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
||||
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
||||
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
|
||||
import { TTeamMember, TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectModel } from "@schema/ProjectSchema";
|
||||
import { ProjectModel } from "@schema/project/ProjectSchema";
|
||||
import { TeamMemberModel } from "@schema/TeamMemberSchema";
|
||||
import { UserModel } from "@schema/UserSchema";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectLimitModel } from "@schema/ProjectsLimits";
|
||||
import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
|
||||
import StripeService from '~/server/services/StripeService';
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user