mirror of
https://github.com/umami-software/umami.git
synced 2026-02-12 00:27:11 +01:00
Merge branch 'dev' into script-simplification
This commit is contained in:
commit
9101f8a478
109 changed files with 17911 additions and 11822 deletions
44
Dockerfile
44
Dockerfile
|
|
@ -3,27 +3,25 @@ FROM node:22-alpine 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
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
# Add yarn timeout to handle slow CPU when Github Actions
|
RUN npm install -g pnpm
|
||||||
RUN yarn config set network-timeout 300000
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN yarn 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:22-alpine 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.js ./src
|
|
||||||
|
|
||||||
ARG DATABASE_TYPE
|
ARG DATABASE_TYPE
|
||||||
ARG BASE_PATH
|
ARG BASE_PATH
|
||||||
|
|
||||||
ENV DATABASE_TYPE $DATABASE_TYPE
|
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
|
||||||
|
|
||||||
RUN yarn 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:22-alpine AS runner
|
||||||
|
|
@ -31,21 +29,24 @@ WORKDIR /app
|
||||||
|
|
||||||
ARG NODE_OPTIONS
|
ARG NODE_OPTIONS
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_OPTIONS $NODE_OPTIONS
|
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
|
||||||
&& yarn add npm-run-all dotenv semver prisma@6.1.0
|
|
||||||
|
# Script dependencies
|
||||||
|
RUN pnpm add npm-run-all dotenv prisma@6.1.0
|
||||||
|
|
||||||
|
# Permissions for prisma
|
||||||
|
RUN chown -R nextjs:nodejs node_modules/.pnpm/
|
||||||
|
|
||||||
# You only need to copy next.config.js if you are NOT using the default configuration
|
|
||||||
COPY --from=builder /app/next.config.js .
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/scripts ./scripts
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
|
||||||
|
|
@ -54,11 +55,14 @@ COPY --from=builder /app/scripts ./scripts
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Custom routes
|
||||||
|
RUN mv ./.next/routes-manifest.json ./.next/routes-manifest-orig.json
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV HOSTNAME 0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV PORT 3000
|
ENV PORT=3000
|
||||||
|
|
||||||
CMD ["yarn", "start-docker"]
|
CMD ["pnpm", "start-docker"]
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -38,18 +38,12 @@ A detailed getting started guide can be found at [umami.is/docs](https://umami.i
|
||||||
- A server with Node.js version 18.18 or newer
|
- A server with Node.js version 18.18 or newer
|
||||||
- A database. Umami supports [MariaDB](https://www.mariadb.org/) (minimum v10.5), [MySQL](https://www.mysql.com/) (minimum v8.0) and [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases.
|
- A database. Umami supports [MariaDB](https://www.mariadb.org/) (minimum v10.5), [MySQL](https://www.mysql.com/) (minimum v8.0) and [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases.
|
||||||
|
|
||||||
### Install Yarn
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g yarn
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
||||||
cd umami
|
cd umami
|
||||||
yarn install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure Umami
|
### Configure Umami
|
||||||
|
|
@ -70,7 +64,7 @@ mysql://username:mypassword@localhost:3306/mydb
|
||||||
### Build the Application
|
### Build the Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn build
|
npm 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**.*
|
||||||
|
|
@ -78,7 +72,7 @@ yarn build
|
||||||
### Start the Application
|
### Start the Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn start
|
npm 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.*
|
||||||
|
|
@ -113,8 +107,8 @@ To get the latest features, simply do a pull, install any new dependencies, and
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
yarn install
|
npm install
|
||||||
yarn build
|
npm run 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:
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,6 @@ export default defineConfig({
|
||||||
env: {
|
env: {
|
||||||
umami_user: 'admin',
|
umami_user: 'admin',
|
||||||
umami_password: 'umami',
|
umami_password: 'umami',
|
||||||
|
umami_user_id: '41e2b680-648e-4b09-bcd7-3e2b10c06264',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
209
cypress/e2e/api-team.cy.ts
Normal file
209
cypress/e2e/api-team.cy.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
describe('Team API tests', () => {
|
||||||
|
Cypress.session.clearAllSavedSessions();
|
||||||
|
|
||||||
|
let teamId;
|
||||||
|
let userId;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
|
||||||
|
cy.fixture('users').then(data => {
|
||||||
|
const userCreate = data.userCreate;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: userCreate,
|
||||||
|
}).then(response => {
|
||||||
|
userId = response.body.id;
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('username', 'cypress1');
|
||||||
|
expect(response.body).to.have.property('role', 'user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Creates a team.', () => {
|
||||||
|
cy.fixture('teams').then(data => {
|
||||||
|
const teamCreate = data.teamCreate;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/teams',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: teamCreate,
|
||||||
|
}).then(response => {
|
||||||
|
teamId = response.body[0].id;
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body[0]).to.have.property('name', 'cypress');
|
||||||
|
expect(response.body[1]).to.have.property('role', 'team-owner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gets a teams by ID.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/teams/${teamId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('id', teamId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Updates a team.', () => {
|
||||||
|
cy.fixture('teams').then(data => {
|
||||||
|
const teamUpdate = data.teamUpdate;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/teams/${teamId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: teamUpdate,
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('id', teamId);
|
||||||
|
expect(response.body).to.have.property('name', 'cypressUpdate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Get all users that belong to a team.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/teams/${teamId}/users`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body.data[0]).to.have.property('id');
|
||||||
|
expect(response.body.data[0]).to.have.property('teamId');
|
||||||
|
expect(response.body.data[0]).to.have.property('userId');
|
||||||
|
expect(response.body.data[0]).to.have.property('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Get a user belonging to a team.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/teams/${teamId}/users/${Cypress.env('umami_user_id')}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('teamId');
|
||||||
|
expect(response.body).to.have.property('userId');
|
||||||
|
expect(response.body).to.have.property('role');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Get all websites belonging to a team.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/teams/${teamId}/websites`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('data');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Add a user to a team.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/teams/${teamId}/users`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
userId,
|
||||||
|
role: 'team-member',
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('userId', userId);
|
||||||
|
expect(response.body).to.have.property('role', 'team-member');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`Update a user's role on a team.`, () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/teams/${teamId}/users/${userId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
role: 'team-view-only',
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('userId', userId);
|
||||||
|
expect(response.body).to.have.property('role', 'team-view-only');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`Remove a user from a team.`, () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/teams/${teamId}/users/${userId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Deletes a team.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/teams/${teamId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('ok', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// it('Gets all teams that belong to a user.', () => {
|
||||||
|
// cy.request({
|
||||||
|
// method: 'GET',
|
||||||
|
// url: `/api/users/${userId}/teams`,
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json',
|
||||||
|
// Authorization: Cypress.env('authorization'),
|
||||||
|
// },
|
||||||
|
// }).then(response => {
|
||||||
|
// expect(response.status).to.eq(200);
|
||||||
|
// expect(response.body).to.have.property('data');
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cy.deleteUser(userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
125
cypress/e2e/api-user.cy.ts
Normal file
125
cypress/e2e/api-user.cy.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
describe('User API tests', () => {
|
||||||
|
Cypress.session.clearAllSavedSessions();
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
|
||||||
|
});
|
||||||
|
|
||||||
|
let userId;
|
||||||
|
|
||||||
|
it('Creates a user.', () => {
|
||||||
|
cy.fixture('users').then(data => {
|
||||||
|
const userCreate = data.userCreate;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: userCreate,
|
||||||
|
}).then(response => {
|
||||||
|
userId = response.body.id;
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('username', 'cypress1');
|
||||||
|
expect(response.body).to.have.property('role', 'user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Returns all users. Admin access is required.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/users',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body.data[0]).to.have.property('id');
|
||||||
|
expect(response.body.data[0]).to.have.property('username');
|
||||||
|
expect(response.body.data[0]).to.have.property('password');
|
||||||
|
expect(response.body.data[0]).to.have.property('role');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Updates a user.', () => {
|
||||||
|
cy.fixture('users').then(data => {
|
||||||
|
const userUpdate = data.userUpdate;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/users/${userId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: userUpdate,
|
||||||
|
}).then(response => {
|
||||||
|
userId = response.body.id;
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('id', userId);
|
||||||
|
expect(response.body).to.have.property('username', 'cypress1');
|
||||||
|
expect(response.body).to.have.property('role', 'view-only');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gets a user by ID.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/users/${userId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('id', userId);
|
||||||
|
expect(response.body).to.have.property('username', 'cypress1');
|
||||||
|
expect(response.body).to.have.property('role', 'view-only');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Deletes a user.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/users/${userId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('ok', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gets all websites that belong to a user.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/users/${userId}/websites`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('data');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gets all teams that belong to a user.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/users/${userId}/teams`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('data');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
150
cypress/e2e/api-website.cy.ts
Normal file
150
cypress/e2e/api-website.cy.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
describe('Website API tests', () => {
|
||||||
|
Cypress.session.clearAllSavedSessions();
|
||||||
|
|
||||||
|
let websiteId;
|
||||||
|
let teamId;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
|
||||||
|
cy.fixture('teams').then(data => {
|
||||||
|
const teamCreate = data.teamCreate;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/teams',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: teamCreate,
|
||||||
|
}).then(response => {
|
||||||
|
teamId = response.body[0].id;
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body[0]).to.have.property('name', 'cypress');
|
||||||
|
expect(response.body[1]).to.have.property('role', 'team-owner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Creates a website for user.', () => {
|
||||||
|
cy.fixture('websites').then(data => {
|
||||||
|
const websiteCreate = data.websiteCreate;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/websites',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: websiteCreate,
|
||||||
|
}).then(response => {
|
||||||
|
websiteId = response.body.id;
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('name', 'Cypress Website');
|
||||||
|
expect(response.body).to.have.property('domain', 'cypress.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Creates a website for team.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/websites',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Team Website',
|
||||||
|
domain: 'teamwebsite.com',
|
||||||
|
teamId: teamId,
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('name', 'Team Website');
|
||||||
|
expect(response.body).to.have.property('domain', 'teamwebsite.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Returns all tracked websites.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/websites',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body.data[0]).to.have.property('id');
|
||||||
|
expect(response.body.data[0]).to.have.property('name');
|
||||||
|
expect(response.body.data[0]).to.have.property('domain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gets a website by ID.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/websites/${websiteId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('name', 'Cypress Website');
|
||||||
|
expect(response.body).to.have.property('domain', 'cypress.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Updates a website.', () => {
|
||||||
|
cy.fixture('websites').then(data => {
|
||||||
|
const websiteUpdate = data.websiteUpdate;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/websites/${websiteId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: websiteUpdate,
|
||||||
|
}).then(response => {
|
||||||
|
websiteId = response.body.id;
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('name', 'Cypress Website Updated');
|
||||||
|
expect(response.body).to.have.property('domain', 'cypressupdated.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Resets a website by removing all data related to the website.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/websites/${websiteId}/reset`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('ok', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Deletes a website.', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/websites/${websiteId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('ok', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
cy.deleteTeam(teamId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
describe('Website tests', () => {
|
|
||||||
Cypress.session.clearAllSavedSessions();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
|
|
||||||
});
|
|
||||||
|
|
||||||
//let userId;
|
|
||||||
|
|
||||||
it('creates a user.', () => {
|
|
||||||
cy.fixture('users').then(data => {
|
|
||||||
const userPost = data.userPost;
|
|
||||||
cy.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/users',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: Cypress.env('authorization'),
|
|
||||||
},
|
|
||||||
body: userPost,
|
|
||||||
}).then(response => {
|
|
||||||
//userId = response.body.id;
|
|
||||||
expect(response.status).to.eq(200);
|
|
||||||
expect(response.body).to.have.property('username', 'cypress1');
|
|
||||||
expect(response.body).to.have.property('role', 'User');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
describe('Website tests', () => {
|
describe('User tests', () => {
|
||||||
Cypress.session.clearAllSavedSessions();
|
Cypress.session.clearAllSavedSessions();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -51,7 +51,7 @@ describe('Website tests', () => {
|
||||||
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
|
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Delete a website', () => {
|
it('Delete a user', () => {
|
||||||
// delete user
|
// delete user
|
||||||
cy.get('table tbody tr')
|
cy.get('table tbody tr')
|
||||||
.contains('td', /Test-user/i)
|
.contains('td', /Test-user/i)
|
||||||
|
|
|
||||||
8
cypress/fixtures/teams.json
Normal file
8
cypress/fixtures/teams.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"teamCreate": {
|
||||||
|
"name": "cypress"
|
||||||
|
},
|
||||||
|
"teamUpdate": {
|
||||||
|
"name": "cypressUpdate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
{
|
{
|
||||||
"userGet": {
|
"userCreate": {
|
||||||
"name": "cypress",
|
|
||||||
"email": "password",
|
|
||||||
"role": "User"
|
|
||||||
},
|
|
||||||
"userPost": {
|
|
||||||
"username": "cypress1",
|
"username": "cypress1",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
"role": "User"
|
"role": "user"
|
||||||
},
|
},
|
||||||
"userDelete": {
|
"userUpdate": {
|
||||||
"name": "Charlie",
|
"username": "cypress1",
|
||||||
"email": "charlie@example.com",
|
"role": "view-only"
|
||||||
"age": 35
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
cypress/fixtures/websites.json
Normal file
10
cypress/fixtures/websites.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"websiteCreate": {
|
||||||
|
"name": "Cypress Website",
|
||||||
|
"domain": "cypress.com"
|
||||||
|
},
|
||||||
|
"websiteUpdate": {
|
||||||
|
"name": "Cypress Website Updated",
|
||||||
|
"domain": "cypressupdated.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,3 +61,63 @@ Cypress.Commands.add('deleteWebsite', (websiteId: string) => {
|
||||||
expect(response.status).to.eq(200);
|
expect(response.status).to.eq(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('addUser', (username: string, password: string, role: string) => {
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
role: role,
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('deleteUser', (userId: string) => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/users/${userId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('addTeam', (name: string) => {
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/teams',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('deleteTeam', (teamId: string) => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/teams/${teamId}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
26
cypress/support/index.d.ts
vendored
26
cypress/support/index.d.ts
vendored
|
|
@ -24,9 +24,33 @@ declare namespace Cypress {
|
||||||
*/
|
*/
|
||||||
addWebsite(name: string, domain: string): Chainable<JQuery<HTMLElement>>;
|
addWebsite(name: string, domain: string): Chainable<JQuery<HTMLElement>>;
|
||||||
/**
|
/**
|
||||||
* Custom command to create a website
|
* Custom command to delete a website
|
||||||
* @example cy.deleteWebsite('02d89813-7a72-41e1-87f0-8d668f85008b')
|
* @example cy.deleteWebsite('02d89813-7a72-41e1-87f0-8d668f85008b')
|
||||||
*/
|
*/
|
||||||
deleteWebsite(websiteId: string): Chainable<JQuery<HTMLElement>>;
|
deleteWebsite(websiteId: string): Chainable<JQuery<HTMLElement>>;
|
||||||
|
/**
|
||||||
|
* Custom command to create a website
|
||||||
|
* @example cy.deleteWebsite('02d89813-7a72-41e1-87f0-8d668f85008b')
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Custom command to create a user
|
||||||
|
* @example cy.addUser('cypress', 'password', 'User')
|
||||||
|
*/
|
||||||
|
addUser(username: string, password: string, role: string): Chainable<JQuery<HTMLElement>>;
|
||||||
|
/**
|
||||||
|
* Custom command to delete a user
|
||||||
|
* @example cy.deleteUser('02d89813-7a72-41e1-87f0-8d668f85008b')
|
||||||
|
*/
|
||||||
|
deleteUser(userId: string): Chainable<JQuery<HTMLElement>>;
|
||||||
|
/**
|
||||||
|
* Custom command to create a team
|
||||||
|
* @example cy.addTeam('cypressTeam')
|
||||||
|
*/
|
||||||
|
addTeam(name: string): Chainable<JQuery<HTMLElement>>;
|
||||||
|
/**
|
||||||
|
* Custom command to create a website
|
||||||
|
* @example cy.deleteTeam('02d89813-7a72-41e1-87f0-8d668f85008b')
|
||||||
|
*/
|
||||||
|
deleteTeam(teamId: string): Chainable<JQuery<HTMLElement>>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
332
db/clickhouse/migrations/05_add_utm_clid.sql
Normal file
332
db/clickhouse/migrations/05_add_utm_clid.sql
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
-- Create Event
|
||||||
|
CREATE TABLE umami.website_event_new
|
||||||
|
(
|
||||||
|
website_id UUID,
|
||||||
|
session_id UUID,
|
||||||
|
visit_id UUID,
|
||||||
|
event_id UUID,
|
||||||
|
--sessions
|
||||||
|
hostname LowCardinality(String),
|
||||||
|
browser LowCardinality(String),
|
||||||
|
os LowCardinality(String),
|
||||||
|
device LowCardinality(String),
|
||||||
|
screen LowCardinality(String),
|
||||||
|
language LowCardinality(String),
|
||||||
|
country LowCardinality(String),
|
||||||
|
subdivision1 LowCardinality(String),
|
||||||
|
subdivision2 LowCardinality(String),
|
||||||
|
city String,
|
||||||
|
--pageviews
|
||||||
|
url_path String,
|
||||||
|
url_query String,
|
||||||
|
utm_source String,
|
||||||
|
utm_medium String,
|
||||||
|
utm_campaign String,
|
||||||
|
utm_content String,
|
||||||
|
utm_term String,
|
||||||
|
referrer_path String,
|
||||||
|
referrer_query String,
|
||||||
|
referrer_domain String,
|
||||||
|
page_title String,
|
||||||
|
--clickIDs
|
||||||
|
gclid String,
|
||||||
|
fbclid String,
|
||||||
|
msclkid String,
|
||||||
|
ttclid String,
|
||||||
|
li_fat_id String,
|
||||||
|
twclid String,
|
||||||
|
--events
|
||||||
|
event_type UInt32,
|
||||||
|
event_name String,
|
||||||
|
tag String,
|
||||||
|
created_at DateTime('UTC'),
|
||||||
|
job_id Nullable(UUID)
|
||||||
|
)
|
||||||
|
ENGINE = MergeTree
|
||||||
|
PARTITION BY toYYYYMM(created_at)
|
||||||
|
ORDER BY (toStartOfHour(created_at), website_id, session_id, visit_id, created_at)
|
||||||
|
PRIMARY KEY (toStartOfHour(created_at), website_id, session_id, visit_id)
|
||||||
|
SETTINGS index_granularity = 8192;
|
||||||
|
|
||||||
|
-- stats hourly
|
||||||
|
CREATE TABLE umami.website_event_stats_hourly_new
|
||||||
|
(
|
||||||
|
website_id UUID,
|
||||||
|
session_id UUID,
|
||||||
|
visit_id UUID,
|
||||||
|
hostname LowCardinality(String),
|
||||||
|
browser LowCardinality(String),
|
||||||
|
os LowCardinality(String),
|
||||||
|
device LowCardinality(String),
|
||||||
|
screen LowCardinality(String),
|
||||||
|
language LowCardinality(String),
|
||||||
|
country LowCardinality(String),
|
||||||
|
subdivision1 LowCardinality(String),
|
||||||
|
city String,
|
||||||
|
entry_url AggregateFunction(argMin, String, DateTime('UTC')),
|
||||||
|
exit_url AggregateFunction(argMax, String, DateTime('UTC')),
|
||||||
|
url_path SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
url_query SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_source SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_medium SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_campaign SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_content SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_term SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
page_title SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
gclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
fbclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
msclkid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
ttclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
li_fat_id SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
twclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
event_type UInt32,
|
||||||
|
event_name SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
views SimpleAggregateFunction(sum, UInt64),
|
||||||
|
min_time SimpleAggregateFunction(min, DateTime('UTC')),
|
||||||
|
max_time SimpleAggregateFunction(max, DateTime('UTC')),
|
||||||
|
tag SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
created_at Datetime('UTC')
|
||||||
|
)
|
||||||
|
ENGINE = AggregatingMergeTree
|
||||||
|
PARTITION BY toYYYYMM(created_at)
|
||||||
|
ORDER BY (
|
||||||
|
website_id,
|
||||||
|
event_type,
|
||||||
|
toStartOfHour(created_at),
|
||||||
|
cityHash64(visit_id),
|
||||||
|
visit_id
|
||||||
|
)
|
||||||
|
SAMPLE BY cityHash64(visit_id);
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv_new
|
||||||
|
TO umami.website_event_stats_hourly_new
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
subdivision1,
|
||||||
|
city,
|
||||||
|
entry_url,
|
||||||
|
exit_url,
|
||||||
|
url_paths as url_path,
|
||||||
|
url_query,
|
||||||
|
utm_source,
|
||||||
|
utm_medium,
|
||||||
|
utm_campaign,
|
||||||
|
utm_content,
|
||||||
|
utm_term,
|
||||||
|
referrer_domain,
|
||||||
|
page_title,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
li_fat_id,
|
||||||
|
twclid,
|
||||||
|
event_type,
|
||||||
|
event_name,
|
||||||
|
views,
|
||||||
|
min_time,
|
||||||
|
max_time,
|
||||||
|
tag,
|
||||||
|
timestamp as created_at
|
||||||
|
FROM (SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
subdivision1,
|
||||||
|
city,
|
||||||
|
argMinState(url_path, created_at) entry_url,
|
||||||
|
argMaxState(url_path, created_at) exit_url,
|
||||||
|
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
|
||||||
|
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
|
||||||
|
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
|
||||||
|
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
|
||||||
|
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
|
||||||
|
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
|
||||||
|
event_type,
|
||||||
|
if(event_type = 2, groupArray(event_name), []) event_name,
|
||||||
|
sumIf(1, event_type = 1) views,
|
||||||
|
min(created_at) min_time,
|
||||||
|
max(created_at) max_time,
|
||||||
|
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
||||||
|
toStartOfHour(created_at) timestamp
|
||||||
|
FROM umami.website_event_new
|
||||||
|
GROUP BY website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
subdivision1,
|
||||||
|
city,
|
||||||
|
event_type,
|
||||||
|
timestamp);
|
||||||
|
|
||||||
|
-- projections
|
||||||
|
ALTER TABLE umami.website_event_new
|
||||||
|
ADD PROJECTION website_event_url_path_projection (
|
||||||
|
SELECT * ORDER BY toStartOfDay(created_at), website_id, url_path, created_at
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE umami.website_event_new MATERIALIZE PROJECTION website_event_url_path_projection;
|
||||||
|
|
||||||
|
ALTER TABLE umami.website_event_new
|
||||||
|
ADD PROJECTION website_event_referrer_domain_projection (
|
||||||
|
SELECT * ORDER BY toStartOfDay(created_at), website_id, referrer_domain, created_at
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE umami.website_event_new MATERIALIZE PROJECTION website_event_referrer_domain_projection;
|
||||||
|
|
||||||
|
-- migration
|
||||||
|
INSERT INTO umami.website_event_new
|
||||||
|
SELECT website_id, session_id, visit_id, event_id, hostname, browser, os, device, screen, language, country, subdivision1, subdivision2, city, url_path, url_query,
|
||||||
|
extract(url_query, 'utm_source=([^&]*)') AS utm_source,
|
||||||
|
extract(url_query, 'utm_medium=([^&]*)') AS utm_medium,
|
||||||
|
extract(url_query, 'utm_campaign=([^&]*)') AS utm_campaign,
|
||||||
|
extract(url_query, 'utm_content=([^&]*)') AS utm_content,
|
||||||
|
extract(url_query, 'utm_term=([^&]*)') AS utm_term,referrer_path, referrer_query, referrer_domain,
|
||||||
|
page_title,
|
||||||
|
extract(url_query, 'gclid=([^&]*)') gclid,
|
||||||
|
extract(url_query, 'fbclid=([^&]*)') fbclid,
|
||||||
|
extract(url_query, 'msclkid=([^&]*)') msclkid,
|
||||||
|
extract(url_query, 'ttclid=([^&]*)') ttclid,
|
||||||
|
extract(url_query, 'li_fat_id=([^&]*)') li_fat_id,
|
||||||
|
extract(url_query, 'twclid=([^&]*)') twclid,
|
||||||
|
event_type, event_name, tag, created_at, job_id
|
||||||
|
FROM umami.website_event
|
||||||
|
|
||||||
|
-- rename tables
|
||||||
|
RENAME TABLE umami.website_event TO umami.website_event_old;
|
||||||
|
RENAME TABLE umami.website_event_new TO umami.website_event;
|
||||||
|
|
||||||
|
RENAME TABLE umami.website_event_stats_hourly TO umami.website_event_stats_hourly_old;
|
||||||
|
RENAME TABLE umami.website_event_stats_hourly_new TO umami.website_event_stats_hourly;
|
||||||
|
|
||||||
|
RENAME TABLE umami.website_event_stats_hourly_mv TO umami.website_event_stats_hourly_mv_old;
|
||||||
|
RENAME TABLE umami.website_event_stats_hourly_mv_new TO umami.website_event_stats_hourly_mv;
|
||||||
|
|
||||||
|
-- recreate view
|
||||||
|
DROP TABLE umami.website_event_stats_hourly_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv
|
||||||
|
TO umami.website_event_stats_hourly
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
subdivision1,
|
||||||
|
city,
|
||||||
|
entry_url,
|
||||||
|
exit_url,
|
||||||
|
url_paths as url_path,
|
||||||
|
url_query,
|
||||||
|
utm_source,
|
||||||
|
utm_medium,
|
||||||
|
utm_campaign,
|
||||||
|
utm_content,
|
||||||
|
utm_term,
|
||||||
|
referrer_domain,
|
||||||
|
page_title,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
li_fat_id,
|
||||||
|
twclid,
|
||||||
|
event_type,
|
||||||
|
event_name,
|
||||||
|
views,
|
||||||
|
min_time,
|
||||||
|
max_time,
|
||||||
|
tag,
|
||||||
|
timestamp as created_at
|
||||||
|
FROM (SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
subdivision1,
|
||||||
|
city,
|
||||||
|
argMinState(url_path, created_at) entry_url,
|
||||||
|
argMaxState(url_path, created_at) exit_url,
|
||||||
|
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
|
||||||
|
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
|
||||||
|
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
|
||||||
|
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
|
||||||
|
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
|
||||||
|
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
|
||||||
|
event_type,
|
||||||
|
if(event_type = 2, groupArray(event_name), []) event_name,
|
||||||
|
sumIf(1, event_type = 1) views,
|
||||||
|
min(created_at) min_time,
|
||||||
|
max(created_at) max_time,
|
||||||
|
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
||||||
|
toStartOfHour(created_at) timestamp
|
||||||
|
FROM umami.website_event
|
||||||
|
GROUP BY website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
subdivision1,
|
||||||
|
city,
|
||||||
|
event_type,
|
||||||
|
timestamp);
|
||||||
122
db/clickhouse/migrations/06_update_subdivision.sql
Normal file
122
db/clickhouse/migrations/06_update_subdivision.sql
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
-- drop projections
|
||||||
|
ALTER TABLE umami.website_event DROP PROJECTION website_event_url_path_projection;
|
||||||
|
ALTER TABLE umami.website_event DROP PROJECTION website_event_referrer_domain_projection;
|
||||||
|
|
||||||
|
--drop view
|
||||||
|
DROP TABLE umami.website_event_stats_hourly_mv;
|
||||||
|
|
||||||
|
-- rename columns
|
||||||
|
ALTER TABLE umami.website_event RENAME COLUMN "subdivision1" TO "region";
|
||||||
|
ALTER TABLE umami.website_event_stats_hourly RENAME COLUMN "subdivision1" TO "region";
|
||||||
|
|
||||||
|
-- drop columns
|
||||||
|
ALTER TABLE umami.website_event DROP COLUMN "subdivision2";
|
||||||
|
|
||||||
|
-- recreate projections
|
||||||
|
ALTER TABLE umami.website_event
|
||||||
|
ADD PROJECTION website_event_url_path_projection (
|
||||||
|
SELECT * ORDER BY toStartOfDay(created_at), website_id, url_path, created_at
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE umami.website_event MATERIALIZE PROJECTION website_event_url_path_projection;
|
||||||
|
|
||||||
|
ALTER TABLE umami.website_event
|
||||||
|
ADD PROJECTION website_event_referrer_domain_projection (
|
||||||
|
SELECT * ORDER BY toStartOfDay(created_at), website_id, referrer_domain, created_at
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE umami.website_event MATERIALIZE PROJECTION website_event_referrer_domain_projection;
|
||||||
|
|
||||||
|
-- recreate view
|
||||||
|
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv
|
||||||
|
TO umami.website_event_stats_hourly
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
entry_url,
|
||||||
|
exit_url,
|
||||||
|
url_paths as url_path,
|
||||||
|
url_query,
|
||||||
|
utm_source,
|
||||||
|
utm_medium,
|
||||||
|
utm_campaign,
|
||||||
|
utm_content,
|
||||||
|
utm_term,
|
||||||
|
referrer_domain,
|
||||||
|
page_title,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
li_fat_id,
|
||||||
|
twclid,
|
||||||
|
event_type,
|
||||||
|
event_name,
|
||||||
|
views,
|
||||||
|
min_time,
|
||||||
|
max_time,
|
||||||
|
tag,
|
||||||
|
timestamp as created_at
|
||||||
|
FROM (SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
argMinState(url_path, created_at) entry_url,
|
||||||
|
argMaxState(url_path, created_at) exit_url,
|
||||||
|
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
|
||||||
|
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
|
||||||
|
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
|
||||||
|
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
|
||||||
|
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
|
||||||
|
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
|
||||||
|
event_type,
|
||||||
|
if(event_type = 2, groupArray(event_name), []) event_name,
|
||||||
|
sumIf(1, event_type = 1) views,
|
||||||
|
min(created_at) min_time,
|
||||||
|
max(created_at) max_time,
|
||||||
|
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
||||||
|
toStartOfHour(created_at) timestamp
|
||||||
|
FROM umami.website_event
|
||||||
|
GROUP BY website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
event_type,
|
||||||
|
timestamp);
|
||||||
103
db/clickhouse/migrations/07_add_distinct_id.sql
Normal file
103
db/clickhouse/migrations/07_add_distinct_id.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
-- add tag column
|
||||||
|
ALTER TABLE umami.website_event ADD COLUMN "distinct_id" String AFTER "tag";
|
||||||
|
ALTER TABLE umami.website_event_stats_hourly ADD COLUMN "distinct_id" String AFTER "tag";
|
||||||
|
ALTER TABLE umami.session_data ADD COLUMN "distinct_id" String AFTER "data_type";
|
||||||
|
|
||||||
|
-- update materialized view
|
||||||
|
DROP TABLE umami.website_event_stats_hourly_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv
|
||||||
|
TO umami.website_event_stats_hourly
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
entry_url,
|
||||||
|
exit_url,
|
||||||
|
url_paths as url_path,
|
||||||
|
url_query,
|
||||||
|
utm_source,
|
||||||
|
utm_medium,
|
||||||
|
utm_campaign,
|
||||||
|
utm_content,
|
||||||
|
utm_term,
|
||||||
|
referrer_domain,
|
||||||
|
page_title,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
li_fat_id,
|
||||||
|
twclid,
|
||||||
|
event_type,
|
||||||
|
event_name,
|
||||||
|
views,
|
||||||
|
min_time,
|
||||||
|
max_time,
|
||||||
|
tag,
|
||||||
|
distinct_id,
|
||||||
|
timestamp as created_at
|
||||||
|
FROM (SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
argMinState(url_path, created_at) entry_url,
|
||||||
|
argMaxState(url_path, created_at) exit_url,
|
||||||
|
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
|
||||||
|
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
|
||||||
|
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
|
||||||
|
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
|
||||||
|
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
|
||||||
|
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
|
||||||
|
event_type,
|
||||||
|
if(event_type = 2, groupArray(event_name), []) event_name,
|
||||||
|
sumIf(1, event_type = 1) views,
|
||||||
|
min(created_at) min_time,
|
||||||
|
max(created_at) max_time,
|
||||||
|
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
||||||
|
distinct_id,
|
||||||
|
toStartOfHour(created_at) timestamp
|
||||||
|
FROM umami.website_event
|
||||||
|
GROUP BY website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
event_type,
|
||||||
|
distinct_id,
|
||||||
|
timestamp);
|
||||||
|
|
@ -13,20 +13,32 @@ CREATE TABLE umami.website_event
|
||||||
screen LowCardinality(String),
|
screen LowCardinality(String),
|
||||||
language LowCardinality(String),
|
language LowCardinality(String),
|
||||||
country LowCardinality(String),
|
country LowCardinality(String),
|
||||||
subdivision1 LowCardinality(String),
|
region LowCardinality(String),
|
||||||
subdivision2 LowCardinality(String),
|
|
||||||
city String,
|
city String,
|
||||||
--pageviews
|
--pageviews
|
||||||
url_path String,
|
url_path String,
|
||||||
url_query String,
|
url_query String,
|
||||||
|
utm_source String,
|
||||||
|
utm_medium String,
|
||||||
|
utm_campaign String,
|
||||||
|
utm_content String,
|
||||||
|
utm_term String,
|
||||||
referrer_path String,
|
referrer_path String,
|
||||||
referrer_query String,
|
referrer_query String,
|
||||||
referrer_domain String,
|
referrer_domain String,
|
||||||
page_title String,
|
page_title String,
|
||||||
|
--clickIDs
|
||||||
|
gclid String,
|
||||||
|
fbclid String,
|
||||||
|
msclkid String,
|
||||||
|
ttclid String,
|
||||||
|
li_fat_id String,
|
||||||
|
twclid String,
|
||||||
--events
|
--events
|
||||||
event_type UInt32,
|
event_type UInt32,
|
||||||
event_name String,
|
event_name String,
|
||||||
tag String,
|
tag String,
|
||||||
|
distinct_id String,
|
||||||
created_at DateTime('UTC'),
|
created_at DateTime('UTC'),
|
||||||
job_id Nullable(UUID)
|
job_id Nullable(UUID)
|
||||||
)
|
)
|
||||||
|
|
@ -64,6 +76,7 @@ CREATE TABLE umami.session_data
|
||||||
number_value Nullable(Decimal64(4)),
|
number_value Nullable(Decimal64(4)),
|
||||||
date_value Nullable(DateTime('UTC')),
|
date_value Nullable(DateTime('UTC')),
|
||||||
data_type UInt32,
|
data_type UInt32,
|
||||||
|
distinct_id String,
|
||||||
created_at DateTime('UTC'),
|
created_at DateTime('UTC'),
|
||||||
job_id Nullable(UUID)
|
job_id Nullable(UUID)
|
||||||
)
|
)
|
||||||
|
|
@ -84,20 +97,32 @@ CREATE TABLE umami.website_event_stats_hourly
|
||||||
screen LowCardinality(String),
|
screen LowCardinality(String),
|
||||||
language LowCardinality(String),
|
language LowCardinality(String),
|
||||||
country LowCardinality(String),
|
country LowCardinality(String),
|
||||||
subdivision1 LowCardinality(String),
|
region LowCardinality(String),
|
||||||
city String,
|
city String,
|
||||||
entry_url AggregateFunction(argMin, String, DateTime('UTC')),
|
entry_url AggregateFunction(argMin, String, DateTime('UTC')),
|
||||||
exit_url AggregateFunction(argMax, String, DateTime('UTC')),
|
exit_url AggregateFunction(argMax, String, DateTime('UTC')),
|
||||||
url_path SimpleAggregateFunction(groupArrayArray, Array(String)),
|
url_path SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
url_query SimpleAggregateFunction(groupArrayArray, Array(String)),
|
url_query SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_source SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_medium SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_campaign SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_content SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_term SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)),
|
referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
page_title SimpleAggregateFunction(groupArrayArray, Array(String)),
|
page_title SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
gclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
fbclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
msclkid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
ttclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
li_fat_id SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
twclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
event_type UInt32,
|
event_type UInt32,
|
||||||
event_name SimpleAggregateFunction(groupArrayArray, Array(String)),
|
event_name SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
views SimpleAggregateFunction(sum, UInt64),
|
views SimpleAggregateFunction(sum, UInt64),
|
||||||
min_time SimpleAggregateFunction(min, DateTime('UTC')),
|
min_time SimpleAggregateFunction(min, DateTime('UTC')),
|
||||||
max_time SimpleAggregateFunction(max, DateTime('UTC')),
|
max_time SimpleAggregateFunction(max, DateTime('UTC')),
|
||||||
tag SimpleAggregateFunction(groupArrayArray, Array(String)),
|
tag SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
distinct_id,
|
||||||
created_at Datetime('UTC')
|
created_at Datetime('UTC')
|
||||||
)
|
)
|
||||||
ENGINE = AggregatingMergeTree
|
ENGINE = AggregatingMergeTree
|
||||||
|
|
@ -125,20 +150,32 @@ SELECT
|
||||||
screen,
|
screen,
|
||||||
language,
|
language,
|
||||||
country,
|
country,
|
||||||
subdivision1,
|
region,
|
||||||
city,
|
city,
|
||||||
entry_url,
|
entry_url,
|
||||||
exit_url,
|
exit_url,
|
||||||
url_paths as url_path,
|
url_paths as url_path,
|
||||||
url_query,
|
url_query,
|
||||||
|
utm_source,
|
||||||
|
utm_medium,
|
||||||
|
utm_campaign,
|
||||||
|
utm_content,
|
||||||
|
utm_term,
|
||||||
referrer_domain,
|
referrer_domain,
|
||||||
page_title,
|
page_title,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
li_fat_id,
|
||||||
|
twclid,
|
||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
views,
|
views,
|
||||||
min_time,
|
min_time,
|
||||||
max_time,
|
max_time,
|
||||||
tag,
|
tag,
|
||||||
|
distinct_id,
|
||||||
timestamp as created_at
|
timestamp as created_at
|
||||||
FROM (SELECT
|
FROM (SELECT
|
||||||
website_id,
|
website_id,
|
||||||
|
|
@ -151,20 +188,32 @@ FROM (SELECT
|
||||||
screen,
|
screen,
|
||||||
language,
|
language,
|
||||||
country,
|
country,
|
||||||
subdivision1,
|
region,
|
||||||
city,
|
city,
|
||||||
argMinState(url_path, created_at) entry_url,
|
argMinState(url_path, created_at) entry_url,
|
||||||
argMaxState(url_path, created_at) exit_url,
|
argMaxState(url_path, created_at) exit_url,
|
||||||
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
|
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
|
||||||
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
|
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
|
||||||
|
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
|
||||||
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
|
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
|
||||||
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
|
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
|
||||||
|
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
|
||||||
|
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
|
||||||
|
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
|
||||||
event_type,
|
event_type,
|
||||||
if(event_type = 2, groupArray(event_name), []) event_name,
|
if(event_type = 2, groupArray(event_name), []) event_name,
|
||||||
sumIf(1, event_type = 1) views,
|
sumIf(1, event_type = 1) views,
|
||||||
min(created_at) min_time,
|
min(created_at) min_time,
|
||||||
max(created_at) max_time,
|
max(created_at) max_time,
|
||||||
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
||||||
|
distinct_id String,
|
||||||
toStartOfHour(created_at) timestamp
|
toStartOfHour(created_at) timestamp
|
||||||
FROM umami.website_event
|
FROM umami.website_event
|
||||||
GROUP BY website_id,
|
GROUP BY website_id,
|
||||||
|
|
@ -177,9 +226,10 @@ GROUP BY website_id,
|
||||||
screen,
|
screen,
|
||||||
language,
|
language,
|
||||||
country,
|
country,
|
||||||
subdivision1,
|
region,
|
||||||
city,
|
city,
|
||||||
event_type,
|
event_type,
|
||||||
|
distinct_id,
|
||||||
timestamp);
|
timestamp);
|
||||||
|
|
||||||
-- projections
|
-- projections
|
||||||
|
|
|
||||||
13
db/mysql/migrations/08_add_utm_clid/migration.sql
Normal file
13
db/mysql/migrations/08_add_utm_clid/migration.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `website_event`
|
||||||
|
ADD COLUMN `fbclid` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `gclid` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `li_fat_id` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `msclkid` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `ttclid` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `twclid` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `utm_campaign` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `utm_content` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `utm_medium` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `utm_source` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `utm_term` VARCHAR(255) NULL;
|
||||||
22
db/mysql/migrations/09_update_hostname_region/migration.sql
Normal file
22
db/mysql/migrations/09_update_hostname_region/migration.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `website_event` ADD COLUMN `hostname` VARCHAR(100) NULL;
|
||||||
|
|
||||||
|
-- DataMigration
|
||||||
|
UPDATE `website_event` w
|
||||||
|
JOIN `session` s
|
||||||
|
ON s.website_id = w.website_id
|
||||||
|
and s.session_id = w.session_id
|
||||||
|
SET w.hostname = s.hostname;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX `session_website_id_created_at_hostname_idx` ON `session`;
|
||||||
|
DROP INDEX `session_website_id_created_at_subdivision1_idx` ON `session`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `session` RENAME COLUMN `subdivision1` TO `region`;
|
||||||
|
ALTER TABLE `session` DROP COLUMN `subdivision2`;
|
||||||
|
ALTER TABLE `session` DROP COLUMN `hostname`;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `website_event_website_id_created_at_hostname_idx` ON `website_event`(`website_id`, `created_at`, `hostname`);
|
||||||
|
CREATE INDEX `session_website_id_created_at_region_idx` ON `session`(`website_id`, `created_at`, `region`);
|
||||||
5
db/mysql/migrations/10_add_distinct_id/migration.sql
Normal file
5
db/mysql/migrations/10_add_distinct_id/migration.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `session` ADD COLUMN `distinct_id` VARCHAR(50) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `session_data` ADD COLUMN `distinct_id` VARCHAR(50) NULL;
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "mysql"
|
provider = "mysql"
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|
@ -29,19 +28,18 @@ model User {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @unique @map("session_id") @db.VarChar(36)
|
id String @id @unique @map("session_id") @db.VarChar(36)
|
||||||
websiteId String @map("website_id") @db.VarChar(36)
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
hostname String? @db.VarChar(100)
|
browser String? @db.VarChar(20)
|
||||||
browser String? @db.VarChar(20)
|
os String? @db.VarChar(20)
|
||||||
os String? @db.VarChar(20)
|
device String? @db.VarChar(20)
|
||||||
device String? @db.VarChar(20)
|
screen String? @db.VarChar(11)
|
||||||
screen String? @db.VarChar(11)
|
language String? @db.VarChar(35)
|
||||||
language String? @db.VarChar(35)
|
country String? @db.Char(2)
|
||||||
country String? @db.Char(2)
|
region String? @db.Char(20)
|
||||||
subdivision1 String? @db.Char(20)
|
city String? @db.VarChar(50)
|
||||||
subdivision2 String? @db.VarChar(50)
|
distinctId String? @map("distinct_id") @db.VarChar(50)
|
||||||
city String? @db.VarChar(50)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
|
||||||
|
|
||||||
websiteEvent WebsiteEvent[]
|
websiteEvent WebsiteEvent[]
|
||||||
sessionData SessionData[]
|
sessionData SessionData[]
|
||||||
|
|
@ -49,14 +47,13 @@ model Session {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
@@index([websiteId, createdAt])
|
@@index([websiteId, createdAt])
|
||||||
@@index([websiteId, createdAt, hostname])
|
|
||||||
@@index([websiteId, createdAt, browser])
|
@@index([websiteId, createdAt, browser])
|
||||||
@@index([websiteId, createdAt, os])
|
@@index([websiteId, createdAt, os])
|
||||||
@@index([websiteId, createdAt, device])
|
@@index([websiteId, createdAt, device])
|
||||||
@@index([websiteId, createdAt, screen])
|
@@index([websiteId, createdAt, screen])
|
||||||
@@index([websiteId, createdAt, language])
|
@@index([websiteId, createdAt, language])
|
||||||
@@index([websiteId, createdAt, country])
|
@@index([websiteId, createdAt, country])
|
||||||
@@index([websiteId, createdAt, subdivision1])
|
@@index([websiteId, createdAt, region])
|
||||||
@@index([websiteId, createdAt, city])
|
@@index([websiteId, createdAt, city])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
|
|
@ -97,13 +94,25 @@ model WebsiteEvent {
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
urlPath String @map("url_path") @db.VarChar(500)
|
urlPath String @map("url_path") @db.VarChar(500)
|
||||||
urlQuery String? @map("url_query") @db.VarChar(500)
|
urlQuery String? @map("url_query") @db.VarChar(500)
|
||||||
|
utmSource String? @map("utm_source") @db.VarChar(255)
|
||||||
|
utmMedium String? @map("utm_medium") @db.VarChar(255)
|
||||||
|
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
|
||||||
|
utmContent String? @map("utm_content") @db.VarChar(255)
|
||||||
|
utmTerm String? @map("utm_term") @db.VarChar(255)
|
||||||
referrerPath String? @map("referrer_path") @db.VarChar(500)
|
referrerPath String? @map("referrer_path") @db.VarChar(500)
|
||||||
referrerQuery String? @map("referrer_query") @db.VarChar(500)
|
referrerQuery String? @map("referrer_query") @db.VarChar(500)
|
||||||
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
|
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
|
||||||
pageTitle String? @map("page_title") @db.VarChar(500)
|
pageTitle String? @map("page_title") @db.VarChar(500)
|
||||||
|
gclid String? @map("gclid") @db.VarChar(255)
|
||||||
|
fbclid String? @map("fbclid") @db.VarChar(255)
|
||||||
|
msclkid String? @map("msclkid") @db.VarChar(255)
|
||||||
|
ttclid String? @map("ttclid") @db.VarChar(255)
|
||||||
|
lifatid String? @map("li_fat_id") @db.VarChar(255)
|
||||||
|
twclid String? @map("twclid") @db.VarChar(255)
|
||||||
eventType Int @default(1) @map("event_type") @db.UnsignedInt
|
eventType Int @default(1) @map("event_type") @db.UnsignedInt
|
||||||
eventName String? @map("event_name") @db.VarChar(50)
|
eventName String? @map("event_name") @db.VarChar(50)
|
||||||
tag String? @db.VarChar(50)
|
tag String? @db.VarChar(50)
|
||||||
|
hostname String? @db.VarChar(100)
|
||||||
|
|
||||||
eventData EventData[]
|
eventData EventData[]
|
||||||
session Session @relation(fields: [sessionId], references: [id])
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
|
@ -121,6 +130,7 @@ model WebsiteEvent {
|
||||||
@@index([websiteId, createdAt, tag])
|
@@index([websiteId, createdAt, tag])
|
||||||
@@index([websiteId, sessionId, createdAt])
|
@@index([websiteId, sessionId, createdAt])
|
||||||
@@index([websiteId, visitId, createdAt])
|
@@index([websiteId, visitId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, hostname])
|
||||||
@@map("website_event")
|
@@map("website_event")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,6 +165,7 @@ model SessionData {
|
||||||
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
|
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
|
||||||
dateValue DateTime? @map("date_value") @db.Timestamp(0)
|
dateValue DateTime? @map("date_value") @db.Timestamp(0)
|
||||||
dataType Int @map("data_type") @db.UnsignedInt
|
dataType Int @map("data_type") @db.UnsignedInt
|
||||||
|
distinctId String? @map("distinct_id") @db.VarChar(50)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
|
||||||
website Website @relation(fields: [websiteId], references: [id])
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
|
|
||||||
13
db/postgresql/migrations/08_add_utm_clid/migration.sql
Normal file
13
db/postgresql/migrations/08_add_utm_clid/migration.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "website_event"
|
||||||
|
ADD COLUMN "fbclid" VARCHAR(255),
|
||||||
|
ADD COLUMN "gclid" VARCHAR(255),
|
||||||
|
ADD COLUMN "li_fat_id" VARCHAR(255),
|
||||||
|
ADD COLUMN "msclkid" VARCHAR(255),
|
||||||
|
ADD COLUMN "ttclid" VARCHAR(255),
|
||||||
|
ADD COLUMN "twclid" VARCHAR(255),
|
||||||
|
ADD COLUMN "utm_campaign" VARCHAR(255),
|
||||||
|
ADD COLUMN "utm_content" VARCHAR(255),
|
||||||
|
ADD COLUMN "utm_medium" VARCHAR(255),
|
||||||
|
ADD COLUMN "utm_source" VARCHAR(255),
|
||||||
|
ADD COLUMN "utm_term" VARCHAR(255);
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "website_event" ADD COLUMN "hostname" VARCHAR(100);
|
||||||
|
|
||||||
|
-- DataMigration
|
||||||
|
UPDATE "website_event" w
|
||||||
|
SET hostname = s.hostname
|
||||||
|
FROM "session" s
|
||||||
|
WHERE s.website_id = w.website_id
|
||||||
|
and s.session_id = w.session_id;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "session_website_id_created_at_hostname_idx";
|
||||||
|
DROP INDEX IF EXISTS "session_website_id_created_at_subdivision1_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "session" RENAME COLUMN "subdivision1" TO "region";
|
||||||
|
ALTER TABLE "session" DROP COLUMN "subdivision2";
|
||||||
|
ALTER TABLE "session" DROP COLUMN "hostname";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "website_event_website_id_created_at_hostname_idx" ON "website_event"("website_id", "created_at", "hostname");
|
||||||
|
CREATE INDEX "session_website_id_created_at_region_idx" ON "session"("website_id", "created_at", "region");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "session" ADD COLUMN "distinct_id" VARCHAR(50);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "session_data" ADD COLUMN "distinct_id" VARCHAR(50);
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|
@ -29,19 +28,18 @@ model User {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @unique @map("session_id") @db.Uuid
|
id String @id @unique @map("session_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
hostname String? @db.VarChar(100)
|
browser String? @db.VarChar(20)
|
||||||
browser String? @db.VarChar(20)
|
os String? @db.VarChar(20)
|
||||||
os String? @db.VarChar(20)
|
device String? @db.VarChar(20)
|
||||||
device String? @db.VarChar(20)
|
screen String? @db.VarChar(11)
|
||||||
screen String? @db.VarChar(11)
|
language String? @db.VarChar(35)
|
||||||
language String? @db.VarChar(35)
|
country String? @db.Char(2)
|
||||||
country String? @db.Char(2)
|
region String? @db.VarChar(20)
|
||||||
subdivision1 String? @db.VarChar(20)
|
city String? @db.VarChar(50)
|
||||||
subdivision2 String? @db.VarChar(50)
|
distinctId String? @map("distinct_id") @db.VarChar(50)
|
||||||
city String? @db.VarChar(50)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
|
|
||||||
websiteEvent WebsiteEvent[]
|
websiteEvent WebsiteEvent[]
|
||||||
sessionData SessionData[]
|
sessionData SessionData[]
|
||||||
|
|
@ -49,14 +47,13 @@ model Session {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
@@index([websiteId, createdAt])
|
@@index([websiteId, createdAt])
|
||||||
@@index([websiteId, createdAt, hostname])
|
|
||||||
@@index([websiteId, createdAt, browser])
|
@@index([websiteId, createdAt, browser])
|
||||||
@@index([websiteId, createdAt, os])
|
@@index([websiteId, createdAt, os])
|
||||||
@@index([websiteId, createdAt, device])
|
@@index([websiteId, createdAt, device])
|
||||||
@@index([websiteId, createdAt, screen])
|
@@index([websiteId, createdAt, screen])
|
||||||
@@index([websiteId, createdAt, language])
|
@@index([websiteId, createdAt, language])
|
||||||
@@index([websiteId, createdAt, country])
|
@@index([websiteId, createdAt, country])
|
||||||
@@index([websiteId, createdAt, subdivision1])
|
@@index([websiteId, createdAt, region])
|
||||||
@@index([websiteId, createdAt, city])
|
@@index([websiteId, createdAt, city])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
|
|
@ -97,13 +94,25 @@ model WebsiteEvent {
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
urlPath String @map("url_path") @db.VarChar(500)
|
urlPath String @map("url_path") @db.VarChar(500)
|
||||||
urlQuery String? @map("url_query") @db.VarChar(500)
|
urlQuery String? @map("url_query") @db.VarChar(500)
|
||||||
|
utmSource String? @map("utm_source") @db.VarChar(255)
|
||||||
|
utmMedium String? @map("utm_medium") @db.VarChar(255)
|
||||||
|
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
|
||||||
|
utmContent String? @map("utm_content") @db.VarChar(255)
|
||||||
|
utmTerm String? @map("utm_term") @db.VarChar(255)
|
||||||
referrerPath String? @map("referrer_path") @db.VarChar(500)
|
referrerPath String? @map("referrer_path") @db.VarChar(500)
|
||||||
referrerQuery String? @map("referrer_query") @db.VarChar(500)
|
referrerQuery String? @map("referrer_query") @db.VarChar(500)
|
||||||
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
|
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
|
||||||
pageTitle String? @map("page_title") @db.VarChar(500)
|
pageTitle String? @map("page_title") @db.VarChar(500)
|
||||||
|
gclid String? @map("gclid") @db.VarChar(255)
|
||||||
|
fbclid String? @map("fbclid") @db.VarChar(255)
|
||||||
|
msclkid String? @map("msclkid") @db.VarChar(255)
|
||||||
|
ttclid String? @map("ttclid") @db.VarChar(255)
|
||||||
|
lifatid String? @map("li_fat_id") @db.VarChar(255)
|
||||||
|
twclid String? @map("twclid") @db.VarChar(255)
|
||||||
eventType Int @default(1) @map("event_type") @db.Integer
|
eventType Int @default(1) @map("event_type") @db.Integer
|
||||||
eventName String? @map("event_name") @db.VarChar(50)
|
eventName String? @map("event_name") @db.VarChar(50)
|
||||||
tag String? @db.VarChar(50)
|
tag String? @db.VarChar(50)
|
||||||
|
hostname String? @db.VarChar(100)
|
||||||
|
|
||||||
eventData EventData[]
|
eventData EventData[]
|
||||||
session Session @relation(fields: [sessionId], references: [id])
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
|
@ -121,6 +130,7 @@ model WebsiteEvent {
|
||||||
@@index([websiteId, createdAt, tag])
|
@@index([websiteId, createdAt, tag])
|
||||||
@@index([websiteId, sessionId, createdAt])
|
@@index([websiteId, sessionId, createdAt])
|
||||||
@@index([websiteId, visitId, createdAt])
|
@@index([websiteId, visitId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, hostname])
|
||||||
@@map("website_event")
|
@@map("website_event")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,6 +165,7 @@ model SessionData {
|
||||||
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
|
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
|
||||||
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
|
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
|
||||||
dataType Int @map("data_type") @db.Integer
|
dataType Int @map("data_type") @db.Integer
|
||||||
|
distinctId String? @map("distinct_id") @db.VarChar(50)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
website Website @relation(fields: [websiteId], references: [id])
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: '/:path*',
|
|
||||||
};
|
|
||||||
|
|
||||||
function customCollectEndpoint(req) {
|
|
||||||
const collectEndpoint = process.env.COLLECT_API_ENDPOINT;
|
|
||||||
|
|
||||||
if (collectEndpoint) {
|
|
||||||
const url = req.nextUrl.clone();
|
|
||||||
const { pathname } = url;
|
|
||||||
|
|
||||||
if (pathname.endsWith(collectEndpoint)) {
|
|
||||||
url.pathname = '/api/send';
|
|
||||||
return NextResponse.rewrite(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function customScriptName(req) {
|
|
||||||
const scriptName = process.env.TRACKER_SCRIPT_NAME;
|
|
||||||
|
|
||||||
if (scriptName) {
|
|
||||||
const url = req.nextUrl.clone();
|
|
||||||
const { pathname } = url;
|
|
||||||
const names = scriptName.split(',').map(name => name.trim().replace(/^\/+/, ''));
|
|
||||||
|
|
||||||
if (names.find(name => pathname.endsWith(name))) {
|
|
||||||
url.pathname = '/script.js';
|
|
||||||
return NextResponse.rewrite(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function middleware(req) {
|
|
||||||
const fns = [customCollectEndpoint, customScriptName];
|
|
||||||
|
|
||||||
for (const fn of fns) {
|
|
||||||
const res = fn(req);
|
|
||||||
if (res) {
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
@ -4,4 +4,7 @@ export default {
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(ts|tsx)$': 'ts-jest',
|
'^.+\\.(ts|tsx)$': 'ts-jest',
|
||||||
},
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -2,4 +2,4 @@
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
import 'dotenv/config';
|
||||||
require('dotenv').config();
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
const pkg = require('./package.json');
|
const pkg = require('./package.json');
|
||||||
|
|
||||||
const TRACKER_SCRIPT = '/script.js';
|
const TRACKER_SCRIPT = '/script.js';
|
||||||
|
|
@ -12,6 +14,7 @@ const corsMaxAge = process.env.CORS_MAX_AGE;
|
||||||
const defaultLocale = process.env.DEFAULT_LOCALE;
|
const defaultLocale = process.env.DEFAULT_LOCALE;
|
||||||
const disableLogin = process.env.DISABLE_LOGIN;
|
const disableLogin = process.env.DISABLE_LOGIN;
|
||||||
const disableUI = process.env.DISABLE_UI;
|
const disableUI = process.env.DISABLE_UI;
|
||||||
|
const faviconURL = process.env.FAVICON_URL;
|
||||||
const forceSSL = process.env.FORCE_SSL;
|
const forceSSL = process.env.FORCE_SSL;
|
||||||
const frameAncestors = process.env.ALLOWED_FRAME_URLS;
|
const frameAncestors = process.env.ALLOWED_FRAME_URLS;
|
||||||
const privateMode = process.env.PRIVATE_MODE;
|
const privateMode = process.env.PRIVATE_MODE;
|
||||||
|
|
@ -62,26 +65,30 @@ const trackerHeaders = [
|
||||||
const apiHeaders = [
|
const apiHeaders = [
|
||||||
{
|
{
|
||||||
key: 'Access-Control-Allow-Origin',
|
key: 'Access-Control-Allow-Origin',
|
||||||
value: '*'
|
value: '*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Access-Control-Allow-Headers',
|
key: 'Access-Control-Allow-Headers',
|
||||||
value: '*'
|
value: '*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Access-Control-Allow-Methods',
|
key: 'Access-Control-Allow-Methods',
|
||||||
value: 'GET, DELETE, POST, PUT'
|
value: 'GET, DELETE, POST, PUT',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Access-Control-Max-Age',
|
key: 'Access-Control-Max-Age',
|
||||||
value: corsMaxAge || '86400'
|
value: corsMaxAge || '86400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'no-cache',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
headers: apiHeaders
|
headers: apiHeaders,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
source: '/:path*',
|
||||||
|
|
@ -176,17 +183,17 @@ if (cloudMode && cloudUrl) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
export default {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
env: {
|
env: {
|
||||||
basePath,
|
basePath,
|
||||||
cloudMode,
|
cloudMode,
|
||||||
cloudUrl,
|
cloudUrl,
|
||||||
configUrl: '/config',
|
|
||||||
currentVersion: pkg.version,
|
currentVersion: pkg.version,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
disableLogin,
|
disableLogin,
|
||||||
disableUI,
|
disableUI,
|
||||||
|
faviconURL,
|
||||||
privateMode,
|
privateMode,
|
||||||
},
|
},
|
||||||
basePath,
|
basePath,
|
||||||
|
|
@ -197,13 +204,11 @@ const config = {
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
experimental: {
|
turbopack: {
|
||||||
turbo: {
|
rules: {
|
||||||
rules: {
|
'*.svg': {
|
||||||
'*.svg': {
|
loaders: ['@svgr/webpack'],
|
||||||
loaders: ['@svgr/webpack'],
|
as: '*.js',
|
||||||
as: '*.js',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -235,5 +240,3 @@ const config = {
|
||||||
return [...redirects];
|
return [...redirects];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
|
||||||
29
package.json
29
package.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "umami",
|
"name": "umami",
|
||||||
"version": "2.17.0",
|
"version": "2.18.0",
|
||||||
"description": "A simple, fast, 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",
|
||||||
"homepage": "https://umami.is",
|
"homepage": "https://umami.is",
|
||||||
|
|
@ -10,11 +10,12 @@
|
||||||
"url": "https://github.com/umami-software/umami.git"
|
"url": "https://github.com/umami-software/umami.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000 --turbo",
|
"dev": "next dev",
|
||||||
|
"dev-turbo": "next dev -p 3001 --turbopack",
|
||||||
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
|
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
|
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
|
||||||
"start-docker": "npm-run-all check-db update-tracker start-server",
|
"start-docker": "npm-run-all check-db update-tracker set-routes-manifest start-server",
|
||||||
"start-env": "node scripts/start-env.js",
|
"start-env": "node scripts/start-env.js",
|
||||||
"start-server": "node server.js",
|
"start-server": "node server.js",
|
||||||
"build-app": "next build",
|
"build-app": "next build",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"build-geo": "node scripts/build-geo.js",
|
"build-geo": "node scripts/build-geo.js",
|
||||||
"build-db-schema": "prisma db pull",
|
"build-db-schema": "prisma db pull",
|
||||||
"build-db-client": "prisma generate",
|
"build-db-client": "prisma generate",
|
||||||
|
"set-routes-manifest": "node scripts/set-routes-manifest.js",
|
||||||
"update-tracker": "node scripts/update-tracker.js",
|
"update-tracker": "node scripts/update-tracker.js",
|
||||||
"update-db": "prisma migrate deploy",
|
"update-db": "prisma migrate deploy",
|
||||||
"check-db": "node scripts/check-db.js",
|
"check-db": "node scripts/check-db.js",
|
||||||
|
|
@ -70,15 +72,14 @@
|
||||||
"@dicebear/core": "^9.2.1",
|
"@dicebear/core": "^9.2.1",
|
||||||
"@fontsource/inter": "^4.5.15",
|
"@fontsource/inter": "^4.5.15",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
"@prisma/client": "6.1.0",
|
"@prisma/client": "6.7.0",
|
||||||
"@prisma/extension-read-replicas": "^0.4.0",
|
"@prisma/extension-read-replicas": "^0.4.1",
|
||||||
"@react-spring/web": "^9.7.3",
|
"@react-spring/web": "^9.7.3",
|
||||||
"@tanstack/react-query": "^5.28.6",
|
"@tanstack/react-query": "^5.28.6",
|
||||||
"@umami/prisma-client": "^0.14.0",
|
|
||||||
"@umami/redis-client": "^0.26.0",
|
"@umami/redis-client": "^0.26.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
"chart.js": "^4.4.2",
|
"chart.js": "^4.4.9",
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"colord": "^2.9.2",
|
"colord": "^2.9.2",
|
||||||
|
|
@ -102,10 +103,10 @@
|
||||||
"kafkajs": "^2.1.0",
|
"kafkajs": "^2.1.0",
|
||||||
"maxmind": "^4.3.24",
|
"maxmind": "^4.3.24",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"next": "15.0.4",
|
"next": "15.3.1",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prisma": "6.1.0",
|
"prisma": "6.7.0",
|
||||||
"pure-rand": "^6.1.0",
|
"pure-rand": "^6.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-basics": "^0.126.0",
|
"react-basics": "^0.126.0",
|
||||||
|
|
@ -120,25 +121,24 @@
|
||||||
"serialize-error": "^12.0.0",
|
"serialize-error": "^12.0.0",
|
||||||
"thenby": "^1.3.4",
|
"thenby": "^1.3.4",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.3",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formatjs/cli": "^4.2.29",
|
"@formatjs/cli": "^4.2.29",
|
||||||
"@netlify/plugin-nextjs": "^5.8.1",
|
"@netlify/plugin-nextjs": "^5.10.6",
|
||||||
"@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",
|
||||||
"@rollup/plugin-node-resolve": "^15.2.0",
|
"@rollup/plugin-node-resolve": "^15.2.0",
|
||||||
"@rollup/plugin-replace": "^5.0.2",
|
"@rollup/plugin-replace": "^5.0.2",
|
||||||
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@svgr/rollup": "^8.1.0",
|
"@svgr/rollup": "^8.1.0",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@types/cypress": "^1.1.3",
|
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@types/react-intl": "^3.0.0",
|
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||||
"@typescript-eslint/parser": "^6.7.3",
|
"@typescript-eslint/parser": "^6.7.3",
|
||||||
|
|
@ -172,7 +172,6 @@
|
||||||
"rollup-plugin-esbuild": "^5.0.0",
|
"rollup-plugin-esbuild": "^5.0.0",
|
||||||
"rollup-plugin-node-externals": "^6.1.1",
|
"rollup-plugin-node-externals": "^6.1.1",
|
||||||
"rollup-plugin-postcss": "^4.0.2",
|
"rollup-plugin-postcss": "^4.0.2",
|
||||||
"rollup-plugin-terser": "^7.0.2",
|
|
||||||
"stylelint": "^15.10.1",
|
"stylelint": "^15.10.1",
|
||||||
"stylelint-config-css-modules": "^4.4.0",
|
"stylelint-config-css-modules": "^4.4.0",
|
||||||
"stylelint-config-prettier": "^9.0.3",
|
"stylelint-config-prettier": "^9.0.3",
|
||||||
|
|
|
||||||
14325
pnpm-lock.yaml
generated
Normal file
14325
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
10
pnpm-workspace.yaml
Normal file
10
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
packages:
|
||||||
|
- '**'
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- cypress
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@prisma/client'
|
||||||
|
- '@prisma/engines'
|
||||||
|
- prisma
|
||||||
50
podman/README.md
Normal file
50
podman/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# How to deploy umami on podman
|
||||||
|
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
1. Rename `env.sample` to `.env`
|
||||||
|
2. Edit `.env` file. At the minimum set the passwords.
|
||||||
|
3. Start umami by running `podman-compose up -d`.
|
||||||
|
|
||||||
|
If you need to stop umami, you can do so by running `podman-compose down`.
|
||||||
|
|
||||||
|
|
||||||
|
### Install systemd service (optional)
|
||||||
|
|
||||||
|
If you want to install a systemd service to run umami, you can use the provided
|
||||||
|
systemd service.
|
||||||
|
|
||||||
|
Edit `umami.service` and change these two variables:
|
||||||
|
|
||||||
|
|
||||||
|
WorkingDirectory=/opt/apps/umami
|
||||||
|
EnvironmentFile=/opt/apps/umami/.env
|
||||||
|
|
||||||
|
`WorkingDirectory` should be changed to the path in which `podman-compose.yml`
|
||||||
|
is located.
|
||||||
|
|
||||||
|
`EnvironmentFile` should be changed to the path in which your `.env`file is
|
||||||
|
located.
|
||||||
|
|
||||||
|
You can run the script `install-systemd-user-service` to install the systemd
|
||||||
|
service under the current user.
|
||||||
|
|
||||||
|
|
||||||
|
./install-systemd-user-service
|
||||||
|
|
||||||
|
Note: this script will enable the service and also start it. So it will assume
|
||||||
|
that umami is not currently running. If you started it previously, bring it
|
||||||
|
down using:
|
||||||
|
|
||||||
|
podman-compose down
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
These files should be compatible with podman 4.3+.
|
||||||
|
|
||||||
|
I have tested this on Debian GNU/Linux 12 (bookworm) and with the podman that
|
||||||
|
is distributed with the official Debian stable mirrors (podman
|
||||||
|
v4.3.1+ds1-8+deb12u1, podman-compose v1.0.3-3).
|
||||||
16
podman/env.sample
Normal file
16
podman/env.sample
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Rename this file to .env and modify the values
|
||||||
|
#
|
||||||
|
# Connection string for Umami’s database.
|
||||||
|
# If you use the bundled DB container, "db" is the hostname.
|
||||||
|
DATABASE_URL=postgresql://umami:replace-me-with-a-random-string@db:5432/umami
|
||||||
|
|
||||||
|
# Database type (e.g. postgresql)
|
||||||
|
DATABASE_TYPE=postgresql
|
||||||
|
|
||||||
|
# A secret string used by Umami (replace with a strong random string)
|
||||||
|
APP_SECRET=replace-me-with-a-random-string
|
||||||
|
|
||||||
|
# Postgres container defaults.
|
||||||
|
POSTGRES_DB=umami
|
||||||
|
POSTGRES_USER=umami
|
||||||
|
POSTGRES_PASSWORD=replace-me-with-a-random-string
|
||||||
10
podman/install-systemd-user-service
Executable file
10
podman/install-systemd-user-service
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
service_name="umami"
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cp $service_name.service ~/.config/systemd/user
|
||||||
|
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable $service_name.service
|
||||||
|
systemctl --user start $service_name.service
|
||||||
41
podman/podman-compose.yml
Normal file
41
podman/podman-compose.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
umami:
|
||||||
|
container_name: umami
|
||||||
|
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
DATABASE_TYPE: ${DATABASE_TYPE}
|
||||||
|
APP_SECRET: ${APP_SECRET}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
init: true
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/heartbeat || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
db:
|
||||||
|
container_name: umami-db
|
||||||
|
image: docker.io/library/postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- umami-db-data:/var/lib/postgresql/data:Z
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
umami-db-data:
|
||||||
14
podman/umami.service
Normal file
14
podman/umami.service
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Umami Container Stack via Podman-Compose
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/apps/umami
|
||||||
|
EnvironmentFile=/opt/apps/umami/.env
|
||||||
|
ExecStart=/usr/bin/podman-compose -f podman-compose.yml up -d
|
||||||
|
ExecStop=/usr/bin/podman-compose -f podman-compose.yml down
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
plugins: [
|
|
||||||
'postcss-flexbugs-fixes',
|
|
||||||
[
|
|
||||||
'postcss-preset-env',
|
|
||||||
{
|
|
||||||
autoprefixer: {
|
|
||||||
flexbox: 'no-2009',
|
|
||||||
},
|
|
||||||
stage: 3,
|
|
||||||
features: {
|
|
||||||
'custom-properties': false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -53,6 +53,12 @@
|
||||||
"value": "Administrateur"
|
"value": "Administrateur"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.affiliate": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Affiliation"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.after": [
|
"label.after": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -77,6 +83,18 @@
|
||||||
"value": "Analytics"
|
"value": "Analytics"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.attribution": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Attribution"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.attribution-description": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Découvrez comment les utilisateurs s'engagent avec votre marketing et ce qui génère des conversions."
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.average": [
|
"label.average": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -119,6 +137,12 @@
|
||||||
"value": "Navigateurs"
|
"value": "Navigateurs"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.campaigns": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Campagnes"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.cancel": [
|
"label.cancel": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -131,6 +155,12 @@
|
||||||
"value": "Changer le mot de passe"
|
"value": "Changer le mot de passe"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.channels": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Canaux"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.cities": [
|
"label.cities": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -152,7 +182,7 @@
|
||||||
"label.compare": [
|
"label.compare": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Compare"
|
"value": "Comparer"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.confirm": [
|
"label.confirm": [
|
||||||
|
|
@ -173,16 +203,28 @@
|
||||||
"value": "Contient"
|
"value": "Contient"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.content": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Contenu"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.continue": [
|
"label.continue": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Continuer"
|
"value": "Continuer"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.conversion-step": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Étape de conversion"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.count": [
|
"label.count": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Count"
|
"value": "Compte"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.countries": [
|
"label.countries": [
|
||||||
|
|
@ -230,13 +272,19 @@
|
||||||
"label.created-by": [
|
"label.created-by": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Crée par"
|
"value": "Créé par"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.currency": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Devise"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.current": [
|
"label.current": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Current"
|
"value": "Actuel"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.current-password": [
|
"label.current-password": [
|
||||||
|
|
@ -347,6 +395,12 @@
|
||||||
"value": "Appareils"
|
"value": "Appareils"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.direct": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Direct"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.dismiss": [
|
"label.dismiss": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -389,6 +443,12 @@
|
||||||
"value": "Modifier le membre"
|
"value": "Modifier le membre"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.email": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "E-mail"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.enable-share-url": [
|
"label.enable-share-url": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -398,13 +458,13 @@
|
||||||
"label.end-step": [
|
"label.end-step": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "End Step"
|
"value": "Étape de fin"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.entry": [
|
"label.entry": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "URL d'entrée"
|
"value": "Chemin d'entrée"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.event": [
|
"label.event": [
|
||||||
|
|
@ -428,7 +488,7 @@
|
||||||
"label.exit": [
|
"label.exit": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Exit URL"
|
"value": "Chemin de sortie"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.false": [
|
"label.false": [
|
||||||
|
|
@ -488,19 +548,19 @@
|
||||||
"label.funnel-description": [
|
"label.funnel-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Suivi des conversions et des taux d'abandons."
|
"value": "Comprenez les taux de conversions et d'abandons des utilisateurs."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.goal": [
|
"label.goal": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Goal"
|
"value": "Objectif"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.goals": [
|
"label.goals": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Goals"
|
"value": "Objectifs"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.goals-description": [
|
"label.goals-description": [
|
||||||
|
|
@ -521,16 +581,22 @@
|
||||||
"value": "Supérieur ou égal à"
|
"value": "Supérieur ou égal à"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.grouped": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Groupé"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.host": [
|
"label.host": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Host"
|
"value": "Hôte"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.hosts": [
|
"label.hosts": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Hosts"
|
"value": "Hôtes"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.insights": [
|
"label.insights": [
|
||||||
|
|
@ -542,7 +608,7 @@
|
||||||
"label.insights-description": [
|
"label.insights-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Analyse précise des données en utilisant des segments et des filtres."
|
"value": "Analysez précisément vos données en utilisant des segments et des filtres."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.is": [
|
"label.is": [
|
||||||
|
|
@ -584,13 +650,13 @@
|
||||||
"label.journey": [
|
"label.journey": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Journey"
|
"value": "Parcours"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.journey-description": [
|
"label.journey-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Comprendre comment les utilisateurs naviguent sur votre site web."
|
"value": "Comprennez comment les utilisateurs naviguent sur votre site."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.language": [
|
"label.language": [
|
||||||
|
|
@ -644,7 +710,7 @@
|
||||||
"label.last-seen": [
|
"label.last-seen": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Last seen"
|
"value": "Vu pour la dernière fois"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.leave": [
|
"label.leave": [
|
||||||
|
|
@ -701,6 +767,12 @@
|
||||||
"value": "Max"
|
"value": "Max"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.medium": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Support"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.member": [
|
"label.member": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -725,6 +797,12 @@
|
||||||
"value": "Téléphone"
|
"value": "Téléphone"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.model": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Modèle"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.more": [
|
"label.more": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -801,12 +879,42 @@
|
||||||
"value": "OK"
|
"value": "OK"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.organic-search": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Recherche organique"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.organic-shopping": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "E-commerce organique"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.organic-social": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Réseau social organique"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.organic-video": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Vidéo organique"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.os": [
|
"label.os": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "OS"
|
"value": "OS"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.other": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Autre"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.overview": [
|
"label.overview": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -855,6 +963,36 @@
|
||||||
"value": "Pages"
|
"value": "Pages"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.paid-ads": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Publicités payantes"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.paid-search": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Recherche payante"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.paid-shopping": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "E-commerce payant"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.paid-social": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Réseau social payant"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.paid-video": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Vidéo payante"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.password": [
|
"label.password": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -864,13 +1002,13 @@
|
||||||
"label.path": [
|
"label.path": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Path"
|
"value": "Chemin"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.paths": [
|
"label.paths": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Paths"
|
"value": "Chemins"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.powered-by": [
|
"label.powered-by": [
|
||||||
|
|
@ -943,6 +1081,12 @@
|
||||||
"value": "Temps réel"
|
"value": "Temps réel"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.referral": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Référent"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.referrer": [
|
"label.referrer": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1024,25 +1168,19 @@
|
||||||
"label.retention-description": [
|
"label.retention-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Mesure de l'attractivité du site en visualisant les taux de visiteurs qui reviennent."
|
"value": "Mesurez l'attractivité de votre site en suivant la fréquence de retour des utilisateurs."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.revenue": [
|
"label.revenue": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Revenue"
|
"value": "Recettes"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.revenue-description": [
|
"label.revenue-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Examinez vos revenus au fil du temps."
|
"value": "Examinez vos recettes et comment dépensent vos utilisateurs."
|
||||||
}
|
|
||||||
],
|
|
||||||
"label.revenue-property": [
|
|
||||||
{
|
|
||||||
"type": 0,
|
|
||||||
"value": "Propriétés des revenues"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.role": [
|
"label.role": [
|
||||||
|
|
@ -1054,7 +1192,7 @@
|
||||||
"label.run-query": [
|
"label.run-query": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Éxécuter la requête"
|
"value": "Exécuter la requête"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.save": [
|
"label.save": [
|
||||||
|
|
@ -1078,7 +1216,7 @@
|
||||||
"label.select": [
|
"label.select": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Selectionner"
|
"value": "Sélectionner"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.select-date": [
|
"label.select-date": [
|
||||||
|
|
@ -1105,6 +1243,12 @@
|
||||||
"value": "Session"
|
"value": "Session"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.session-data": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Session data"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.sessions": [
|
"label.sessions": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1129,10 +1273,22 @@
|
||||||
"value": "Journée"
|
"value": "Journée"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.sms": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "SMS"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.sources": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Sources"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.start-step": [
|
"label.start-step": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Etape de démarrage"
|
"value": "Étape de départ"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.steps": [
|
"label.steps": [
|
||||||
|
|
@ -1153,6 +1309,18 @@
|
||||||
"value": "Tablette"
|
"value": "Tablette"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.tag": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Tag"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.tags": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Tags"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.team": [
|
"label.team": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1207,6 +1375,12 @@
|
||||||
"value": "Équipes"
|
"value": "Équipes"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.terms": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "Mots clés"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.theme": [
|
"label.theme": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1357,12 +1531,6 @@
|
||||||
"value": "Utilisateur"
|
"value": "Utilisateur"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.user-property": [
|
|
||||||
{
|
|
||||||
"type": 0,
|
|
||||||
"value": "Propriétés d'utilisateurs"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"label.username": [
|
"label.username": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1384,7 +1552,7 @@
|
||||||
"label.utm-description": [
|
"label.utm-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Suivi de campagnes via les paramètres UTM."
|
"value": "Suivez vos campagnes via les paramètres UTM."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.value": [
|
"label.value": [
|
||||||
|
|
@ -1426,7 +1594,7 @@
|
||||||
"label.visit-duration": [
|
"label.visit-duration": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Temps de visite moyen"
|
"value": "Temps de visite"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.visitors": [
|
"label.visitors": [
|
||||||
|
|
@ -1526,7 +1694,7 @@
|
||||||
"message.collected-data": [
|
"message.collected-data": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Collected data"
|
"value": "Donnée collectée"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.confirm-delete": [
|
"message.confirm-delete": [
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,13 @@
|
||||||
"label.change-password": [
|
"label.change-password": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "更新密码"
|
"value": "修改密码"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.channels": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "渠道"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.cities": [
|
"label.cities": [
|
||||||
|
|
@ -236,13 +242,13 @@
|
||||||
"label.current": [
|
"label.current": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "目前"
|
"value": "当前"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.current-password": [
|
"label.current-password": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "目前密码"
|
"value": "当前密码"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.custom-range": [
|
"label.custom-range": [
|
||||||
|
|
@ -254,7 +260,7 @@
|
||||||
"label.dashboard": [
|
"label.dashboard": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "仪表板"
|
"value": "仪表盘"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.data": [
|
"label.data": [
|
||||||
|
|
@ -380,7 +386,7 @@
|
||||||
"label.edit-dashboard": [
|
"label.edit-dashboard": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "编辑仪表板"
|
"value": "编辑仪表盘"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.edit-member": [
|
"label.edit-member": [
|
||||||
|
|
@ -488,7 +494,7 @@
|
||||||
"label.funnel-description": [
|
"label.funnel-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "了解用户的转换率和退出率。"
|
"value": "了解用户的转化率和跳出率。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.goal": [
|
"label.goal": [
|
||||||
|
|
@ -930,7 +936,7 @@
|
||||||
"label.properties": [
|
"label.properties": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Properties"
|
"value": "属性"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.property": [
|
"label.property": [
|
||||||
|
|
@ -1044,7 +1050,7 @@
|
||||||
"label.retention-description": [
|
"label.retention-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "通过跟踪用户返回的频率来衡量网站的用户粘性。"
|
"value": "通过追踪用户回访频率来衡量您网站的用户粘性。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.revenue": [
|
"label.revenue": [
|
||||||
|
|
@ -1056,7 +1062,7 @@
|
||||||
"label.revenue-description": [
|
"label.revenue-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "查看您的收入随时间的变化。"
|
"value": "查看随时间变化的收入数据。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.revenue-property": [
|
"label.revenue-property": [
|
||||||
|
|
@ -1104,7 +1110,7 @@
|
||||||
"label.select-date": [
|
"label.select-date": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "选择数据"
|
"value": "选择日期"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.select-role": [
|
"label.select-role": [
|
||||||
|
|
@ -1188,7 +1194,7 @@
|
||||||
"label.team-manager": [
|
"label.team-manager": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "团队管理者"
|
"value": "团队管理员"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.team-member": [
|
"label.team-member": [
|
||||||
|
|
@ -1404,7 +1410,7 @@
|
||||||
"label.utm-description": [
|
"label.utm-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "通过UTM参数追踪您的广告活动。"
|
"value": "通过 UTM 参数追踪您的广告活动。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.value": [
|
"label.value": [
|
||||||
|
|
@ -1428,7 +1434,7 @@
|
||||||
"label.view-only": [
|
"label.view-only": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "仅浏览量"
|
"value": "仅浏览"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.views": [
|
"label.views": [
|
||||||
|
|
@ -1446,7 +1452,7 @@
|
||||||
"label.visit-duration": [
|
"label.visit-duration": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "平均访问时间"
|
"value": "平均访问时长"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.visitors": [
|
"label.visitors": [
|
||||||
|
|
@ -1494,7 +1500,7 @@
|
||||||
"message.action-confirmation": [
|
"message.action-confirmation": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "在下面的框中输入 "
|
"value": "请在下方输入框中输入 "
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 1,
|
"type": 1,
|
||||||
|
|
@ -1502,7 +1508,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": " 以确认。"
|
"value": " 以确认操作。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.active-users": [
|
"message.active-users": [
|
||||||
|
|
@ -1516,7 +1522,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": " 人"
|
"value": " 位访客"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.collected-data": [
|
"message.collected-data": [
|
||||||
|
|
@ -1584,7 +1590,7 @@
|
||||||
"message.delete-team-warning": [
|
"message.delete-team-warning": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "删除团队也会删除所有团队的网站。"
|
"value": "删除团队也会删除所有团队网站。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.delete-website-warning": [
|
"message.delete-website-warning": [
|
||||||
|
|
@ -1596,7 +1602,7 @@
|
||||||
"message.error": [
|
"message.error": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "出现错误。"
|
"value": "发生错误。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.event-log": [
|
"message.event-log": [
|
||||||
|
|
@ -1648,7 +1654,7 @@
|
||||||
"message.new-version-available": [
|
"message.new-version-available": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Umami 的新版本 "
|
"value": "Umami 新版本 "
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 1,
|
"type": 1,
|
||||||
|
|
@ -1656,13 +1662,13 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": " 已推出!"
|
"value": " 已发布!"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.no-data-available": [
|
"message.no-data-available": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "无可用数据。"
|
"value": "暂无数据。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.no-event-data": [
|
"message.no-event-data": [
|
||||||
|
|
@ -1680,25 +1686,25 @@
|
||||||
"message.no-results-found": [
|
"message.no-results-found": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "没有找到任何结果。"
|
"value": "未找到结果。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.no-team-websites": [
|
"message.no-team-websites": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "这个团队没有任何网站。"
|
"value": "该团队暂无网站。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.no-teams": [
|
"message.no-teams": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "你还没有创建任何团队。"
|
"value": "您尚未创建任何团队。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.no-users": [
|
"message.no-users": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "没有任何用户。"
|
"value": "暂无用户。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.no-websites-configured": [
|
"message.no-websites-configured": [
|
||||||
|
|
@ -1710,13 +1716,13 @@
|
||||||
"message.page-not-found": [
|
"message.page-not-found": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "网页未找到。"
|
"value": "页面未找到。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.reset-website": [
|
"message.reset-website": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "如果确定重置该网站,请在下面的输入框中输入 "
|
"value": "如确定要重置该网站,请在下面输入 "
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 1,
|
"type": 1,
|
||||||
|
|
@ -1724,13 +1730,13 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": " 进行二次确认。"
|
"value": " 以确认。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.reset-website-warning": [
|
"message.reset-website-warning": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "本网站的所有统计数据将被删除,但您的跟踪代码将保持不变。"
|
"value": "此网站的所有统计数据将被删除,但您的跟踪代码将保持不变。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.saved": [
|
"message.saved": [
|
||||||
|
|
@ -1756,7 +1762,7 @@
|
||||||
"message.team-already-member": [
|
"message.team-already-member": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "你已经是该团队的成员。"
|
"value": "你已是该团队的成员。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.team-not-found": [
|
"message.team-not-found": [
|
||||||
|
|
@ -1768,7 +1774,7 @@
|
||||||
"message.team-websites-info": [
|
"message.team-websites-info": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "团队中的任何人都可查看网站。"
|
"value": "团队成员均可查看网站数据。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.tracking-code": [
|
"message.tracking-code": [
|
||||||
|
|
@ -1780,13 +1786,13 @@
|
||||||
"message.transfer-team-website-to-user": [
|
"message.transfer-team-website-to-user": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "将该网站转入您的账户?"
|
"value": "将此网站转移到您的账户?"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.transfer-user-website-to-team": [
|
"message.transfer-user-website-to-team": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "选择要将该网站转移到哪个团队。"
|
"value": "选择要转移此网站的团队。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.transfer-website": [
|
"message.transfer-website": [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import replace from '@rollup/plugin-replace';
|
import replace from '@rollup/plugin-replace';
|
||||||
import { terser } from 'rollup-plugin-terser';
|
import terser from '@rollup/plugin-terser';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
input: 'src/tracker/index.js',
|
input: 'src/tracker/index.js',
|
||||||
|
|
|
||||||
48
scripts/data-migrations/convert-utm-clid-columns.sql
Normal file
48
scripts/data-migrations/convert-utm-clid-columns.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
-----------------------------------------------------
|
||||||
|
-- postgreSQL
|
||||||
|
-----------------------------------------------------
|
||||||
|
UPDATE "website_event" we
|
||||||
|
SET fbclid = url.fbclid,
|
||||||
|
gclid = url.gclid,
|
||||||
|
li_fat_id = url.li_fat_id,
|
||||||
|
msclkid = url.msclkid,
|
||||||
|
ttclid = url.ttclid,
|
||||||
|
twclid = url.twclid,
|
||||||
|
utm_campaign = url.utm_campaign,
|
||||||
|
utm_content = url.utm_content,
|
||||||
|
utm_medium = url.utm_medium,
|
||||||
|
utm_source = url.utm_source,
|
||||||
|
utm_term = url.utm_term
|
||||||
|
FROM (SELECT event_id, website_id, session_id,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)fbclid=([^&]+)', 'i'))[1] AS fbclid,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)gclid=([^&]+)', 'i'))[1] AS gclid,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)li_fat_id=([^&]+)', 'i'))[1] AS li_fat_id,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)msclkid=([^&]+)', 'i'))[1] AS msclkid,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)ttclid=([^&]+)', 'i'))[1] AS ttclid,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)twclid=([^&]+)', 'i'))[1] AS twclid,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)utm_campaign=([^&]+)', 'i'))[1] AS utm_campaign,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)utm_content=([^&]+)', 'i'))[1] AS utm_content,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)utm_medium=([^&]+)', 'i'))[1] AS utm_medium,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)utm_source=([^&]+)', 'i'))[1] AS utm_source,
|
||||||
|
(regexp_matches(url_query, '(?:[&?]|^)utm_term=([^&]+)', 'i'))[1] AS utm_term
|
||||||
|
FROM "website_event") url
|
||||||
|
WHERE we.event_id = url.event_id
|
||||||
|
and we.session_id = url.session_id
|
||||||
|
and we.website_id = url.website_id;
|
||||||
|
|
||||||
|
-----------------------------------------------------
|
||||||
|
-- mySQL
|
||||||
|
-----------------------------------------------------
|
||||||
|
UPDATE `website_event`
|
||||||
|
SET fbclid = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)fbclid=[^&]+'), '=', -1), '&', 1),
|
||||||
|
gclid = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)gclid=[^&]+'), '=', -1), '&', 1),
|
||||||
|
li_fat_id = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)li_fat_id=[^&]+'), '=', -1), '&', 1),
|
||||||
|
msclkid = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)msclkid=[^&]+'), '=', -1), '&', 1),
|
||||||
|
ttclid = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)ttclid=[^&]+'), '=', -1), '&', 1),
|
||||||
|
twclid = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)twclid=[^&]+'), '=', -1), '&', 1),
|
||||||
|
utm_campaign = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)utm_campaign=[^&]+'), '=', -1), '&', 1),
|
||||||
|
utm_content = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)utm_content=[^&]+'), '=', -1), '&', 1),
|
||||||
|
utm_medium = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)utm_medium=[^&]+'), '=', -1), '&', 1),
|
||||||
|
utm_source = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)utm_source=[^&]+'), '=', -1), '&', 1),
|
||||||
|
utm_term = SUBSTRING_INDEX(SUBSTRING_INDEX(REGEXP_SUBSTR(url_query, '(?:[&?]|^)utm_term=[^&]+'), '=', -1), '&', 1)
|
||||||
|
WHERE 1 = 1;
|
||||||
74
scripts/set-routes-manifest.js
Normal file
74
scripts/set-routes-manifest.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const routesManifestPath = path.resolve(__dirname, '../.next/routes-manifest.json');
|
||||||
|
const originalPath = path.resolve(__dirname, '../.next/routes-manifest-orig.json');
|
||||||
|
const originalManifest = require(originalPath);
|
||||||
|
|
||||||
|
const API_PATH = '/api/:path*';
|
||||||
|
const TRACKER_SCRIPT = '/script.js';
|
||||||
|
|
||||||
|
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT;
|
||||||
|
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME;
|
||||||
|
|
||||||
|
const headers = [];
|
||||||
|
const rewrites = [];
|
||||||
|
|
||||||
|
if (collectApiEndpoint) {
|
||||||
|
const apiRoute = originalManifest.headers.find(route => route.source === API_PATH);
|
||||||
|
const routeRegex = new RegExp(apiRoute.regex);
|
||||||
|
|
||||||
|
rewrites.push({
|
||||||
|
source: collectApiEndpoint,
|
||||||
|
destination: '/api/send',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!routeRegex.test(collectApiEndpoint)) {
|
||||||
|
headers.push({
|
||||||
|
source: collectApiEndpoint,
|
||||||
|
headers: apiRoute.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackerScriptName) {
|
||||||
|
const trackerRoute = originalManifest.headers.find(route => route.source === TRACKER_SCRIPT);
|
||||||
|
|
||||||
|
const names = trackerScriptName?.split(',').map(name => name.trim());
|
||||||
|
|
||||||
|
if (names) {
|
||||||
|
names.forEach(name => {
|
||||||
|
const normalizedSource = `/${name.replace(/^\/+/, '')}`;
|
||||||
|
|
||||||
|
rewrites.push({
|
||||||
|
source: normalizedSource,
|
||||||
|
destination: TRACKER_SCRIPT,
|
||||||
|
});
|
||||||
|
|
||||||
|
headers.push({
|
||||||
|
source: normalizedSource,
|
||||||
|
headers: trackerRoute.headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routesManifest = { ...originalManifest };
|
||||||
|
|
||||||
|
if (rewrites.length !== 0) {
|
||||||
|
const { buildCustomRoute } = require('next/dist/lib/build-custom-route');
|
||||||
|
|
||||||
|
const builtHeaders = headers.map(header => buildCustomRoute('header', header));
|
||||||
|
const builtRewrites = rewrites.map(rewrite => buildCustomRoute('rewrite', rewrite));
|
||||||
|
|
||||||
|
routesManifest.headers = [...originalManifest.headers, ...builtHeaders];
|
||||||
|
routesManifest.rewrites = [...builtRewrites, ...originalManifest.rewrites];
|
||||||
|
|
||||||
|
console.log('Using updated Next.js routes manifest');
|
||||||
|
} else {
|
||||||
|
console.log('Using original Next.js routes manifest');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(routesManifestPath, JSON.stringify(routesManifest, null, 2));
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
'use client';
|
|
||||||
import TestConsole from './TestConsole';
|
|
||||||
|
|
||||||
export default function ConsolePage({ websiteId }) {
|
|
||||||
return <TestConsole websiteId={websiteId} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
'use client';
|
||||||
import { Button } from 'react-basics';
|
import { Button } from 'react-basics';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
|
|
@ -9,7 +10,7 @@ import WebsiteChart from '../websites/[websiteId]/WebsiteChart';
|
||||||
import { useApi, useNavigation } from '@/components/hooks';
|
import { useApi, useNavigation } from '@/components/hooks';
|
||||||
import styles from './TestConsole.module.css';
|
import styles from './TestConsole.module.css';
|
||||||
|
|
||||||
export function TestConsole({ websiteId }: { websiteId: string }) {
|
export function TestConsole({ websiteId }: { websiteId?: string }) {
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['websites:me'],
|
queryKey: ['websites:me'],
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import ConsolePage from '../ConsolePage';
|
import TestConsole from '../TestConsole';
|
||||||
|
|
||||||
async function getEnabled() {
|
async function getEnabled() {
|
||||||
return !!process.env.ENABLE_TEST_CONSOLE;
|
return !!process.env.ENABLE_TEST_CONSOLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ({ params }: { params: { websiteId: string } }) {
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
|
|
||||||
const enabled = await getEnabled();
|
const enabled = await getEnabled();
|
||||||
|
|
@ -14,7 +14,7 @@ export default async function ({ params }: { params: { websiteId: string } }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ConsolePage websiteId={websiteId?.[0]} />;
|
return <TestConsole websiteId={websiteId?.[0]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ import GoalReport from '../goals/GoalsReport';
|
||||||
import InsightsReport from '../insights/InsightsReport';
|
import InsightsReport from '../insights/InsightsReport';
|
||||||
import JourneyReport from '../journey/JourneyReport';
|
import JourneyReport from '../journey/JourneyReport';
|
||||||
import RetentionReport from '../retention/RetentionReport';
|
import RetentionReport from '../retention/RetentionReport';
|
||||||
import UTMReport from '../utm/UTMReport';
|
|
||||||
import RevenueReport from '../revenue/RevenueReport';
|
import RevenueReport from '../revenue/RevenueReport';
|
||||||
|
import UTMReport from '../utm/UTMReport';
|
||||||
|
import AttributionReport from '../attribution/AttributionReport';
|
||||||
|
|
||||||
const reports = {
|
const reports = {
|
||||||
funnel: FunnelReport,
|
funnel: FunnelReport,
|
||||||
|
|
@ -18,6 +19,7 @@ const reports = {
|
||||||
goals: GoalReport,
|
goals: GoalReport,
|
||||||
journey: JourneyReport,
|
journey: JourneyReport,
|
||||||
revenue: RevenueReport,
|
revenue: RevenueReport,
|
||||||
|
attribution: AttributionReport,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReportPage({ reportId }: { reportId: string }) {
|
export default function ReportPage({ reportId }: { reportId: string }) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
display: flex;
|
||||||
|
align-self: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
188
src/app/(main)/reports/attribution/AttributionParameters.tsx
Normal file
188
src/app/(main)/reports/attribution/AttributionParameters.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import Icons from '@/components/icons';
|
||||||
|
import { useContext, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
Form,
|
||||||
|
FormButtons,
|
||||||
|
FormInput,
|
||||||
|
FormRow,
|
||||||
|
Icon,
|
||||||
|
Item,
|
||||||
|
Popup,
|
||||||
|
PopupTrigger,
|
||||||
|
SubmitButton,
|
||||||
|
Toggle,
|
||||||
|
} from 'react-basics';
|
||||||
|
import BaseParameters from '../[reportId]/BaseParameters';
|
||||||
|
import ParameterList from '../[reportId]/ParameterList';
|
||||||
|
import PopupForm from '../[reportId]/PopupForm';
|
||||||
|
import { ReportContext } from '../[reportId]/Report';
|
||||||
|
import FunnelStepAddForm from '../funnel/FunnelStepAddForm';
|
||||||
|
import styles from './AttributionParameters.module.css';
|
||||||
|
import AttributionStepAddForm from './AttributionStepAddForm';
|
||||||
|
import useRevenueValues from '@/components/hooks/queries/useRevenueValues';
|
||||||
|
|
||||||
|
export function AttributionParameters() {
|
||||||
|
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { id, parameters } = report || {};
|
||||||
|
const { websiteId, dateRange, steps } = parameters || {};
|
||||||
|
const queryEnabled = websiteId && dateRange && steps.length > 0;
|
||||||
|
const [model, setModel] = useState('');
|
||||||
|
const [revenueMode, setRevenueMode] = useState(false);
|
||||||
|
|
||||||
|
const { data: currencyValues = [] } = useRevenueValues(
|
||||||
|
websiteId,
|
||||||
|
dateRange?.startDate,
|
||||||
|
dateRange?.endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = (data: any, e: any) => {
|
||||||
|
if (revenueMode === false) {
|
||||||
|
delete data.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
runReport(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheck = () => {
|
||||||
|
setRevenueMode(!revenueMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddStep = (step: { type: string; value: string }) => {
|
||||||
|
if (step.type === 'url') {
|
||||||
|
setRevenueMode(false);
|
||||||
|
}
|
||||||
|
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateStep = (
|
||||||
|
close: () => void,
|
||||||
|
index: number,
|
||||||
|
step: { type: string; value: string },
|
||||||
|
) => {
|
||||||
|
if (step.type === 'url') {
|
||||||
|
setRevenueMode(false);
|
||||||
|
}
|
||||||
|
const steps = [...parameters.steps];
|
||||||
|
steps[index] = step;
|
||||||
|
updateReport({ parameters: { steps } });
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveStep = (index: number) => {
|
||||||
|
const steps = [...parameters.steps];
|
||||||
|
delete steps[index];
|
||||||
|
updateReport({ parameters: { steps: steps.filter(n => n) } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddStepButton = () => {
|
||||||
|
return (
|
||||||
|
<PopupTrigger disabled={steps.length > 0}>
|
||||||
|
<Button disabled={steps.length > 0}>
|
||||||
|
<Icon>
|
||||||
|
<Icons.Plus />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Popup alignment="start">
|
||||||
|
<PopupForm>
|
||||||
|
<FunnelStepAddForm onChange={handleAddStep} />
|
||||||
|
</PopupForm>
|
||||||
|
</Popup>
|
||||||
|
</PopupTrigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: 'First-Click', value: 'firstClick' },
|
||||||
|
{ label: 'Last-Click', value: 'lastClick' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderModelValue = (value: any) => {
|
||||||
|
return items.find(item => item.value === value)?.label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onModelChange = (value: any) => {
|
||||||
|
setModel(value);
|
||||||
|
updateReport({ parameters: { model } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
||||||
|
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
|
||||||
|
<FormRow label={formatMessage(labels.model)}>
|
||||||
|
<FormInput name="model" rules={{ required: formatMessage(labels.required) }}>
|
||||||
|
<Dropdown
|
||||||
|
items={items}
|
||||||
|
value={model}
|
||||||
|
renderValue={renderModelValue}
|
||||||
|
onChange={onModelChange}
|
||||||
|
>
|
||||||
|
{({ value, label }) => {
|
||||||
|
return <Item key={value}>{label}</Item>;
|
||||||
|
}}
|
||||||
|
</Dropdown>
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label={formatMessage(labels.conversionStep)} action={<AddStepButton />}>
|
||||||
|
<ParameterList>
|
||||||
|
{steps.map((step: { type: string; value: string }, index: number) => {
|
||||||
|
return (
|
||||||
|
<PopupTrigger key={index}>
|
||||||
|
<ParameterList.Item
|
||||||
|
className={styles.item}
|
||||||
|
icon={step.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}
|
||||||
|
onRemove={() => handleRemoveStep(index)}
|
||||||
|
>
|
||||||
|
<div className={styles.value}>
|
||||||
|
<div>{step.value}</div>
|
||||||
|
</div>
|
||||||
|
</ParameterList.Item>
|
||||||
|
<Popup alignment="start">
|
||||||
|
{(close: () => void) => (
|
||||||
|
<PopupForm>
|
||||||
|
<AttributionStepAddForm
|
||||||
|
type={step.type}
|
||||||
|
value={step.value}
|
||||||
|
onChange={handleUpdateStep.bind(null, close, index)}
|
||||||
|
/>
|
||||||
|
</PopupForm>
|
||||||
|
)}
|
||||||
|
</Popup>
|
||||||
|
</PopupTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ParameterList>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<Toggle
|
||||||
|
checked={revenueMode}
|
||||||
|
onChecked={handleCheck}
|
||||||
|
disabled={currencyValues.length === 0 || steps[0]?.type === 'url'}
|
||||||
|
>
|
||||||
|
<b>Revenue Mode</b>
|
||||||
|
</Toggle>
|
||||||
|
</FormRow>
|
||||||
|
{revenueMode && (
|
||||||
|
<FormRow label={formatMessage(labels.currency)}>
|
||||||
|
<FormInput name="currency" rules={{ required: formatMessage(labels.required) }}>
|
||||||
|
<Dropdown items={currencyValues.map(item => item.currency)}>
|
||||||
|
{item => <Item key={item}>{item}</Item>}
|
||||||
|
</Dropdown>
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
<FormButtons>
|
||||||
|
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
|
||||||
|
{formatMessage(labels.runQuery)}
|
||||||
|
</SubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttributionParameters;
|
||||||
27
src/app/(main)/reports/attribution/AttributionReport.tsx
Normal file
27
src/app/(main)/reports/attribution/AttributionReport.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import Money from '@/assets/money.svg';
|
||||||
|
import { REPORT_TYPES } from '@/lib/constants';
|
||||||
|
import Report from '../[reportId]/Report';
|
||||||
|
import ReportBody from '../[reportId]/ReportBody';
|
||||||
|
import ReportHeader from '../[reportId]/ReportHeader';
|
||||||
|
import ReportMenu from '../[reportId]/ReportMenu';
|
||||||
|
import AttributionParameters from './AttributionParameters';
|
||||||
|
import AttributionView from './AttributionView';
|
||||||
|
|
||||||
|
const defaultParameters = {
|
||||||
|
type: REPORT_TYPES.attribution,
|
||||||
|
parameters: { model: 'firstClick', steps: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AttributionReport({ reportId }: { reportId?: string }) {
|
||||||
|
return (
|
||||||
|
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||||
|
<ReportHeader icon={<Money />} />
|
||||||
|
<ReportMenu>
|
||||||
|
<AttributionParameters />
|
||||||
|
</ReportMenu>
|
||||||
|
<ReportBody>
|
||||||
|
<AttributionView />
|
||||||
|
</ReportBody>
|
||||||
|
</Report>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
'use client';
|
||||||
|
import AttributionReport from './AttributionReport';
|
||||||
|
|
||||||
|
export default function AttributionReportPage() {
|
||||||
|
return <AttributionReport />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
.dropdown {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { Button, FormRow, TextField, Flexbox, Dropdown, Item } from 'react-basics';
|
||||||
|
import styles from './AttributionStepAddForm.module.css';
|
||||||
|
|
||||||
|
export interface AttributionStepAddFormProps {
|
||||||
|
type?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (step: { type: string; value: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttributionStepAddForm({
|
||||||
|
type: defaultType = 'url',
|
||||||
|
value: defaultValue = '',
|
||||||
|
onChange,
|
||||||
|
}: AttributionStepAddFormProps) {
|
||||||
|
const [type, setType] = useState(defaultType);
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const items = [
|
||||||
|
{ label: formatMessage(labels.url), value: 'url' },
|
||||||
|
{ label: formatMessage(labels.event), value: 'event' },
|
||||||
|
];
|
||||||
|
const isDisabled = !type || !value;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onChange({ type, value });
|
||||||
|
setValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = e => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTypeValue = (value: any) => {
|
||||||
|
return items.find(item => item.value === value)?.label;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flexbox direction="column" gap={10}>
|
||||||
|
<FormRow label={formatMessage(defaultValue ? labels.update : labels.add)}>
|
||||||
|
<Flexbox gap={10}>
|
||||||
|
<Dropdown
|
||||||
|
className={styles.dropdown}
|
||||||
|
items={items}
|
||||||
|
value={type}
|
||||||
|
renderValue={renderTypeValue}
|
||||||
|
onChange={(value: any) => setType(value)}
|
||||||
|
>
|
||||||
|
{({ value, label }) => {
|
||||||
|
return <Item key={value}>{label}</Item>;
|
||||||
|
}}
|
||||||
|
</Dropdown>
|
||||||
|
<TextField
|
||||||
|
className={styles.input}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
autoFocus={true}
|
||||||
|
autoComplete="off"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</Flexbox>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={isDisabled}>
|
||||||
|
{formatMessage(defaultValue ? labels.update : labels.add)}
|
||||||
|
</Button>
|
||||||
|
</FormRow>
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttributionStepAddForm;
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50% 50%;
|
||||||
|
gap: 20px;
|
||||||
|
border-top: 1px solid var(--base300);
|
||||||
|
padding-top: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
134
src/app/(main)/reports/attribution/AttributionView.tsx
Normal file
134
src/app/(main)/reports/attribution/AttributionView.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import PieChart from '@/components/charts/PieChart';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { Grid, GridRow } from '@/components/layout/Grid';
|
||||||
|
import ListTable from '@/components/metrics/ListTable';
|
||||||
|
import MetricCard from '@/components/metrics/MetricCard';
|
||||||
|
import MetricsBar from '@/components/metrics/MetricsBar';
|
||||||
|
import { CHART_COLORS } from '@/lib/constants';
|
||||||
|
import { formatLongNumber } from '@/lib/format';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { ReportContext } from '../[reportId]/Report';
|
||||||
|
import styles from './AttributionView.module.css';
|
||||||
|
|
||||||
|
export interface AttributionViewProps {
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttributionView({ isLoading }: AttributionViewProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { report } = useContext(ReportContext);
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
parameters: { currency },
|
||||||
|
} = report || {};
|
||||||
|
const ATTRIBUTION_PARAMS = [
|
||||||
|
{ value: 'referrer', label: formatMessage(labels.referrers) },
|
||||||
|
{ value: 'paidAds', label: formatMessage(labels.paidAds) },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageviews, visitors, visits } = data.total;
|
||||||
|
|
||||||
|
const metrics = data
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: pageviews,
|
||||||
|
label: formatMessage(labels.views),
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: visits,
|
||||||
|
label: formatMessage(labels.visits),
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: visitors,
|
||||||
|
label: formatMessage(labels.visitors),
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
function UTMTable(UTMTableProps: { data: any; title: string; utm: string }) {
|
||||||
|
const { data, title, utm } = UTMTableProps;
|
||||||
|
const total = data[utm].reduce((sum, { value }) => {
|
||||||
|
return +sum + +value;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListTable
|
||||||
|
title={title}
|
||||||
|
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
|
||||||
|
currency={currency}
|
||||||
|
data={data[utm].map(({ name, value }) => ({
|
||||||
|
x: name,
|
||||||
|
y: Number(value),
|
||||||
|
z: (value / total) * 100,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<MetricsBar isFetched={data}>
|
||||||
|
{metrics?.map(({ label, value, formatValue }) => {
|
||||||
|
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
|
||||||
|
})}
|
||||||
|
</MetricsBar>
|
||||||
|
{ATTRIBUTION_PARAMS.map(({ value, label }) => {
|
||||||
|
const items = data[value];
|
||||||
|
const total = items.reduce((sum, { value }) => {
|
||||||
|
return +sum + +value;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: items.map(({ name }) => name),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: items.map(({ value }) => value),
|
||||||
|
backgroundColor: CHART_COLORS,
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={value} className={styles.row}>
|
||||||
|
<div>
|
||||||
|
<div className={styles.title}>{label}</div>
|
||||||
|
<ListTable
|
||||||
|
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
|
||||||
|
currency={currency}
|
||||||
|
data={items.map(({ name, value }) => ({
|
||||||
|
x: name,
|
||||||
|
y: Number(value),
|
||||||
|
z: (value / total) * 100,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<PieChart type="doughnut" data={chartData} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Grid>
|
||||||
|
<GridRow columns="two">
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
|
||||||
|
</GridRow>
|
||||||
|
<GridRow columns="three">
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
|
||||||
|
</GridRow>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttributionView;
|
||||||
10
src/app/(main)/reports/attribution/page.tsx
Normal file
10
src/app/(main)/reports/attribution/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import AttributionReportPage from './AttributionReportPage';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return <AttributionReportPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Attribution Report',
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@ import Magnet from '@/assets/magnet.svg';
|
||||||
import Path from '@/assets/path.svg';
|
import Path from '@/assets/path.svg';
|
||||||
import Tag from '@/assets/tag.svg';
|
import Tag from '@/assets/tag.svg';
|
||||||
import Target from '@/assets/target.svg';
|
import Target from '@/assets/target.svg';
|
||||||
|
import Network from '@/assets/network.svg';
|
||||||
import { useMessages, useTeamUrl } from '@/components/hooks';
|
import { useMessages, useTeamUrl } from '@/components/hooks';
|
||||||
import PageHeader from '@/components/layout/PageHeader';
|
import PageHeader from '@/components/layout/PageHeader';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
@ -58,6 +59,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
|
||||||
url: renderTeamUrl('/reports/revenue'),
|
url: renderTeamUrl('/reports/revenue'),
|
||||||
icon: <Money />,
|
icon: <Money />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: formatMessage(labels.attribution),
|
||||||
|
description: formatMessage(labels.attributionDescription),
|
||||||
|
url: renderTeamUrl('/reports/attribution'),
|
||||||
|
icon: <Network />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,33 @@ import EventsDataTable from './EventsDataTable';
|
||||||
import EventsMetricsBar from './EventsMetricsBar';
|
import EventsMetricsBar from './EventsMetricsBar';
|
||||||
import EventsChart from '@/components/metrics/EventsChart';
|
import EventsChart from '@/components/metrics/EventsChart';
|
||||||
import { GridRow } from '@/components/layout/Grid';
|
import { GridRow } from '@/components/layout/Grid';
|
||||||
import MetricsTable from '@/components/metrics/MetricsTable';
|
import EventsTable from '@/components/metrics/EventsTable';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
import { Item, Tabs } from 'react-basics';
|
import { Item, Tabs } from 'react-basics';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import EventProperties from './EventProperties';
|
import EventProperties from './EventProperties';
|
||||||
|
|
||||||
export default function EventsPage({ websiteId }) {
|
export default function EventsPage({ websiteId }) {
|
||||||
|
const [label, setLabel] = useState(null);
|
||||||
const [tab, setTab] = useState('activity');
|
const [tab, setTab] = useState('activity');
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
const handleLabelClick = (value: string) => {
|
||||||
|
setLabel(value !== label ? value : '');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WebsiteHeader websiteId={websiteId} />
|
<WebsiteHeader websiteId={websiteId} />
|
||||||
<EventsMetricsBar websiteId={websiteId} />
|
<EventsMetricsBar websiteId={websiteId} />
|
||||||
<GridRow columns="two-one">
|
<GridRow columns="two-one">
|
||||||
<EventsChart websiteId={websiteId} />
|
<EventsChart websiteId={websiteId} focusLabel={label} />
|
||||||
<MetricsTable
|
<EventsTable
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
type="event"
|
type="event"
|
||||||
title={formatMessage(labels.events)}
|
title={formatMessage(labels.events)}
|
||||||
metric={formatMessage(labels.actions)}
|
metric={formatMessage(labels.actions)}
|
||||||
|
onLabelClick={handleLabelClick}
|
||||||
/>
|
/>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import RealtimeCountries from './RealtimeCountries';
|
||||||
import WebsiteHeader from '../WebsiteHeader';
|
import WebsiteHeader from '../WebsiteHeader';
|
||||||
import { percentFilter } from '@/lib/filters';
|
import { percentFilter } from '@/lib/filters';
|
||||||
|
|
||||||
export function WebsiteRealtimePage({ websiteId }) {
|
export function WebsiteRealtimePage({ websiteId }: { websiteId: string }) {
|
||||||
const { data, isLoading, error } = useRealtime(websiteId);
|
const { data, isLoading, error } = useRealtime(websiteId);
|
||||||
|
|
||||||
if (isLoading || error) {
|
if (isLoading || error) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import WebsiteRealtimePage from './WebsiteRealtimePage';
|
import WebsiteRealtimePage from './WebsiteRealtimePage';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
export default async function ({ params }: { params: { websiteId: string } }) {
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
|
|
||||||
return <WebsiteRealtimePage websiteId={websiteId} />;
|
return <WebsiteRealtimePage websiteId={websiteId} />;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default function SessionsDataTable({
|
||||||
const queryResult = useWebsiteSessions(websiteId);
|
const queryResult = useWebsiteSessions(websiteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable queryResult={queryResult} allowSearch={false} renderEmpty={() => children}>
|
<DataTable queryResult={queryResult} allowSearch={true} renderEmpty={() => children}>
|
||||||
{({ data }) => <SessionsTable data={data} showDomain={!websiteId} />}
|
{({ data }) => <SessionsTable data={data} showDomain={!websiteId} />}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,19 @@ export function SessionActivity({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.timeline}>
|
<div className={styles.timeline}>
|
||||||
{data.map(({ eventId, createdAt, urlPath, eventName, visitId }) => {
|
{data.map(({ id, createdAt, urlPath, eventName, visitId }) => {
|
||||||
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
|
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
|
||||||
lastDay = createdAt;
|
lastDay = createdAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={eventId}>
|
<Fragment key={id}>
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<div className={styles.header}>{formatTimezoneDate(createdAt, 'PPPP')}</div>
|
<div className={styles.header}>{formatTimezoneDate(createdAt, 'PPPP')}</div>
|
||||||
)}
|
)}
|
||||||
<div key={eventId} className={styles.row}>
|
<div className={styles.row}>
|
||||||
<div className={styles.time}>
|
<div className={styles.time}>
|
||||||
<StatusLight color={`#${visitId?.substring(0, 6)}`}>
|
<StatusLight color={`#${visitId?.substring(0, 6)}`}>
|
||||||
{formatTimezoneDate(createdAt, 'h:mm:ss aaa')}
|
{formatTimezoneDate(createdAt, 'pp')}
|
||||||
</StatusLight>
|
</StatusLight>
|
||||||
</div>
|
</div>
|
||||||
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ export default function SessionInfo({ data }) {
|
||||||
<dd>
|
<dd>
|
||||||
{data?.id} <CopyIcon value={data?.id} />
|
{data?.id} <CopyIcon value={data?.id} />
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt>{formatMessage(labels.distinctId)}</dt>
|
||||||
|
<dd>{data?.distinctId}</dd>
|
||||||
<dt>{formatMessage(labels.lastSeen)}</dt>
|
<dt>{formatMessage(labels.lastSeen)}</dt>
|
||||||
<dd>{formatTimezoneDate(data?.lastAt, 'PPPPpp')}</dd>
|
<dd>{formatTimezoneDate(data?.lastAt, 'PPPPpp')}</dd>
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@ export default function SessionInfo({ data }) {
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.Location />
|
<Icons.Location />
|
||||||
</Icon>
|
</Icon>
|
||||||
{getRegionName(data?.subdivision1)}
|
{getRegionName(data?.region)}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dt>{formatMessage(labels.city)}</dt>
|
<dt>{formatMessage(labels.city)}</dt>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { json } from '@/lib/response';
|
import { json } from '@/lib/response';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { auth, error } = await parseRequest(request);
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
50
src/app/api/reports/attribution/route.ts
Normal file
50
src/app/api/reports/attribution/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
|
import { parseRequest } from '@/lib/request';
|
||||||
|
import { json, unauthorized } from '@/lib/response';
|
||||||
|
import { reportParms } from '@/lib/schema';
|
||||||
|
import { getAttribution } from '@/queries/sql/reports/getAttribution';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
...reportParms,
|
||||||
|
model: z.string().regex(/firstClick|lastClick/i),
|
||||||
|
steps: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
type: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
currency: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
model,
|
||||||
|
steps,
|
||||||
|
currency,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getAttribution(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
model: model,
|
||||||
|
steps,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
}
|
||||||
|
|
@ -29,16 +29,12 @@ const schema = z.object({
|
||||||
ip: z.string().ip().optional(),
|
ip: z.string().ip().optional(),
|
||||||
userAgent: z.string().optional(),
|
userAgent: z.string().optional(),
|
||||||
timestamp: z.coerce.number().int().optional(),
|
timestamp: z.coerce.number().int().optional(),
|
||||||
|
id: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
// Bot check
|
|
||||||
if (!process.env.DISABLE_BOT_CHECK && isbot(request.headers.get('user-agent'))) {
|
|
||||||
return json({ beep: 'boop' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -59,6 +55,7 @@ export async function POST(request: Request) {
|
||||||
title,
|
title,
|
||||||
tag,
|
tag,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
id,
|
||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
// Cache check
|
// Cache check
|
||||||
|
|
@ -83,8 +80,15 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client info
|
// Client info
|
||||||
const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
|
const { ip, userAgent, device, browser, os, country, region, city } = await getClientInfo(
|
||||||
await getClientInfo(request, payload);
|
request,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bot check
|
||||||
|
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
|
||||||
|
return json({ beep: 'boop' });
|
||||||
|
}
|
||||||
|
|
||||||
// IP block
|
// IP block
|
||||||
if (hasBlockedIp(ip)) {
|
if (hasBlockedIp(ip)) {
|
||||||
|
|
@ -97,7 +101,7 @@ export async function POST(request: Request) {
|
||||||
const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
|
const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
|
||||||
const visitSalt = hash(startOfHour(createdAt).toUTCString());
|
const visitSalt = hash(startOfHour(createdAt).toUTCString());
|
||||||
|
|
||||||
const sessionId = uuid(websiteId, ip, userAgent, sessionSalt);
|
const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
|
||||||
|
|
||||||
// Find session
|
// Find session
|
||||||
if (!clickhouse.enabled && !cache?.sessionId) {
|
if (!clickhouse.enabled && !cache?.sessionId) {
|
||||||
|
|
@ -109,16 +113,15 @@ export async function POST(request: Request) {
|
||||||
await createSession({
|
await createSession({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
websiteId,
|
websiteId,
|
||||||
hostname,
|
|
||||||
browser,
|
browser,
|
||||||
os,
|
os,
|
||||||
device,
|
device,
|
||||||
screen,
|
screen,
|
||||||
language,
|
language,
|
||||||
country,
|
country,
|
||||||
subdivision1,
|
region,
|
||||||
subdivision2,
|
|
||||||
city,
|
city,
|
||||||
|
distinctId: id,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!e.message.toLowerCase().includes('unique constraint')) {
|
if (!e.message.toLowerCase().includes('unique constraint')) {
|
||||||
|
|
@ -142,18 +145,33 @@ export async function POST(request: Request) {
|
||||||
const base = hostname ? `https://${hostname}` : 'https://localhost';
|
const base = hostname ? `https://${hostname}` : 'https://localhost';
|
||||||
const currentUrl = new URL(url, base);
|
const currentUrl = new URL(url, base);
|
||||||
|
|
||||||
let urlPath = currentUrl.pathname;
|
let urlPath = currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname;
|
||||||
const urlQuery = currentUrl.search.substring(1);
|
const urlQuery = currentUrl.search.substring(1);
|
||||||
const urlDomain = currentUrl.hostname.replace(/^www./, '');
|
const urlDomain = currentUrl.hostname.replace(/^www./, '');
|
||||||
|
|
||||||
if (process.env.REMOVE_TRAILING_SLASH) {
|
|
||||||
urlPath = urlPath.replace(/(.+)\/$/, '$1');
|
|
||||||
}
|
|
||||||
|
|
||||||
let referrerPath: string;
|
let referrerPath: string;
|
||||||
let referrerQuery: string;
|
let referrerQuery: string;
|
||||||
let referrerDomain: string;
|
let referrerDomain: string;
|
||||||
|
|
||||||
|
// UTM Params
|
||||||
|
const utmSource = currentUrl.searchParams.get('utm_source');
|
||||||
|
const utmMedium = currentUrl.searchParams.get('utm_medium');
|
||||||
|
const utmCampaign = currentUrl.searchParams.get('utm_campaign');
|
||||||
|
const utmContent = currentUrl.searchParams.get('utm_content');
|
||||||
|
const utmTerm = currentUrl.searchParams.get('utm_term');
|
||||||
|
|
||||||
|
// Click IDs
|
||||||
|
const gclid = currentUrl.searchParams.get('gclid');
|
||||||
|
const fbclid = currentUrl.searchParams.get('fbclid');
|
||||||
|
const msclkid = currentUrl.searchParams.get('msclkid');
|
||||||
|
const ttclid = currentUrl.searchParams.get('ttclid');
|
||||||
|
const lifatid = currentUrl.searchParams.get('li_fat_id');
|
||||||
|
const twclid = currentUrl.searchParams.get('twclid');
|
||||||
|
|
||||||
|
if (process.env.REMOVE_TRAILING_SLASH) {
|
||||||
|
urlPath = urlPath.replace(/(.+)\/$/, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
if (referrer) {
|
if (referrer) {
|
||||||
const referrerUrl = new URL(referrer, base);
|
const referrerUrl = new URL(referrer, base);
|
||||||
|
|
||||||
|
|
@ -171,10 +189,21 @@ export async function POST(request: Request) {
|
||||||
visitId,
|
visitId,
|
||||||
urlPath: safeDecodeURI(urlPath),
|
urlPath: safeDecodeURI(urlPath),
|
||||||
urlQuery,
|
urlQuery,
|
||||||
|
utmSource,
|
||||||
|
utmMedium,
|
||||||
|
utmCampaign,
|
||||||
|
utmContent,
|
||||||
|
utmTerm,
|
||||||
referrerPath: safeDecodeURI(referrerPath),
|
referrerPath: safeDecodeURI(referrerPath),
|
||||||
referrerQuery,
|
referrerQuery,
|
||||||
referrerDomain,
|
referrerDomain,
|
||||||
pageTitle: safeDecodeURIComponent(title),
|
pageTitle: safeDecodeURIComponent(title),
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
lifatid,
|
||||||
|
twclid,
|
||||||
eventName: name,
|
eventName: name,
|
||||||
eventData: data,
|
eventData: data,
|
||||||
hostname: hostname || urlDomain,
|
hostname: hostname || urlDomain,
|
||||||
|
|
@ -184,10 +213,10 @@ export async function POST(request: Request) {
|
||||||
screen,
|
screen,
|
||||||
language,
|
language,
|
||||||
country,
|
country,
|
||||||
subdivision1,
|
region,
|
||||||
subdivision2,
|
|
||||||
city,
|
city,
|
||||||
tag,
|
tag,
|
||||||
|
distinctId: id,
|
||||||
createdAt,
|
createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +230,7 @@ export async function POST(request: Request) {
|
||||||
websiteId,
|
websiteId,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionData: data,
|
sessionData: data,
|
||||||
|
distinctId: id,
|
||||||
createdAt,
|
createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
|
export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().max(50),
|
name: z.string().max(50).optional(),
|
||||||
accessCode: z.string().max(50),
|
accessCode: z.string().max(50).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.string().max(255),
|
username: z.string().max(255),
|
||||||
password: z.string().max(255).optional(),
|
password: z.string().max(255).optional(),
|
||||||
role: z.string().regex(/admin|user|view-only/i),
|
role: z
|
||||||
|
.string()
|
||||||
|
.regex(/admin|user|view-only/i)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { json } from '@/lib/response';
|
|
||||||
import { CURRENT_VERSION } from '@/lib/constants';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return json({ version: CURRENT_VERSION });
|
|
||||||
}
|
|
||||||
|
|
@ -136,7 +136,15 @@ function getChannels(data: { domain: string; query: string; visitors: number }[]
|
||||||
|
|
||||||
const prefix = /utm_medium=(.*cp.*|ppc|retargeting|paid.*)/.test(query) ? 'paid' : 'organic';
|
const prefix = /utm_medium=(.*cp.*|ppc|retargeting|paid.*)/.test(query) ? 'paid' : 'organic';
|
||||||
|
|
||||||
if (SEARCH_DOMAINS.some(match(domain)) || /utm_medium=organic/.test(query)) {
|
if (PAID_AD_PARAMS.some(match(query))) {
|
||||||
|
channels.paidAds += Number(visitors);
|
||||||
|
} else if (/utm_medium=(referral|app|link)/.test(query)) {
|
||||||
|
channels.referral += Number(visitors);
|
||||||
|
} else if (/utm_medium=affiliate/.test(query)) {
|
||||||
|
channels.affiliate += Number(visitors);
|
||||||
|
} else if (/utm_(source|medium)=sms/.test(query)) {
|
||||||
|
channels.sms += Number(visitors);
|
||||||
|
} else if (SEARCH_DOMAINS.some(match(domain)) || /utm_medium=organic/.test(query)) {
|
||||||
channels[`${prefix}Search`] += Number(visitors);
|
channels[`${prefix}Search`] += Number(visitors);
|
||||||
} else if (
|
} else if (
|
||||||
SOCIAL_DOMAINS.some(match(domain)) ||
|
SOCIAL_DOMAINS.some(match(domain)) ||
|
||||||
|
|
@ -152,14 +160,6 @@ function getChannels(data: { domain: string; query: string; visitors: number }[]
|
||||||
channels[`${prefix}Shopping`] += Number(visitors);
|
channels[`${prefix}Shopping`] += Number(visitors);
|
||||||
} else if (VIDEO_DOMAINS.some(match(domain)) || /utm_medium=(.*video.*)/.test(query)) {
|
} else if (VIDEO_DOMAINS.some(match(domain)) || /utm_medium=(.*video.*)/.test(query)) {
|
||||||
channels[`${prefix}Video`] += Number(visitors);
|
channels[`${prefix}Video`] += Number(visitors);
|
||||||
} else if (PAID_AD_PARAMS.some(match(query))) {
|
|
||||||
channels.paidAds += Number(visitors);
|
|
||||||
} else if (/utm_medium=(referral|app|link)/.test(query)) {
|
|
||||||
channels.referral += Number(visitors);
|
|
||||||
} else if (/utm_medium=affiliate/.test(query)) {
|
|
||||||
channels.affiliate += Number(visitors);
|
|
||||||
} else if (/utm_(source|medium)=sms/.test(query)) {
|
|
||||||
channels.sms += Number(visitors);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export async function POST(
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
domain: z.string(),
|
domain: z.string(),
|
||||||
shareId: z.string().regex(SHARE_ID_REGEX).nullable(),
|
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
|
||||||
1
src/assets/network.svg
Normal file
1
src/assets/network.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg"><g id="_x30_6_network"><path d="m28 19c-.809 0-1.54.325-2.08.847l-6.011-3.01c.058-.271.091-.55.091-.837s-.033-.566-.091-.837l6.011-3.01c.54.522 1.271.847 2.08.847 1.654 0 3-1.346 3-3s-1.346-3-3-3-3 1.346-3 3c0 .123.022.24.036.359l-6.036 3.023c-.521-.597-1.21-1.035-2-1.24v-5.326c1.162-.415 2-1.514 2-2.816 0-1.654-1.346-3-3-3s-3 1.346-3 3c0 1.302.838 2.401 2 2.815v5.327c-.79.205-1.478.643-2 1.24l-6.037-3.022c.015-.12.037-.237.037-.36 0-1.654-1.346-3-3-3s-3 1.346-3 3 1.346 3 3 3c.809 0 1.54-.325 2.08-.847l6.011 3.01c-.058.271-.091.55-.091.837s.033.566.091.837l-6.011 3.01c-.54-.522-1.271-.847-2.08-.847-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3c0-.123-.022-.24-.036-.359l6.036-3.023c.521.597 1.21 1.035 2 1.24v5.326c-1.162.415-2 1.514-2 2.816 0 1.654 1.346 3 3 3s3-1.346 3-3c0-1.302-.838-2.401-2-2.816v-5.326c.79-.205 1.478-.643 2-1.24l6.037 3.022c-.015.12-.037.237-.037.36 0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3zm0-10c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1zm-24 2c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm0 12c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm12-20c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1zm0 26c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm0-11c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2zm12 5c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -7,7 +7,7 @@ const formats = {
|
||||||
millisecond: 'T',
|
millisecond: 'T',
|
||||||
second: 'pp',
|
second: 'pp',
|
||||||
minute: 'p',
|
minute: 'p',
|
||||||
hour: 'h:mm aaa - PP',
|
hour: 'p - PP',
|
||||||
day: 'PPPP',
|
day: 'PPPP',
|
||||||
week: 'PPPP',
|
week: 'PPPP',
|
||||||
month: 'LLLL yyyy',
|
month: 'LLLL yyyy',
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function Chart({
|
||||||
className,
|
className,
|
||||||
chartOptions,
|
chartOptions,
|
||||||
}: ChartProps) {
|
}: ChartProps) {
|
||||||
const canvas = useRef();
|
const canvas = useRef(null);
|
||||||
const chart = useRef(null);
|
const chart = useRef(null);
|
||||||
const [legendItems, setLegendItems] = useState([]);
|
const [legendItems, setLegendItems] = useState([]);
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ export function Chart({
|
||||||
dataset.data = data?.datasets[index]?.data;
|
dataset.data = data?.datasets[index]?.data;
|
||||||
|
|
||||||
if (chart.current.legend.legendItems[index]) {
|
if (chart.current.legend.legendItems[index]) {
|
||||||
chart.current.legend.legendItems[index].text = data?.datasets[index]?.label;
|
chart.current.legend.legendItems[index].text = data.datasets[index]?.label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -95,6 +95,12 @@ export function Chart({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.focusLabel !== null) {
|
||||||
|
chart.current.data.datasets.forEach(ds => {
|
||||||
|
ds.hidden = data.focusLabel ? ds.label !== data.focusLabel : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
chart.current.options = options;
|
chart.current.options = options;
|
||||||
|
|
||||||
// Allow config changes before update
|
// Allow config changes before update
|
||||||
|
|
@ -105,16 +111,6 @@ export function Chart({
|
||||||
setLegendItems(chart.current.legend.legendItems);
|
setLegendItems(chart.current.legend.legendItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
if (!chart.current) {
|
|
||||||
createChart(data);
|
|
||||||
} else {
|
|
||||||
updateChart(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [data, options]);
|
|
||||||
|
|
||||||
const handleLegendClick = (item: LegendItem) => {
|
const handleLegendClick = (item: LegendItem) => {
|
||||||
if (type === 'bar') {
|
if (type === 'bar') {
|
||||||
const { datasetIndex } = item;
|
const { datasetIndex } = item;
|
||||||
|
|
@ -136,6 +132,16 @@ export function Chart({
|
||||||
setLegendItems(chart.current.legend.legendItems);
|
setLegendItems(chart.current.legend.legendItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
if (!chart.current) {
|
||||||
|
createChart(data);
|
||||||
|
} else {
|
||||||
|
updateChart(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, options]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classNames(styles.chart, className)}>
|
<div className={classNames(styles.chart, className)}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { GROUPED_DOMAINS } from '@/lib/constants';
|
import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants';
|
||||||
|
|
||||||
function getHostName(url: string) {
|
function getHostName(url: string) {
|
||||||
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
|
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
|
||||||
|
|
@ -10,10 +10,10 @@ export function Favicon({ domain, ...props }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = process.env.faviconURL || FAVICON_URL;
|
||||||
const hostName = domain ? getHostName(domain) : null;
|
const hostName = domain ? getHostName(domain) : null;
|
||||||
const src = hostName
|
const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName;
|
||||||
? `https://icons.duckduckgo.com/ip3/${GROUPED_DOMAINS[hostName]?.domain || hostName}.ico`
|
const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null;
|
||||||
: null;
|
|
||||||
|
|
||||||
return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null;
|
return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ export function useLogin(): {
|
||||||
user: any;
|
user: any;
|
||||||
setUser: (data: any) => void;
|
setUser: (data: any) => void;
|
||||||
} & UseQueryResult {
|
} & UseQueryResult {
|
||||||
const { get, useQuery } = useApi();
|
const { post, useQuery } = useApi();
|
||||||
const user = useStore(selector);
|
const user = useStore(selector);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['login'],
|
queryKey: ['login'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await get('/auth/verify');
|
const data = await post('/auth/verify');
|
||||||
|
|
||||||
setUser(data);
|
setUser(data);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ export function useFormat() {
|
||||||
return countryNames[value] || value;
|
return countryNames[value] || value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatRegion = (value: string): string => {
|
const formatRegion = (value?: string): string => {
|
||||||
const [country] = value.split('-');
|
const [country] = value?.split('-') || [];
|
||||||
return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value;
|
return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export const labels = defineMessages({
|
||||||
all: { id: 'label.all', defaultMessage: 'All' },
|
all: { id: 'label.all', defaultMessage: 'All' },
|
||||||
session: { id: 'label.session', defaultMessage: 'Session' },
|
session: { id: 'label.session', defaultMessage: 'Session' },
|
||||||
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
|
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
|
||||||
|
distinctId: { id: 'label.distinct-id', defaultMessage: 'Distinct ID' },
|
||||||
pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
|
pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
|
||||||
activity: { id: 'label.activity', defaultMessage: 'Activity' },
|
activity: { id: 'label.activity', defaultMessage: 'Activity' },
|
||||||
dismiss: { id: 'label.dismiss', defaultMessage: 'Dismiss' },
|
dismiss: { id: 'label.dismiss', defaultMessage: 'Dismiss' },
|
||||||
|
|
@ -163,7 +164,13 @@ export const labels = defineMessages({
|
||||||
id: 'label.revenue-description',
|
id: 'label.revenue-description',
|
||||||
defaultMessage: 'Look into your revenue data and how users are spending.',
|
defaultMessage: 'Look into your revenue data and how users are spending.',
|
||||||
},
|
},
|
||||||
|
attribution: { id: 'label.attribution', defaultMessage: 'Attribution' },
|
||||||
|
attributionDescription: {
|
||||||
|
id: 'label.attribution-description',
|
||||||
|
defaultMessage: 'See how users engage with your marketing and what drives conversions.',
|
||||||
|
},
|
||||||
currency: { id: 'label.currency', defaultMessage: 'Currency' },
|
currency: { id: 'label.currency', defaultMessage: 'Currency' },
|
||||||
|
model: { id: 'label.model', defaultMessage: 'Model' },
|
||||||
url: { id: 'label.url', defaultMessage: 'URL' },
|
url: { id: 'label.url', defaultMessage: 'URL' },
|
||||||
urls: { id: 'label.urls', defaultMessage: 'URLs' },
|
urls: { id: 'label.urls', defaultMessage: 'URLs' },
|
||||||
path: { id: 'label.path', defaultMessage: 'Path' },
|
path: { id: 'label.path', defaultMessage: 'Path' },
|
||||||
|
|
@ -257,6 +264,7 @@ export const labels = defineMessages({
|
||||||
id: 'label.utm-description',
|
id: 'label.utm-description',
|
||||||
defaultMessage: 'Track your campaigns through UTM parameters.',
|
defaultMessage: 'Track your campaigns through UTM parameters.',
|
||||||
},
|
},
|
||||||
|
conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion Step' },
|
||||||
steps: { id: 'label.steps', defaultMessage: 'Steps' },
|
steps: { id: 'label.steps', defaultMessage: 'Steps' },
|
||||||
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
|
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
|
||||||
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
|
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
|
||||||
|
|
@ -281,6 +289,11 @@ export const labels = defineMessages({
|
||||||
firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' },
|
firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' },
|
||||||
properties: { id: 'label.properties', defaultMessage: 'Properties' },
|
properties: { id: 'label.properties', defaultMessage: 'Properties' },
|
||||||
channels: { id: 'label.channels', defaultMessage: 'Channels' },
|
channels: { id: 'label.channels', defaultMessage: 'Channels' },
|
||||||
|
sources: { id: 'label.sources', defaultMessage: 'Sources' },
|
||||||
|
medium: { id: 'label.medium', defaultMessage: 'Medium' },
|
||||||
|
campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' },
|
||||||
|
content: { id: 'label.content', defaultMessage: 'Content' },
|
||||||
|
terms: { id: 'label.terms', defaultMessage: 'Terms' },
|
||||||
direct: { id: 'label.direct', defaultMessage: 'Direct' },
|
direct: { id: 'label.direct', defaultMessage: 'Direct' },
|
||||||
referral: { id: 'label.referral', defaultMessage: 'Referral' },
|
referral: { id: 'label.referral', defaultMessage: 'Referral' },
|
||||||
affiliate: { id: 'label.affiliate', defaultMessage: 'Affiliate' },
|
affiliate: { id: 'label.affiliate', defaultMessage: 'Affiliate' },
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
import { colord } from 'colord';
|
import { colord } from 'colord';
|
||||||
import BarChart from '@/components/charts/BarChart';
|
import BarChart from '@/components/charts/BarChart';
|
||||||
import { useDateRange, useLocale, useWebsiteEventsSeries } from '@/components/hooks';
|
import { useDateRange, useLocale, useWebsiteEventsSeries } from '@/components/hooks';
|
||||||
import { renderDateLabels } from '@/lib/charts';
|
import { renderDateLabels } from '@/lib/charts';
|
||||||
import { CHART_COLORS } from '@/lib/constants';
|
import { CHART_COLORS } from '@/lib/constants';
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
export interface EventsChartProps {
|
export interface EventsChartProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
focusLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventsChart({ websiteId, className }: EventsChartProps) {
|
export function EventsChart({ websiteId, className, focusLabel }: EventsChartProps) {
|
||||||
const {
|
const {
|
||||||
dateRange: { startDate, endDate, unit, value },
|
dateRange: { startDate, endDate, unit, value },
|
||||||
} = useDateRange(websiteId);
|
} = useDateRange(websiteId);
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { data, isLoading } = useWebsiteEventsSeries(websiteId);
|
const { data, isLoading } = useWebsiteEventsSeries(websiteId);
|
||||||
|
const [label, setLabel] = useState<string>(focusLabel);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
@ -42,8 +44,15 @@ export function EventsChart({ websiteId, className }: EventsChartProps) {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
focusLabel,
|
||||||
};
|
};
|
||||||
}, [data, startDate, endDate, unit]);
|
}, [data, startDate, endDate, unit, focusLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (label !== focusLabel) {
|
||||||
|
setLabel(focusLabel);
|
||||||
|
}
|
||||||
|
}, [focusLabel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BarChart
|
<BarChart
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,28 @@
|
||||||
import MetricsTable, { MetricsTableProps } from './MetricsTable';
|
import MetricsTable, { MetricsTableProps } from './MetricsTable';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
export function EventsTable(props: MetricsTableProps) {
|
export interface EventsTableProps extends MetricsTableProps {
|
||||||
|
onLabelClick?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
function handleDataLoad(data: any) {
|
const handleDataLoad = (data: any) => {
|
||||||
props.onDataLoad?.(data);
|
props.onDataLoad?.(data);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const renderLabel = ({ x: label }) => {
|
||||||
|
if (onLabelClick) {
|
||||||
|
return (
|
||||||
|
<div onClick={() => onLabelClick(label)} style={{ cursor: 'pointer' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<MetricsTable
|
||||||
|
|
@ -15,6 +31,7 @@ export function EventsTable(props: MetricsTableProps) {
|
||||||
type="event"
|
type="event"
|
||||||
metric={formatMessage(labels.actions)}
|
metric={formatMessage(labels.actions)}
|
||||||
onDataLoad={handleDataLoad}
|
onDataLoad={handleDataLoad}
|
||||||
|
renderLabel={renderLabel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { FixedSizeList } from 'react-window';
|
|
||||||
import { useSpring, animated, config } from '@react-spring/web';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import Empty from '@/components/common/Empty';
|
import Empty from '@/components/common/Empty';
|
||||||
import { formatLongNumber } from '@/lib/format';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
import styles from './ListTable.module.css';
|
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
||||||
|
import { animated, config, useSpring } from '@react-spring/web';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { FixedSizeList } from 'react-window';
|
||||||
|
import styles from './ListTable.module.css';
|
||||||
|
|
||||||
const ITEM_SIZE = 30;
|
const ITEM_SIZE = 30;
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface ListTableProps {
|
||||||
virtualize?: boolean;
|
virtualize?: boolean;
|
||||||
showPercentage?: boolean;
|
showPercentage?: boolean;
|
||||||
itemCount?: number;
|
itemCount?: number;
|
||||||
|
currency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListTable({
|
export function ListTable({
|
||||||
|
|
@ -33,6 +34,7 @@ export function ListTable({
|
||||||
virtualize = false,
|
virtualize = false,
|
||||||
showPercentage = true,
|
showPercentage = true,
|
||||||
itemCount = 10,
|
itemCount = 10,
|
||||||
|
currency,
|
||||||
}: ListTableProps) {
|
}: ListTableProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
|
@ -48,6 +50,7 @@ export function ListTable({
|
||||||
animate={animate && !virtualize}
|
animate={animate && !virtualize}
|
||||||
showPercentage={showPercentage}
|
showPercentage={showPercentage}
|
||||||
change={renderChange ? renderChange(row, index) : null}
|
change={renderChange ? renderChange(row, index) : null}
|
||||||
|
currency={currency}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -81,7 +84,15 @@ export function ListTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedRow = ({ label, value = 0, percent, change, animate, showPercentage = true }) => {
|
const AnimatedRow = ({
|
||||||
|
label,
|
||||||
|
value = 0,
|
||||||
|
percent,
|
||||||
|
change,
|
||||||
|
animate,
|
||||||
|
showPercentage = true,
|
||||||
|
currency,
|
||||||
|
}) => {
|
||||||
const props = useSpring({
|
const props = useSpring({
|
||||||
width: percent,
|
width: percent,
|
||||||
y: value,
|
y: value,
|
||||||
|
|
@ -95,7 +106,9 @@ const AnimatedRow = ({ label, value = 0, percent, change, animate, showPercentag
|
||||||
<div className={styles.value}>
|
<div className={styles.value}>
|
||||||
{change}
|
{change}
|
||||||
<animated.div className={styles.value} title={props?.y as any}>
|
<animated.div className={styles.value} title={props?.y as any}>
|
||||||
{props.y?.to(formatLongNumber)}
|
{currency
|
||||||
|
? props.y?.to(n => formatLongCurrency(n, currency))
|
||||||
|
: props.y?.to(formatLongNumber)}
|
||||||
</animated.div>
|
</animated.div>
|
||||||
</div>
|
</div>
|
||||||
{showPercentage && (
|
{showPercentage && (
|
||||||
|
|
|
||||||
|
|
@ -60,19 +60,24 @@ export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDomain = (x: string) => {
|
||||||
|
for (const { domain, match } of GROUPED_DOMAINS) {
|
||||||
|
if (Array.isArray(match) ? match.some(str => x.includes(str)) : x.includes(match)) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '_other';
|
||||||
|
};
|
||||||
|
|
||||||
const groupedFilter = (data: any[]) => {
|
const groupedFilter = (data: any[]) => {
|
||||||
const groups = { _other: 0 };
|
const groups = { _other: 0 };
|
||||||
|
|
||||||
for (const { x, y } of data) {
|
for (const { x, y } of data) {
|
||||||
for (const { domain, match } of GROUPED_DOMAINS) {
|
const domain = getDomain(x);
|
||||||
if (Array.isArray(match) ? match.some(str => x.includes(str)) : x.includes(match)) {
|
if (!groups[domain]) {
|
||||||
if (!groups[domain]) {
|
groups[domain] = 0;
|
||||||
groups[domain] = 0;
|
|
||||||
}
|
|
||||||
groups[domain] += +y;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
groups._other += +y;
|
groups[domain] += +y;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(groups)
|
return Object.keys(groups)
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@
|
||||||
"label.add-step": "Ajouter une étape",
|
"label.add-step": "Ajouter une étape",
|
||||||
"label.add-website": "Ajouter un site",
|
"label.add-website": "Ajouter un site",
|
||||||
"label.admin": "Administrateur",
|
"label.admin": "Administrateur",
|
||||||
|
"label.affiliate": "Affiliation",
|
||||||
"label.after": "Après",
|
"label.after": "Après",
|
||||||
"label.all": "Tout",
|
"label.all": "Tout",
|
||||||
"label.all-time": "Toutes les données",
|
"label.all-time": "Toutes les données",
|
||||||
"label.analytics": "Analytics",
|
"label.analytics": "Analytics",
|
||||||
|
"label.attribution": "Attribution",
|
||||||
|
"label.attribution-description": "Découvrez comment les utilisateurs s'engagent avec votre marketing et ce qui génère des conversions.",
|
||||||
"label.average": "Moyenne",
|
"label.average": "Moyenne",
|
||||||
"label.back": "Retour",
|
"label.back": "Retour",
|
||||||
"label.before": "Avant",
|
"label.before": "Avant",
|
||||||
|
|
@ -19,17 +22,21 @@
|
||||||
"label.breakdown": "Répartition",
|
"label.breakdown": "Répartition",
|
||||||
"label.browser": "Navigateur",
|
"label.browser": "Navigateur",
|
||||||
"label.browsers": "Navigateurs",
|
"label.browsers": "Navigateurs",
|
||||||
|
"label.campaigns": "Campagnes",
|
||||||
"label.cancel": "Annuler",
|
"label.cancel": "Annuler",
|
||||||
"label.change-password": "Changer le mot de passe",
|
"label.change-password": "Changer le mot de passe",
|
||||||
|
"label.channels": "Canaux",
|
||||||
"label.cities": "Villes",
|
"label.cities": "Villes",
|
||||||
"label.city": "Ville",
|
"label.city": "Ville",
|
||||||
"label.clear-all": "Réinitialiser",
|
"label.clear-all": "Réinitialiser",
|
||||||
"label.compare": "Compare",
|
"label.compare": "Comparer",
|
||||||
"label.confirm": "Confirmer",
|
"label.confirm": "Confirmer",
|
||||||
"label.confirm-password": "Confirmation du mot de passe",
|
"label.confirm-password": "Confirmation du mot de passe",
|
||||||
"label.contains": "Contient",
|
"label.contains": "Contient",
|
||||||
|
"label.content": "Contenu",
|
||||||
"label.continue": "Continuer",
|
"label.continue": "Continuer",
|
||||||
"label.count": "Count",
|
"label.conversion-step": "Étape de conversion",
|
||||||
|
"label.count": "Compte",
|
||||||
"label.countries": "Pays",
|
"label.countries": "Pays",
|
||||||
"label.country": "Pays",
|
"label.country": "Pays",
|
||||||
"label.create": "Créer",
|
"label.create": "Créer",
|
||||||
|
|
@ -37,8 +44,9 @@
|
||||||
"label.create-team": "Créer une équipe",
|
"label.create-team": "Créer une équipe",
|
||||||
"label.create-user": "Créer un utilisateur",
|
"label.create-user": "Créer un utilisateur",
|
||||||
"label.created": "Créé",
|
"label.created": "Créé",
|
||||||
"label.created-by": "Crée par",
|
"label.created-by": "Créé par",
|
||||||
"label.current": "Current",
|
"label.currency": "Devise",
|
||||||
|
"label.current": "Actuel",
|
||||||
"label.current-password": "Mot de passe actuel",
|
"label.current-password": "Mot de passe actuel",
|
||||||
"label.custom-range": "Période personnalisée",
|
"label.custom-range": "Période personnalisée",
|
||||||
"label.dashboard": "Tableau de bord",
|
"label.dashboard": "Tableau de bord",
|
||||||
|
|
@ -57,6 +65,7 @@
|
||||||
"label.details": "Détails",
|
"label.details": "Détails",
|
||||||
"label.device": "Appareil",
|
"label.device": "Appareil",
|
||||||
"label.devices": "Appareils",
|
"label.devices": "Appareils",
|
||||||
|
"label.direct": "Direct",
|
||||||
"label.dismiss": "Ignorer",
|
"label.dismiss": "Ignorer",
|
||||||
"label.does-not-contain": "Ne contient pas",
|
"label.does-not-contain": "Ne contient pas",
|
||||||
"label.domain": "Domaine",
|
"label.domain": "Domaine",
|
||||||
|
|
@ -64,13 +73,14 @@
|
||||||
"label.edit": "Modifier",
|
"label.edit": "Modifier",
|
||||||
"label.edit-dashboard": "Modifier le tableau de bord",
|
"label.edit-dashboard": "Modifier le tableau de bord",
|
||||||
"label.edit-member": "Modifier le membre",
|
"label.edit-member": "Modifier le membre",
|
||||||
|
"label.email": "E-mail",
|
||||||
"label.enable-share-url": "Activer l'URL de partage",
|
"label.enable-share-url": "Activer l'URL de partage",
|
||||||
"label.end-step": "End Step",
|
"label.end-step": "Étape de fin",
|
||||||
"label.entry": "URL d'entrée",
|
"label.entry": "Chemin d'entrée",
|
||||||
"label.event": "Évènement",
|
"label.event": "Évènement",
|
||||||
"label.event-data": "Données d'évènements",
|
"label.event-data": "Données d'évènements",
|
||||||
"label.events": "Évènements",
|
"label.events": "Évènements",
|
||||||
"label.exit": "Exit URL",
|
"label.exit": "Chemin de sortie",
|
||||||
"label.false": "Faux",
|
"label.false": "Faux",
|
||||||
"label.field": "Champ",
|
"label.field": "Champ",
|
||||||
"label.fields": "Champs",
|
"label.fields": "Champs",
|
||||||
|
|
@ -80,31 +90,32 @@
|
||||||
"label.filters": "Filtres",
|
"label.filters": "Filtres",
|
||||||
"label.first-seen": "Vu pour la première fois",
|
"label.first-seen": "Vu pour la première fois",
|
||||||
"label.funnel": "Entonnoir",
|
"label.funnel": "Entonnoir",
|
||||||
"label.funnel-description": "Suivi des conversions et des taux d'abandons.",
|
"label.funnel-description": "Comprenez les taux de conversions et d'abandons des utilisateurs.",
|
||||||
"label.goal": "Goal",
|
"label.goal": "Objectif",
|
||||||
"label.goals": "Goals",
|
"label.goals": "Objectifs",
|
||||||
"label.goals-description": "Suivez vos objectifs en matière de pages vues et d'événements.",
|
"label.goals-description": "Suivez vos objectifs en matière de pages vues et d'événements.",
|
||||||
"label.greater-than": "Supérieur à",
|
"label.greater-than": "Supérieur à",
|
||||||
"label.greater-than-equals": "Supérieur ou égal à",
|
"label.greater-than-equals": "Supérieur ou égal à",
|
||||||
"label.host": "Host",
|
"label.grouped": "Groupé",
|
||||||
"label.hosts": "Hosts",
|
"label.host": "Hôte",
|
||||||
|
"label.hosts": "Hôtes",
|
||||||
"label.insights": "Insights",
|
"label.insights": "Insights",
|
||||||
"label.insights-description": "Analyse précise des données en utilisant des segments et des filtres.",
|
"label.insights-description": "Analysez précisément vos données en utilisant des segments et des filtres.",
|
||||||
"label.is": "Est",
|
"label.is": "Est",
|
||||||
"label.is-not": "N'est pas",
|
"label.is-not": "N'est pas",
|
||||||
"label.is-not-set": "N'est pas défini",
|
"label.is-not-set": "N'est pas défini",
|
||||||
"label.is-set": "Est défini",
|
"label.is-set": "Est défini",
|
||||||
"label.join": "Rejoindre",
|
"label.join": "Rejoindre",
|
||||||
"label.join-team": "Rejoindre une équipe",
|
"label.join-team": "Rejoindre une équipe",
|
||||||
"label.journey": "Journey",
|
"label.journey": "Parcours",
|
||||||
"label.journey-description": "Comprendre comment les utilisateurs naviguent sur votre site web.",
|
"label.journey-description": "Comprennez comment les utilisateurs naviguent sur votre site.",
|
||||||
"label.language": "Langue",
|
"label.language": "Langue",
|
||||||
"label.languages": "Langues",
|
"label.languages": "Langues",
|
||||||
"label.laptop": "Portable",
|
"label.laptop": "Portable",
|
||||||
"label.last-days": "{x} derniers jours",
|
"label.last-days": "{x} derniers jours",
|
||||||
"label.last-hours": "{x} dernières heures",
|
"label.last-hours": "{x} dernières heures",
|
||||||
"label.last-months": "{x} derniers mois",
|
"label.last-months": "{x} derniers mois",
|
||||||
"label.last-seen": "Last seen",
|
"label.last-seen": "Vu pour la dernière fois",
|
||||||
"label.leave": "Quitter",
|
"label.leave": "Quitter",
|
||||||
"label.leave-team": "Quitter l'équipe",
|
"label.leave-team": "Quitter l'équipe",
|
||||||
"label.less-than": "Inférieur à",
|
"label.less-than": "Inférieur à",
|
||||||
|
|
@ -114,10 +125,12 @@
|
||||||
"label.manage": "Gérer",
|
"label.manage": "Gérer",
|
||||||
"label.manager": "Manager",
|
"label.manager": "Manager",
|
||||||
"label.max": "Max",
|
"label.max": "Max",
|
||||||
|
"label.medium": "Support",
|
||||||
"label.member": "Membre",
|
"label.member": "Membre",
|
||||||
"label.members": "Membres",
|
"label.members": "Membres",
|
||||||
"label.min": "Min",
|
"label.min": "Min",
|
||||||
"label.mobile": "Téléphone",
|
"label.mobile": "Téléphone",
|
||||||
|
"label.model": "Modèle",
|
||||||
"label.more": "Plus",
|
"label.more": "Plus",
|
||||||
"label.my-account": "Mon compte",
|
"label.my-account": "Mon compte",
|
||||||
"label.my-websites": "Mes sites",
|
"label.my-websites": "Mes sites",
|
||||||
|
|
@ -126,16 +139,26 @@
|
||||||
"label.none": "Aucun",
|
"label.none": "Aucun",
|
||||||
"label.number-of-records": "{x} {x, plural, one {enregistrement} other {enregistrements}}",
|
"label.number-of-records": "{x} {x, plural, one {enregistrement} other {enregistrements}}",
|
||||||
"label.ok": "OK",
|
"label.ok": "OK",
|
||||||
|
"label.organic-search": "Recherche organique",
|
||||||
|
"label.organic-shopping": "E-commerce organique",
|
||||||
|
"label.organic-social": "Réseau social organique",
|
||||||
|
"label.organic-video": "Vidéo organique",
|
||||||
"label.os": "OS",
|
"label.os": "OS",
|
||||||
|
"label.other": "Autre",
|
||||||
"label.overview": "Vue d'ensemble",
|
"label.overview": "Vue d'ensemble",
|
||||||
"label.owner": "Propriétaire",
|
"label.owner": "Propriétaire",
|
||||||
"label.page-of": "Page {current} sur {total}",
|
"label.page-of": "Page {current} sur {total}",
|
||||||
"label.page-views": "Pages vues",
|
"label.page-views": "Pages vues",
|
||||||
"label.pageTitle": "Titre de page",
|
"label.pageTitle": "Titre de page",
|
||||||
"label.pages": "Pages",
|
"label.pages": "Pages",
|
||||||
|
"label.paid-ads": "Publicités payantes",
|
||||||
|
"label.paid-search": "Recherche payante",
|
||||||
|
"label.paid-shopping": "E-commerce payant",
|
||||||
|
"label.paid-social": "Réseau social payant",
|
||||||
|
"label.paid-video": "Vidéo payante",
|
||||||
"label.password": "Mot de passe",
|
"label.password": "Mot de passe",
|
||||||
"label.path": "Path",
|
"label.path": "Chemin",
|
||||||
"label.paths": "Paths",
|
"label.paths": "Chemins",
|
||||||
"label.powered-by": "Propulsé par {name}",
|
"label.powered-by": "Propulsé par {name}",
|
||||||
"label.previous": "Précédent",
|
"label.previous": "Précédent",
|
||||||
"label.previous-period": "Période précédente",
|
"label.previous-period": "Période précédente",
|
||||||
|
|
@ -147,6 +170,7 @@
|
||||||
"label.query": "Requête",
|
"label.query": "Requête",
|
||||||
"label.query-parameters": "Paramètres de requête",
|
"label.query-parameters": "Paramètres de requête",
|
||||||
"label.realtime": "Temps réel",
|
"label.realtime": "Temps réel",
|
||||||
|
"label.referral": "Référent",
|
||||||
"label.referrer": "Site référent",
|
"label.referrer": "Site référent",
|
||||||
"label.referrers": "Sites référents",
|
"label.referrers": "Sites référents",
|
||||||
"label.refresh": "Rafraîchir",
|
"label.refresh": "Rafraîchir",
|
||||||
|
|
@ -160,28 +184,32 @@
|
||||||
"label.reset": "Réinitialiser",
|
"label.reset": "Réinitialiser",
|
||||||
"label.reset-website": "Réinitialiser les statistiques",
|
"label.reset-website": "Réinitialiser les statistiques",
|
||||||
"label.retention": "Rétention",
|
"label.retention": "Rétention",
|
||||||
"label.retention-description": "Mesure de l'attractivité du site en visualisant les taux de visiteurs qui reviennent.",
|
"label.retention-description": "Mesurez l'attractivité de votre site en suivant la fréquence de retour des utilisateurs.",
|
||||||
"label.revenue": "Revenue",
|
"label.revenue": "Recettes",
|
||||||
"label.revenue-description": "Examinez vos revenus au fil du temps.",
|
"label.revenue-description": "Examinez vos recettes et comment dépensent vos utilisateurs.",
|
||||||
"label.revenue-property": "Propriétés des revenues",
|
|
||||||
"label.role": "Rôle",
|
"label.role": "Rôle",
|
||||||
"label.run-query": "Éxécuter la requête",
|
"label.run-query": "Exécuter la requête",
|
||||||
"label.save": "Enregistrer",
|
"label.save": "Enregistrer",
|
||||||
"label.screens": "Résolutions d'écran",
|
"label.screens": "Résolutions d'écran",
|
||||||
"label.search": "Rechercher",
|
"label.search": "Rechercher",
|
||||||
"label.select": "Selectionner",
|
"label.select": "Sélectionner",
|
||||||
"label.select-date": "Choisir une période",
|
"label.select-date": "Choisir une période",
|
||||||
"label.select-role": "Choisir un rôle",
|
"label.select-role": "Choisir un rôle",
|
||||||
"label.select-website": "Choisir un site",
|
"label.select-website": "Choisir un site",
|
||||||
"label.session": "Session",
|
"label.session": "Session",
|
||||||
|
"label.session-data": "Session data",
|
||||||
"label.sessions": "Sessions",
|
"label.sessions": "Sessions",
|
||||||
"label.settings": "Paramètres",
|
"label.settings": "Paramètres",
|
||||||
"label.share-url": "URL de partage",
|
"label.share-url": "URL de partage",
|
||||||
"label.single-day": "Journée",
|
"label.single-day": "Journée",
|
||||||
"label.start-step": "Etape de démarrage",
|
"label.sms": "SMS",
|
||||||
|
"label.sources": "Sources",
|
||||||
|
"label.start-step": "Étape de départ",
|
||||||
"label.steps": "Étapes",
|
"label.steps": "Étapes",
|
||||||
"label.sum": "Somme",
|
"label.sum": "Somme",
|
||||||
"label.tablet": "Tablette",
|
"label.tablet": "Tablette",
|
||||||
|
"label.tag": "Tag",
|
||||||
|
"label.tags": "Tags",
|
||||||
"label.team": "Équipe",
|
"label.team": "Équipe",
|
||||||
"label.team-id": "ID d'équipe",
|
"label.team-id": "ID d'équipe",
|
||||||
"label.team-manager": "Manager de l'équipe",
|
"label.team-manager": "Manager de l'équipe",
|
||||||
|
|
@ -191,6 +219,7 @@
|
||||||
"label.team-view-only": "Vue d'équipe uniquement",
|
"label.team-view-only": "Vue d'équipe uniquement",
|
||||||
"label.team-websites": "Sites d'équipes",
|
"label.team-websites": "Sites d'équipes",
|
||||||
"label.teams": "Équipes",
|
"label.teams": "Équipes",
|
||||||
|
"label.terms": "Mots clés",
|
||||||
"label.theme": "Thème",
|
"label.theme": "Thème",
|
||||||
"label.this-month": "Ce mois",
|
"label.this-month": "Ce mois",
|
||||||
"label.this-week": "Cette semaine",
|
"label.this-week": "Cette semaine",
|
||||||
|
|
@ -216,18 +245,17 @@
|
||||||
"label.url": "URL",
|
"label.url": "URL",
|
||||||
"label.urls": "URLs",
|
"label.urls": "URLs",
|
||||||
"label.user": "Utilisateur",
|
"label.user": "Utilisateur",
|
||||||
"label.user-property": "Propriétés d'utilisateurs",
|
|
||||||
"label.username": "Nom d'utilisateur",
|
"label.username": "Nom d'utilisateur",
|
||||||
"label.users": "Utilisateurs",
|
"label.users": "Utilisateurs",
|
||||||
"label.utm": "UTM",
|
"label.utm": "UTM",
|
||||||
"label.utm-description": "Suivi de campagnes via les paramètres UTM.",
|
"label.utm-description": "Suivez vos campagnes via les paramètres UTM.",
|
||||||
"label.value": "Valeur",
|
"label.value": "Valeur",
|
||||||
"label.view": "Voir",
|
"label.view": "Voir",
|
||||||
"label.view-details": "Voir les détails",
|
"label.view-details": "Voir les détails",
|
||||||
"label.view-only": "Consultation",
|
"label.view-only": "Consultation",
|
||||||
"label.views": "Vues",
|
"label.views": "Vues",
|
||||||
"label.views-per-visit": "Vues par visite",
|
"label.views-per-visit": "Vues par visite",
|
||||||
"label.visit-duration": "Temps de visite moyen",
|
"label.visit-duration": "Temps de visite",
|
||||||
"label.visitors": "Visiteurs",
|
"label.visitors": "Visiteurs",
|
||||||
"label.visits": "Visites",
|
"label.visits": "Visites",
|
||||||
"label.website": "Site",
|
"label.website": "Site",
|
||||||
|
|
@ -237,7 +265,7 @@
|
||||||
"label.yesterday": "Hier",
|
"label.yesterday": "Hier",
|
||||||
"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.collected-data": "Collected data",
|
"message.collected-data": "Donnée collectée",
|
||||||
"message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?",
|
"message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?",
|
||||||
"message.confirm-leave": "Êtes-vous sûr de vouloir quitter {target} ?",
|
"message.confirm-leave": "Êtes-vous sûr de vouloir quitter {target} ?",
|
||||||
"message.confirm-remove": "Êtes-vous sûr de vouloir retirer {target} ?",
|
"message.confirm-remove": "Êtes-vous sûr de vouloir retirer {target} ?",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@
|
||||||
"label.browser": "浏览器",
|
"label.browser": "浏览器",
|
||||||
"label.browsers": "浏览器",
|
"label.browsers": "浏览器",
|
||||||
"label.cancel": "取消",
|
"label.cancel": "取消",
|
||||||
"label.change-password": "更新密码",
|
"label.change-password": "修改密码",
|
||||||
|
"label.channels": "渠道",
|
||||||
"label.cities": "市/县",
|
"label.cities": "市/县",
|
||||||
"label.city": "市/县",
|
"label.city": "市/县",
|
||||||
"label.clear-all": "清除全部",
|
"label.clear-all": "清除全部",
|
||||||
|
|
@ -38,10 +39,10 @@
|
||||||
"label.create-user": "创建用户",
|
"label.create-user": "创建用户",
|
||||||
"label.created": "已创建",
|
"label.created": "已创建",
|
||||||
"label.created-by": "创建者",
|
"label.created-by": "创建者",
|
||||||
"label.current": "目前",
|
"label.current": "当前",
|
||||||
"label.current-password": "目前密码",
|
"label.current-password": "当前密码",
|
||||||
"label.custom-range": "自定义时间段",
|
"label.custom-range": "自定义时间段",
|
||||||
"label.dashboard": "仪表板",
|
"label.dashboard": "仪表盘",
|
||||||
"label.data": "统计数据",
|
"label.data": "统计数据",
|
||||||
"label.date": "日期",
|
"label.date": "日期",
|
||||||
"label.date-range": "时间段",
|
"label.date-range": "时间段",
|
||||||
|
|
@ -62,7 +63,7 @@
|
||||||
"label.domain": "域名",
|
"label.domain": "域名",
|
||||||
"label.dropoff": "丢弃",
|
"label.dropoff": "丢弃",
|
||||||
"label.edit": "编辑",
|
"label.edit": "编辑",
|
||||||
"label.edit-dashboard": "编辑仪表板",
|
"label.edit-dashboard": "编辑仪表盘",
|
||||||
"label.edit-member": "编辑成员",
|
"label.edit-member": "编辑成员",
|
||||||
"label.enable-share-url": "启用共享链接",
|
"label.enable-share-url": "启用共享链接",
|
||||||
"label.end-step": "结束步骤",
|
"label.end-step": "结束步骤",
|
||||||
|
|
@ -80,7 +81,7 @@
|
||||||
"label.filters": "筛选",
|
"label.filters": "筛选",
|
||||||
"label.first-seen": "首次出现",
|
"label.first-seen": "首次出现",
|
||||||
"label.funnel": "分析",
|
"label.funnel": "分析",
|
||||||
"label.funnel-description": "了解用户的转换率和退出率。",
|
"label.funnel-description": "了解用户的转化率和跳出率。",
|
||||||
"label.goal": "目标",
|
"label.goal": "目标",
|
||||||
"label.goals": "目标",
|
"label.goals": "目标",
|
||||||
"label.goals-description": "跟踪页面浏览量和事件的目标。",
|
"label.goals-description": "跟踪页面浏览量和事件的目标。",
|
||||||
|
|
@ -141,7 +142,7 @@
|
||||||
"label.previous-period": "上一时期",
|
"label.previous-period": "上一时期",
|
||||||
"label.previous-year": "上一年",
|
"label.previous-year": "上一年",
|
||||||
"label.profile": "个人资料",
|
"label.profile": "个人资料",
|
||||||
"label.properties": "Properties",
|
"label.properties": "属性",
|
||||||
"label.property": "属性",
|
"label.property": "属性",
|
||||||
"label.queries": "查询",
|
"label.queries": "查询",
|
||||||
"label.query": "查询",
|
"label.query": "查询",
|
||||||
|
|
@ -160,9 +161,9 @@
|
||||||
"label.reset": "重置",
|
"label.reset": "重置",
|
||||||
"label.reset-website": "重置统计数据",
|
"label.reset-website": "重置统计数据",
|
||||||
"label.retention": "保留",
|
"label.retention": "保留",
|
||||||
"label.retention-description": "通过跟踪用户返回的频率来衡量网站的用户粘性。",
|
"label.retention-description": "通过追踪用户回访频率来衡量您网站的用户粘性。",
|
||||||
"label.revenue": "收入",
|
"label.revenue": "收入",
|
||||||
"label.revenue-description": "查看您的收入随时间的变化。",
|
"label.revenue-description": "查看随时间变化的收入数据。",
|
||||||
"label.revenue-property": "收入值",
|
"label.revenue-property": "收入值",
|
||||||
"label.role": "角色",
|
"label.role": "角色",
|
||||||
"label.run-query": "查询",
|
"label.run-query": "查询",
|
||||||
|
|
@ -170,7 +171,7 @@
|
||||||
"label.screens": "屏幕尺寸",
|
"label.screens": "屏幕尺寸",
|
||||||
"label.search": "搜索",
|
"label.search": "搜索",
|
||||||
"label.select": "选择",
|
"label.select": "选择",
|
||||||
"label.select-date": "选择数据",
|
"label.select-date": "选择日期",
|
||||||
"label.select-role": "选择角色",
|
"label.select-role": "选择角色",
|
||||||
"label.select-website": "选择网站",
|
"label.select-website": "选择网站",
|
||||||
"label.session": "Session",
|
"label.session": "Session",
|
||||||
|
|
@ -184,7 +185,7 @@
|
||||||
"label.tablet": "平板",
|
"label.tablet": "平板",
|
||||||
"label.team": "团队",
|
"label.team": "团队",
|
||||||
"label.team-id": "团队 ID",
|
"label.team-id": "团队 ID",
|
||||||
"label.team-manager": "团队管理者",
|
"label.team-manager": "团队管理员",
|
||||||
"label.team-member": "团队成员",
|
"label.team-member": "团队成员",
|
||||||
"label.team-name": "团队名称",
|
"label.team-name": "团队名称",
|
||||||
"label.team-owner": "团队所有者",
|
"label.team-owner": "团队所有者",
|
||||||
|
|
@ -220,14 +221,14 @@
|
||||||
"label.username": "用户名",
|
"label.username": "用户名",
|
||||||
"label.users": "用户",
|
"label.users": "用户",
|
||||||
"label.utm": "UTM",
|
"label.utm": "UTM",
|
||||||
"label.utm-description": "通过UTM参数追踪您的广告活动。",
|
"label.utm-description": "通过 UTM 参数追踪您的广告活动。",
|
||||||
"label.value": "值",
|
"label.value": "值",
|
||||||
"label.view": "查看",
|
"label.view": "查看",
|
||||||
"label.view-details": "查看更多",
|
"label.view-details": "查看更多",
|
||||||
"label.view-only": "仅浏览量",
|
"label.view-only": "仅浏览",
|
||||||
"label.views": "浏览量",
|
"label.views": "浏览量",
|
||||||
"label.views-per-visit": "每次访问的浏览量",
|
"label.views-per-visit": "每次访问的浏览量",
|
||||||
"label.visit-duration": "平均访问时间",
|
"label.visit-duration": "平均访问时长",
|
||||||
"label.visitors": "访客",
|
"label.visitors": "访客",
|
||||||
"label.visits": "访问次数",
|
"label.visits": "访问次数",
|
||||||
"label.website": "网站",
|
"label.website": "网站",
|
||||||
|
|
@ -235,41 +236,41 @@
|
||||||
"label.websites": "网站",
|
"label.websites": "网站",
|
||||||
"label.window": "窗口",
|
"label.window": "窗口",
|
||||||
"label.yesterday": "昨天",
|
"label.yesterday": "昨天",
|
||||||
"message.action-confirmation": "在下面的框中输入 {confirmation} 以确认。",
|
"message.action-confirmation": "请在下方输入框中输入 {confirmation} 以确认操作。",
|
||||||
"message.active-users": "当前在线 {x} 人",
|
"message.active-users": "当前在线 {x} 位访客",
|
||||||
"message.collected-data": "已收集的数据",
|
"message.collected-data": "已收集的数据",
|
||||||
"message.confirm-delete": "你确定要删除 {target} 吗?",
|
"message.confirm-delete": "你确定要删除 {target} 吗?",
|
||||||
"message.confirm-leave": "你确定要离开 {target} 吗?",
|
"message.confirm-leave": "你确定要离开 {target} 吗?",
|
||||||
"message.confirm-remove": "您确定要移除 {target} ?",
|
"message.confirm-remove": "您确定要移除 {target} ?",
|
||||||
"message.confirm-reset": "您确定要重置 {target} 的数据吗?",
|
"message.confirm-reset": "您确定要重置 {target} 的数据吗?",
|
||||||
"message.delete-team-warning": "删除团队也会删除所有团队的网站。",
|
"message.delete-team-warning": "删除团队也会删除所有团队网站。",
|
||||||
"message.delete-website-warning": "所有相关数据将会被删除。",
|
"message.delete-website-warning": "所有相关数据将会被删除。",
|
||||||
"message.error": "出现错误。",
|
"message.error": "发生错误。",
|
||||||
"message.event-log": "{url} 上的 {event}",
|
"message.event-log": "{url} 上的 {event}",
|
||||||
"message.go-to-settings": "去设置",
|
"message.go-to-settings": "去设置",
|
||||||
"message.incorrect-username-password": "用户名或密码不正确。",
|
"message.incorrect-username-password": "用户名或密码不正确。",
|
||||||
"message.invalid-domain": "无效域名",
|
"message.invalid-domain": "无效域名",
|
||||||
"message.min-password-length": "密码最短长度为 {n} 个字符",
|
"message.min-password-length": "密码最短长度为 {n} 个字符",
|
||||||
"message.new-version-available": "Umami 的新版本 {version} 已推出!",
|
"message.new-version-available": "Umami 新版本 {version} 已发布!",
|
||||||
"message.no-data-available": "无可用数据。",
|
"message.no-data-available": "暂无数据。",
|
||||||
"message.no-event-data": "无可用事件。",
|
"message.no-event-data": "无可用事件。",
|
||||||
"message.no-match-password": "密码不一致",
|
"message.no-match-password": "密码不一致",
|
||||||
"message.no-results-found": "没有找到任何结果。",
|
"message.no-results-found": "未找到结果。",
|
||||||
"message.no-team-websites": "这个团队没有任何网站。",
|
"message.no-team-websites": "该团队暂无网站。",
|
||||||
"message.no-teams": "你还没有创建任何团队。",
|
"message.no-teams": "您尚未创建任何团队。",
|
||||||
"message.no-users": "没有任何用户。",
|
"message.no-users": "暂无用户。",
|
||||||
"message.no-websites-configured": "你还没有设置任何网站。",
|
"message.no-websites-configured": "你还没有设置任何网站。",
|
||||||
"message.page-not-found": "网页未找到。",
|
"message.page-not-found": "页面未找到。",
|
||||||
"message.reset-website": "如果确定重置该网站,请在下面的输入框中输入 {confirmation} 进行二次确认。",
|
"message.reset-website": "如确定要重置该网站,请在下面输入 {confirmation} 以确认。",
|
||||||
"message.reset-website-warning": "本网站的所有统计数据将被删除,但您的跟踪代码将保持不变。",
|
"message.reset-website-warning": "此网站的所有统计数据将被删除,但您的跟踪代码将保持不变。",
|
||||||
"message.saved": "保存成功。",
|
"message.saved": "保存成功。",
|
||||||
"message.share-url": "这是 {target} 的共享链接。",
|
"message.share-url": "这是 {target} 的共享链接。",
|
||||||
"message.team-already-member": "你已经是该团队的成员。",
|
"message.team-already-member": "你已是该团队的成员。",
|
||||||
"message.team-not-found": "未找到团队。",
|
"message.team-not-found": "未找到团队。",
|
||||||
"message.team-websites-info": "团队中的任何人都可查看网站。",
|
"message.team-websites-info": "团队成员均可查看网站数据。",
|
||||||
"message.tracking-code": "跟踪代码",
|
"message.tracking-code": "跟踪代码",
|
||||||
"message.transfer-team-website-to-user": "将该网站转入您的账户?",
|
"message.transfer-team-website-to-user": "将此网站转移到您的账户?",
|
||||||
"message.transfer-user-website-to-team": "选择要将该网站转移到哪个团队。",
|
"message.transfer-user-website-to-team": "选择要转移此网站的团队。",
|
||||||
"message.transfer-website": "将网站所有权转移到您的账户或其他团队。",
|
"message.transfer-website": "将网站所有权转移到您的账户或其他团队。",
|
||||||
"message.triggered-event": "触发事件",
|
"message.triggered-event": "触发事件",
|
||||||
"message.user-deleted": "用户已删除。",
|
"message.user-deleted": "用户已删除。",
|
||||||
|
|
|
||||||
41
src/lib/__tests__/charts.test.ts
Normal file
41
src/lib/__tests__/charts.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { renderNumberLabels } from '../charts';
|
||||||
|
|
||||||
|
// test for renderNumberLabels
|
||||||
|
|
||||||
|
describe('renderNumberLabels', () => {
|
||||||
|
test.each([
|
||||||
|
['1000000', '1.0m'],
|
||||||
|
['2500000', '2.5m'],
|
||||||
|
])("formats numbers ≥ 1 million as 'Xm' (%s → %s)", (input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([['150000', '150k']])("formats numbers ≥ 100K as 'Xk' (%s → %s)", (input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([['12500', '12.5k']])(
|
||||||
|
"formats numbers ≥ 10K as 'X.Xk' (%s → %s)",
|
||||||
|
(input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([['1500', '1.50k']])("formats numbers ≥ 1K as 'X.XXk' (%s → %s)", (input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([['999', '999']])(
|
||||||
|
'calls formatNumber for values < 1000 (%s → %s)',
|
||||||
|
(input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['0', '0'],
|
||||||
|
['-5000', '-5000'],
|
||||||
|
])('handles edge cases correctly (%s → %s)', (input, expected) => {
|
||||||
|
expect(renderNumberLabels(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,15 +11,15 @@ export function renderDateLabels(unit: string, locale: string) {
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 'minute':
|
case 'minute':
|
||||||
return formatDate(d, 'h:mm', locale);
|
return formatDate(d, 'p', locale).split(' ')[0];
|
||||||
case 'hour':
|
case 'hour':
|
||||||
return formatDate(d, 'p', locale);
|
return formatDate(d, 'p', locale);
|
||||||
case 'day':
|
case 'day':
|
||||||
return formatDate(d, 'MMM d', locale);
|
return formatDate(d, 'PP', locale).replace(/\W*20\d{2}\W*/, ''); // Remove year
|
||||||
case 'month':
|
case 'month':
|
||||||
return formatDate(d, 'MMM', locale);
|
return formatDate(d, 'MMM', locale);
|
||||||
case 'year':
|
case 'year':
|
||||||
return formatDate(d, 'YYY', locale);
|
return formatDate(d, 'yyyy', locale);
|
||||||
default:
|
default:
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable no-unused-vars */
|
|
||||||
export const CURRENT_VERSION = process.env.currentVersion;
|
export const CURRENT_VERSION = process.env.currentVersion;
|
||||||
export const AUTH_TOKEN = 'umami.auth';
|
export const AUTH_TOKEN = 'umami.auth';
|
||||||
export const LOCALE_CONFIG = 'umami.locale';
|
export const LOCALE_CONFIG = 'umami.locale';
|
||||||
|
|
@ -12,6 +11,7 @@ export const HOMEPAGE_URL = 'https://umami.is';
|
||||||
export const REPO_URL = 'https://github.com/umami-software/umami';
|
export const REPO_URL = 'https://github.com/umami-software/umami';
|
||||||
export const UPDATES_URL = 'https://api.umami.is/v1/updates';
|
export const UPDATES_URL = 'https://api.umami.is/v1/updates';
|
||||||
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
|
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
|
||||||
|
export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
|
||||||
|
|
||||||
export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US';
|
export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US';
|
||||||
export const DEFAULT_THEME = 'light';
|
export const DEFAULT_THEME = 'light';
|
||||||
|
|
@ -42,8 +42,8 @@ export const SESSION_COLUMNS = [
|
||||||
'screen',
|
'screen',
|
||||||
'language',
|
'language',
|
||||||
'country',
|
'country',
|
||||||
'region',
|
|
||||||
'city',
|
'city',
|
||||||
|
'region',
|
||||||
'host',
|
'host',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ export const FILTER_COLUMNS = {
|
||||||
browser: 'browser',
|
browser: 'browser',
|
||||||
device: 'device',
|
device: 'device',
|
||||||
country: 'country',
|
country: 'country',
|
||||||
region: 'subdivision1',
|
region: 'region',
|
||||||
city: 'city',
|
city: 'city',
|
||||||
language: 'language',
|
language: 'language',
|
||||||
event: 'event_name',
|
event: 'event_name',
|
||||||
|
|
@ -124,6 +124,7 @@ export const REPORT_TYPES = {
|
||||||
utm: 'utm',
|
utm: 'utm',
|
||||||
journey: 'journey',
|
journey: 'journey',
|
||||||
revenue: 'revenue',
|
revenue: 'revenue',
|
||||||
|
attribution: 'attribution',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const REPORT_PARAMETERS = {
|
export const REPORT_PARAMETERS = {
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,12 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
|
||||||
// Cloudflare headers
|
// Cloudflare headers
|
||||||
if (headers.get('cf-ipcountry')) {
|
if (headers.get('cf-ipcountry')) {
|
||||||
const country = decodeHeader(headers.get('cf-ipcountry'));
|
const country = decodeHeader(headers.get('cf-ipcountry'));
|
||||||
const subdivision1 = decodeHeader(headers.get('cf-region-code'));
|
const region = decodeHeader(headers.get('cf-region-code'));
|
||||||
const city = decodeHeader(headers.get('cf-ipcity'));
|
const city = decodeHeader(headers.get('cf-ipcity'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
country,
|
country,
|
||||||
subdivision1: getRegionCode(country, subdivision1),
|
region: getRegionCode(country, region),
|
||||||
city,
|
city,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -109,12 +109,12 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
|
||||||
// Vercel headers
|
// Vercel headers
|
||||||
if (headers.get('x-vercel-ip-country')) {
|
if (headers.get('x-vercel-ip-country')) {
|
||||||
const country = decodeHeader(headers.get('x-vercel-ip-country'));
|
const country = decodeHeader(headers.get('x-vercel-ip-country'));
|
||||||
const subdivision1 = decodeHeader(headers.get('x-vercel-ip-country-region'));
|
const region = decodeHeader(headers.get('x-vercel-ip-country-region'));
|
||||||
const city = decodeHeader(headers.get('x-vercel-ip-city'));
|
const city = decodeHeader(headers.get('x-vercel-ip-city'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
country,
|
country,
|
||||||
subdivision1: getRegionCode(country, subdivision1),
|
region: getRegionCode(country, region),
|
||||||
city,
|
city,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -131,14 +131,12 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
|
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
|
||||||
const subdivision1 = result.subdivisions?.[0]?.iso_code;
|
const region = result.subdivisions?.[0]?.iso_code;
|
||||||
const subdivision2 = result.subdivisions?.[1]?.names?.en;
|
|
||||||
const city = result.city?.names?.en;
|
const city = result.city?.names?.en;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
country,
|
country,
|
||||||
subdivision1: getRegionCode(country, subdivision1),
|
region: getRegionCode(country, region),
|
||||||
subdivision2,
|
|
||||||
city,
|
city,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -149,14 +147,13 @@ export async function getClientInfo(request: Request, payload: Record<string, an
|
||||||
const ip = payload?.ip || getIpAddress(request.headers);
|
const ip = payload?.ip || getIpAddress(request.headers);
|
||||||
const location = await getLocation(ip, request.headers, !!payload?.ip);
|
const location = await getLocation(ip, request.headers, !!payload?.ip);
|
||||||
const country = location?.country;
|
const country = location?.country;
|
||||||
const subdivision1 = location?.subdivision1;
|
const region = location?.region;
|
||||||
const subdivision2 = location?.subdivision2;
|
|
||||||
const city = location?.city;
|
const city = location?.city;
|
||||||
const browser = browserName(userAgent);
|
const browser = browserName(userAgent);
|
||||||
const os = detectOS(userAgent) as string;
|
const os = detectOS(userAgent) as string;
|
||||||
const device = getDevice(payload?.screen, os);
|
const device = getDevice(payload?.screen, os);
|
||||||
|
|
||||||
return { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device };
|
return { userAgent, browser, os, ip, country, region, city, device };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasBlockedIp(clientIp: string) {
|
export function hasBlockedIp(clientIp: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import prisma from '@umami/prisma-client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { readReplicas } from '@prisma/extension-read-replicas';
|
||||||
import { formatInTimeZone } from 'date-fns-tz';
|
import { formatInTimeZone } from 'date-fns-tz';
|
||||||
import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db';
|
import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db';
|
||||||
import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
|
import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
|
||||||
|
|
@ -10,6 +11,16 @@ import { filtersToArray } from './params';
|
||||||
|
|
||||||
const log = debug('umami:prisma');
|
const log = debug('umami:prisma');
|
||||||
|
|
||||||
|
const PRISMA = 'prisma';
|
||||||
|
const PRISMA_LOG_OPTIONS = {
|
||||||
|
log: [
|
||||||
|
{
|
||||||
|
emit: 'event',
|
||||||
|
level: 'query',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const MYSQL_DATE_FORMATS = {
|
const MYSQL_DATE_FORMATS = {
|
||||||
minute: '%Y-%m-%dT%H:%i:00',
|
minute: '%Y-%m-%dT%H:%i:00',
|
||||||
hour: '%Y-%m-%d %H:00:00',
|
hour: '%Y-%m-%d %H:00:00',
|
||||||
|
|
@ -151,7 +162,7 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}):
|
||||||
|
|
||||||
if (name === 'referrer') {
|
if (name === 'referrer') {
|
||||||
arr.push(
|
arr.push(
|
||||||
`and (website_event.referrer_domain != session.hostname or website_event.referrer_domain is null)`,
|
`and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -234,14 +245,16 @@ async function rawQuery(sql: string, data: object): Promise<any> {
|
||||||
return db === MYSQL ? '?' : `$${params.length}${type ?? ''}`;
|
return db === MYSQL ? '?' : `$${params.length}${type ?? ''}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return prisma.rawQuery(query, params);
|
return process.env.DATABASE_REPLICA_URL
|
||||||
|
? client.$replica().$queryRawUnsafe(query, ...params)
|
||||||
|
: client.$queryRawUnsafe(query, ...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pagedQuery<T>(model: string, criteria: T, pageParams: PageParams) {
|
async function pagedQuery<T>(model: string, criteria: T, pageParams: PageParams) {
|
||||||
const { page = 1, pageSize, orderBy, sortDescending = false } = pageParams || {};
|
const { page = 1, pageSize, orderBy, sortDescending = false } = pageParams || {};
|
||||||
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
const data = await prisma.client[model].findMany({
|
const data = await client[model].findMany({
|
||||||
...criteria,
|
...criteria,
|
||||||
...{
|
...{
|
||||||
...(size > 0 && { take: +size, skip: +size * (+page - 1) }),
|
...(size > 0 && { take: +size, skip: +size * (+page - 1) }),
|
||||||
|
|
@ -255,7 +268,7 @@ async function pagedQuery<T>(model: string, criteria: T, pageParams: PageParams)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const count = await prisma.client[model].count({ where: (criteria as any).where });
|
const count = await client[model].count({ where: (criteria as any).where });
|
||||||
|
|
||||||
return { data, count, page: +page, pageSize: size, orderBy };
|
return { data, count, page: +page, pageSize: size, orderBy };
|
||||||
}
|
}
|
||||||
|
|
@ -323,8 +336,55 @@ function getSearchParameters(query: string, filters: { [key: string]: any }[]) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transaction(input: any, options?: any) {
|
||||||
|
return client.$transaction(input, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClient(params?: {
|
||||||
|
logQuery?: boolean;
|
||||||
|
queryLogger?: () => void;
|
||||||
|
replicaUrl?: string;
|
||||||
|
options?: any;
|
||||||
|
}): PrismaClient {
|
||||||
|
const {
|
||||||
|
logQuery = !!process.env.LOG_QUERY,
|
||||||
|
queryLogger,
|
||||||
|
replicaUrl = process.env.DATABASE_REPLICA_URL,
|
||||||
|
options,
|
||||||
|
} = params || {};
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
errorFormat: 'pretty',
|
||||||
|
...(logQuery && PRISMA_LOG_OPTIONS),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (replicaUrl) {
|
||||||
|
prisma.$extends(
|
||||||
|
readReplicas({
|
||||||
|
url: replicaUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logQuery) {
|
||||||
|
prisma.$on('query' as never, queryLogger || log);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
global[PRISMA] = prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Prisma initialized');
|
||||||
|
|
||||||
|
return prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = global[PRISMA] || getClient();
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...prisma,
|
client,
|
||||||
|
transaction,
|
||||||
getAddIntervalQuery,
|
getAddIntervalQuery,
|
||||||
getCastColumnQuery,
|
getCastColumnQuery,
|
||||||
getDayDiffQuery,
|
getDayDiffQuery,
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ import { REDIS, UmamiRedisClient } from '@umami/redis-client';
|
||||||
const enabled = !!process.env.REDIS_URL;
|
const enabled = !!process.env.REDIS_URL;
|
||||||
|
|
||||||
function getClient() {
|
function getClient() {
|
||||||
const client = new UmamiRedisClient(process.env.REDIS_URL);
|
const redis = new UmamiRedisClient(process.env.REDIS_URL);
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
global[REDIS] = client;
|
global[REDIS] = redis;
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return redis;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = global[REDIS] || getClient();
|
const client = global[REDIS] || getClient();
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export const reportTypeParam = z.enum([
|
||||||
'goals',
|
'goals',
|
||||||
'journey',
|
'journey',
|
||||||
'revenue',
|
'revenue',
|
||||||
|
'attribution',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const reportParms = {
|
export const reportParms = {
|
||||||
|
|
|
||||||
|
|
@ -197,8 +197,7 @@ export interface SessionData {
|
||||||
screen: string;
|
screen: string;
|
||||||
language: string;
|
language: string;
|
||||||
country: string;
|
country: string;
|
||||||
subdivision1: string;
|
region: string;
|
||||||
subdivision2: string;
|
|
||||||
city: string;
|
city: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
|
||||||
limit 1000)
|
limit 1000)
|
||||||
select * from events
|
select * from events
|
||||||
`,
|
`,
|
||||||
{ ...params, query: `%${search}%` },
|
{ ...params, search: `%${search}%` },
|
||||||
pageParams,
|
pageParams,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,21 @@ export async function saveEvent(args: {
|
||||||
visitId: string;
|
visitId: string;
|
||||||
urlPath: string;
|
urlPath: string;
|
||||||
urlQuery?: string;
|
urlQuery?: string;
|
||||||
|
utmSource?: string;
|
||||||
|
utmMedium?: string;
|
||||||
|
utmCampaign?: string;
|
||||||
|
utmContent?: string;
|
||||||
|
utmTerm?: string;
|
||||||
referrerPath?: string;
|
referrerPath?: string;
|
||||||
referrerQuery?: string;
|
referrerQuery?: string;
|
||||||
referrerDomain?: string;
|
referrerDomain?: string;
|
||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
|
gclid?: string;
|
||||||
|
fbclid?: string;
|
||||||
|
msclkid?: string;
|
||||||
|
ttclid?: string;
|
||||||
|
lifatid?: string;
|
||||||
|
twclid?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventData?: any;
|
eventData?: any;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
|
|
@ -25,10 +36,10 @@ export async function saveEvent(args: {
|
||||||
screen?: string;
|
screen?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
subdivision1?: string;
|
region?: string;
|
||||||
subdivision2?: string;
|
|
||||||
city?: string;
|
city?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
distinctId?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
|
|
@ -43,13 +54,25 @@ async function relationalQuery(data: {
|
||||||
visitId: string;
|
visitId: string;
|
||||||
urlPath: string;
|
urlPath: string;
|
||||||
urlQuery?: string;
|
urlQuery?: string;
|
||||||
|
utmSource?: string;
|
||||||
|
utmMedium?: string;
|
||||||
|
utmCampaign?: string;
|
||||||
|
utmContent?: string;
|
||||||
|
utmTerm?: string;
|
||||||
referrerPath?: string;
|
referrerPath?: string;
|
||||||
referrerQuery?: string;
|
referrerQuery?: string;
|
||||||
referrerDomain?: string;
|
referrerDomain?: string;
|
||||||
|
gclid?: string;
|
||||||
|
fbclid?: string;
|
||||||
|
msclkid?: string;
|
||||||
|
ttclid?: string;
|
||||||
|
lifatid?: string;
|
||||||
|
twclid?: string;
|
||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventData?: any;
|
eventData?: any;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
hostname?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
|
|
@ -58,13 +81,25 @@ async function relationalQuery(data: {
|
||||||
visitId,
|
visitId,
|
||||||
urlPath,
|
urlPath,
|
||||||
urlQuery,
|
urlQuery,
|
||||||
|
utmSource,
|
||||||
|
utmMedium,
|
||||||
|
utmCampaign,
|
||||||
|
utmContent,
|
||||||
|
utmTerm,
|
||||||
referrerPath,
|
referrerPath,
|
||||||
referrerQuery,
|
referrerQuery,
|
||||||
referrerDomain,
|
referrerDomain,
|
||||||
eventName,
|
eventName,
|
||||||
eventData,
|
eventData,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
lifatid,
|
||||||
|
twclid,
|
||||||
tag,
|
tag,
|
||||||
|
hostname,
|
||||||
createdAt,
|
createdAt,
|
||||||
} = data;
|
} = data;
|
||||||
const websiteEventId = uuid();
|
const websiteEventId = uuid();
|
||||||
|
|
@ -77,13 +112,25 @@ async function relationalQuery(data: {
|
||||||
visitId,
|
visitId,
|
||||||
urlPath: urlPath?.substring(0, URL_LENGTH),
|
urlPath: urlPath?.substring(0, URL_LENGTH),
|
||||||
urlQuery: urlQuery?.substring(0, URL_LENGTH),
|
urlQuery: urlQuery?.substring(0, URL_LENGTH),
|
||||||
|
utmSource,
|
||||||
|
utmMedium,
|
||||||
|
utmCampaign,
|
||||||
|
utmContent,
|
||||||
|
utmTerm,
|
||||||
referrerPath: referrerPath?.substring(0, URL_LENGTH),
|
referrerPath: referrerPath?.substring(0, URL_LENGTH),
|
||||||
referrerQuery: referrerQuery?.substring(0, URL_LENGTH),
|
referrerQuery: referrerQuery?.substring(0, URL_LENGTH),
|
||||||
referrerDomain: referrerDomain?.substring(0, URL_LENGTH),
|
referrerDomain: referrerDomain?.substring(0, URL_LENGTH),
|
||||||
pageTitle: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
|
pageTitle: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
lifatid,
|
||||||
|
twclid,
|
||||||
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
||||||
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
||||||
tag,
|
tag,
|
||||||
|
hostname,
|
||||||
createdAt,
|
createdAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -109,10 +156,21 @@ async function clickhouseQuery(data: {
|
||||||
visitId: string;
|
visitId: string;
|
||||||
urlPath: string;
|
urlPath: string;
|
||||||
urlQuery?: string;
|
urlQuery?: string;
|
||||||
|
utmSource?: string;
|
||||||
|
utmMedium?: string;
|
||||||
|
utmCampaign?: string;
|
||||||
|
utmContent?: string;
|
||||||
|
utmTerm?: string;
|
||||||
referrerPath?: string;
|
referrerPath?: string;
|
||||||
referrerQuery?: string;
|
referrerQuery?: string;
|
||||||
referrerDomain?: string;
|
referrerDomain?: string;
|
||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
|
gclid?: string;
|
||||||
|
fbclid?: string;
|
||||||
|
msclkid?: string;
|
||||||
|
ttclid?: string;
|
||||||
|
lifatid?: string;
|
||||||
|
twclid?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventData?: any;
|
eventData?: any;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
|
|
@ -122,10 +180,10 @@ async function clickhouseQuery(data: {
|
||||||
screen?: string;
|
screen?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
subdivision1?: string;
|
region?: string;
|
||||||
subdivision2?: string;
|
|
||||||
city?: string;
|
city?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
distinctId?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
|
|
@ -134,17 +192,28 @@ async function clickhouseQuery(data: {
|
||||||
visitId,
|
visitId,
|
||||||
urlPath,
|
urlPath,
|
||||||
urlQuery,
|
urlQuery,
|
||||||
|
utmSource,
|
||||||
|
utmMedium,
|
||||||
|
utmCampaign,
|
||||||
|
utmContent,
|
||||||
|
utmTerm,
|
||||||
referrerPath,
|
referrerPath,
|
||||||
referrerQuery,
|
referrerQuery,
|
||||||
referrerDomain,
|
referrerDomain,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
msclkid,
|
||||||
|
ttclid,
|
||||||
|
lifatid,
|
||||||
|
twclid,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
eventName,
|
eventName,
|
||||||
eventData,
|
eventData,
|
||||||
country,
|
country,
|
||||||
subdivision1,
|
region,
|
||||||
subdivision2,
|
|
||||||
city,
|
city,
|
||||||
tag,
|
tag,
|
||||||
|
distinctId,
|
||||||
createdAt,
|
createdAt,
|
||||||
...args
|
...args
|
||||||
} = data;
|
} = data;
|
||||||
|
|
@ -159,23 +228,29 @@ async function clickhouseQuery(data: {
|
||||||
visit_id: visitId,
|
visit_id: visitId,
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
country: country,
|
country: country,
|
||||||
subdivision1:
|
region: country && region ? (region.includes('-') ? region : `${country}-${region}`) : null,
|
||||||
country && subdivision1
|
|
||||||
? subdivision1.includes('-')
|
|
||||||
? subdivision1
|
|
||||||
: `${country}-${subdivision1}`
|
|
||||||
: null,
|
|
||||||
subdivision2: subdivision2,
|
|
||||||
city: city,
|
city: city,
|
||||||
url_path: urlPath?.substring(0, URL_LENGTH),
|
url_path: urlPath?.substring(0, URL_LENGTH),
|
||||||
url_query: urlQuery?.substring(0, URL_LENGTH),
|
url_query: urlQuery?.substring(0, URL_LENGTH),
|
||||||
|
utm_source: utmSource,
|
||||||
|
utm_medium: utmMedium,
|
||||||
|
utm_campaign: utmCampaign,
|
||||||
|
utm_content: utmContent,
|
||||||
|
utm_term: utmTerm,
|
||||||
referrer_path: referrerPath?.substring(0, URL_LENGTH),
|
referrer_path: referrerPath?.substring(0, URL_LENGTH),
|
||||||
referrer_query: referrerQuery?.substring(0, URL_LENGTH),
|
referrer_query: referrerQuery?.substring(0, URL_LENGTH),
|
||||||
referrer_domain: referrerDomain?.substring(0, URL_LENGTH),
|
referrer_domain: referrerDomain?.substring(0, URL_LENGTH),
|
||||||
page_title: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
|
page_title: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
|
||||||
|
gclid: gclid,
|
||||||
|
fbclid: fbclid,
|
||||||
|
msclkid: msclkid,
|
||||||
|
ttclid: ttclid,
|
||||||
|
li_fat_id: lifatid,
|
||||||
|
twclid: twclid,
|
||||||
event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
||||||
event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
|
distinct_id: distinctId,
|
||||||
created_at: getUTCString(createdAt),
|
created_at: getUTCString(createdAt),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
where website_event.website_id = {{websiteId::uuid}}
|
where website_event.website_id = {{websiteId::uuid}}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
${dateQuery}
|
${dateQuery}
|
||||||
order by website_event.created_at desc
|
order by website_event.created_at asc
|
||||||
limit 100
|
limit 100
|
||||||
`,
|
`,
|
||||||
params,
|
params,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ async function relationalQuery(
|
||||||
let excludeDomain = '';
|
let excludeDomain = '';
|
||||||
|
|
||||||
if (column === 'referrer_domain') {
|
if (column === 'referrer_domain') {
|
||||||
excludeDomain = `and website_event.referrer_domain != session.hostname
|
excludeDomain = `and website_event.referrer_domain != website_event.hostname
|
||||||
and website_event.referrer_domain != ''`;
|
and website_event.referrer_domain != ''`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
511
src/queries/sql/reports/getAttribution.ts
Normal file
511
src/queries/sql/reports/getAttribution.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
import clickhouse from '@/lib/clickhouse';
|
||||||
|
import { EVENT_TYPE } from '@/lib/constants';
|
||||||
|
import { CLICKHOUSE, getDatabaseType, POSTGRESQL, PRISMA, runQuery } from '@/lib/db';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function getAttribution(
|
||||||
|
...args: [
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
model: string;
|
||||||
|
steps: { type: string; value: string }[];
|
||||||
|
currency: string;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
return runQuery({
|
||||||
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relationalQuery(
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
model: string;
|
||||||
|
steps: { type: string; value: string }[];
|
||||||
|
currency: string;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
referrer: { name: string; value: number }[];
|
||||||
|
paidAds: { name: string; value: number }[];
|
||||||
|
utm_source: { name: string; value: number }[];
|
||||||
|
utm_medium: { name: string; value: number }[];
|
||||||
|
utm_campaign: { name: string; value: number }[];
|
||||||
|
utm_content: { name: string; value: number }[];
|
||||||
|
utm_term: { name: string; value: number }[];
|
||||||
|
total: { pageviews: number; visitors: number; visits: number };
|
||||||
|
}> {
|
||||||
|
const { startDate, endDate, model, steps, currency } = criteria;
|
||||||
|
const { rawQuery } = prisma;
|
||||||
|
const conversionStep = steps[0].value;
|
||||||
|
const eventType = steps[0].type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
||||||
|
const column = steps[0].type === 'url' ? 'url_path' : 'event_name';
|
||||||
|
const db = getDatabaseType();
|
||||||
|
const like = db === POSTGRESQL ? 'ilike' : 'like';
|
||||||
|
|
||||||
|
function getUTMQuery(utmColumn: string) {
|
||||||
|
return `
|
||||||
|
select
|
||||||
|
coalesce(we.${utmColumn}, '') name,
|
||||||
|
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
|
||||||
|
from model m
|
||||||
|
join website_event we
|
||||||
|
on we.created_at = m.created_at
|
||||||
|
and we.session_id = m.session_id
|
||||||
|
${currency ? 'join events e on e.session_id = m.session_id' : ''}
|
||||||
|
where we.website_id = {{websiteId::uuid}}
|
||||||
|
and we.created_at between {{startDate}} and {{endDate}}
|
||||||
|
${currency ? '' : `and we.${utmColumn} != ''`}
|
||||||
|
group by 1
|
||||||
|
order by 2 desc
|
||||||
|
limit 20`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventQuery = `WITH events AS (
|
||||||
|
select distinct
|
||||||
|
session_id,
|
||||||
|
max(created_at) max_dt
|
||||||
|
from website_event
|
||||||
|
where website_id = {{websiteId::uuid}}
|
||||||
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
and ${column} = {{conversionStep}}
|
||||||
|
and event_type = {{eventType}}
|
||||||
|
group by 1),`;
|
||||||
|
|
||||||
|
const revenueEventQuery = `WITH events AS (
|
||||||
|
select
|
||||||
|
we.session_id,
|
||||||
|
max(ed.created_at) max_dt,
|
||||||
|
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) value
|
||||||
|
from event_data ed
|
||||||
|
join website_event we
|
||||||
|
on we.event_id = ed.website_event_Id
|
||||||
|
and we.website_id = ed.website_id
|
||||||
|
join (select website_event_id
|
||||||
|
from event_data
|
||||||
|
where website_id = {{websiteId::uuid}}
|
||||||
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
and data_key ${like} '%currency%'
|
||||||
|
and string_value = {{currency}}) currency
|
||||||
|
on currency.website_event_id = ed.website_event_id
|
||||||
|
where ed.website_id = {{websiteId::uuid}}
|
||||||
|
and ed.created_at between {{startDate}} and {{endDate}}
|
||||||
|
and ${column} = {{conversionStep}}
|
||||||
|
and ed.data_key ${like} '%revenue%'
|
||||||
|
group by 1),`;
|
||||||
|
|
||||||
|
function getModelQuery(model: string) {
|
||||||
|
return model === 'firstClick'
|
||||||
|
? `\n
|
||||||
|
model AS (select e.session_id,
|
||||||
|
min(we.created_at) created_at
|
||||||
|
from events e
|
||||||
|
join website_event we
|
||||||
|
on we.session_id = e.session_id
|
||||||
|
where we.website_id = {{websiteId::uuid}}
|
||||||
|
and we.created_at between {{startDate}} and {{endDate}}
|
||||||
|
group by e.session_id)`
|
||||||
|
: `\n
|
||||||
|
model AS (select e.session_id,
|
||||||
|
max(we.created_at) created_at
|
||||||
|
from events e
|
||||||
|
join website_event we
|
||||||
|
on we.session_id = e.session_id
|
||||||
|
where we.website_id = {{websiteId::uuid}}
|
||||||
|
and we.created_at between {{startDate}} and {{endDate}}
|
||||||
|
and we.created_at < e.max_dt
|
||||||
|
group by e.session_id)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrerRes = await rawQuery(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
select coalesce(we.referrer_domain, '') name,
|
||||||
|
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
|
||||||
|
from model m
|
||||||
|
join website_event we
|
||||||
|
on we.created_at = m.created_at
|
||||||
|
and we.session_id = m.session_id
|
||||||
|
join session s
|
||||||
|
on s.session_id = m.session_id
|
||||||
|
${currency ? 'join events e on e.session_id = m.session_id' : ''}
|
||||||
|
where we.website_id = {{websiteId::uuid}}
|
||||||
|
and we.created_at between {{startDate}} and {{endDate}}
|
||||||
|
${
|
||||||
|
currency
|
||||||
|
? ''
|
||||||
|
: `and we.referrer_domain != hostname
|
||||||
|
and we.referrer_domain != ''`
|
||||||
|
}
|
||||||
|
group by 1
|
||||||
|
order by 2 desc
|
||||||
|
limit 20
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const paidAdsres = await rawQuery(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)},
|
||||||
|
|
||||||
|
results AS (
|
||||||
|
select case
|
||||||
|
when coalesce(gclid, '') != '' then 'Google Ads'
|
||||||
|
when coalesce(fbclid, '') != '' then 'Facebook / Meta'
|
||||||
|
when coalesce(msclkid, '') != '' then 'Microsoft Ads'
|
||||||
|
when coalesce(ttclid, '') != '' then 'TikTok Ads'
|
||||||
|
when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads'
|
||||||
|
when coalesce(twclid, '') != '' then 'Twitter Ads (X)'
|
||||||
|
else ''
|
||||||
|
end name,
|
||||||
|
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
|
||||||
|
from model m
|
||||||
|
join website_event we
|
||||||
|
on we.created_at = m.created_at
|
||||||
|
and we.session_id = m.session_id
|
||||||
|
${currency ? 'join events e on e.session_id = m.session_id' : ''}
|
||||||
|
where we.website_id = {{websiteId::uuid}}
|
||||||
|
and we.created_at between {{startDate}} and {{endDate}}
|
||||||
|
group by 1
|
||||||
|
order by 2 desc
|
||||||
|
limit 20)
|
||||||
|
SELECT *
|
||||||
|
FROM results
|
||||||
|
${currency ? '' : `WHERE name != ''`}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceRes = await rawQuery(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_source')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediumRes = await rawQuery(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_medium')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const campaignRes = await rawQuery(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_campaign')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentRes = await rawQuery(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_content')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const termRes = await rawQuery(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_term')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalRes = await rawQuery(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
count(*) as "pageviews",
|
||||||
|
count(distinct session_id) as "visitors",
|
||||||
|
count(distinct visit_id) as "visits"
|
||||||
|
from website_event
|
||||||
|
where website_id = {{websiteId::uuid}}
|
||||||
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
and ${column} = {{conversionStep}}
|
||||||
|
and event_type = {{eventType}}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
).then(result => result?.[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
referrer: referrerRes,
|
||||||
|
paidAds: paidAdsres,
|
||||||
|
utm_source: sourceRes,
|
||||||
|
utm_medium: mediumRes,
|
||||||
|
utm_campaign: campaignRes,
|
||||||
|
utm_content: contentRes,
|
||||||
|
utm_term: termRes,
|
||||||
|
total: totalRes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickhouseQuery(
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
model: string;
|
||||||
|
steps: { type: string; value: string }[];
|
||||||
|
currency: string;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
referrer: { name: string; value: number }[];
|
||||||
|
paidAds: { name: string; value: number }[];
|
||||||
|
utm_source: { name: string; value: number }[];
|
||||||
|
utm_medium: { name: string; value: number }[];
|
||||||
|
utm_campaign: { name: string; value: number }[];
|
||||||
|
utm_content: { name: string; value: number }[];
|
||||||
|
utm_term: { name: string; value: number }[];
|
||||||
|
total: { pageviews: number; visitors: number; visits: number };
|
||||||
|
}> {
|
||||||
|
const { startDate, endDate, model, steps, currency } = criteria;
|
||||||
|
const { rawQuery } = clickhouse;
|
||||||
|
const conversionStep = steps[0].value;
|
||||||
|
const eventType = steps[0].type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
||||||
|
const column = steps[0].type === 'url' ? 'url_path' : 'event_name';
|
||||||
|
|
||||||
|
function getUTMQuery(utmColumn: string) {
|
||||||
|
return `
|
||||||
|
select
|
||||||
|
we.${utmColumn} name,
|
||||||
|
${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
|
||||||
|
from model m
|
||||||
|
join website_event we
|
||||||
|
on we.created_at = m.created_at
|
||||||
|
and we.session_id = m.session_id
|
||||||
|
${currency ? 'join events e on e.session_id = m.session_id' : ''}
|
||||||
|
where we.website_id = {websiteId:UUID}
|
||||||
|
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
${currency ? '' : `and we.${utmColumn} != ''`}
|
||||||
|
group by 1
|
||||||
|
order by 2 desc
|
||||||
|
limit 20`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventQuery = `WITH events AS (
|
||||||
|
select distinct
|
||||||
|
session_id,
|
||||||
|
max(created_at) max_dt
|
||||||
|
from website_event
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
and ${column} = {conversionStep:String}
|
||||||
|
and event_type = {eventType:UInt32}
|
||||||
|
group by 1),`;
|
||||||
|
|
||||||
|
const revenueEventQuery = `WITH events AS (
|
||||||
|
select
|
||||||
|
ed.session_id,
|
||||||
|
max(ed.created_at) max_dt,
|
||||||
|
sum(coalesce(toDecimal64(number_value, 2), toDecimal64(string_value, 2))) as value
|
||||||
|
from event_data ed
|
||||||
|
join (select event_id
|
||||||
|
from event_data
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
and positionCaseInsensitive(data_key, 'currency') > 0
|
||||||
|
and string_value = {currency:String}) c
|
||||||
|
on c.event_id = ed.event_id
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
and ${column} = {conversionStep:String}
|
||||||
|
and positionCaseInsensitive(ed.data_key, 'revenue') > 0
|
||||||
|
group by 1),`;
|
||||||
|
|
||||||
|
function getModelQuery(model: string) {
|
||||||
|
return model === 'firstClick'
|
||||||
|
? `\n
|
||||||
|
model AS (select e.session_id,
|
||||||
|
min(we.created_at) created_at
|
||||||
|
from events e
|
||||||
|
join website_event we
|
||||||
|
on we.session_id = e.session_id
|
||||||
|
where we.website_id = {websiteId:UUID}
|
||||||
|
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
group by e.session_id)`
|
||||||
|
: `\n
|
||||||
|
model AS (select e.session_id,
|
||||||
|
max(we.created_at) created_at
|
||||||
|
from events e
|
||||||
|
join website_event we
|
||||||
|
on we.session_id = e.session_id
|
||||||
|
where we.website_id = {websiteId:UUID}
|
||||||
|
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
and we.created_at < e.max_dt
|
||||||
|
group by e.session_id)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrerRes = await rawQuery<
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
select we.referrer_domain name,
|
||||||
|
${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
|
||||||
|
from model m
|
||||||
|
join website_event we
|
||||||
|
on we.created_at = m.created_at
|
||||||
|
and we.session_id = m.session_id
|
||||||
|
${currency ? 'join events e on e.session_id = m.session_id' : ''}
|
||||||
|
where we.website_id = {websiteId:UUID}
|
||||||
|
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
${
|
||||||
|
currency
|
||||||
|
? ''
|
||||||
|
: `and we.referrer_domain != hostname
|
||||||
|
and we.referrer_domain != ''`
|
||||||
|
}
|
||||||
|
group by 1
|
||||||
|
order by 2 desc
|
||||||
|
limit 20
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const paidAdsres = await rawQuery<
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
select multiIf(gclid != '', 'Google Ads',
|
||||||
|
fbclid != '', 'Facebook / Meta',
|
||||||
|
msclkid != '', 'Microsoft Ads',
|
||||||
|
ttclid != '', 'TikTok Ads',
|
||||||
|
li_fat_id != '', ' LinkedIn Ads',
|
||||||
|
twclid != '', 'Twitter Ads (X)','') name,
|
||||||
|
${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
|
||||||
|
from model m
|
||||||
|
join website_event we
|
||||||
|
on we.created_at = m.created_at
|
||||||
|
and we.session_id = m.session_id
|
||||||
|
${currency ? 'join events e on e.session_id = m.session_id' : ''}
|
||||||
|
where we.website_id = {websiteId:UUID}
|
||||||
|
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
${currency ? '' : `and name != ''`}
|
||||||
|
group by 1
|
||||||
|
order by 2 desc
|
||||||
|
limit 20
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceRes = await rawQuery<
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_source')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediumRes = await rawQuery<
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_medium')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const campaignRes = await rawQuery<
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_campaign')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentRes = await rawQuery<
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_content')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const termRes = await rawQuery<
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
${currency ? revenueEventQuery : eventQuery}
|
||||||
|
${getModelQuery(model)}
|
||||||
|
${getUTMQuery('utm_term')}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalRes = await rawQuery<{ pageviews: number; visitors: number; visits: number }>(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
count(*) as "pageviews",
|
||||||
|
uniqExact(session_id) as "visitors",
|
||||||
|
uniqExact(visit_id) as "visits"
|
||||||
|
from website_event
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
and ${column} = {conversionStep:String}
|
||||||
|
and event_type = {eventType:UInt32}
|
||||||
|
`,
|
||||||
|
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
||||||
|
).then(result => result?.[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
referrer: referrerRes,
|
||||||
|
paidAds: paidAdsres,
|
||||||
|
utm_source: sourceRes,
|
||||||
|
utm_medium: mediumRes,
|
||||||
|
utm_campaign: campaignRes,
|
||||||
|
utm_content: contentRes,
|
||||||
|
utm_term: termRes,
|
||||||
|
total: totalRes,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue