mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
Compare commits
53 commits
7241e5286e
...
5ce1b40330
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce1b40330 | ||
|
|
60271779de | ||
|
|
33e927ed1f | ||
|
|
2993db14f0 | ||
|
|
1483241494 | ||
|
|
c427c6f547 | ||
|
|
33cb195fd0 | ||
|
|
64767b1896 | ||
|
|
be1b787789 | ||
|
|
dae7327ed3 | ||
|
|
a06490af74 | ||
|
|
65f657dd23 | ||
|
|
6b584338e3 | ||
|
|
41d2a24f9d | ||
|
|
1ae13513d2 | ||
|
|
9a269ab811 | ||
|
|
32aee652a5 | ||
|
|
16cae691f6 | ||
|
|
1390e09400 | ||
|
|
23ff20a10b | ||
|
|
58acee8d25 | ||
|
|
a0940d78a7 | ||
|
|
89b985652a | ||
|
|
7b3be59c8d | ||
|
|
b08413ebea | ||
|
|
f47e1072d9 | ||
|
|
22f1b7d7c9 | ||
|
|
b0f38b266b | ||
|
|
e7cb613cec | ||
|
|
170821e2f9 | ||
|
|
935517ce3a | ||
|
|
c481bc5dcc | ||
|
|
b7807ed466 | ||
|
|
f5896f071b | ||
|
|
06251e1317 | ||
|
|
9a2827b50d | ||
|
|
b0c1f9041d | ||
|
|
cb034a1371 | ||
|
|
4b954fbc7c | ||
|
|
f5d6d0ebaf | ||
|
|
3071ee8b88 | ||
|
|
b16b98ffe8 | ||
|
|
4d70c3baf1 | ||
|
|
c86ea1a74f | ||
|
|
805bc57bbb | ||
|
|
92a7355ce3 | ||
|
|
e5a5aeecb5 | ||
|
|
046cb6ef62 | ||
|
|
beb2bc0a06 | ||
|
|
776e404c6f | ||
|
|
50bfee3328 | ||
|
|
a645dc7ba5 | ||
|
|
e6586c60b1 |
93 changed files with 3153 additions and 1340 deletions
|
|
@ -7,3 +7,5 @@ node_modules
|
||||||
.idea
|
.idea
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
scripts/seed
|
||||||
|
scripts/seed-data.ts
|
||||||
|
|
|
||||||
22
Dockerfile
22
Dockerfile
|
|
@ -1,5 +1,7 @@
|
||||||
|
ARG NODE_IMAGE_VERSION="22-alpine"
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM node:22-alpine AS deps
|
FROM node:${NODE_IMAGE_VERSION} AS deps
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -8,26 +10,25 @@ RUN npm install -g pnpm
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
FROM node:22-alpine AS builder
|
FROM node:${NODE_IMAGE_VERSION} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY docker/middleware.ts ./src
|
COPY docker/middleware.ts ./src
|
||||||
|
|
||||||
ARG DATABASE_TYPE
|
|
||||||
ARG BASE_PATH
|
ARG BASE_PATH
|
||||||
|
|
||||||
ENV DATABASE_TYPE=$DATABASE_TYPE
|
|
||||||
ENV BASE_PATH=$BASE_PATH
|
ENV BASE_PATH=$BASE_PATH
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV DATABASE_URL="postgresql://user:pass@localhost:5432/dummy"
|
||||||
|
|
||||||
RUN npm run build-docker
|
RUN npm run build-docker
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM node:22-alpine AS runner
|
FROM node:${NODE_IMAGE_VERSION} AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG PRISMA_VERSION="6.19.0"
|
||||||
ARG NODE_OPTIONS
|
ARG NODE_OPTIONS
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
@ -36,13 +37,14 @@ ENV NODE_OPTIONS=$NODE_OPTIONS
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
RUN npm install -g pnpm
|
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
&& apk add --no-cache curl
|
&& apk add --no-cache curl \
|
||||||
|
&& npm install -g pnpm
|
||||||
|
|
||||||
# Script dependencies
|
# Script dependencies
|
||||||
RUN pnpm --allow-build='@prisma/engines' add npm-run-all dotenv chalk semver prisma@6.18.0 @prisma/adapter-pg@6.18.0
|
RUN pnpm --allow-build='@prisma/engines' add npm-run-all dotenv chalk semver \
|
||||||
|
prisma@${PRISMA_VERSION} \
|
||||||
|
@prisma/adapter-pg@${PRISMA_VERSION}
|
||||||
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -27,10 +27,10 @@ A detailed getting started guide can be found at [umami.is/docs](https://umami.i
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- A server with Node.js version 18.18 or newer
|
- A server with Node.js version 18.18+.
|
||||||
- A database. Umami supports [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases.
|
- A PostgreSQL database version v12.14+.
|
||||||
|
|
||||||
### Get the Source Code and Install Packages
|
### Get the source code and install packages
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/umami-software/umami.git
|
git clone https://github.com/umami-software/umami.git
|
||||||
|
|
@ -58,7 +58,7 @@ postgresql://username:mypassword@localhost:5432/mydb
|
||||||
pnpm run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
_The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**._
|
The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.
|
||||||
|
|
||||||
### Start the Application
|
### Start the Application
|
||||||
|
|
||||||
|
|
@ -66,37 +66,36 @@ _The build step will create tables in your database if you are installing for th
|
||||||
pnpm run start
|
pnpm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
_By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly._
|
By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐳 Installing with Docker
|
## 🐳 Installing with Docker
|
||||||
|
|
||||||
To build the Umami container and start up a Postgres database, run:
|
Umami provides Docker images as well as a Docker compose file for easy deployment.
|
||||||
|
|
||||||
```bash
|
Docker image:
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull docker.umami.is/umami-software/umami:latest
|
docker pull docker.umami.is/umami-software/umami:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Docker compose to run Umami with a Postgres database, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 Getting Updates
|
## 🔄 Getting Updates
|
||||||
> [!WARNING]
|
|
||||||
> If you are updating from Umami V2, image "postgresql-latest" is deprecated. You must change it to "latest".
|
|
||||||
> e.g., rename `docker.umami.is/umami-software/umami:postgresql-latest` to `docker.umami.is/umami-software/umami:latest`.
|
|
||||||
|
|
||||||
To get the latest features, simply do a pull, install any new dependencies, and rebuild:
|
To get the latest features, simply do a pull, install any new dependencies, and rebuild:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
To update the Docker image, simply pull the new images and rebuild:
|
To update the Docker image, simply pull the new images and rebuild:
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,14 @@
|
||||||
"arrowParentheses": "asNeeded"
|
"arrowParentheses": "asNeeded"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"css": {
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineEnding": "lf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|
|
||||||
30
package.json
30
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "umami",
|
"name": "umami",
|
||||||
"version": "3.0.1",
|
"version": "3.0.2",
|
||||||
"description": "A modern, privacy-focused alternative to Google Analytics.",
|
"description": "A modern, privacy-focused alternative to Google Analytics.",
|
||||||
"author": "Umami Software, Inc. <hello@umami.is>",
|
"author": "Umami Software, Inc. <hello@umami.is>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"cypress-open": "cypress open cypress run",
|
"cypress-open": "cypress open cypress run",
|
||||||
"cypress-run": "cypress run cypress run",
|
"cypress-run": "cypress run cypress run",
|
||||||
|
"seed-data": "tsx scripts/seed-data.ts",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"check": "biome check --write"
|
"check": "biome check --write"
|
||||||
|
|
@ -71,8 +72,8 @@
|
||||||
"@prisma/extension-read-replicas": "^0.4.1",
|
"@prisma/extension-read-replicas": "^0.4.1",
|
||||||
"@react-spring/web": "^10.0.3",
|
"@react-spring/web": "^10.0.3",
|
||||||
"@svgr/cli": "^8.1.0",
|
"@svgr/cli": "^8.1.0",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@umami/react-zen": "^0.210.0",
|
"@umami/react-zen": "^0.211.0",
|
||||||
"@umami/redis-client": "^0.29.0",
|
"@umami/redis-client": "^0.29.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
|
@ -91,7 +92,7 @@
|
||||||
"esbuild": "^0.25.11",
|
"esbuild": "^0.25.11",
|
||||||
"fs-extra": "^11.3.2",
|
"fs-extra": "^11.3.2",
|
||||||
"immer": "^10.2.0",
|
"immer": "^10.2.0",
|
||||||
"ipaddr.js": "^2.0.1",
|
"ipaddr.js": "^2.3.0",
|
||||||
"is-ci": "^3.0.1",
|
"is-ci": "^3.0.1",
|
||||||
"is-docker": "^3.0.0",
|
"is-docker": "^3.0.0",
|
||||||
"is-localhost-ip": "^2.0.0",
|
"is-localhost-ip": "^2.0.0",
|
||||||
|
|
@ -101,15 +102,15 @@
|
||||||
"kafkajs": "^2.1.0",
|
"kafkajs": "^2.1.0",
|
||||||
"lucide-react": "^0.543.0",
|
"lucide-react": "^0.543.0",
|
||||||
"maxmind": "^5.0.0",
|
"maxmind": "^5.0.0",
|
||||||
"next": "15.5.3",
|
"next": "^15.5.7",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"prisma": "^6.18.0",
|
"prisma": "^6.18.0",
|
||||||
"pure-rand": "^7.0.1",
|
"pure-rand": "^7.0.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.1",
|
||||||
"react-error-boundary": "^4.0.4",
|
"react-error-boundary": "^4.0.4",
|
||||||
"react-intl": "^7.1.14",
|
"react-intl": "^7.1.14",
|
||||||
"react-simple-maps": "^2.3.0",
|
"react-simple-maps": "^2.3.0",
|
||||||
|
|
@ -121,13 +122,13 @@
|
||||||
"thenby": "^1.3.4",
|
"thenby": "^1.3.4",
|
||||||
"ua-parser-js": "^2.0.6",
|
"ua-parser-js": "^2.0.6",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.13",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.6",
|
"@biomejs/biome": "^2.3.8",
|
||||||
"@formatjs/cli": "^4.2.29",
|
"@formatjs/cli": "^4.2.29",
|
||||||
"@netlify/plugin-nextjs": "^5.14.4",
|
"@netlify/plugin-nextjs": "^5.15.1",
|
||||||
"@rollup/plugin-alias": "^5.0.0",
|
"@rollup/plugin-alias": "^5.0.0",
|
||||||
"@rollup/plugin-commonjs": "^25.0.4",
|
"@rollup/plugin-commonjs": "^25.0.4",
|
||||||
"@rollup/plugin-json": "^6.0.0",
|
"@rollup/plugin-json": "^6.0.0",
|
||||||
|
|
@ -137,7 +138,7 @@
|
||||||
"@rollup/plugin-typescript": "^12.3.0",
|
"@rollup/plugin-typescript": "^12.3.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.9.2",
|
"@types/node": "^24.9.2",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
|
|
@ -155,7 +156,7 @@
|
||||||
"rollup": "^4.52.5",
|
"rollup": "^4.52.5",
|
||||||
"rollup-plugin-copy": "^3.4.0",
|
"rollup-plugin-copy": "^3.4.0",
|
||||||
"rollup-plugin-delete": "^3.0.1",
|
"rollup-plugin-delete": "^3.0.1",
|
||||||
"rollup-plugin-dts": "^6.2.3",
|
"rollup-plugin-dts": "^6.3.0",
|
||||||
"rollup-plugin-node-externals": "^8.1.1",
|
"rollup-plugin-node-externals": "^8.1.1",
|
||||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||||
"rollup-plugin-postcss": "^4.0.2",
|
"rollup-plugin-postcss": "^4.0.2",
|
||||||
|
|
@ -164,9 +165,10 @@
|
||||||
"stylelint-config-prettier": "^9.0.3",
|
"stylelint-config-prettier": "^9.0.3",
|
||||||
"stylelint-config-recommended": "^14.0.0",
|
"stylelint-config-recommended": "^14.0.0",
|
||||||
"tar": "^6.1.2",
|
"tar": "^6.1.2",
|
||||||
"ts-jest": "^29.4.5",
|
"ts-jest": "^29.4.6",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2369
pnpm-lock.yaml
generated
2369
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
121
scripts/seed-data.ts
Normal file
121
scripts/seed-data.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umami Sample Data Generator
|
||||||
|
*
|
||||||
|
* Generates realistic analytics data for local development and testing.
|
||||||
|
* Creates two demo websites:
|
||||||
|
* - Demo Blog: Low traffic (~100 sessions/month)
|
||||||
|
* - Demo SaaS: Average traffic (~500 sessions/day)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run seed-data # Generate 30 days of data
|
||||||
|
* npm run seed-data -- --days 90 # Generate 90 days of data
|
||||||
|
* npm run seed-data -- --clear # Clear existing demo data first
|
||||||
|
* npm run seed-data -- --verbose # Show detailed progress
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type SeedConfig, seed } from './seed/index.js';
|
||||||
|
|
||||||
|
function parseArgs(): SeedConfig {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
const config: SeedConfig = {
|
||||||
|
days: 30,
|
||||||
|
clear: false,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
|
||||||
|
if (arg === '--days' && args[i + 1]) {
|
||||||
|
config.days = parseInt(args[i + 1], 10);
|
||||||
|
if (isNaN(config.days) || config.days < 1) {
|
||||||
|
console.error('Error: --days must be a positive integer');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
} else if (arg === '--clear') {
|
||||||
|
config.clear = true;
|
||||||
|
} else if (arg === '--verbose' || arg === '-v') {
|
||||||
|
config.verbose = true;
|
||||||
|
} else if (arg === '--help' || arg === '-h') {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
} else if (arg.startsWith('--days=')) {
|
||||||
|
config.days = parseInt(arg.split('=')[1], 10);
|
||||||
|
if (isNaN(config.days) || config.days < 1) {
|
||||||
|
console.error('Error: --days must be a positive integer');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(`
|
||||||
|
Umami Sample Data Generator
|
||||||
|
|
||||||
|
Generates realistic analytics data for local development and testing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npm run seed-data [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--days <number> Number of days of data to generate (default: 30)
|
||||||
|
--clear Clear existing demo data before generating
|
||||||
|
--verbose, -v Show detailed progress
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npm run seed-data # Generate 30 days of data
|
||||||
|
npm run seed-data -- --days 90 # Generate 90 days of data
|
||||||
|
npm run seed-data -- --clear # Clear existing demo data first
|
||||||
|
npm run seed-data -- --days 7 -v # Generate 7 days with verbose output
|
||||||
|
|
||||||
|
Generated Sites:
|
||||||
|
- Demo Blog: Low traffic (~90 sessions/month)
|
||||||
|
- Demo SaaS: Average traffic (~500 sessions/day) with revenue tracking
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This script is blocked from running in production environments
|
||||||
|
(NODE_ENV=production or cloud platforms like Vercel/Netlify/Railway).
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkEnvironment(): void {
|
||||||
|
const nodeEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
|
if (nodeEnv === 'production') {
|
||||||
|
console.error('\nError: seed-data cannot run in production environment.');
|
||||||
|
console.error('This script is only for local development and testing.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.VERCEL || process.env.NETLIFY || process.env.RAILWAY_ENVIRONMENT) {
|
||||||
|
console.error('\nError: seed-data cannot run in cloud environments.');
|
||||||
|
console.error('This script is only for local development and testing.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('\nUmami Sample Data Generator\n');
|
||||||
|
|
||||||
|
checkEnvironment();
|
||||||
|
|
||||||
|
const config = parseArgs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seed(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\nError generating seed data:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
80
scripts/seed/distributions/devices.ts
Normal file
80
scripts/seed/distributions/devices.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { pickRandom, type WeightedOption, weightedRandom } from '../utils.js';
|
||||||
|
|
||||||
|
export type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
||||||
|
|
||||||
|
const deviceWeights: WeightedOption<DeviceType>[] = [
|
||||||
|
{ value: 'desktop', weight: 0.55 },
|
||||||
|
{ value: 'mobile', weight: 0.4 },
|
||||||
|
{ value: 'tablet', weight: 0.05 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const browsersByDevice: Record<DeviceType, WeightedOption<string>[]> = {
|
||||||
|
desktop: [
|
||||||
|
{ value: 'Chrome', weight: 0.65 },
|
||||||
|
{ value: 'Safari', weight: 0.12 },
|
||||||
|
{ value: 'Firefox', weight: 0.1 },
|
||||||
|
{ value: 'Edge', weight: 0.1 },
|
||||||
|
{ value: 'Opera', weight: 0.03 },
|
||||||
|
],
|
||||||
|
mobile: [
|
||||||
|
{ value: 'Chrome', weight: 0.55 },
|
||||||
|
{ value: 'Safari', weight: 0.35 },
|
||||||
|
{ value: 'Samsung', weight: 0.05 },
|
||||||
|
{ value: 'Firefox', weight: 0.03 },
|
||||||
|
{ value: 'Opera', weight: 0.02 },
|
||||||
|
],
|
||||||
|
tablet: [
|
||||||
|
{ value: 'Safari', weight: 0.6 },
|
||||||
|
{ value: 'Chrome', weight: 0.35 },
|
||||||
|
{ value: 'Firefox', weight: 0.05 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const osByDevice: Record<DeviceType, WeightedOption<string>[]> = {
|
||||||
|
desktop: [
|
||||||
|
{ value: 'Windows 10', weight: 0.5 },
|
||||||
|
{ value: 'Mac OS', weight: 0.3 },
|
||||||
|
{ value: 'Linux', weight: 0.12 },
|
||||||
|
{ value: 'Chrome OS', weight: 0.05 },
|
||||||
|
{ value: 'Windows 11', weight: 0.03 },
|
||||||
|
],
|
||||||
|
mobile: [
|
||||||
|
{ value: 'iOS', weight: 0.45 },
|
||||||
|
{ value: 'Android', weight: 0.55 },
|
||||||
|
],
|
||||||
|
tablet: [
|
||||||
|
{ value: 'iOS', weight: 0.75 },
|
||||||
|
{ value: 'Android', weight: 0.25 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const screensByDevice: Record<DeviceType, string[]> = {
|
||||||
|
desktop: [
|
||||||
|
'1920x1080',
|
||||||
|
'2560x1440',
|
||||||
|
'1366x768',
|
||||||
|
'1440x900',
|
||||||
|
'3840x2160',
|
||||||
|
'1536x864',
|
||||||
|
'1680x1050',
|
||||||
|
'2560x1080',
|
||||||
|
],
|
||||||
|
mobile: ['390x844', '414x896', '375x812', '360x800', '428x926', '393x873', '412x915', '360x780'],
|
||||||
|
tablet: ['1024x768', '768x1024', '834x1194', '820x1180', '810x1080', '800x1280'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
device: DeviceType;
|
||||||
|
browser: string;
|
||||||
|
os: string;
|
||||||
|
screen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRandomDevice(): DeviceInfo {
|
||||||
|
const device = weightedRandom(deviceWeights);
|
||||||
|
const browser = weightedRandom(browsersByDevice[device]);
|
||||||
|
const os = weightedRandom(osByDevice[device]);
|
||||||
|
const screen = pickRandom(screensByDevice[device]);
|
||||||
|
|
||||||
|
return { device, browser, os, screen };
|
||||||
|
}
|
||||||
144
scripts/seed/distributions/geographic.ts
Normal file
144
scripts/seed/distributions/geographic.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { pickRandom, type WeightedOption, weightedRandom } from '../utils.js';
|
||||||
|
|
||||||
|
interface GeoLocation {
|
||||||
|
country: string;
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryWeights: WeightedOption<string>[] = [
|
||||||
|
{ value: 'US', weight: 0.35 },
|
||||||
|
{ value: 'GB', weight: 0.08 },
|
||||||
|
{ value: 'DE', weight: 0.06 },
|
||||||
|
{ value: 'FR', weight: 0.05 },
|
||||||
|
{ value: 'CA', weight: 0.04 },
|
||||||
|
{ value: 'AU', weight: 0.03 },
|
||||||
|
{ value: 'IN', weight: 0.08 },
|
||||||
|
{ value: 'BR', weight: 0.04 },
|
||||||
|
{ value: 'JP', weight: 0.03 },
|
||||||
|
{ value: 'NL', weight: 0.02 },
|
||||||
|
{ value: 'ES', weight: 0.02 },
|
||||||
|
{ value: 'IT', weight: 0.02 },
|
||||||
|
{ value: 'PL', weight: 0.02 },
|
||||||
|
{ value: 'SE', weight: 0.01 },
|
||||||
|
{ value: 'MX', weight: 0.02 },
|
||||||
|
{ value: 'KR', weight: 0.02 },
|
||||||
|
{ value: 'SG', weight: 0.01 },
|
||||||
|
{ value: 'ID', weight: 0.02 },
|
||||||
|
{ value: 'PH', weight: 0.01 },
|
||||||
|
{ value: 'TH', weight: 0.01 },
|
||||||
|
{ value: 'VN', weight: 0.01 },
|
||||||
|
{ value: 'RU', weight: 0.02 },
|
||||||
|
{ value: 'UA', weight: 0.01 },
|
||||||
|
{ value: 'ZA', weight: 0.01 },
|
||||||
|
{ value: 'NG', weight: 0.01 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const regionsByCountry: Record<string, { region: string; city: string }[]> = {
|
||||||
|
US: [
|
||||||
|
{ region: 'CA', city: 'San Francisco' },
|
||||||
|
{ region: 'CA', city: 'Los Angeles' },
|
||||||
|
{ region: 'NY', city: 'New York' },
|
||||||
|
{ region: 'TX', city: 'Austin' },
|
||||||
|
{ region: 'TX', city: 'Houston' },
|
||||||
|
{ region: 'WA', city: 'Seattle' },
|
||||||
|
{ region: 'IL', city: 'Chicago' },
|
||||||
|
{ region: 'MA', city: 'Boston' },
|
||||||
|
{ region: 'CO', city: 'Denver' },
|
||||||
|
{ region: 'GA', city: 'Atlanta' },
|
||||||
|
{ region: 'FL', city: 'Miami' },
|
||||||
|
{ region: 'PA', city: 'Philadelphia' },
|
||||||
|
],
|
||||||
|
GB: [
|
||||||
|
{ region: 'ENG', city: 'London' },
|
||||||
|
{ region: 'ENG', city: 'Manchester' },
|
||||||
|
{ region: 'ENG', city: 'Birmingham' },
|
||||||
|
{ region: 'SCT', city: 'Edinburgh' },
|
||||||
|
{ region: 'ENG', city: 'Bristol' },
|
||||||
|
],
|
||||||
|
DE: [
|
||||||
|
{ region: 'BE', city: 'Berlin' },
|
||||||
|
{ region: 'BY', city: 'Munich' },
|
||||||
|
{ region: 'HH', city: 'Hamburg' },
|
||||||
|
{ region: 'HE', city: 'Frankfurt' },
|
||||||
|
{ region: 'NW', city: 'Cologne' },
|
||||||
|
],
|
||||||
|
FR: [
|
||||||
|
{ region: 'IDF', city: 'Paris' },
|
||||||
|
{ region: 'ARA', city: 'Lyon' },
|
||||||
|
{ region: 'PAC', city: 'Marseille' },
|
||||||
|
{ region: 'OCC', city: 'Toulouse' },
|
||||||
|
],
|
||||||
|
CA: [
|
||||||
|
{ region: 'ON', city: 'Toronto' },
|
||||||
|
{ region: 'BC', city: 'Vancouver' },
|
||||||
|
{ region: 'QC', city: 'Montreal' },
|
||||||
|
{ region: 'AB', city: 'Calgary' },
|
||||||
|
],
|
||||||
|
AU: [
|
||||||
|
{ region: 'NSW', city: 'Sydney' },
|
||||||
|
{ region: 'VIC', city: 'Melbourne' },
|
||||||
|
{ region: 'QLD', city: 'Brisbane' },
|
||||||
|
{ region: 'WA', city: 'Perth' },
|
||||||
|
],
|
||||||
|
IN: [
|
||||||
|
{ region: 'MH', city: 'Mumbai' },
|
||||||
|
{ region: 'KA', city: 'Bangalore' },
|
||||||
|
{ region: 'DL', city: 'New Delhi' },
|
||||||
|
{ region: 'TN', city: 'Chennai' },
|
||||||
|
{ region: 'TG', city: 'Hyderabad' },
|
||||||
|
],
|
||||||
|
BR: [
|
||||||
|
{ region: 'SP', city: 'Sao Paulo' },
|
||||||
|
{ region: 'RJ', city: 'Rio de Janeiro' },
|
||||||
|
{ region: 'MG', city: 'Belo Horizonte' },
|
||||||
|
],
|
||||||
|
JP: [
|
||||||
|
{ region: '13', city: 'Tokyo' },
|
||||||
|
{ region: '27', city: 'Osaka' },
|
||||||
|
{ region: '23', city: 'Nagoya' },
|
||||||
|
],
|
||||||
|
NL: [
|
||||||
|
{ region: 'NH', city: 'Amsterdam' },
|
||||||
|
{ region: 'ZH', city: 'Rotterdam' },
|
||||||
|
{ region: 'ZH', city: 'The Hague' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultRegions = [{ region: '', city: '' }];
|
||||||
|
|
||||||
|
export function getRandomGeo(): GeoLocation {
|
||||||
|
const country = weightedRandom(countryWeights);
|
||||||
|
const regions = regionsByCountry[country] || defaultRegions;
|
||||||
|
const { region, city } = pickRandom(regions);
|
||||||
|
|
||||||
|
return { country, region, city };
|
||||||
|
}
|
||||||
|
|
||||||
|
const languages: WeightedOption<string>[] = [
|
||||||
|
{ value: 'en-US', weight: 0.4 },
|
||||||
|
{ value: 'en-GB', weight: 0.08 },
|
||||||
|
{ value: 'de-DE', weight: 0.06 },
|
||||||
|
{ value: 'fr-FR', weight: 0.05 },
|
||||||
|
{ value: 'es-ES', weight: 0.05 },
|
||||||
|
{ value: 'pt-BR', weight: 0.04 },
|
||||||
|
{ value: 'ja-JP', weight: 0.03 },
|
||||||
|
{ value: 'zh-CN', weight: 0.05 },
|
||||||
|
{ value: 'ko-KR', weight: 0.02 },
|
||||||
|
{ value: 'ru-RU', weight: 0.02 },
|
||||||
|
{ value: 'it-IT', weight: 0.02 },
|
||||||
|
{ value: 'nl-NL', weight: 0.02 },
|
||||||
|
{ value: 'pl-PL', weight: 0.02 },
|
||||||
|
{ value: 'hi-IN', weight: 0.04 },
|
||||||
|
{ value: 'ar-SA', weight: 0.02 },
|
||||||
|
{ value: 'tr-TR', weight: 0.02 },
|
||||||
|
{ value: 'vi-VN', weight: 0.01 },
|
||||||
|
{ value: 'th-TH', weight: 0.01 },
|
||||||
|
{ value: 'id-ID', weight: 0.02 },
|
||||||
|
{ value: 'sv-SE', weight: 0.01 },
|
||||||
|
{ value: 'da-DK', weight: 0.01 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getRandomLanguage(): string {
|
||||||
|
return weightedRandom(languages);
|
||||||
|
}
|
||||||
163
scripts/seed/distributions/referrers.ts
Normal file
163
scripts/seed/distributions/referrers.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { pickRandom, randomInt, type WeightedOption, weightedRandom } from '../utils.js';
|
||||||
|
|
||||||
|
export type ReferrerType = 'direct' | 'organic' | 'social' | 'paid' | 'referral';
|
||||||
|
|
||||||
|
export interface ReferrerInfo {
|
||||||
|
type: ReferrerType;
|
||||||
|
domain: string | null;
|
||||||
|
path: string | null;
|
||||||
|
utmSource: string | null;
|
||||||
|
utmMedium: string | null;
|
||||||
|
utmCampaign: string | null;
|
||||||
|
utmContent: string | null;
|
||||||
|
utmTerm: string | null;
|
||||||
|
gclid: string | null;
|
||||||
|
fbclid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrerTypeWeights: WeightedOption<ReferrerType>[] = [
|
||||||
|
{ value: 'direct', weight: 0.4 },
|
||||||
|
{ value: 'organic', weight: 0.25 },
|
||||||
|
{ value: 'social', weight: 0.15 },
|
||||||
|
{ value: 'paid', weight: 0.1 },
|
||||||
|
{ value: 'referral', weight: 0.1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const searchEngines = [
|
||||||
|
{ domain: 'google.com', path: '/search' },
|
||||||
|
{ domain: 'bing.com', path: '/search' },
|
||||||
|
{ domain: 'duckduckgo.com', path: '/' },
|
||||||
|
{ domain: 'yahoo.com', path: '/search' },
|
||||||
|
{ domain: 'baidu.com', path: '/s' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const socialPlatforms = [
|
||||||
|
{ domain: 'twitter.com', path: null },
|
||||||
|
{ domain: 'x.com', path: null },
|
||||||
|
{ domain: 'linkedin.com', path: '/feed' },
|
||||||
|
{ domain: 'facebook.com', path: null },
|
||||||
|
{ domain: 'reddit.com', path: '/r/programming' },
|
||||||
|
{ domain: 'news.ycombinator.com', path: '/item' },
|
||||||
|
{ domain: 'threads.net', path: null },
|
||||||
|
{ domain: 'bsky.app', path: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const referralSites = [
|
||||||
|
{ domain: 'medium.com', path: '/@author/article' },
|
||||||
|
{ domain: 'dev.to', path: '/post' },
|
||||||
|
{ domain: 'hashnode.com', path: '/blog' },
|
||||||
|
{ domain: 'techcrunch.com', path: '/article' },
|
||||||
|
{ domain: 'producthunt.com', path: '/posts' },
|
||||||
|
{ domain: 'indiehackers.com', path: '/post' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PaidCampaign {
|
||||||
|
source: string;
|
||||||
|
medium: string;
|
||||||
|
campaign: string;
|
||||||
|
useGclid?: boolean;
|
||||||
|
useFbclid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidCampaigns: PaidCampaign[] = [
|
||||||
|
{ source: 'google', medium: 'cpc', campaign: 'brand_search', useGclid: true },
|
||||||
|
{ source: 'google', medium: 'cpc', campaign: 'product_awareness', useGclid: true },
|
||||||
|
{ source: 'facebook', medium: 'paid_social', campaign: 'retargeting', useFbclid: true },
|
||||||
|
{ source: 'facebook', medium: 'paid_social', campaign: 'lookalike', useFbclid: true },
|
||||||
|
{ source: 'linkedin', medium: 'cpc', campaign: 'b2b_targeting' },
|
||||||
|
{ source: 'twitter', medium: 'paid_social', campaign: 'launch_promo' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const organicCampaigns = [
|
||||||
|
{ source: 'newsletter', medium: 'email', campaign: 'weekly_digest' },
|
||||||
|
{ source: 'newsletter', medium: 'email', campaign: 'product_update' },
|
||||||
|
{ source: 'partner', medium: 'referral', campaign: 'integration_launch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function generateClickId(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRandomReferrer(): ReferrerInfo {
|
||||||
|
const type = weightedRandom(referrerTypeWeights);
|
||||||
|
|
||||||
|
const result: ReferrerInfo = {
|
||||||
|
type,
|
||||||
|
domain: null,
|
||||||
|
path: null,
|
||||||
|
utmSource: null,
|
||||||
|
utmMedium: null,
|
||||||
|
utmCampaign: null,
|
||||||
|
utmContent: null,
|
||||||
|
utmTerm: null,
|
||||||
|
gclid: null,
|
||||||
|
fbclid: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'direct':
|
||||||
|
// No referrer data
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'organic': {
|
||||||
|
const engine = pickRandom(searchEngines);
|
||||||
|
result.domain = engine.domain;
|
||||||
|
result.path = engine.path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'social': {
|
||||||
|
const platform = pickRandom(socialPlatforms);
|
||||||
|
result.domain = platform.domain;
|
||||||
|
result.path = platform.path;
|
||||||
|
|
||||||
|
// Some social traffic has UTM params
|
||||||
|
if (Math.random() < 0.3) {
|
||||||
|
result.utmSource = platform.domain.replace('.com', '').replace('.net', '');
|
||||||
|
result.utmMedium = 'social';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'paid': {
|
||||||
|
const campaign = pickRandom(paidCampaigns);
|
||||||
|
result.utmSource = campaign.source;
|
||||||
|
result.utmMedium = campaign.medium;
|
||||||
|
result.utmCampaign = campaign.campaign;
|
||||||
|
result.utmContent = `ad_${randomInt(1, 5)}`;
|
||||||
|
|
||||||
|
if (campaign.useGclid) {
|
||||||
|
result.gclid = generateClickId();
|
||||||
|
result.domain = 'google.com';
|
||||||
|
result.path = '/search';
|
||||||
|
} else if (campaign.useFbclid) {
|
||||||
|
result.fbclid = generateClickId();
|
||||||
|
result.domain = 'facebook.com';
|
||||||
|
result.path = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'referral': {
|
||||||
|
// Mix of pure referrals and organic campaigns
|
||||||
|
if (Math.random() < 0.6) {
|
||||||
|
const site = pickRandom(referralSites);
|
||||||
|
result.domain = site.domain;
|
||||||
|
result.path = site.path;
|
||||||
|
} else {
|
||||||
|
const campaign = pickRandom(organicCampaigns);
|
||||||
|
result.utmSource = campaign.source;
|
||||||
|
result.utmMedium = campaign.medium;
|
||||||
|
result.utmCampaign = campaign.campaign;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
69
scripts/seed/distributions/temporal.ts
Normal file
69
scripts/seed/distributions/temporal.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { randomInt, type WeightedOption, weightedRandom } from '../utils.js';
|
||||||
|
|
||||||
|
const hourlyWeights: WeightedOption<number>[] = [
|
||||||
|
{ value: 0, weight: 0.02 },
|
||||||
|
{ value: 1, weight: 0.01 },
|
||||||
|
{ value: 2, weight: 0.01 },
|
||||||
|
{ value: 3, weight: 0.01 },
|
||||||
|
{ value: 4, weight: 0.01 },
|
||||||
|
{ value: 5, weight: 0.02 },
|
||||||
|
{ value: 6, weight: 0.03 },
|
||||||
|
{ value: 7, weight: 0.05 },
|
||||||
|
{ value: 8, weight: 0.07 },
|
||||||
|
{ value: 9, weight: 0.08 },
|
||||||
|
{ value: 10, weight: 0.09 },
|
||||||
|
{ value: 11, weight: 0.08 },
|
||||||
|
{ value: 12, weight: 0.07 },
|
||||||
|
{ value: 13, weight: 0.08 },
|
||||||
|
{ value: 14, weight: 0.09 },
|
||||||
|
{ value: 15, weight: 0.08 },
|
||||||
|
{ value: 16, weight: 0.07 },
|
||||||
|
{ value: 17, weight: 0.06 },
|
||||||
|
{ value: 18, weight: 0.05 },
|
||||||
|
{ value: 19, weight: 0.04 },
|
||||||
|
{ value: 20, weight: 0.03 },
|
||||||
|
{ value: 21, weight: 0.03 },
|
||||||
|
{ value: 22, weight: 0.02 },
|
||||||
|
{ value: 23, weight: 0.02 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dayOfWeekWeights: WeightedOption<number>[] = [
|
||||||
|
{ value: 0, weight: 0.08 }, // Sunday
|
||||||
|
{ value: 1, weight: 0.16 }, // Monday
|
||||||
|
{ value: 2, weight: 0.17 }, // Tuesday
|
||||||
|
{ value: 3, weight: 0.17 }, // Wednesday
|
||||||
|
{ value: 4, weight: 0.16 }, // Thursday
|
||||||
|
{ value: 5, weight: 0.15 }, // Friday
|
||||||
|
{ value: 6, weight: 0.11 }, // Saturday
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getWeightedHour(): number {
|
||||||
|
return weightedRandom(hourlyWeights);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayOfWeekMultiplier(dayOfWeek: number): number {
|
||||||
|
const weight = dayOfWeekWeights.find(d => d.value === dayOfWeek)?.weight ?? 0.14;
|
||||||
|
return weight / 0.14; // Normalize around 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTimestampForDay(day: Date): Date {
|
||||||
|
const hour = getWeightedHour();
|
||||||
|
const minute = randomInt(0, 59);
|
||||||
|
const second = randomInt(0, 59);
|
||||||
|
const millisecond = randomInt(0, 999);
|
||||||
|
|
||||||
|
const timestamp = new Date(day);
|
||||||
|
timestamp.setHours(hour, minute, second, millisecond);
|
||||||
|
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionCountForDay(baseCount: number, day: Date): number {
|
||||||
|
const dayOfWeek = day.getDay();
|
||||||
|
const multiplier = getDayOfWeekMultiplier(dayOfWeek);
|
||||||
|
|
||||||
|
// Add some random variance (±20%)
|
||||||
|
const variance = 0.8 + Math.random() * 0.4;
|
||||||
|
|
||||||
|
return Math.round(baseCount * multiplier * variance);
|
||||||
|
}
|
||||||
191
scripts/seed/generators/events.ts
Normal file
191
scripts/seed/generators/events.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { getRandomReferrer } from '../distributions/referrers.js';
|
||||||
|
import { addSeconds, randomInt, uuid } from '../utils.js';
|
||||||
|
import type { SessionData } from './sessions.js';
|
||||||
|
|
||||||
|
export const EVENT_TYPE = {
|
||||||
|
pageView: 1,
|
||||||
|
customEvent: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface PageConfig {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
weight: number;
|
||||||
|
avgTimeOnPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomEventConfig {
|
||||||
|
name: string;
|
||||||
|
weight: number;
|
||||||
|
pages?: string[];
|
||||||
|
data?: Record<string, string[] | number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyConfig {
|
||||||
|
pages: string[];
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventData {
|
||||||
|
id: string;
|
||||||
|
websiteId: string;
|
||||||
|
sessionId: string;
|
||||||
|
visitId: string;
|
||||||
|
eventType: number;
|
||||||
|
urlPath: string;
|
||||||
|
urlQuery: string | null;
|
||||||
|
pageTitle: string | null;
|
||||||
|
hostname: string;
|
||||||
|
referrerDomain: string | null;
|
||||||
|
referrerPath: string | null;
|
||||||
|
utmSource: string | null;
|
||||||
|
utmMedium: string | null;
|
||||||
|
utmCampaign: string | null;
|
||||||
|
utmContent: string | null;
|
||||||
|
utmTerm: string | null;
|
||||||
|
gclid: string | null;
|
||||||
|
fbclid: string | null;
|
||||||
|
eventName: string | null;
|
||||||
|
tag: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDataEntry {
|
||||||
|
id: string;
|
||||||
|
websiteId: string;
|
||||||
|
websiteEventId: string;
|
||||||
|
dataKey: string;
|
||||||
|
stringValue: string | null;
|
||||||
|
numberValue: number | null;
|
||||||
|
dateValue: Date | null;
|
||||||
|
dataType: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteConfig {
|
||||||
|
hostname: string;
|
||||||
|
pages: PageConfig[];
|
||||||
|
journeys: JourneyConfig[];
|
||||||
|
customEvents: CustomEventConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageTitle(pages: PageConfig[], path: string): string | null {
|
||||||
|
const page = pages.find(p => p.path === path);
|
||||||
|
return page?.title ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageTimeOnPage(pages: PageConfig[], path: string): number {
|
||||||
|
const page = pages.find(p => p.path === path);
|
||||||
|
return page?.avgTimeOnPage ?? 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateEventsForSession(
|
||||||
|
session: SessionData,
|
||||||
|
siteConfig: SiteConfig,
|
||||||
|
journey: string[],
|
||||||
|
): { events: EventData[]; eventDataEntries: EventDataEntry[] } {
|
||||||
|
const events: EventData[] = [];
|
||||||
|
const eventDataEntries: EventDataEntry[] = [];
|
||||||
|
const visitId = uuid();
|
||||||
|
|
||||||
|
let currentTime = session.createdAt;
|
||||||
|
const referrer = getRandomReferrer();
|
||||||
|
|
||||||
|
for (let i = 0; i < journey.length; i++) {
|
||||||
|
const pagePath = journey[i];
|
||||||
|
const isFirstPage = i === 0;
|
||||||
|
|
||||||
|
const eventId = uuid();
|
||||||
|
const pageTitle = getPageTitle(siteConfig.pages, pagePath);
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
id: eventId,
|
||||||
|
websiteId: session.websiteId,
|
||||||
|
sessionId: session.id,
|
||||||
|
visitId,
|
||||||
|
eventType: EVENT_TYPE.pageView,
|
||||||
|
urlPath: pagePath,
|
||||||
|
urlQuery: null,
|
||||||
|
pageTitle,
|
||||||
|
hostname: siteConfig.hostname,
|
||||||
|
referrerDomain: isFirstPage ? referrer.domain : null,
|
||||||
|
referrerPath: isFirstPage ? referrer.path : null,
|
||||||
|
utmSource: isFirstPage ? referrer.utmSource : null,
|
||||||
|
utmMedium: isFirstPage ? referrer.utmMedium : null,
|
||||||
|
utmCampaign: isFirstPage ? referrer.utmCampaign : null,
|
||||||
|
utmContent: isFirstPage ? referrer.utmContent : null,
|
||||||
|
utmTerm: isFirstPage ? referrer.utmTerm : null,
|
||||||
|
gclid: isFirstPage ? referrer.gclid : null,
|
||||||
|
fbclid: isFirstPage ? referrer.fbclid : null,
|
||||||
|
eventName: null,
|
||||||
|
tag: null,
|
||||||
|
createdAt: currentTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for custom events on this page
|
||||||
|
for (const customEvent of siteConfig.customEvents) {
|
||||||
|
// Check if this event can occur on this page
|
||||||
|
if (customEvent.pages && !customEvent.pages.includes(pagePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random chance based on weight
|
||||||
|
if (Math.random() < customEvent.weight) {
|
||||||
|
currentTime = addSeconds(currentTime, randomInt(2, 15));
|
||||||
|
|
||||||
|
const customEventId = uuid();
|
||||||
|
events.push({
|
||||||
|
id: customEventId,
|
||||||
|
websiteId: session.websiteId,
|
||||||
|
sessionId: session.id,
|
||||||
|
visitId,
|
||||||
|
eventType: EVENT_TYPE.customEvent,
|
||||||
|
urlPath: pagePath,
|
||||||
|
urlQuery: null,
|
||||||
|
pageTitle,
|
||||||
|
hostname: siteConfig.hostname,
|
||||||
|
referrerDomain: null,
|
||||||
|
referrerPath: null,
|
||||||
|
utmSource: null,
|
||||||
|
utmMedium: null,
|
||||||
|
utmCampaign: null,
|
||||||
|
utmContent: null,
|
||||||
|
utmTerm: null,
|
||||||
|
gclid: null,
|
||||||
|
fbclid: null,
|
||||||
|
eventName: customEvent.name,
|
||||||
|
tag: null,
|
||||||
|
createdAt: currentTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate event data if configured
|
||||||
|
if (customEvent.data) {
|
||||||
|
for (const [key, values] of Object.entries(customEvent.data)) {
|
||||||
|
const value = values[Math.floor(Math.random() * values.length)];
|
||||||
|
const isNumber = typeof value === 'number';
|
||||||
|
|
||||||
|
eventDataEntries.push({
|
||||||
|
id: uuid(),
|
||||||
|
websiteId: session.websiteId,
|
||||||
|
websiteEventId: customEventId,
|
||||||
|
dataKey: key,
|
||||||
|
stringValue: isNumber ? null : String(value),
|
||||||
|
numberValue: isNumber ? value : null,
|
||||||
|
dateValue: null,
|
||||||
|
dataType: isNumber ? 2 : 1, // 1 = string, 2 = number
|
||||||
|
createdAt: currentTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time spent on page before navigating
|
||||||
|
const timeOnPage = getPageTimeOnPage(siteConfig.pages, pagePath);
|
||||||
|
const variance = Math.floor(timeOnPage * 0.5);
|
||||||
|
const actualTime = timeOnPage + randomInt(-variance, variance);
|
||||||
|
currentTime = addSeconds(currentTime, Math.max(5, actualTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { events, eventDataEntries };
|
||||||
|
}
|
||||||
65
scripts/seed/generators/revenue.ts
Normal file
65
scripts/seed/generators/revenue.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { randomFloat, uuid } from '../utils.js';
|
||||||
|
import type { EventData } from './events.js';
|
||||||
|
|
||||||
|
export interface RevenueConfig {
|
||||||
|
eventName: string;
|
||||||
|
minAmount: number;
|
||||||
|
maxAmount: number;
|
||||||
|
currency: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueData {
|
||||||
|
id: string;
|
||||||
|
websiteId: string;
|
||||||
|
sessionId: string;
|
||||||
|
eventId: string;
|
||||||
|
eventName: string;
|
||||||
|
currency: string;
|
||||||
|
revenue: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRevenue(event: EventData, config: RevenueConfig): RevenueData | null {
|
||||||
|
if (event.eventName !== config.eventName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.random() > config.weight) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revenue = randomFloat(config.minAmount, config.maxAmount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
websiteId: event.websiteId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
eventId: event.id,
|
||||||
|
eventName: event.eventName!,
|
||||||
|
currency: config.currency,
|
||||||
|
revenue: Math.round(revenue * 100) / 100, // Round to 2 decimal places
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRevenueForEvents(
|
||||||
|
events: EventData[],
|
||||||
|
configs: RevenueConfig[],
|
||||||
|
): RevenueData[] {
|
||||||
|
const revenueEntries: RevenueData[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.eventName) continue;
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const revenue = generateRevenue(event, config);
|
||||||
|
if (revenue) {
|
||||||
|
revenueEntries.push(revenue);
|
||||||
|
break; // Only one revenue per event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return revenueEntries;
|
||||||
|
}
|
||||||
52
scripts/seed/generators/sessions.ts
Normal file
52
scripts/seed/generators/sessions.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { getRandomDevice } from '../distributions/devices.js';
|
||||||
|
import { getRandomGeo, getRandomLanguage } from '../distributions/geographic.js';
|
||||||
|
import { generateTimestampForDay } from '../distributions/temporal.js';
|
||||||
|
import { uuid } from '../utils.js';
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
id: string;
|
||||||
|
websiteId: string;
|
||||||
|
browser: string;
|
||||||
|
os: string;
|
||||||
|
device: string;
|
||||||
|
screen: string;
|
||||||
|
language: string;
|
||||||
|
country: string;
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(websiteId: string, day: Date): SessionData {
|
||||||
|
const deviceInfo = getRandomDevice();
|
||||||
|
const geo = getRandomGeo();
|
||||||
|
const language = getRandomLanguage();
|
||||||
|
const createdAt = generateTimestampForDay(day);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
websiteId,
|
||||||
|
browser: deviceInfo.browser,
|
||||||
|
os: deviceInfo.os,
|
||||||
|
device: deviceInfo.device,
|
||||||
|
screen: deviceInfo.screen,
|
||||||
|
language,
|
||||||
|
country: geo.country,
|
||||||
|
region: geo.region,
|
||||||
|
city: geo.city,
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessions(websiteId: string, day: Date, count: number): SessionData[] {
|
||||||
|
const sessions: SessionData[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
sessions.push(createSession(websiteId, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt to maintain chronological order
|
||||||
|
sessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
|
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
378
scripts/seed/index.ts
Normal file
378
scripts/seed/index.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { type Prisma, PrismaClient } from '../../src/generated/prisma/client.js';
|
||||||
|
import { getSessionCountForDay } from './distributions/temporal.js';
|
||||||
|
import {
|
||||||
|
type EventData,
|
||||||
|
type EventDataEntry,
|
||||||
|
generateEventsForSession,
|
||||||
|
} from './generators/events.js';
|
||||||
|
import {
|
||||||
|
generateRevenueForEvents,
|
||||||
|
type RevenueConfig,
|
||||||
|
type RevenueData,
|
||||||
|
} from './generators/revenue.js';
|
||||||
|
import { createSessions, type SessionData } from './generators/sessions.js';
|
||||||
|
import {
|
||||||
|
BLOG_SESSIONS_PER_DAY,
|
||||||
|
BLOG_WEBSITE_DOMAIN,
|
||||||
|
BLOG_WEBSITE_NAME,
|
||||||
|
getBlogJourney,
|
||||||
|
getBlogSiteConfig,
|
||||||
|
} from './sites/blog.js';
|
||||||
|
import {
|
||||||
|
getSaasJourney,
|
||||||
|
getSaasSiteConfig,
|
||||||
|
SAAS_SESSIONS_PER_DAY,
|
||||||
|
SAAS_WEBSITE_DOMAIN,
|
||||||
|
SAAS_WEBSITE_NAME,
|
||||||
|
saasRevenueConfigs,
|
||||||
|
} from './sites/saas.js';
|
||||||
|
import { formatNumber, generateDatesBetween, progressBar, subDays, uuid } from './utils.js';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 1000;
|
||||||
|
|
||||||
|
type SessionCreateInput = Prisma.SessionCreateManyInput;
|
||||||
|
type WebsiteEventCreateInput = Prisma.WebsiteEventCreateManyInput;
|
||||||
|
type EventDataCreateInput = Prisma.EventDataCreateManyInput;
|
||||||
|
type RevenueCreateInput = Prisma.RevenueCreateManyInput;
|
||||||
|
|
||||||
|
export interface SeedConfig {
|
||||||
|
days: number;
|
||||||
|
clear: boolean;
|
||||||
|
verbose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeedResult {
|
||||||
|
websites: number;
|
||||||
|
sessions: number;
|
||||||
|
events: number;
|
||||||
|
eventData: number;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchInsertSessions(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
data: SessionCreateInput[],
|
||||||
|
verbose: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
||||||
|
const batch = data.slice(i, i + BATCH_SIZE);
|
||||||
|
await prisma.session.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
if (verbose) {
|
||||||
|
console.log(
|
||||||
|
` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} session records`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchInsertEvents(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
data: WebsiteEventCreateInput[],
|
||||||
|
verbose: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
||||||
|
const batch = data.slice(i, i + BATCH_SIZE);
|
||||||
|
await prisma.websiteEvent.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
if (verbose) {
|
||||||
|
console.log(
|
||||||
|
` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} event records`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchInsertEventData(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
data: EventDataCreateInput[],
|
||||||
|
verbose: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
||||||
|
const batch = data.slice(i, i + BATCH_SIZE);
|
||||||
|
await prisma.eventData.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
if (verbose) {
|
||||||
|
console.log(
|
||||||
|
` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} eventData records`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchInsertRevenue(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
data: RevenueCreateInput[],
|
||||||
|
verbose: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
||||||
|
const batch = data.slice(i, i + BATCH_SIZE);
|
||||||
|
await prisma.revenue.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
if (verbose) {
|
||||||
|
console.log(
|
||||||
|
` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} revenue records`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAdminUser(prisma: PrismaClient): Promise<string> {
|
||||||
|
const adminUser = await prisma.user.findFirst({
|
||||||
|
where: { role: 'admin' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adminUser) {
|
||||||
|
throw new Error(
|
||||||
|
'No admin user found in the database.\n' +
|
||||||
|
'Please ensure you have run the initial setup and created an admin user.\n' +
|
||||||
|
'The default admin user is created during first build (username: admin, password: umami).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return adminUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWebsite(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
name: string,
|
||||||
|
domain: string,
|
||||||
|
adminUserId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const websiteId = uuid();
|
||||||
|
|
||||||
|
await prisma.website.create({
|
||||||
|
data: {
|
||||||
|
id: websiteId,
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
userId: adminUserId,
|
||||||
|
createdBy: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return websiteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearDemoData(prisma: PrismaClient): Promise<void> {
|
||||||
|
console.log('Clearing existing demo data...');
|
||||||
|
|
||||||
|
const demoWebsites = await prisma.website.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ name: BLOG_WEBSITE_NAME }, { name: SAAS_WEBSITE_NAME }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const websiteIds = demoWebsites.map(w => w.id);
|
||||||
|
|
||||||
|
if (websiteIds.length === 0) {
|
||||||
|
console.log(' No existing demo websites found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Found ${websiteIds.length} demo website(s)`);
|
||||||
|
|
||||||
|
// Delete in correct order due to foreign key constraints
|
||||||
|
await prisma.revenue.deleteMany({ where: { websiteId: { in: websiteIds } } });
|
||||||
|
await prisma.eventData.deleteMany({ where: { websiteId: { in: websiteIds } } });
|
||||||
|
await prisma.sessionData.deleteMany({ where: { websiteId: { in: websiteIds } } });
|
||||||
|
await prisma.websiteEvent.deleteMany({ where: { websiteId: { in: websiteIds } } });
|
||||||
|
await prisma.session.deleteMany({ where: { websiteId: { in: websiteIds } } });
|
||||||
|
await prisma.segment.deleteMany({ where: { websiteId: { in: websiteIds } } });
|
||||||
|
await prisma.report.deleteMany({ where: { websiteId: { in: websiteIds } } });
|
||||||
|
await prisma.website.deleteMany({ where: { id: { in: websiteIds } } });
|
||||||
|
|
||||||
|
console.log(' Cleared existing demo data');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteGeneratorConfig {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
sessionsPerDay: number;
|
||||||
|
getSiteConfig: () => ReturnType<typeof getBlogSiteConfig>;
|
||||||
|
getJourney: () => string[];
|
||||||
|
revenueConfigs?: RevenueConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSiteData(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
config: SiteGeneratorConfig,
|
||||||
|
days: Date[],
|
||||||
|
adminUserId: string,
|
||||||
|
verbose: boolean,
|
||||||
|
): Promise<{ sessions: number; events: number; eventData: number; revenue: number }> {
|
||||||
|
console.log(`\nGenerating data for ${config.name}...`);
|
||||||
|
|
||||||
|
const websiteId = await createWebsite(prisma, config.name, config.domain, adminUserId);
|
||||||
|
console.log(` Created website: ${config.name} (${websiteId})`);
|
||||||
|
|
||||||
|
const siteConfig = config.getSiteConfig();
|
||||||
|
|
||||||
|
const allSessions: SessionData[] = [];
|
||||||
|
const allEvents: EventData[] = [];
|
||||||
|
const allEventData: EventDataEntry[] = [];
|
||||||
|
const allRevenue: RevenueData[] = [];
|
||||||
|
|
||||||
|
for (let dayIndex = 0; dayIndex < days.length; dayIndex++) {
|
||||||
|
const day = days[dayIndex];
|
||||||
|
const sessionCount = getSessionCountForDay(config.sessionsPerDay, day);
|
||||||
|
const sessions = createSessions(websiteId, day, sessionCount);
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const journey = config.getJourney();
|
||||||
|
const { events, eventDataEntries } = generateEventsForSession(session, siteConfig, journey);
|
||||||
|
|
||||||
|
allSessions.push(session);
|
||||||
|
allEvents.push(...events);
|
||||||
|
allEventData.push(...eventDataEntries);
|
||||||
|
|
||||||
|
if (config.revenueConfigs) {
|
||||||
|
const revenueEntries = generateRevenueForEvents(events, config.revenueConfigs);
|
||||||
|
allRevenue.push(...revenueEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show progress (every day in verbose mode, otherwise every 2 days)
|
||||||
|
const shouldShowProgress = verbose || dayIndex % 2 === 0 || dayIndex === days.length - 1;
|
||||||
|
if (shouldShowProgress) {
|
||||||
|
process.stdout.write(
|
||||||
|
`\r ${progressBar(dayIndex + 1, days.length)} Day ${dayIndex + 1}/${days.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(''); // New line after progress bar
|
||||||
|
|
||||||
|
// Batch insert all data
|
||||||
|
console.log(` Inserting ${formatNumber(allSessions.length)} sessions...`);
|
||||||
|
await batchInsertSessions(prisma, allSessions as SessionCreateInput[], verbose);
|
||||||
|
|
||||||
|
console.log(` Inserting ${formatNumber(allEvents.length)} events...`);
|
||||||
|
await batchInsertEvents(prisma, allEvents as WebsiteEventCreateInput[], verbose);
|
||||||
|
|
||||||
|
if (allEventData.length > 0) {
|
||||||
|
console.log(` Inserting ${formatNumber(allEventData.length)} event data entries...`);
|
||||||
|
await batchInsertEventData(prisma, allEventData as EventDataCreateInput[], verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allRevenue.length > 0) {
|
||||||
|
console.log(` Inserting ${formatNumber(allRevenue.length)} revenue entries...`);
|
||||||
|
await batchInsertRevenue(prisma, allRevenue as RevenueCreateInput[], verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: allSessions.length,
|
||||||
|
events: allEvents.length,
|
||||||
|
eventData: allEventData.length,
|
||||||
|
revenue: allRevenue.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPrismaClient(): PrismaClient {
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
'DATABASE_URL environment variable is not set.\n' +
|
||||||
|
'Please set DATABASE_URL in your .env file or environment.\n' +
|
||||||
|
'Example: DATABASE_URL=postgresql://user:password@localhost:5432/umami',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let schema: string | undefined;
|
||||||
|
try {
|
||||||
|
const connectionUrl = new URL(url);
|
||||||
|
schema = connectionUrl.searchParams.get('schema') ?? undefined;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'DATABASE_URL is not a valid URL.\n' +
|
||||||
|
'Expected format: postgresql://user:password@host:port/database\n' +
|
||||||
|
`Received: ${url.substring(0, 30)}...`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: url }, { schema });
|
||||||
|
|
||||||
|
return new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
errorFormat: 'pretty',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seed(config: SeedConfig): Promise<SeedResult> {
|
||||||
|
const prisma = createPrismaClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = subDays(endDate, config.days);
|
||||||
|
const days = generateDatesBetween(startDate, endDate);
|
||||||
|
|
||||||
|
console.log(`\nSeed Configuration:`);
|
||||||
|
console.log(
|
||||||
|
` Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`,
|
||||||
|
);
|
||||||
|
console.log(` Days: ${days.length}`);
|
||||||
|
console.log(` Clear existing: ${config.clear}`);
|
||||||
|
|
||||||
|
if (config.clear) {
|
||||||
|
await clearDemoData(prisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find admin user to own the demo websites
|
||||||
|
const adminUserId = await findAdminUser(prisma);
|
||||||
|
console.log(` Using admin user: ${adminUserId}`);
|
||||||
|
|
||||||
|
// Generate Blog site (low traffic)
|
||||||
|
const blogResults = await generateSiteData(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
name: BLOG_WEBSITE_NAME,
|
||||||
|
domain: BLOG_WEBSITE_DOMAIN,
|
||||||
|
sessionsPerDay: BLOG_SESSIONS_PER_DAY,
|
||||||
|
getSiteConfig: getBlogSiteConfig,
|
||||||
|
getJourney: getBlogJourney,
|
||||||
|
},
|
||||||
|
days,
|
||||||
|
adminUserId,
|
||||||
|
config.verbose,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate SaaS site (high traffic)
|
||||||
|
const saasResults = await generateSiteData(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
name: SAAS_WEBSITE_NAME,
|
||||||
|
domain: SAAS_WEBSITE_DOMAIN,
|
||||||
|
sessionsPerDay: SAAS_SESSIONS_PER_DAY,
|
||||||
|
getSiteConfig: getSaasSiteConfig,
|
||||||
|
getJourney: getSaasJourney,
|
||||||
|
revenueConfigs: saasRevenueConfigs,
|
||||||
|
},
|
||||||
|
days,
|
||||||
|
adminUserId,
|
||||||
|
config.verbose,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: SeedResult = {
|
||||||
|
websites: 2,
|
||||||
|
sessions: blogResults.sessions + saasResults.sessions,
|
||||||
|
events: blogResults.events + saasResults.events,
|
||||||
|
eventData: blogResults.eventData + saasResults.eventData,
|
||||||
|
revenue: blogResults.revenue + saasResults.revenue,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n${'─'.repeat(50)}`);
|
||||||
|
console.log(`Seed Complete!`);
|
||||||
|
console.log(`${'─'.repeat(50)}`);
|
||||||
|
console.log(` Websites: ${formatNumber(result.websites)}`);
|
||||||
|
console.log(` Sessions: ${formatNumber(result.sessions)}`);
|
||||||
|
console.log(` Events: ${formatNumber(result.events)}`);
|
||||||
|
console.log(` Event Data: ${formatNumber(result.eventData)}`);
|
||||||
|
console.log(` Revenue: ${formatNumber(result.revenue)}`);
|
||||||
|
console.log(`${'─'.repeat(50)}\n`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
108
scripts/seed/sites/blog.ts
Normal file
108
scripts/seed/sites/blog.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import type {
|
||||||
|
CustomEventConfig,
|
||||||
|
JourneyConfig,
|
||||||
|
PageConfig,
|
||||||
|
SiteConfig,
|
||||||
|
} from '../generators/events.js';
|
||||||
|
import { type WeightedOption, weightedRandom } from '../utils.js';
|
||||||
|
|
||||||
|
export const BLOG_WEBSITE_NAME = 'Demo Blog';
|
||||||
|
export const BLOG_WEBSITE_DOMAIN = 'blog.example.com';
|
||||||
|
|
||||||
|
const blogPosts = [
|
||||||
|
'getting-started-with-analytics',
|
||||||
|
'privacy-first-tracking',
|
||||||
|
'understanding-your-visitors',
|
||||||
|
'improving-page-performance',
|
||||||
|
'seo-best-practices',
|
||||||
|
'content-marketing-guide',
|
||||||
|
'building-audience-trust',
|
||||||
|
'data-driven-decisions',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const blogPages: PageConfig[] = [
|
||||||
|
{ path: '/', title: 'Demo Blog - Home', weight: 0.25, avgTimeOnPage: 30 },
|
||||||
|
{ path: '/blog', title: 'Blog Posts', weight: 0.2, avgTimeOnPage: 45 },
|
||||||
|
{ path: '/about', title: 'About Us', weight: 0.1, avgTimeOnPage: 60 },
|
||||||
|
{ path: '/contact', title: 'Contact', weight: 0.05, avgTimeOnPage: 45 },
|
||||||
|
...blogPosts.map(slug => ({
|
||||||
|
path: `/blog/${slug}`,
|
||||||
|
title: slug
|
||||||
|
.split('-')
|
||||||
|
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' '),
|
||||||
|
weight: 0.05,
|
||||||
|
avgTimeOnPage: 180,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const blogJourneys: JourneyConfig[] = [
|
||||||
|
// Direct to blog post (organic search)
|
||||||
|
{ pages: ['/blog/getting-started-with-analytics'], weight: 0.15 },
|
||||||
|
{ pages: ['/blog/privacy-first-tracking'], weight: 0.12 },
|
||||||
|
{ pages: ['/blog/understanding-your-visitors'], weight: 0.1 },
|
||||||
|
|
||||||
|
// Homepage bounces
|
||||||
|
{ pages: ['/'], weight: 0.15 },
|
||||||
|
|
||||||
|
// Homepage to blog listing
|
||||||
|
{ pages: ['/', '/blog'], weight: 0.1 },
|
||||||
|
|
||||||
|
// Homepage to blog post
|
||||||
|
{ pages: ['/', '/blog', '/blog/seo-best-practices'], weight: 0.08 },
|
||||||
|
{ pages: ['/', '/blog', '/blog/content-marketing-guide'], weight: 0.08 },
|
||||||
|
|
||||||
|
// About page visits
|
||||||
|
{ pages: ['/', '/about'], weight: 0.07 },
|
||||||
|
{ pages: ['/', '/about', '/contact'], weight: 0.05 },
|
||||||
|
|
||||||
|
// Blog post to another
|
||||||
|
{ pages: ['/blog/improving-page-performance', '/blog/data-driven-decisions'], weight: 0.05 },
|
||||||
|
|
||||||
|
// Longer sessions
|
||||||
|
{ pages: ['/', '/blog', '/blog/building-audience-trust', '/about'], weight: 0.05 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const blogCustomEvents: CustomEventConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'newsletter_signup',
|
||||||
|
weight: 0.03,
|
||||||
|
pages: ['/', '/blog'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'share_click',
|
||||||
|
weight: 0.05,
|
||||||
|
pages: blogPosts.map(slug => `/blog/${slug}`),
|
||||||
|
data: {
|
||||||
|
platform: ['twitter', 'linkedin', 'facebook', 'copy_link'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scroll_depth',
|
||||||
|
weight: 0.2,
|
||||||
|
pages: blogPosts.map(slug => `/blog/${slug}`),
|
||||||
|
data: {
|
||||||
|
depth: [25, 50, 75, 100],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getBlogSiteConfig(): SiteConfig {
|
||||||
|
return {
|
||||||
|
hostname: BLOG_WEBSITE_DOMAIN,
|
||||||
|
pages: blogPages,
|
||||||
|
journeys: blogJourneys,
|
||||||
|
customEvents: blogCustomEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlogJourney(): string[] {
|
||||||
|
const journeyWeights: WeightedOption<string[]>[] = blogJourneys.map(j => ({
|
||||||
|
value: j.pages,
|
||||||
|
weight: j.weight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return weightedRandom(journeyWeights);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BLOG_SESSIONS_PER_DAY = 3; // ~90 sessions per month
|
||||||
185
scripts/seed/sites/saas.ts
Normal file
185
scripts/seed/sites/saas.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import type {
|
||||||
|
CustomEventConfig,
|
||||||
|
JourneyConfig,
|
||||||
|
PageConfig,
|
||||||
|
SiteConfig,
|
||||||
|
} from '../generators/events.js';
|
||||||
|
import type { RevenueConfig } from '../generators/revenue.js';
|
||||||
|
import { type WeightedOption, weightedRandom } from '../utils.js';
|
||||||
|
|
||||||
|
export const SAAS_WEBSITE_NAME = 'Demo SaaS';
|
||||||
|
export const SAAS_WEBSITE_DOMAIN = 'app.example.com';
|
||||||
|
|
||||||
|
const docsSections = [
|
||||||
|
'getting-started',
|
||||||
|
'installation',
|
||||||
|
'configuration',
|
||||||
|
'api-reference',
|
||||||
|
'integrations',
|
||||||
|
];
|
||||||
|
|
||||||
|
const blogPosts = [
|
||||||
|
'announcing-v2',
|
||||||
|
'customer-success-story',
|
||||||
|
'product-roadmap',
|
||||||
|
'security-best-practices',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const saasPages: PageConfig[] = [
|
||||||
|
{ path: '/', title: 'Demo SaaS - Analytics Made Simple', weight: 0.2, avgTimeOnPage: 45 },
|
||||||
|
{ path: '/features', title: 'Features', weight: 0.15, avgTimeOnPage: 90 },
|
||||||
|
{ path: '/pricing', title: 'Pricing', weight: 0.15, avgTimeOnPage: 120 },
|
||||||
|
{ path: '/docs', title: 'Documentation', weight: 0.1, avgTimeOnPage: 60 },
|
||||||
|
{ path: '/blog', title: 'Blog', weight: 0.05, avgTimeOnPage: 45 },
|
||||||
|
{ path: '/signup', title: 'Sign Up', weight: 0.08, avgTimeOnPage: 90 },
|
||||||
|
{ path: '/login', title: 'Login', weight: 0.05, avgTimeOnPage: 30 },
|
||||||
|
{ path: '/demo', title: 'Request Demo', weight: 0.05, avgTimeOnPage: 60 },
|
||||||
|
...docsSections.map(slug => ({
|
||||||
|
path: `/docs/${slug}`,
|
||||||
|
title: `Docs: ${slug
|
||||||
|
.split('-')
|
||||||
|
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' ')}`,
|
||||||
|
weight: 0.02,
|
||||||
|
avgTimeOnPage: 180,
|
||||||
|
})),
|
||||||
|
...blogPosts.map(slug => ({
|
||||||
|
path: `/blog/${slug}`,
|
||||||
|
title: slug
|
||||||
|
.split('-')
|
||||||
|
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' '),
|
||||||
|
weight: 0.02,
|
||||||
|
avgTimeOnPage: 150,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const saasJourneys: JourneyConfig[] = [
|
||||||
|
// Conversion funnel
|
||||||
|
{ pages: ['/', '/features', '/pricing', '/signup'], weight: 0.12 },
|
||||||
|
{ pages: ['/', '/pricing', '/signup'], weight: 0.1 },
|
||||||
|
{ pages: ['/pricing', '/signup'], weight: 0.08 },
|
||||||
|
|
||||||
|
// Feature exploration
|
||||||
|
{ pages: ['/', '/features'], weight: 0.1 },
|
||||||
|
{ pages: ['/', '/features', '/pricing'], weight: 0.08 },
|
||||||
|
|
||||||
|
// Documentation users
|
||||||
|
{ pages: ['/docs', '/docs/getting-started'], weight: 0.08 },
|
||||||
|
{ pages: ['/docs/getting-started', '/docs/installation', '/docs/configuration'], weight: 0.06 },
|
||||||
|
{ pages: ['/docs/api-reference'], weight: 0.05 },
|
||||||
|
|
||||||
|
// Blog readers
|
||||||
|
{ pages: ['/blog/announcing-v2'], weight: 0.05 },
|
||||||
|
{ pages: ['/blog/customer-success-story'], weight: 0.04 },
|
||||||
|
|
||||||
|
// Returning users
|
||||||
|
{ pages: ['/login'], weight: 0.08 },
|
||||||
|
|
||||||
|
// Bounces
|
||||||
|
{ pages: ['/'], weight: 0.08 },
|
||||||
|
{ pages: ['/pricing'], weight: 0.05 },
|
||||||
|
|
||||||
|
// Demo requests
|
||||||
|
{ pages: ['/', '/demo'], weight: 0.03 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const saasCustomEvents: CustomEventConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'signup_started',
|
||||||
|
weight: 0.6,
|
||||||
|
pages: ['/signup'],
|
||||||
|
data: {
|
||||||
|
plan: ['free', 'pro', 'enterprise'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'signup_completed',
|
||||||
|
weight: 0.3,
|
||||||
|
pages: ['/signup'],
|
||||||
|
data: {
|
||||||
|
plan: ['free', 'pro', 'enterprise'],
|
||||||
|
method: ['email', 'google', 'github'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'purchase',
|
||||||
|
weight: 0.15,
|
||||||
|
pages: ['/signup', '/pricing'],
|
||||||
|
data: {
|
||||||
|
plan: ['pro', 'enterprise'],
|
||||||
|
billing: ['monthly', 'annual'],
|
||||||
|
revenue: [29, 49, 99, 299],
|
||||||
|
currency: ['USD'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'demo_requested',
|
||||||
|
weight: 0.5,
|
||||||
|
pages: ['/demo'],
|
||||||
|
data: {
|
||||||
|
company_size: ['1-10', '11-50', '51-200', '200+'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature_viewed',
|
||||||
|
weight: 0.3,
|
||||||
|
pages: ['/features'],
|
||||||
|
data: {
|
||||||
|
feature: ['analytics', 'reports', 'api', 'integrations', 'privacy'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cta_click',
|
||||||
|
weight: 0.15,
|
||||||
|
pages: ['/', '/features', '/pricing'],
|
||||||
|
data: {
|
||||||
|
button: ['hero_signup', 'nav_signup', 'pricing_cta', 'footer_cta'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'docs_search',
|
||||||
|
weight: 0.2,
|
||||||
|
pages: ['/docs', ...docsSections.map(s => `/docs/${s}`)],
|
||||||
|
data: {
|
||||||
|
query_type: ['api', 'setup', 'integration', 'troubleshooting'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const saasRevenueConfigs: RevenueConfig[] = [
|
||||||
|
{
|
||||||
|
eventName: 'purchase',
|
||||||
|
minAmount: 29,
|
||||||
|
maxAmount: 29,
|
||||||
|
currency: 'USD',
|
||||||
|
weight: 0.7, // 70% Pro plan
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventName: 'purchase',
|
||||||
|
minAmount: 299,
|
||||||
|
maxAmount: 299,
|
||||||
|
currency: 'USD',
|
||||||
|
weight: 0.3, // 30% Enterprise
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getSaasSiteConfig(): SiteConfig {
|
||||||
|
return {
|
||||||
|
hostname: SAAS_WEBSITE_DOMAIN,
|
||||||
|
pages: saasPages,
|
||||||
|
journeys: saasJourneys,
|
||||||
|
customEvents: saasCustomEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSaasJourney(): string[] {
|
||||||
|
const journeyWeights: WeightedOption<string[]>[] = saasJourneys.map(j => ({
|
||||||
|
value: j.pages,
|
||||||
|
weight: j.weight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return weightedRandom(journeyWeights);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SAAS_SESSIONS_PER_DAY = 500;
|
||||||
85
scripts/seed/utils.ts
Normal file
85
scripts/seed/utils.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export interface WeightedOption<T> {
|
||||||
|
value: T;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weightedRandom<T>(options: WeightedOption<T>[]): T {
|
||||||
|
const totalWeight = options.reduce((sum, opt) => sum + opt.weight, 0);
|
||||||
|
let random = Math.random() * totalWeight;
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
random -= option.weight;
|
||||||
|
if (random <= 0) {
|
||||||
|
return option.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options[options.length - 1].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomFloat(min: number, max: number): number {
|
||||||
|
return Math.random() * (max - min) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickRandom<T>(array: T[]): T {
|
||||||
|
return array[Math.floor(Math.random() * array.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shuffleArray<T>(array: T[]): T[] {
|
||||||
|
const result = [...array];
|
||||||
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[result[i], result[j]] = [result[j], result[i]];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuid(): string {
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDatesBetween(startDate: Date, endDate: Date): Date[] {
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const current = new Date(startDate);
|
||||||
|
current.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
while (current <= endDate) {
|
||||||
|
dates.push(new Date(current));
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addHours(date: Date, hours: number): Date {
|
||||||
|
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMinutes(date: Date, minutes: number): Date {
|
||||||
|
return new Date(date.getTime() + minutes * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSeconds(date: Date, seconds: number): Date {
|
||||||
|
return new Date(date.getTime() + seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subDays(date: Date, days: number): Date {
|
||||||
|
return new Date(date.getTime() - days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(num: number): string {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function progressBar(current: number, total: number, width = 30): string {
|
||||||
|
const percent = current / total;
|
||||||
|
const filled = Math.round(width * percent);
|
||||||
|
const empty = width - filled;
|
||||||
|
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${Math.round(percent * 100)}%`;
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,11 @@ export function LinksTable(props: DataTableProps) {
|
||||||
<DataColumn id="slug" label={formatMessage(labels.link)}>
|
<DataColumn id="slug" label={formatMessage(labels.link)}>
|
||||||
{({ slug }: any) => {
|
{({ slug }: any) => {
|
||||||
const url = getSlugUrl(slug);
|
const url = getSlugUrl(slug);
|
||||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
return (
|
||||||
|
<ExternalLink href={url} prefetch={false}>
|
||||||
|
{url}
|
||||||
|
</ExternalLink>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="url" label={formatMessage(labels.destinationUrl)}>
|
<DataColumn id="url" label={formatMessage(labels.destinationUrl)}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Icon, Text } from '@umami/react-zen';
|
import { IconLabel } from '@umami/react-zen';
|
||||||
import { LinkButton } from '@/components/common/LinkButton';
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { useLink, useMessages, useSlug } from '@/components/hooks';
|
import { useLink, useMessages, useSlug } from '@/components/hooks';
|
||||||
|
|
@ -10,12 +10,9 @@ export function LinkHeader() {
|
||||||
const link = useLink();
|
const link = useLink();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader title={link.name} description={link.url} icon={<Link />} marginBottom="3">
|
<PageHeader title={link.name} description={link.url} icon={<Link />}>
|
||||||
<LinkButton href={getSlugUrl(link.slug)} target="_blank">
|
<LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor>
|
||||||
<Icon>
|
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
|
||||||
<ExternalLink />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.view)}</Text>
|
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@ export function PixelsTable(props: DataTableProps) {
|
||||||
<DataColumn id="url" label="URL">
|
<DataColumn id="url" label="URL">
|
||||||
{({ slug }: any) => {
|
{({ slug }: any) => {
|
||||||
const url = getSlugUrl(slug);
|
const url = getSlugUrl(slug);
|
||||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
return (
|
||||||
|
<ExternalLink href={url} prefetch={false}>
|
||||||
|
{url}
|
||||||
|
</ExternalLink>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Icon, Text } from '@umami/react-zen';
|
import { IconLabel } from '@umami/react-zen';
|
||||||
import { LinkButton } from '@/components/common/LinkButton';
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { useMessages, usePixel, useSlug } from '@/components/hooks';
|
import { useMessages, usePixel, useSlug } from '@/components/hooks';
|
||||||
|
|
@ -10,12 +10,9 @@ export function PixelHeader() {
|
||||||
const pixel = usePixel();
|
const pixel = usePixel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader title={pixel.name} icon={<Grid2x2 />} marginBottom="3">
|
<PageHeader title={pixel.name} icon={<Grid2x2 />}>
|
||||||
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false}>
|
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor>
|
||||||
<Icon>
|
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
|
||||||
<ExternalLink />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.view)}</Text>
|
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { Icon, Row } from '@umami/react-zen';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { DataGrid } from '@/components/common/DataGrid';
|
import { DataGrid } from '@/components/common/DataGrid';
|
||||||
import { useLoginQuery, useNavigation, useUserWebsitesQuery } from '@/components/hooks';
|
import { useLoginQuery, useNavigation, useUserWebsitesQuery } from '@/components/hooks';
|
||||||
|
import { Favicon } from '@/index';
|
||||||
import { WebsitesTable } from './WebsitesTable';
|
import { WebsitesTable } from './WebsitesTable';
|
||||||
|
|
||||||
export function WebsitesDataTable({
|
export function WebsitesDataTable({
|
||||||
|
|
@ -21,7 +23,12 @@ export function WebsitesDataTable({
|
||||||
const { renderUrl } = useNavigation();
|
const { renderUrl } = useNavigation();
|
||||||
|
|
||||||
const renderLink = (row: any) => (
|
const renderLink = (row: any) => (
|
||||||
<Link href={renderUrl(`/websites/${row.id}`, false)}>{row.name}</Link>
|
<Row alignItems="center" gap="3">
|
||||||
|
<Icon size="md" color="muted">
|
||||||
|
<Favicon domain={row.domain} />
|
||||||
|
</Icon>
|
||||||
|
<Link href={renderUrl(`/websites/${row.id}`, false)}>{row.name}</Link>
|
||||||
|
</Row>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,18 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
|
||||||
const { renderUrl, pathname } = useNavigation();
|
const { renderUrl, pathname } = useNavigation();
|
||||||
const isSettings = pathname.endsWith('/settings');
|
const isSettings = pathname.endsWith('/settings');
|
||||||
|
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
if (isSettings) {
|
if (isSettings) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} marginBottom="3">
|
<PageHeader
|
||||||
|
title={website.name}
|
||||||
|
icon={<Favicon domain={website.domain} />}
|
||||||
|
titleHref={renderUrl(`/websites/${website.id}`, false)}
|
||||||
|
>
|
||||||
<Row alignItems="center" gap="6" wrap="wrap">
|
<Row alignItems="center" gap="6" wrap="wrap">
|
||||||
<ActiveUsers websiteId={website.id} />
|
<ActiveUsers websiteId={website.id} />
|
||||||
|
|
||||||
|
|
@ -29,7 +35,7 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
|
||||||
<Icon>
|
<Icon>
|
||||||
<Edit />
|
<Edit />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>Edit</Text>
|
<Text>{formatMessage(labels.edit)}</Text>
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { checkPassword } from '@/lib/password';
|
||||||
import redis from '@/lib/redis';
|
import redis from '@/lib/redis';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { json, unauthorized } from '@/lib/response';
|
import { json, unauthorized } from '@/lib/response';
|
||||||
import { getUserByUsername } from '@/queries/prisma';
|
import { getAllUserTeams, getUserByUsername } from '@/queries/prisma';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
|
@ -39,8 +39,10 @@ export async function POST(request: Request) {
|
||||||
token = createSecureToken({ userId: user.id, role }, secret());
|
token = createSecureToken({ userId: user.id, role }, secret());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const teams = await getAllUserTeams(id);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
token,
|
token,
|
||||||
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin },
|
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin, teams },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export async function POST(request: Request) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
let cache = null;
|
||||||
for (const data of body) {
|
for (const data of body) {
|
||||||
// Recreate a fresh Request since `new Request(request)` will have the following error:
|
// Recreate a fresh Request since `new Request(request)` will have the following error:
|
||||||
// > Cannot read private member #state from an object whose class did not declare it
|
// > Cannot read private member #state from an object whose class did not declare it
|
||||||
|
|
@ -33,9 +34,12 @@ export async function POST(request: Request) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await send.POST(newRequest);
|
const response = await send.POST(newRequest);
|
||||||
|
const responseJson = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
errors.push({ index, response: await response.json() });
|
errors.push({ index, response: responseJson });
|
||||||
|
} else {
|
||||||
|
cache ??= responseJson.cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
index++;
|
index++;
|
||||||
|
|
@ -46,6 +50,7 @@ export async function POST(request: Request) {
|
||||||
processed: body.length - errors.length,
|
processed: body.length - errors.length,
|
||||||
errors: errors.length,
|
errors: errors.length,
|
||||||
details: errors,
|
details: errors,
|
||||||
|
cache,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return serverError(e);
|
return serverError(e);
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ const schema = z.object({
|
||||||
userAgent: z.string().optional(),
|
userAgent: z.string().optional(),
|
||||||
timestamp: z.coerce.number().int().optional(),
|
timestamp: z.coerce.number().int().optional(),
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
|
browser: z.string().optional(),
|
||||||
|
os: z.string().optional(),
|
||||||
|
device: z.string().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
data => {
|
data => {
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,7 @@ export function LoginForm() {
|
||||||
onSuccess: async ({ token, user }) => {
|
onSuccess: async ({ token, user }) => {
|
||||||
setClientAuthToken(token);
|
setClientAuthToken(token);
|
||||||
setUser(user);
|
setUser(user);
|
||||||
|
router.push('/');
|
||||||
router.push('/websites');
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { LAST_TEAM_CONFIG } from '@/lib/constants';
|
import { LAST_TEAM_CONFIG } from '@/lib/constants';
|
||||||
import { getItem, removeItem } from '@/lib/storage';
|
import { getItem } from '@/lib/storage';
|
||||||
|
|
||||||
export default function RootPage() {
|
export default function RootPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -11,8 +11,6 @@ export default function RootPage() {
|
||||||
if (lastTeam) {
|
if (lastTeam) {
|
||||||
redirect(`/teams/${lastTeam}/websites`);
|
redirect(`/teams/${lastTeam}/websites`);
|
||||||
} else {
|
} else {
|
||||||
removeItem(LAST_TEAM_CONFIG);
|
|
||||||
|
|
||||||
redirect(`/websites`);
|
redirect(`/websites`);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column, useTheme } from '@umami/react-zen';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
|
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
|
||||||
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
|
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
|
||||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||||
|
|
@ -10,6 +11,16 @@ import { Header } from './Header';
|
||||||
|
|
||||||
export function SharePage({ shareId }) {
|
export function SharePage({ shareId }) {
|
||||||
const { shareToken, isLoading } = useShareTokenQuery(shareId);
|
const { shareToken, isLoading } = useShareTokenQuery(shareId);
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window?.location?.href);
|
||||||
|
const theme = url.searchParams.get('theme');
|
||||||
|
|
||||||
|
if (theme === 'light' || theme === 'dark') {
|
||||||
|
setTheme(theme);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isLoading || !shareToken) {
|
if (isLoading || !shareToken) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { Icon, Row, Text } from '@umami/react-zen';
|
import { Icon, Row, Text } from '@umami/react-zen';
|
||||||
import Link from 'next/link';
|
import Link, { type LinkProps } from 'next/link';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { ExternalLink as LinkIcon } from '@/components/icons';
|
import { ExternalLink as LinkIcon } from '@/components/icons';
|
||||||
|
|
||||||
export function ExternalLink({ href, children, ...props }) {
|
export function ExternalLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: LinkProps & { href: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Row alignItems="center" overflow="hidden" gap>
|
<Row alignItems="center" overflow="hidden" gap>
|
||||||
<Text title={href} truncate>
|
<Text title={href} truncate>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface LinkButtonProps extends ButtonProps {
|
||||||
scroll?: boolean;
|
scroll?: boolean;
|
||||||
variant?: any;
|
variant?: any;
|
||||||
prefetch?: boolean;
|
prefetch?: boolean;
|
||||||
|
asAnchor?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,15 +20,22 @@ export function LinkButton({
|
||||||
target,
|
target,
|
||||||
prefetch,
|
prefetch,
|
||||||
children,
|
children,
|
||||||
|
asAnchor,
|
||||||
...props
|
...props
|
||||||
}: LinkButtonProps) {
|
}: LinkButtonProps) {
|
||||||
const { dir } = useLocale();
|
const { dir } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button {...props} variant={variant} asChild>
|
<Button {...props} variant={variant} asChild>
|
||||||
<Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}>
|
{asAnchor ? (
|
||||||
{children}
|
<a href={href} target={target}>
|
||||||
</Link>
|
{children}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen';
|
import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { LinkButton } from './LinkButton';
|
||||||
|
|
||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
title,
|
title,
|
||||||
|
|
@ -7,6 +8,7 @@ export function PageHeader({
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
showBorder = true,
|
showBorder = true,
|
||||||
|
titleHref,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -14,6 +16,7 @@ export function PageHeader({
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
showBorder?: boolean;
|
showBorder?: boolean;
|
||||||
|
titleHref?: string;
|
||||||
allowEdit?: boolean;
|
allowEdit?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
|
@ -33,7 +36,13 @@ export function PageHeader({
|
||||||
{icon}
|
{icon}
|
||||||
</Icon>
|
</Icon>
|
||||||
)}
|
)}
|
||||||
{title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>}
|
{title && titleHref ? (
|
||||||
|
<LinkButton href={titleHref} variant="quiet">
|
||||||
|
<Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
|
||||||
|
</LinkButton>
|
||||||
|
) : (
|
||||||
|
title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
{description && (
|
{description && (
|
||||||
<Text color="muted" truncate style={{ maxWidth: 600 }} title={description}>
|
<Text color="muted" truncate style={{ maxWidth: 600 }} title={description}>
|
||||||
|
|
@ -41,7 +50,9 @@ export function PageHeader({
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Column>
|
</Column>
|
||||||
<Row justifyContent="flex-end">{children}</Row>
|
<Row justifyContent="flex-end" alignItems="center">
|
||||||
|
{children}
|
||||||
|
</Row>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
Text,
|
Text,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import type { Key } from 'react';
|
||||||
import {
|
import {
|
||||||
useConfig,
|
useConfig,
|
||||||
useLoginQuery,
|
useLoginQuery,
|
||||||
|
|
@ -33,7 +34,8 @@ import {
|
||||||
Users,
|
Users,
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
import { Switch } from '@/components/svg';
|
import { Switch } from '@/components/svg';
|
||||||
import { DOCS_URL } from '@/lib/constants';
|
import { DOCS_URL, LAST_TEAM_CONFIG } from '@/lib/constants';
|
||||||
|
import { removeItem } from '@/lib/storage';
|
||||||
|
|
||||||
export interface TeamsButtonProps {
|
export interface TeamsButtonProps {
|
||||||
showText?: boolean;
|
showText?: boolean;
|
||||||
|
|
@ -44,7 +46,7 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
|
||||||
const { user } = useLoginQuery();
|
const { user } = useLoginQuery();
|
||||||
const { cloudMode } = useConfig();
|
const { cloudMode } = useConfig();
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { teamId } = useNavigation();
|
const { teamId, router } = useNavigation();
|
||||||
const { isMobile } = useMobile();
|
const { isMobile } = useMobile();
|
||||||
const team = user?.teams?.find(({ id }) => id === teamId);
|
const team = user?.teams?.find(({ id }) => id === teamId);
|
||||||
const selectedKeys = new Set([teamId || 'user']);
|
const selectedKeys = new Set([teamId || 'user']);
|
||||||
|
|
@ -54,7 +56,16 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
|
||||||
return cloudMode ? `${process.env.cloudUrl}${url}` : url;
|
return cloudMode ? `${process.env.cloudUrl}${url}` : url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = async () => {};
|
const handleAction = async (key: Key) => {
|
||||||
|
if (key === 'user') {
|
||||||
|
removeItem(LAST_TEAM_CONFIG);
|
||||||
|
if (cloudMode) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuTrigger>
|
<MenuTrigger>
|
||||||
|
|
@ -84,16 +95,16 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Popover placement="bottom start">
|
<Popover placement="bottom start">
|
||||||
<Column minWidth="300px">
|
<Column minWidth="300px">
|
||||||
<Menu autoFocus="last" onAction={handleAction}>
|
<Menu autoFocus="last">
|
||||||
<SubmenuTrigger>
|
<SubmenuTrigger>
|
||||||
<MenuItem id="teams" showChecked={false} showSubMenuIcon>
|
<MenuItem id="teams" showChecked={false} showSubMenuIcon>
|
||||||
<IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} />
|
<IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Popover placement={isMobile ? 'bottom start' : 'right top'}>
|
<Popover placement={isMobile ? 'bottom start' : 'right top'}>
|
||||||
<Column minWidth="300px">
|
<Column minWidth="300px">
|
||||||
<Menu selectionMode="single" selectedKeys={selectedKeys}>
|
<Menu selectionMode="single" selectedKeys={selectedKeys} onAction={handleAction}>
|
||||||
<MenuSection title={formatMessage(labels.myAccount)}>
|
<MenuSection title={formatMessage(labels.myAccount)}>
|
||||||
<MenuItem id="user" href={getUrl('/')}>
|
<MenuItem id="user">
|
||||||
<IconLabel icon={<User />} label={user.username} />
|
<IconLabel icon={<User />} label={user.username} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuSection>
|
</MenuSection>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen';
|
import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen';
|
||||||
import { isAfter } from 'date-fns';
|
import { isAfter } from 'date-fns';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks';
|
import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { ChevronRight } from '@/components/icons';
|
import { ChevronRight } from '@/components/icons';
|
||||||
import { getDateRangeValue } from '@/lib/date';
|
import { getDateRangeValue } from '@/lib/date';
|
||||||
|
|
@ -45,13 +45,9 @@ export function WebsiteDateFilter({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIncrement = useCallback(
|
const handleIncrement = increment => {
|
||||||
(increment: number) => {
|
router.push(updateParams({ offset: Number(offset) + increment }));
|
||||||
router.push(updateParams({ offset: +offset + increment }));
|
};
|
||||||
},
|
|
||||||
[offset],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelect = (compare: any) => {
|
const handleSelect = (compare: any) => {
|
||||||
router.push(updateParams({ compare }));
|
router.push(updateParams({ compare }));
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { StatusLight, Text } from '@umami/react-zen';
|
import { StatusLight, Text } from '@umami/react-zen';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
import { useActyiveUsersQuery, useMessages } from '@/components/hooks';
|
import { useActyiveUsersQuery, useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
export function ActiveUsers({
|
export function ActiveUsers({
|
||||||
|
|
@ -27,10 +28,12 @@ export function ActiveUsers({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusLight variant="success">
|
<LinkButton href={`/websites/${websiteId}/realtime`} variant="quiet">
|
||||||
<Text size="2" weight="medium">
|
<StatusLight variant="success">
|
||||||
{count} {formatMessage(labels.online)}
|
<Text size="2" weight="medium">
|
||||||
</Text>
|
{count} {formatMessage(labels.online)}
|
||||||
</StatusLight>
|
</Text>
|
||||||
|
</StatusLight>
|
||||||
|
</LinkButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,12 @@ export function Legend({
|
||||||
return (
|
return (
|
||||||
<Row key={text} onClick={() => onClick(item)}>
|
<Row key={text} onClick={() => onClick(item)}>
|
||||||
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
|
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
|
||||||
<Text size="2" color={hidden ? 'disabled' : undefined} wrap="nowrap">
|
<Text
|
||||||
|
size="2"
|
||||||
|
color={hidden ? 'disabled' : undefined}
|
||||||
|
truncate={true}
|
||||||
|
style={{ maxWidth: '300px' }}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
</StatusLight>
|
</StatusLight>
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,7 @@
|
||||||
"label.websites": "المواقع",
|
"label.websites": "المواقع",
|
||||||
"label.window": "النافذة",
|
"label.window": "النافذة",
|
||||||
"label.yesterday": "الأمس",
|
"label.yesterday": "الأمس",
|
||||||
|
"label.behavior": "السلوك",
|
||||||
"message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.",
|
"message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.",
|
||||||
"message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}",
|
"message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}",
|
||||||
"message.bad-request": "Bad request",
|
"message.bad-request": "Bad request",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Сярэдняе",
|
"label.average": "Сярэдняе",
|
||||||
"label.back": "Назад",
|
"label.back": "Назад",
|
||||||
"label.before": "Да",
|
"label.before": "Да",
|
||||||
|
"label.behavior": "Паводзіны",
|
||||||
"label.boards": "Дошкі",
|
"label.boards": "Дошкі",
|
||||||
"label.bounce-rate": "Паказчык адмоваў",
|
"label.bounce-rate": "Паказчык адмоваў",
|
||||||
"label.breakdown": "Разбіўка",
|
"label.breakdown": "Разбіўка",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Средно",
|
"label.average": "Средно",
|
||||||
"label.back": "Назад",
|
"label.back": "Назад",
|
||||||
"label.before": "Преди",
|
"label.before": "Преди",
|
||||||
|
"label.behavior": "Поведение",
|
||||||
"label.boards": "Дъски",
|
"label.boards": "Дъски",
|
||||||
"label.bounce-rate": "Kоефициент на отказ",
|
"label.bounce-rate": "Kоефициент на отказ",
|
||||||
"label.breakdown": "Разбивка",
|
"label.breakdown": "Разбивка",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "গড়",
|
"label.average": "গড়",
|
||||||
"label.back": "পেছনে",
|
"label.back": "পেছনে",
|
||||||
"label.before": "পূর্বে",
|
"label.before": "পূর্বে",
|
||||||
|
"label.behavior": "আচরণ",
|
||||||
"label.boards": "বোর্ডসমূহ",
|
"label.boards": "বোর্ডসমূহ",
|
||||||
"label.bounce-rate": "উপরে উঠার হার",
|
"label.bounce-rate": "উপরে উঠার হার",
|
||||||
"label.breakdown": "ভাঙ্গন",
|
"label.breakdown": "ভাঙ্গন",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Prosjek",
|
"label.average": "Prosjek",
|
||||||
"label.back": "Nazad",
|
"label.back": "Nazad",
|
||||||
"label.before": "Prije",
|
"label.before": "Prije",
|
||||||
|
"label.behavior": "Ponašanje",
|
||||||
"label.boards": "Ploče",
|
"label.boards": "Ploče",
|
||||||
"label.bounce-rate": "Stopa napuštanja",
|
"label.bounce-rate": "Stopa napuštanja",
|
||||||
"label.breakdown": "Pregled po kategorijama",
|
"label.breakdown": "Pregled po kategorijama",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Mitjana",
|
"label.average": "Mitjana",
|
||||||
"label.back": "Enrere",
|
"label.back": "Enrere",
|
||||||
"label.before": "Abans",
|
"label.before": "Abans",
|
||||||
|
"label.behavior": "Comportament",
|
||||||
"label.boards": "Taulers",
|
"label.boards": "Taulers",
|
||||||
"label.bounce-rate": "Percentatge de rebot",
|
"label.bounce-rate": "Percentatge de rebot",
|
||||||
"label.breakdown": "Desglossament",
|
"label.breakdown": "Desglossament",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Průměr",
|
"label.average": "Průměr",
|
||||||
"label.back": "Zpět",
|
"label.back": "Zpět",
|
||||||
"label.before": "Před",
|
"label.before": "Před",
|
||||||
|
"label.behavior": "Chování",
|
||||||
"label.boards": "Nástěnky",
|
"label.boards": "Nástěnky",
|
||||||
"label.bounce-rate": "Okamžité opuštění",
|
"label.bounce-rate": "Okamžité opuštění",
|
||||||
"label.breakdown": "Rozpis",
|
"label.breakdown": "Rozpis",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Gennemsnit",
|
"label.average": "Gennemsnit",
|
||||||
"label.back": "Tilbage",
|
"label.back": "Tilbage",
|
||||||
"label.before": "Før",
|
"label.before": "Før",
|
||||||
|
"label.behavior": "Adfærd",
|
||||||
"label.boards": "Tavler",
|
"label.boards": "Tavler",
|
||||||
"label.bounce-rate": "Afvisningsprocent",
|
"label.bounce-rate": "Afvisningsprocent",
|
||||||
"label.breakdown": "Opdeling",
|
"label.breakdown": "Opdeling",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Durchschnitt",
|
"label.average": "Durchschnitt",
|
||||||
"label.back": "Zrugg",
|
"label.back": "Zrugg",
|
||||||
"label.before": "Vor",
|
"label.before": "Vor",
|
||||||
|
"label.behavior": "Verhalte",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "Absprungsrate",
|
"label.bounce-rate": "Absprungsrate",
|
||||||
"label.breakdown": "Uufschlüsselig",
|
"label.breakdown": "Uufschlüsselig",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Durchschnitt",
|
"label.average": "Durchschnitt",
|
||||||
"label.back": "Zurück",
|
"label.back": "Zurück",
|
||||||
"label.before": "Vor",
|
"label.before": "Vor",
|
||||||
|
"label.behavior": "Verhalten",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "Absprungrate",
|
"label.bounce-rate": "Absprungrate",
|
||||||
"label.breakdown": "Aufschlüsselung",
|
"label.breakdown": "Aufschlüsselung",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "Ποσοστό αναπήδησης",
|
"label.bounce-rate": "Ποσοστό αναπήδησης",
|
||||||
"label.breakdown": "Breakdown",
|
"label.breakdown": "Breakdown",
|
||||||
|
"label.behavior": "Συμπεριφορά",
|
||||||
"label.browser": "Browser",
|
"label.browser": "Browser",
|
||||||
"label.browsers": "Προγράμματα περιήγησης",
|
"label.browsers": "Προγράμματα περιήγησης",
|
||||||
"label.campaigns": "Campaigns",
|
"label.campaigns": "Campaigns",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Average",
|
"label.average": "Average",
|
||||||
"label.back": "Back",
|
"label.back": "Back",
|
||||||
"label.before": "Before",
|
"label.before": "Before",
|
||||||
|
"label.behavior": "Behavior",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "Bounce rate",
|
"label.bounce-rate": "Bounce rate",
|
||||||
"label.breakdown": "Breakdown",
|
"label.breakdown": "Breakdown",
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,7 @@
|
||||||
"label.websites": "Websites",
|
"label.websites": "Websites",
|
||||||
"label.window": "Window",
|
"label.window": "Window",
|
||||||
"label.yesterday": "Yesterday",
|
"label.yesterday": "Yesterday",
|
||||||
|
"label.behavior": "Behavior",
|
||||||
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
|
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
|
||||||
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
|
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
|
||||||
"message.bad-request": "Bad request",
|
"message.bad-request": "Bad request",
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,7 @@
|
||||||
"label.websites": "Sitios web",
|
"label.websites": "Sitios web",
|
||||||
"label.window": "Ventana",
|
"label.window": "Ventana",
|
||||||
"label.yesterday": "Ayer",
|
"label.yesterday": "Ayer",
|
||||||
|
"label.behavior": "Comportamiento",
|
||||||
"message.action-confirmation": "Escriba {confirmation} en el cuadro a continuación para confirmar.",
|
"message.action-confirmation": "Escriba {confirmation} en el cuadro a continuación para confirmar.",
|
||||||
"message.active-users": "{x} {x, plural, one {activo} other {activos}}",
|
"message.active-users": "{x} {x, plural, one {activo} other {activos}}",
|
||||||
"message.bad-request": "Bad request",
|
"message.bad-request": "Bad request",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "میانگین",
|
"label.average": "میانگین",
|
||||||
"label.back": "بازگشت",
|
"label.back": "بازگشت",
|
||||||
"label.before": "قبل از",
|
"label.before": "قبل از",
|
||||||
|
"label.behavior": "رفتار",
|
||||||
"label.boards": "بردها",
|
"label.boards": "بردها",
|
||||||
"label.bounce-rate": "نرخ ریزش",
|
"label.bounce-rate": "نرخ ریزش",
|
||||||
"label.breakdown": "تفکیک",
|
"label.breakdown": "تفکیک",
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,7 @@
|
||||||
"label.websites": "Verkkosivut",
|
"label.websites": "Verkkosivut",
|
||||||
"label.window": "Window",
|
"label.window": "Window",
|
||||||
"label.yesterday": "Yesterday",
|
"label.yesterday": "Yesterday",
|
||||||
|
"label.behavior": "Behavior",
|
||||||
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
|
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
|
||||||
"message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
|
"message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
|
||||||
"message.bad-request": "Bad request",
|
"message.bad-request": "Bad request",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Miðal",
|
"label.average": "Miðal",
|
||||||
"label.back": "Aftur",
|
"label.back": "Aftur",
|
||||||
"label.before": "Áðrenn",
|
"label.before": "Áðrenn",
|
||||||
|
"label.behavior": "Atferð",
|
||||||
"label.boards": "Borð",
|
"label.boards": "Borð",
|
||||||
"label.bounce-rate": "Bounce prosenttal",
|
"label.bounce-rate": "Bounce prosenttal",
|
||||||
"label.breakdown": "Sundurgreining",
|
"label.breakdown": "Sundurgreining",
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,9 @@
|
||||||
"label.websites": "Sites",
|
"label.websites": "Sites",
|
||||||
"label.window": "Fenêtre",
|
"label.window": "Fenêtre",
|
||||||
"label.yesterday": "Hier",
|
"label.yesterday": "Hier",
|
||||||
|
"label.behavior": "Comportement",
|
||||||
|
"label.traffic": "Trafic",
|
||||||
|
"label.segments": "Segments",
|
||||||
"message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.",
|
"message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.",
|
||||||
"message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement",
|
"message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement",
|
||||||
"message.bad-request": "Bad request",
|
"message.bad-request": "Bad request",
|
||||||
|
|
@ -315,13 +318,13 @@
|
||||||
"message.no-teams": "Vous n'avez pas créé d'équipe.",
|
"message.no-teams": "Vous n'avez pas créé d'équipe.",
|
||||||
"message.no-users": "Aucun utilisateur.",
|
"message.no-users": "Aucun utilisateur.",
|
||||||
"message.no-websites-configured": "Vous n'avez pas configuré de site.",
|
"message.no-websites-configured": "Vous n'avez pas configuré de site.",
|
||||||
"message.not-found": "Not found",
|
"message.not-found": "Non trouvé!",
|
||||||
"message.nothing-selected": "Nothing selected.",
|
"message.nothing-selected": "Rien n'est sélectionné.",
|
||||||
"message.page-not-found": "Page non trouvée.",
|
"message.page-not-found": "Page non trouvée.",
|
||||||
"message.reset-website": "Pour réinitialiser ce site, taper {confirmation} ci-dessous pour confirmer.",
|
"message.reset-website": "Pour réinitialiser ce site, taper {confirmation} ci-dessous pour confirmer.",
|
||||||
"message.reset-website-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.",
|
"message.reset-website-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.",
|
||||||
"message.saved": "Enregistré.",
|
"message.saved": "Enregistré.",
|
||||||
"message.sever-error": "Server error",
|
"message.sever-error": "Erreur serveur",
|
||||||
"message.share-url": "Les statistiques de votre site sont accessibles publiquement sur cette URL :",
|
"message.share-url": "Les statistiques de votre site sont accessibles publiquement sur cette URL :",
|
||||||
"message.team-already-member": "Vous êtes déjà membre de cette équipe.",
|
"message.team-already-member": "Vous êtes déjà membre de cette équipe.",
|
||||||
"message.team-not-found": "Équipe non trouvée.",
|
"message.team-not-found": "Équipe non trouvée.",
|
||||||
|
|
@ -331,7 +334,7 @@
|
||||||
"message.transfer-user-website-to-team": "Choisir l'équipe à laquelle transférer ce site.",
|
"message.transfer-user-website-to-team": "Choisir l'équipe à laquelle transférer ce site.",
|
||||||
"message.transfer-website": "Transférer la propriété du site sur votre compte ou à une autre équipe.",
|
"message.transfer-website": "Transférer la propriété du site sur votre compte ou à une autre équipe.",
|
||||||
"message.triggered-event": "Évènement déclenché",
|
"message.triggered-event": "Évènement déclenché",
|
||||||
"message.unauthorized": "Unauthorized",
|
"message.unauthorized": "Non authorisé!",
|
||||||
"message.user-deleted": "Utilisateur supprimé.",
|
"message.user-deleted": "Utilisateur supprimé.",
|
||||||
"message.viewed-page": "Page vue",
|
"message.viewed-page": "Page vue",
|
||||||
"message.visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}"
|
"message.visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}"
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,7 @@
|
||||||
"label.websites": "Sitios web",
|
"label.websites": "Sitios web",
|
||||||
"label.window": "Ventá",
|
"label.window": "Ventá",
|
||||||
"label.yesterday": "Onte",
|
"label.yesterday": "Onte",
|
||||||
|
"label.behavior": "Comportamento",
|
||||||
"message.action-confirmation": "Escribe {confirmation} na caixa de embaixo para confirmar.",
|
"message.action-confirmation": "Escribe {confirmation} na caixa de embaixo para confirmar.",
|
||||||
"message.active-users": "{x} actual {x, plural, one {visitante} other {visitantes}}",
|
"message.active-users": "{x} actual {x, plural, one {visitante} other {visitantes}}",
|
||||||
"message.bad-request": "Bad request",
|
"message.bad-request": "Bad request",
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,7 @@
|
||||||
"label.value": "Value",
|
"label.value": "Value",
|
||||||
"label.view": "View",
|
"label.view": "View",
|
||||||
"label.view-details": "פרטים נוספים",
|
"label.view-details": "פרטים נוספים",
|
||||||
|
"label.behavior": "התנהגות",
|
||||||
"label.view-only": "View only",
|
"label.view-only": "View only",
|
||||||
"label.views": "צפיות",
|
"label.views": "צפיות",
|
||||||
"label.views-per-visit": "Views per visit",
|
"label.views-per-visit": "Views per visit",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "औसत",
|
"label.average": "औसत",
|
||||||
"label.back": "पीछे",
|
"label.back": "पीछे",
|
||||||
"label.before": "पहले",
|
"label.before": "पहले",
|
||||||
|
"label.behavior": "व्यवहार",
|
||||||
"label.boards": "बोर्ड्स",
|
"label.boards": "बोर्ड्स",
|
||||||
"label.bounce-rate": "उछाल दर",
|
"label.bounce-rate": "उछाल दर",
|
||||||
"label.breakdown": "विभाजन",
|
"label.breakdown": "विभाजन",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Prosjek",
|
"label.average": "Prosjek",
|
||||||
"label.back": "Natrag ",
|
"label.back": "Natrag ",
|
||||||
"label.before": "Prije",
|
"label.before": "Prije",
|
||||||
|
"label.behavior": "Ponašanje",
|
||||||
"label.boards": "Ploče",
|
"label.boards": "Ploče",
|
||||||
"label.bounce-rate": "Stopa napuštanja",
|
"label.bounce-rate": "Stopa napuštanja",
|
||||||
"label.breakdown": "Raspad",
|
"label.breakdown": "Raspad",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Átlag",
|
"label.average": "Átlag",
|
||||||
"label.back": "Vissza",
|
"label.back": "Vissza",
|
||||||
"label.before": "Előtt",
|
"label.before": "Előtt",
|
||||||
|
"label.behavior": "Viselkedés",
|
||||||
"label.boards": "Táblák",
|
"label.boards": "Táblák",
|
||||||
"label.bounce-rate": "Visszafordulási arány",
|
"label.bounce-rate": "Visszafordulási arány",
|
||||||
"label.breakdown": "Bontás",
|
"label.breakdown": "Bontás",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Rata-rata",
|
"label.average": "Rata-rata",
|
||||||
"label.back": "Kembali",
|
"label.back": "Kembali",
|
||||||
"label.before": "Sebelum",
|
"label.before": "Sebelum",
|
||||||
|
"label.behavior": "Perilaku",
|
||||||
"label.boards": "Papan",
|
"label.boards": "Papan",
|
||||||
"label.bounce-rate": "Rasio pentalan",
|
"label.bounce-rate": "Rasio pentalan",
|
||||||
"label.breakdown": "Rincian",
|
"label.breakdown": "Rincian",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Media",
|
"label.average": "Media",
|
||||||
"label.back": "Indietro",
|
"label.back": "Indietro",
|
||||||
"label.before": "Prima",
|
"label.before": "Prima",
|
||||||
|
"label.behavior": "Comportamento",
|
||||||
"label.boards": "Bacheche",
|
"label.boards": "Bacheche",
|
||||||
"label.bounce-rate": "Frequenza di rimbalzo",
|
"label.bounce-rate": "Frequenza di rimbalzo",
|
||||||
"label.breakdown": "Dettaglio",
|
"label.breakdown": "Dettaglio",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "平均",
|
"label.average": "平均",
|
||||||
"label.back": "戻る",
|
"label.back": "戻る",
|
||||||
"label.before": "直前",
|
"label.before": "直前",
|
||||||
|
"label.behavior": "行動",
|
||||||
"label.boards": "ボード",
|
"label.boards": "ボード",
|
||||||
"label.bounce-rate": "直帰率",
|
"label.bounce-rate": "直帰率",
|
||||||
"label.breakdown": "故障",
|
"label.breakdown": "故障",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "ជាមធ្យម",
|
"label.average": "ជាមធ្យម",
|
||||||
"label.back": "ថយក្រោយ",
|
"label.back": "ថយក្រោយ",
|
||||||
"label.before": "មុន",
|
"label.before": "មុន",
|
||||||
|
"label.behavior": "អាកប្បកិរិយា",
|
||||||
"label.boards": "ក្តារ",
|
"label.boards": "ក្តារ",
|
||||||
"label.bounce-rate": "ចំនួនវិលត្រឡប់",
|
"label.bounce-rate": "ចំនួនវិលត្រឡប់",
|
||||||
"label.breakdown": "បំបែកលម្អិត",
|
"label.breakdown": "បំបែកលម្អិត",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "평균",
|
"label.average": "평균",
|
||||||
"label.back": "뒤로",
|
"label.back": "뒤로",
|
||||||
"label.before": "이전",
|
"label.before": "이전",
|
||||||
|
"label.behavior": "행동",
|
||||||
"label.boards": "보드",
|
"label.boards": "보드",
|
||||||
"label.bounce-rate": "이탈률",
|
"label.bounce-rate": "이탈률",
|
||||||
"label.breakdown": "세부 사항",
|
"label.breakdown": "세부 사항",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Vidurkis",
|
"label.average": "Vidurkis",
|
||||||
"label.back": "Atgal",
|
"label.back": "Atgal",
|
||||||
"label.before": "Prieš",
|
"label.before": "Prieš",
|
||||||
|
"label.behavior": "Elgsena",
|
||||||
"label.boards": "Lentos",
|
"label.boards": "Lentos",
|
||||||
"label.bounce-rate": "Atmetimo rodiklis",
|
"label.bounce-rate": "Atmetimo rodiklis",
|
||||||
"label.breakdown": "Išskaidymas",
|
"label.breakdown": "Išskaidymas",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Дундаж",
|
"label.average": "Дундаж",
|
||||||
"label.back": "Буцах",
|
"label.back": "Буцах",
|
||||||
"label.before": "Өмнө",
|
"label.before": "Өмнө",
|
||||||
|
"label.behavior": "Зан төлөв",
|
||||||
"label.boards": "Самбарууд",
|
"label.boards": "Самбарууд",
|
||||||
"label.bounce-rate": "Нэг хуудас үзээд гарсан",
|
"label.bounce-rate": "Нэг хуудас үзээд гарсан",
|
||||||
"label.breakdown": "Задаргаа",
|
"label.breakdown": "Задаргаа",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Average",
|
"label.average": "Average",
|
||||||
"label.back": "Kembali",
|
"label.back": "Kembali",
|
||||||
"label.before": "Before",
|
"label.before": "Before",
|
||||||
|
"label.behavior": "Behavior",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "Kadar lantunan",
|
"label.bounce-rate": "Kadar lantunan",
|
||||||
"label.breakdown": "Breakdown",
|
"label.breakdown": "Breakdown",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "ပျမ်းမျှ",
|
"label.average": "ပျမ်းမျှ",
|
||||||
"label.back": "နောက်သို့",
|
"label.back": "နောက်သို့",
|
||||||
"label.before": "မတိုင်မီ",
|
"label.before": "မတိုင်မီ",
|
||||||
|
"label.behavior": "အပြုအမူ",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "Bounce နှုန်း",
|
"label.bounce-rate": "Bounce နှုန်း",
|
||||||
"label.breakdown": "ခွဲခြမ်းစိတ်ဖြာမှု",
|
"label.breakdown": "ခွဲခြမ်းစိတ်ဖြာမှု",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Gjennomsnnitt",
|
"label.average": "Gjennomsnnitt",
|
||||||
"label.back": "Tilbake",
|
"label.back": "Tilbake",
|
||||||
"label.before": "Før",
|
"label.before": "Før",
|
||||||
|
"label.behavior": "Atferd",
|
||||||
"label.boards": "Tavler",
|
"label.boards": "Tavler",
|
||||||
"label.bounce-rate": "Avvisningsfrekvens",
|
"label.bounce-rate": "Avvisningsfrekvens",
|
||||||
"label.breakdown": "Nedbrytning",
|
"label.breakdown": "Nedbrytning",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Gemiddelde",
|
"label.average": "Gemiddelde",
|
||||||
"label.back": "Terug",
|
"label.back": "Terug",
|
||||||
"label.before": "Voor",
|
"label.before": "Voor",
|
||||||
|
"label.behavior": "Gedrag",
|
||||||
"label.boards": "Borden",
|
"label.boards": "Borden",
|
||||||
"label.bounce-rate": "Bouncepercentage",
|
"label.bounce-rate": "Bouncepercentage",
|
||||||
"label.breakdown": "Opsplitsen",
|
"label.breakdown": "Opsplitsen",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Średnia",
|
"label.average": "Średnia",
|
||||||
"label.back": "Powrót",
|
"label.back": "Powrót",
|
||||||
"label.before": "Przed",
|
"label.before": "Przed",
|
||||||
|
"label.behavior": "Zachowanie",
|
||||||
"label.boards": "Tablice",
|
"label.boards": "Tablice",
|
||||||
"label.bounce-rate": "Współczynnik odrzuceń",
|
"label.bounce-rate": "Współczynnik odrzuceń",
|
||||||
"label.breakdown": "Rozbicie",
|
"label.breakdown": "Rozbicie",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Média",
|
"label.average": "Média",
|
||||||
"label.back": "Voltar",
|
"label.back": "Voltar",
|
||||||
"label.before": "Antes",
|
"label.before": "Antes",
|
||||||
|
"label.behavior": "Comportamento",
|
||||||
"label.boards": "Quadros",
|
"label.boards": "Quadros",
|
||||||
"label.bounce-rate": "Taxa de rejeição",
|
"label.bounce-rate": "Taxa de rejeição",
|
||||||
"label.breakdown": "Detalhamento",
|
"label.breakdown": "Detalhamento",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Média",
|
"label.average": "Média",
|
||||||
"label.back": "Voltar",
|
"label.back": "Voltar",
|
||||||
"label.before": "Antes",
|
"label.before": "Antes",
|
||||||
|
"label.behavior": "Comportamento",
|
||||||
"label.boards": "Quadros",
|
"label.boards": "Quadros",
|
||||||
"label.bounce-rate": "Taxa de rejeição",
|
"label.bounce-rate": "Taxa de rejeição",
|
||||||
"label.breakdown": "Detalhamento",
|
"label.breakdown": "Detalhamento",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Mediu",
|
"label.average": "Mediu",
|
||||||
"label.back": "Înapoi",
|
"label.back": "Înapoi",
|
||||||
"label.before": "Înainte",
|
"label.before": "Înainte",
|
||||||
|
"label.behavior": "Comportament",
|
||||||
"label.boards": "Panouri",
|
"label.boards": "Panouri",
|
||||||
"label.bounce-rate": "Rata de respingere",
|
"label.bounce-rate": "Rata de respingere",
|
||||||
"label.breakdown": "Detaliat",
|
"label.breakdown": "Detaliat",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Средний",
|
"label.average": "Средний",
|
||||||
"label.back": "Назад",
|
"label.back": "Назад",
|
||||||
"label.before": "До",
|
"label.before": "До",
|
||||||
|
"label.behavior": "Поведение",
|
||||||
"label.boards": "Доски",
|
"label.boards": "Доски",
|
||||||
"label.bounce-rate": "Отказы",
|
"label.bounce-rate": "Отказы",
|
||||||
"label.breakdown": "Авария",
|
"label.breakdown": "Авария",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Average",
|
"label.average": "Average",
|
||||||
"label.back": "ආපසු",
|
"label.back": "ආපසු",
|
||||||
"label.before": "Before",
|
"label.before": "Before",
|
||||||
|
"label.behavior": "අචරණය",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "Bounce rate",
|
"label.bounce-rate": "Bounce rate",
|
||||||
"label.breakdown": "Breakdown",
|
"label.breakdown": "Breakdown",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Priemer",
|
"label.average": "Priemer",
|
||||||
"label.back": "Späť",
|
"label.back": "Späť",
|
||||||
"label.before": "Pred",
|
"label.before": "Pred",
|
||||||
|
"label.behavior": "Správanie",
|
||||||
"label.boards": "Tabule",
|
"label.boards": "Tabule",
|
||||||
"label.bounce-rate": "Okamžité opustenie",
|
"label.bounce-rate": "Okamžité opustenie",
|
||||||
"label.breakdown": "Rozpis",
|
"label.breakdown": "Rozpis",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Povprečno",
|
"label.average": "Povprečno",
|
||||||
"label.back": "Nazaj",
|
"label.back": "Nazaj",
|
||||||
"label.before": "Pred",
|
"label.before": "Pred",
|
||||||
|
"label.behavior": "Obnašanje",
|
||||||
"label.boards": "Table",
|
"label.boards": "Table",
|
||||||
"label.bounce-rate": "Odbojna stopnja",
|
"label.bounce-rate": "Odbojna stopnja",
|
||||||
"label.breakdown": "Razčlenitev",
|
"label.breakdown": "Razčlenitev",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Genomsnitt",
|
"label.average": "Genomsnitt",
|
||||||
"label.back": "Tillbaka",
|
"label.back": "Tillbaka",
|
||||||
"label.before": "Före",
|
"label.before": "Före",
|
||||||
|
"label.behavior": "Beteende",
|
||||||
"label.boards": "Anslagstavlor",
|
"label.boards": "Anslagstavlor",
|
||||||
"label.bounce-rate": "Avvisningsfrekvens",
|
"label.bounce-rate": "Avvisningsfrekvens",
|
||||||
"label.breakdown": "Analys",
|
"label.breakdown": "Analys",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"label.back": "பின்னால்",
|
"label.back": "பின்னால்",
|
||||||
"label.before": "Before",
|
"label.before": "Before",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
|
"label.behavior": "நடத்தை",
|
||||||
"label.bounce-rate": "துள்ளல் விகிதம்",
|
"label.bounce-rate": "துள்ளல் விகிதம்",
|
||||||
"label.breakdown": "Breakdown",
|
"label.breakdown": "Breakdown",
|
||||||
"label.browser": "Browser",
|
"label.browser": "Browser",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Average",
|
"label.average": "Average",
|
||||||
"label.back": "ย้อนกลับ",
|
"label.back": "ย้อนกลับ",
|
||||||
"label.before": "Before",
|
"label.before": "Before",
|
||||||
|
"label.behavior": "พฤติกรรม",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "อัตราตีกลับ",
|
"label.bounce-rate": "อัตราตีกลับ",
|
||||||
"label.breakdown": "Breakdown",
|
"label.breakdown": "Breakdown",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Ortalama",
|
"label.average": "Ortalama",
|
||||||
"label.back": "Geri",
|
"label.back": "Geri",
|
||||||
"label.before": "Önce",
|
"label.before": "Önce",
|
||||||
|
"label.behavior": "Davranış",
|
||||||
"label.boards": "Panolar",
|
"label.boards": "Panolar",
|
||||||
"label.bounce-rate": "Tek sayfa ziyaret oranı",
|
"label.bounce-rate": "Tek sayfa ziyaret oranı",
|
||||||
"label.breakdown": "Dağılım",
|
"label.breakdown": "Dağılım",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Середній",
|
"label.average": "Середній",
|
||||||
"label.back": "Назад",
|
"label.back": "Назад",
|
||||||
"label.before": "До",
|
"label.before": "До",
|
||||||
|
"label.behavior": "Поведінка",
|
||||||
"label.boards": "Дошки",
|
"label.boards": "Дошки",
|
||||||
"label.bounce-rate": "Показник відмов",
|
"label.bounce-rate": "Показник відмов",
|
||||||
"label.breakdown": "Розподіл",
|
"label.breakdown": "Розподіл",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "Average",
|
"label.average": "Average",
|
||||||
"label.back": "پیچھے",
|
"label.back": "پیچھے",
|
||||||
"label.before": "Before",
|
"label.before": "Before",
|
||||||
|
"label.behavior": "رویے",
|
||||||
"label.boards": "Boards",
|
"label.boards": "Boards",
|
||||||
"label.bounce-rate": "اچھال کی شرح",
|
"label.bounce-rate": "اچھال کی شرح",
|
||||||
"label.breakdown": "Breakdown",
|
"label.breakdown": "Breakdown",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"label.average": "Oʻrtacha",
|
"label.average": "Oʻrtacha",
|
||||||
"label.back": "Orqaga",
|
"label.back": "Orqaga",
|
||||||
"label.before": "Oldin",
|
"label.before": "Oldin",
|
||||||
|
"label.behavior": "Xulq-atvor",
|
||||||
"label.bounce-rate": "Chiqib ketish darajasi",
|
"label.bounce-rate": "Chiqib ketish darajasi",
|
||||||
"label.breakdown": "Tahlil",
|
"label.breakdown": "Tahlil",
|
||||||
"label.browser": "Brauzer",
|
"label.browser": "Brauzer",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"label.average": "Trung bình",
|
"label.average": "Trung bình",
|
||||||
"label.back": "Quay lại",
|
"label.back": "Quay lại",
|
||||||
"label.before": "Trước đó",
|
"label.before": "Trước đó",
|
||||||
|
"label.behavior": "Hành vi",
|
||||||
"label.bounce-rate": "Tỷ lệ thoát trang",
|
"label.bounce-rate": "Tỷ lệ thoát trang",
|
||||||
"label.breakdown": "Phân tích chi tiết",
|
"label.breakdown": "Phân tích chi tiết",
|
||||||
"label.browser": "Trình duyệt",
|
"label.browser": "Trình duyệt",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "平均",
|
"label.average": "平均",
|
||||||
"label.back": "返回",
|
"label.back": "返回",
|
||||||
"label.before": "之前",
|
"label.before": "之前",
|
||||||
|
"label.behavior": "行为",
|
||||||
"label.boards": "看板",
|
"label.boards": "看板",
|
||||||
"label.bounce-rate": "跳出率",
|
"label.bounce-rate": "跳出率",
|
||||||
"label.breakdown": "故障",
|
"label.breakdown": "故障",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"label.average": "平均",
|
"label.average": "平均",
|
||||||
"label.back": "返回",
|
"label.back": "返回",
|
||||||
"label.before": "之前",
|
"label.before": "之前",
|
||||||
|
"label.behavior": "行為",
|
||||||
"label.boards": "看板",
|
"label.boards": "看板",
|
||||||
"label.bounce-rate": "跳出率",
|
"label.bounce-rate": "跳出率",
|
||||||
"label.breakdown": "細項分析",
|
"label.breakdown": "細項分析",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { v4, v5 } from 'uuid';
|
import { v4, v5, v7 } from 'uuid';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
|
|
@ -57,7 +57,9 @@ export function secret() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uuid(...args: any) {
|
export function uuid(...args: any) {
|
||||||
if (!args.length) return v4();
|
if (args.length) {
|
||||||
|
return v5(hash(...args, secret()), v5.DNS);
|
||||||
|
}
|
||||||
|
|
||||||
return v5(hash(...args, secret()), v5.DNS);
|
return process.env.USE_UUIDV7 ? v7() : v4();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,9 @@ export async function getClientInfo(request: Request, payload: Record<string, an
|
||||||
const country = safeDecodeURIComponent(location?.country);
|
const country = safeDecodeURIComponent(location?.country);
|
||||||
const region = safeDecodeURIComponent(location?.region);
|
const region = safeDecodeURIComponent(location?.region);
|
||||||
const city = safeDecodeURIComponent(location?.city);
|
const city = safeDecodeURIComponent(location?.city);
|
||||||
const browser = browserName(userAgent);
|
const browser = payload?.browser ?? browserName(userAgent);
|
||||||
const os = detectOS(userAgent) as string;
|
const os = payload?.os ?? (detectOS(userAgent) as string);
|
||||||
const device = getDevice(userAgent, payload?.screen);
|
const device = payload?.device ?? getDevice(userAgent, payload?.screen);
|
||||||
|
|
||||||
return { userAgent, browser, os, ip, country, region, city, device };
|
return { userAgent, browser, os, ip, country, region, city, device };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,10 @@ async function rawQuery(sql: string, data: Record<string, any>, name?: string):
|
||||||
return `$${params.length}${type ?? ''}`;
|
return `$${params.length}${type ?? ''}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (process.env.DATABASE_REPLICA_URL && '$replica' in client) {
|
||||||
|
return client.$replica().$queryRawUnsafe(query, ...params);
|
||||||
|
}
|
||||||
|
|
||||||
return client.$queryRawUnsafe(query, ...params);
|
return client.$queryRawUnsafe(query, ...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,54 +300,54 @@ function getSchema() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClient() {
|
function getClient() {
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = process.env.DATABASE_URL;
|
const url = process.env.DATABASE_URL;
|
||||||
const replicaUrl = process.env.DATABASE_REPLICA_URL;
|
const replicaUrl = process.env.DATABASE_REPLICA_URL;
|
||||||
const logQuery = process.env.LOG_QUERY;
|
const logQuery = process.env.LOG_QUERY;
|
||||||
|
const schema = getSchema();
|
||||||
|
|
||||||
const connectionUrl = new URL(url);
|
const baseAdapter = new PrismaPg({ connectionString: url }, { schema });
|
||||||
const schema = connectionUrl.searchParams.get('schema') ?? undefined;
|
|
||||||
|
|
||||||
const adapter = new PrismaPg({ connectionString: url.toString() }, { schema });
|
const baseClient = new PrismaClient({
|
||||||
|
adapter: baseAdapter,
|
||||||
const prisma = new PrismaClient({
|
|
||||||
adapter,
|
|
||||||
errorFormat: 'pretty',
|
errorFormat: 'pretty',
|
||||||
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
|
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (replicaUrl) {
|
if (logQuery) {
|
||||||
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl.toString() }, { schema });
|
baseClient.$on('query', log);
|
||||||
|
|
||||||
const replicaClient = new PrismaClient({
|
|
||||||
adapter: replicaAdapter,
|
|
||||||
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
prisma.$extends(
|
|
||||||
readReplicas({
|
|
||||||
replicas: [replicaClient],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!replicaUrl) {
|
||||||
|
log('Prisma initialized');
|
||||||
|
globalThis[PRISMA] ??= baseClient;
|
||||||
|
return baseClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema });
|
||||||
|
|
||||||
|
const replicaClient = new PrismaClient({
|
||||||
|
adapter: replicaAdapter,
|
||||||
|
errorFormat: 'pretty',
|
||||||
|
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
|
||||||
|
});
|
||||||
|
|
||||||
if (logQuery) {
|
if (logQuery) {
|
||||||
prisma.$on('query' as never, log);
|
replicaClient.$on('query', log);
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Prisma initialized');
|
const extended = baseClient.$extends(
|
||||||
|
readReplicas({
|
||||||
|
replicas: [replicaClient],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (!globalThis[PRISMA]) {
|
log('Prisma initialized (with replica)');
|
||||||
globalThis[PRISMA] = prisma;
|
globalThis[PRISMA] ??= extended;
|
||||||
}
|
|
||||||
|
|
||||||
return prisma;
|
return extended;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client: PrismaClient = globalThis[PRISMA] || getClient();
|
const client = (globalThis[PRISMA] || getClient()) as ReturnType<typeof getClient>;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
client,
|
client,
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,9 @@ export async function resetWebsite(websiteId: string) {
|
||||||
|
|
||||||
return transaction(
|
return transaction(
|
||||||
[
|
[
|
||||||
|
client.revenue.deleteMany({
|
||||||
|
where: { websiteId },
|
||||||
|
}),
|
||||||
client.eventData.deleteMany({
|
client.eventData.deleteMany({
|
||||||
where: { websiteId },
|
where: { websiteId },
|
||||||
}),
|
}),
|
||||||
|
|
@ -175,35 +178,44 @@ export async function deleteWebsite(websiteId: string) {
|
||||||
const { client, transaction } = prisma;
|
const { client, transaction } = prisma;
|
||||||
const cloudMode = !!process.env.CLOUD_MODE;
|
const cloudMode = !!process.env.CLOUD_MODE;
|
||||||
|
|
||||||
return transaction([
|
return transaction(
|
||||||
client.eventData.deleteMany({
|
[
|
||||||
where: { websiteId },
|
client.revenue.deleteMany({
|
||||||
}),
|
where: { websiteId },
|
||||||
client.sessionData.deleteMany({
|
}),
|
||||||
where: { websiteId },
|
client.eventData.deleteMany({
|
||||||
}),
|
where: { websiteId },
|
||||||
client.websiteEvent.deleteMany({
|
}),
|
||||||
where: { websiteId },
|
client.sessionData.deleteMany({
|
||||||
}),
|
where: { websiteId },
|
||||||
client.session.deleteMany({
|
}),
|
||||||
where: { websiteId },
|
client.websiteEvent.deleteMany({
|
||||||
}),
|
where: { websiteId },
|
||||||
client.report.deleteMany({
|
}),
|
||||||
where: {
|
client.session.deleteMany({
|
||||||
websiteId,
|
where: { websiteId },
|
||||||
},
|
}),
|
||||||
}),
|
client.report.deleteMany({
|
||||||
cloudMode
|
where: { websiteId },
|
||||||
? client.website.update({
|
}),
|
||||||
data: {
|
client.segment.deleteMany({
|
||||||
deletedAt: new Date(),
|
where: { websiteId },
|
||||||
},
|
}),
|
||||||
where: { id: websiteId },
|
cloudMode
|
||||||
})
|
? client.website.update({
|
||||||
: client.website.delete({
|
data: {
|
||||||
where: { id: websiteId },
|
deletedAt: new Date(),
|
||||||
}),
|
},
|
||||||
]).then(async data => {
|
where: { id: websiteId },
|
||||||
|
})
|
||||||
|
: client.website.delete({
|
||||||
|
where: { id: websiteId },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
).then(async data => {
|
||||||
if (cloudMode) {
|
if (cloudMode) {
|
||||||
await redis.client.del(`website:${websiteId}`);
|
await redis.client.del(`website:${websiteId}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue