Merge branch 'snapshot-rework'

This commit is contained in:
Emily
2024-12-27 15:27:42 +01:00
159 changed files with 12261 additions and 10850 deletions

View File

@@ -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"]

View File

@@ -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: ''
}
}
]

View File

@@ -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",

View File

@@ -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'));

View File

@@ -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';

View File

@@ -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 } })
]);

View File

@@ -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"

View File

@@ -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"]

View File

@@ -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>

View File

@@ -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');

View File

@@ -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

View 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>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { TProject } from '@schema/ProjectSchema';
import type { TProject } from '@schema/project/ProjectSchema';
const { user } = useLoggedUser()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;
},
},
},
],
});

View File

@@ -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 }
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 }
}

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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>

View 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;
}

View File

@@ -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;
}

View 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 };
}

View File

@@ -1,9 +0,0 @@
const pricingDrawerVisible = ref<boolean>(false);
export function usePricingDrawer() {
return { visible: pricingDrawerVisible };
}

View File

@@ -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();

View File

@@ -0,0 +1,7 @@
const app = useRuntimeConfig();
export function useSelfhosted() {
return app.public.SELFHOSTED === 'TRUE';
}

View File

@@ -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() {

View File

@@ -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
}
}

View 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 }
}

View File

@@ -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,

View File

@@ -23,12 +23,6 @@ const entries = [
const loggedUser = useLoggedUser();
const { setToken } = useAccessToken();
function logout() {
loggedUser.value = { logged: false }
setToken('');
location.reload();
}
</script>

View File

@@ -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'
})

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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";

View File

@@ -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
}
}

View 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();

View File

@@ -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
}

View 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();

View 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();

View File

@@ -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
}

View File

@@ -1,4 +1,4 @@
import { ProjectModel } from "@schema/ProjectSchema";
import { ProjectModel } from "@schema/project/ProjectSchema";
import { UserModel } from "@schema/UserSchema";

View File

@@ -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';

View File

@@ -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";

View File

@@ -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;
});

View File

@@ -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);
})
});

View 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 }
});

View File

@@ -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());

View File

@@ -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 })

View 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;
});

View File

@@ -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);
});

View File

@@ -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 } },

View 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 }
});

View File

@@ -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 || ''
}
}
});

View File

@@ -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 };
});

View File

@@ -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 };
});

View File

@@ -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;
});

View File

@@ -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;
});

View File

@@ -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';

View File

@@ -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');

View File

@@ -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";

View File

@@ -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 = {

View File

@@ -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 => {

View File

@@ -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;
});

View File

@@ -1,5 +1,5 @@
import { AggregateOptions, Model, Types } from "mongoose";
import { ProjectModel } from "@schema/ProjectSchema";
import { ProjectModel } from "@schema/project/ProjectSchema";
export type MetricsTimeline = {

View 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 }
});

View 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 }
});

View File

@@ -1,4 +1,4 @@
import { AppsumoCodeTryModel } from "@schema/AppsumoCodeTrySchema";
import { AppsumoCodeTryModel } from "@schema/appsumo/AppsumoCodeTrySchema";
export default defineEventHandler(async event => {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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);

View File

@@ -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';

View File

@@ -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 => {

View File

@@ -1,4 +1,4 @@
import { ProjectModel, TProject } from "@schema/ProjectSchema";
import { ProjectModel, TProject } from "@schema/project/ProjectSchema";
export default defineEventHandler(async event => {

View File

@@ -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 => {

View File

@@ -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";

View File

@@ -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