mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
Merge branch 'dev' into master
This commit is contained in:
@@ -24,13 +24,13 @@ body:
|
||||
render: shell
|
||||
- type: input
|
||||
attributes:
|
||||
label: Which Umami version are you using? (if relevant)
|
||||
label: Which Umami version are you using?
|
||||
description: 'For example: 2.18.0, 2.15.1, 1.39.0, etc'
|
||||
- type: input
|
||||
attributes:
|
||||
label: Which browser are you using? (if relevant)
|
||||
description: 'For example: Chrome, Edge, Firefox, etc'
|
||||
label: How are you deploying your application?
|
||||
description: 'For example: Vercel, Railway, Docker, etc'
|
||||
- type: input
|
||||
attributes:
|
||||
label: How are you deploying your application? (if relevant)
|
||||
description: 'For example: Vercel, Railway, Docker, etc'
|
||||
label: Which browser are you using?
|
||||
description: 'For example: Chrome, Edge, Firefox, etc'
|
||||
|
||||
@@ -124,4 +124,4 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.compute.outputs.docker_tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -17,10 +17,10 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- name: Use Node.js 18.18
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- run: npm install --global pnpm
|
||||
- run: pnpm install
|
||||
|
||||
+10
-1
@@ -9,18 +9,23 @@ package-lock.json
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# next.js
|
||||
next-env.d.ts
|
||||
/.next
|
||||
/out
|
||||
|
||||
# production
|
||||
/build
|
||||
/public/script.js
|
||||
/public/recorder.js
|
||||
/geo
|
||||
/dist
|
||||
/generated
|
||||
/src/generated
|
||||
pm2.yml
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -30,6 +35,11 @@ package-lock.json
|
||||
*.log
|
||||
.vscode
|
||||
.tool-versions
|
||||
.claude
|
||||
.agents
|
||||
tmpclaude*
|
||||
CLAUDE.md
|
||||
nul
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -42,4 +52,3 @@ yarn-error.log*
|
||||
*.env.*
|
||||
|
||||
*.dev.yml
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
npx lint-staged
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": ["stylelint-config-recommended", "stylelint-config-css-modules"],
|
||||
"rules": {
|
||||
"no-descending-specificity": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Contributing to Umami
|
||||
|
||||
Thanks for your interest in contributing to Umami! This document outlines the process for contributing code.
|
||||
|
||||
## Branching
|
||||
|
||||
Umami uses the following long-lived branches:
|
||||
|
||||
- `master` — stable, released code. **Do not open PRs against `master`.**
|
||||
- `dev` — active development. **All pull requests should target `dev`.**
|
||||
|
||||
Feature branches and fixes are merged into `dev`, and `dev` is periodically merged into `master` for releases.
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. Fork the repository and create your branch from `dev`:
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git checkout -b my-feature
|
||||
```
|
||||
2. Make your changes. Keep PRs focused — one logical change per PR.
|
||||
3. Ensure the project builds and lints cleanly:
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm lint
|
||||
```
|
||||
4. Push your branch and open a pull request **against the `dev` branch**.
|
||||
5. Fill in the PR description with what changed and why. Link any related issues.
|
||||
|
||||
PRs opened against `master` will be asked to retarget `dev`.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- Search [existing issues](https://github.com/umami-software/umami/issues) before opening a new one.
|
||||
- For bugs, include reproduction steps, expected vs. actual behavior, and your environment (Umami version, database, browser).
|
||||
- For feature requests, describe the use case before the proposed solution.
|
||||
|
||||
## Development Setup
|
||||
|
||||
See the [README](./README.md) for instructions on installing dependencies, configuring the database, and running Umami locally.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](./LICENSE).
|
||||
+5
-3
@@ -14,7 +14,7 @@ FROM node:${NODE_IMAGE_VERSION} AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
COPY docker/middleware.ts ./src
|
||||
COPY docker/proxy.ts ./src
|
||||
|
||||
ARG BASE_PATH
|
||||
|
||||
@@ -28,7 +28,7 @@ RUN npm run build-docker
|
||||
FROM node:${NODE_IMAGE_VERSION} AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ARG PRISMA_VERSION="6.19.0"
|
||||
ARG PRISMA_VERSION="7.3.0"
|
||||
ARG NODE_OPTIONS
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -44,10 +44,12 @@ RUN set -x \
|
||||
# Script dependencies
|
||||
RUN pnpm --allow-build='@prisma/engines' add npm-run-all dotenv chalk semver \
|
||||
prisma@${PRISMA_VERSION} \
|
||||
@prisma/client@${PRISMA_VERSION} \
|
||||
@prisma/adapter-pg@${PRISMA_VERSION}
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/generated ./generated
|
||||
|
||||
@@ -63,4 +65,4 @@ EXPOSE 3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
CMD ["pnpm", "start-docker"]
|
||||
CMD ["pnpm", "start-docker"]
|
||||
@@ -46,6 +46,10 @@ Create an `.env` file with the following:
|
||||
DATABASE_URL=connection-url
|
||||
```
|
||||
|
||||
Optional: set `API_URL` to change the base URL used by internal UI API calls.
|
||||
Relative paths are served under `BASE_PATH`; absolute URLs are called directly by the browser.
|
||||
For example, `API_URL=/internal-api` or `API_URL=https://api.example.com/api`.
|
||||
|
||||
The connection URL format:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:3000',
|
||||
},
|
||||
// default username / password on init
|
||||
env: {
|
||||
umami_user: 'admin',
|
||||
umami_password: 'umami',
|
||||
umami_user_id: '41e2b680-648e-4b09-bcd7-3e2b10c06264',
|
||||
},
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
version: '3'
|
||||
services:
|
||||
umami:
|
||||
build: ../
|
||||
#image: ghcr.io/umami-software/umami:postgresql-latest
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
DATABASE_URL: postgresql://umami:umami@db:5432/umami
|
||||
DATABASE_TYPE: postgresql
|
||||
APP_SECRET: replace-me-with-a-random-string
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'curl http://localhost:3000/api/heartbeat']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: umami
|
||||
POSTGRES_USER: umami
|
||||
POSTGRES_PASSWORD: umami
|
||||
volumes:
|
||||
- umami-db-data:/var/lib/postgresql/data
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
cypress:
|
||||
image: 'cypress/included:13.6.0'
|
||||
depends_on:
|
||||
- umami
|
||||
- db
|
||||
environment:
|
||||
- CYPRESS_baseUrl=http://umami:3000
|
||||
- CYPRESS_umami_user=admin
|
||||
- CYPRESS_umami_password=umami
|
||||
volumes:
|
||||
- ./tsconfig.json:/tsconfig.json
|
||||
- ../cypress.config.ts:/cypress.config.ts
|
||||
- ./:/cypress
|
||||
- ../node_modules/:/node_modules
|
||||
- ../src/lib/crypto.ts:/src/lib/crypto.ts
|
||||
volumes:
|
||||
umami-db-data:
|
||||
@@ -1,209 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
import { uuid } from '../../src/lib/crypto';
|
||||
|
||||
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('Creates a website with a fixed ID.', () => {
|
||||
cy.fixture('websites').then(data => {
|
||||
const websiteCreate = data.websiteCreate;
|
||||
const fixedId = uuid();
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: '/api/websites',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: Cypress.env('authorization'),
|
||||
},
|
||||
body: { ...websiteCreate, id: fixedId },
|
||||
}).then(response => {
|
||||
expect(response.status).to.eq(200);
|
||||
expect(response.body).to.have.property('id', fixedId);
|
||||
expect(response.body).to.have.property('name', 'Cypress Website');
|
||||
expect(response.body).to.have.property('domain', 'cypress.com');
|
||||
|
||||
// cleanup
|
||||
cy.request({
|
||||
method: 'DELETE',
|
||||
url: `/api/websites/${fixedId}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: Cypress.env('authorization'),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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('Updates a website with only shareId.', () => {
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: `/api/websites/${websiteId}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: Cypress.env('authorization'),
|
||||
},
|
||||
body: { shareId: 'ABCDEF' },
|
||||
}).then(response => {
|
||||
expect(response.status).to.eq(200);
|
||||
expect(response.body).to.have.property('shareId', 'ABCDEF');
|
||||
});
|
||||
});
|
||||
|
||||
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,36 +0,0 @@
|
||||
describe('Login tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/login');
|
||||
});
|
||||
|
||||
it(
|
||||
'logs user in with correct credentials and logs user out',
|
||||
{
|
||||
defaultCommandTimeout: 10000,
|
||||
},
|
||||
() => {
|
||||
cy.getDataTest('input-username').find('input').as('inputUsername').click();
|
||||
cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 });
|
||||
cy.get('@inputUsername').click();
|
||||
cy.getDataTest('input-password')
|
||||
.find('input')
|
||||
.type(Cypress.env('umami_password'), { delay: 0 });
|
||||
cy.getDataTest('button-submit').click();
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
|
||||
cy.logout();
|
||||
},
|
||||
);
|
||||
|
||||
it('login with blank inputs or incorrect credentials', () => {
|
||||
cy.getDataTest('button-submit').click();
|
||||
cy.contains(/Required/i).should('be.visible');
|
||||
|
||||
cy.getDataTest('input-username').find('input').as('inputUsername');
|
||||
cy.get('@inputUsername').click();
|
||||
cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 });
|
||||
cy.get('@inputUsername').click();
|
||||
cy.getDataTest('input-password').find('input').type('wrongpassword', { delay: 0 });
|
||||
cy.getDataTest('button-submit').click();
|
||||
cy.contains(/Incorrect username and\/or password./i).should('be.visible');
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
describe('User tests', () => {
|
||||
Cypress.session.clearAllSavedSessions();
|
||||
|
||||
beforeEach(() => {
|
||||
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
|
||||
cy.visit('/settings/users');
|
||||
});
|
||||
|
||||
it('Add a User', () => {
|
||||
// add user
|
||||
cy.contains(/Create user/i).should('be.visible');
|
||||
cy.getDataTest('button-create-user').click();
|
||||
cy.getDataTest('input-username').find('input').as('inputName').click();
|
||||
cy.get('@inputName').type('Test-user', { delay: 0 });
|
||||
cy.getDataTest('input-password').find('input').as('inputPassword').click();
|
||||
cy.get('@inputPassword').type('testPasswordCypress', { delay: 0 });
|
||||
cy.getDataTest('dropdown-role').click();
|
||||
cy.getDataTest('dropdown-item-user').click();
|
||||
cy.getDataTest('button-submit').click();
|
||||
cy.get('td[label="Username"]').should('contain.text', 'Test-user');
|
||||
cy.get('td[label="Role"]').should('contain.text', 'User');
|
||||
});
|
||||
|
||||
it('Edit a User role and password', () => {
|
||||
// edit user
|
||||
cy.get('table tbody tr')
|
||||
.contains('td', /Test-user/i)
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.getDataTest('link-button-edit').click(); // Clicks the button inside the row
|
||||
});
|
||||
cy.getDataTest('input-password').find('input').as('inputPassword').click();
|
||||
cy.get('@inputPassword').type('newPassword', { delay: 0 });
|
||||
cy.getDataTest('dropdown-role').click();
|
||||
cy.getDataTest('dropdown-item-viewOnly').click();
|
||||
cy.getDataTest('button-submit').click();
|
||||
|
||||
cy.visit('/settings/users');
|
||||
cy.get('table tbody tr')
|
||||
.contains('td', /Test-user/i)
|
||||
.parent()
|
||||
.should('contain.text', 'View only');
|
||||
|
||||
cy.logout();
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/login');
|
||||
cy.getDataTest('input-username').find('input').as('inputUsername').click();
|
||||
cy.get('@inputUsername').type('Test-user', { delay: 0 });
|
||||
cy.get('@inputUsername').click();
|
||||
cy.getDataTest('input-password').find('input').type('newPassword', { delay: 0 });
|
||||
cy.getDataTest('button-submit').click();
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
|
||||
});
|
||||
|
||||
it('Delete a user', () => {
|
||||
// delete user
|
||||
cy.get('table tbody tr')
|
||||
.contains('td', /Test-user/i)
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.getDataTest('button-delete').click(); // Clicks the button inside the row
|
||||
});
|
||||
cy.contains(/Are you sure you want to delete Test-user?/i).should('be.visible');
|
||||
cy.getDataTest('button-confirm').click();
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
describe('Website tests', () => {
|
||||
Cypress.session.clearAllSavedSessions();
|
||||
|
||||
beforeEach(() => {
|
||||
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
|
||||
});
|
||||
|
||||
it('Add a website', () => {
|
||||
// add website
|
||||
cy.visit('/settings/websites');
|
||||
cy.getDataTest('button-website-add').click();
|
||||
cy.contains(/Add website/i).should('be.visible');
|
||||
cy.getDataTest('input-name').find('input').as('inputUsername').click();
|
||||
cy.getDataTest('input-name').find('input').type('Add test', { delay: 0 });
|
||||
cy.getDataTest('input-domain').find('input').click();
|
||||
cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 0 });
|
||||
cy.getDataTest('button-submit').click();
|
||||
cy.get('td[label="Name"]').should('contain.text', 'Add test');
|
||||
cy.get('td[label="Domain"]').should('contain.text', 'addtest.com');
|
||||
|
||||
// clean-up data
|
||||
cy.getDataTest('link-button-edit').first().click();
|
||||
cy.contains(/Details/i).should('be.visible');
|
||||
cy.getDataTest('text-field-websiteId')
|
||||
.find('input')
|
||||
.then($input => {
|
||||
const websiteId = $input[0].value;
|
||||
cy.deleteWebsite(websiteId);
|
||||
});
|
||||
cy.visit('/settings/websites');
|
||||
cy.contains(/Add test/i).should('not.exist');
|
||||
});
|
||||
|
||||
it('Edit a website', () => {
|
||||
// prep data
|
||||
cy.addWebsite('Update test', 'updatetest.com');
|
||||
cy.visit('/settings/websites');
|
||||
|
||||
// edit website
|
||||
cy.getDataTest('link-button-edit').first().click();
|
||||
cy.contains(/Details/i).should('be.visible');
|
||||
cy.getDataTest('input-name').find('input').click();
|
||||
cy.getDataTest('input-name').find('input').clear();
|
||||
cy.getDataTest('input-name').find('input').type('Updated website', { delay: 0 });
|
||||
cy.getDataTest('input-domain').find('input').click();
|
||||
cy.getDataTest('input-domain').find('input').clear();
|
||||
cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 0 });
|
||||
cy.getDataTest('button-submit').click({ force: true });
|
||||
cy.getDataTest('input-name').find('input').should('have.value', 'Updated website');
|
||||
cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com');
|
||||
|
||||
// verify tracking script
|
||||
cy.get('div')
|
||||
.contains(/Tracking code/i)
|
||||
.click();
|
||||
cy.get('textarea').should('contain.text', Cypress.config().baseUrl + '/script.js');
|
||||
|
||||
// clean-up data
|
||||
cy.get('div')
|
||||
.contains(/Details/i)
|
||||
.click();
|
||||
cy.contains(/Details/i).should('be.visible');
|
||||
cy.getDataTest('text-field-websiteId')
|
||||
.find('input')
|
||||
.then($input => {
|
||||
const websiteId = $input[0].value;
|
||||
cy.deleteWebsite(websiteId);
|
||||
});
|
||||
cy.visit('/settings/websites');
|
||||
cy.contains(/Add test/i).should('not.exist');
|
||||
});
|
||||
|
||||
it('Delete a website', () => {
|
||||
// prep data
|
||||
cy.addWebsite('Delete test', 'deletetest.com');
|
||||
cy.visit('/settings/websites');
|
||||
|
||||
// delete website
|
||||
cy.getDataTest('link-button-edit').first().click();
|
||||
cy.contains(/Data/i).should('be.visible');
|
||||
cy.get('div').contains(/Data/i).click();
|
||||
cy.contains(/All website data will be deleted./i).should('be.visible');
|
||||
cy.getDataTest('button-delete').click();
|
||||
cy.contains(/Type DELETE in the box below to confirm./i).should('be.visible');
|
||||
cy.get('input[name="confirm"').type('DELETE');
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.contains(/Delete test/i).should('not.exist');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"teamCreate": {
|
||||
"name": "cypress"
|
||||
},
|
||||
"teamUpdate": {
|
||||
"name": "cypressUpdate"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"userCreate": {
|
||||
"username": "cypress1",
|
||||
"password": "password",
|
||||
"role": "user"
|
||||
},
|
||||
"userUpdate": {
|
||||
"username": "cypress1",
|
||||
"role": "view-only"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"websiteCreate": {
|
||||
"name": "Cypress Website",
|
||||
"domain": "cypress.com"
|
||||
},
|
||||
"websiteUpdate": {
|
||||
"name": "Cypress Website Updated",
|
||||
"domain": "cypressupdated.com"
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
import { uuid } from '../../src/lib/crypto';
|
||||
|
||||
Cypress.Commands.add('getDataTest', (value: string) => {
|
||||
return cy.get(`[data-test=${value}]`);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('logout', () => {
|
||||
cy.getDataTest('button-profile').click();
|
||||
cy.getDataTest('item-logout').click();
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/login');
|
||||
});
|
||||
|
||||
Cypress.Commands.add('login', (username: string, password: string) => {
|
||||
cy.session([username, password], () => {
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
body: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
Cypress.env('authorization', `bearer ${response.body.token}`);
|
||||
window.localStorage.setItem('umami.auth', JSON.stringify(response.body.token));
|
||||
})
|
||||
.its('status')
|
||||
.should('eq', 200);
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('addWebsite', (name: string, domain: string) => {
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: '/api/websites',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: Cypress.env('authorization'),
|
||||
},
|
||||
body: {
|
||||
id: uuid(),
|
||||
createdBy: '41e2b680-648e-4b09-bcd7-3e2b10c06264',
|
||||
name: name,
|
||||
domain: domain,
|
||||
},
|
||||
}).then(response => {
|
||||
expect(response.status).to.eq(200);
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('deleteWebsite', (websiteId: string) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Vendored
-56
@@ -1,56 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
/* global JQuery */
|
||||
|
||||
declare namespace Cypress {
|
||||
interface Chainable {
|
||||
/**
|
||||
* Custom command to select DOM element by data-test attribute.
|
||||
* @example cy.getDataTest('greeting')
|
||||
*/
|
||||
getDataTest(value: string): Chainable<JQuery<HTMLElement>>;
|
||||
/**
|
||||
* Custom command to logout through UI.
|
||||
* @example cy.logout()
|
||||
*/
|
||||
logout(): Chainable<JQuery<HTMLElement>>;
|
||||
/**
|
||||
* Custom command to login user into the app.
|
||||
* @example cy.login('admin', 'password)
|
||||
*/
|
||||
login(username: string, password: string): Chainable<JQuery<HTMLElement>>;
|
||||
/**
|
||||
* Custom command to create a website
|
||||
* @example cy.addWebsite('test', 'test.com')
|
||||
*/
|
||||
addWebsite(name: string, domain: string): Chainable<JQuery<HTMLElement>>;
|
||||
/**
|
||||
* Custom command to delete a website
|
||||
* @example cy.deleteWebsite('02d89813-7a72-41e1-87f0-8d668f85008b')
|
||||
*/
|
||||
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>>;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress", "node"]
|
||||
},
|
||||
"include": ["**/*.ts", "../cypress.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
-- Add performance columns to website_event
|
||||
ALTER TABLE umami.website_event ADD COLUMN lcp Nullable(Decimal(10, 1)) AFTER twclid;
|
||||
ALTER TABLE umami.website_event ADD COLUMN inp Nullable(Decimal(10, 1)) AFTER lcp;
|
||||
ALTER TABLE umami.website_event ADD COLUMN cls Nullable(Decimal(10, 4)) AFTER inp;
|
||||
ALTER TABLE umami.website_event ADD COLUMN fcp Nullable(Decimal(10, 1)) AFTER cls;
|
||||
ALTER TABLE umami.website_event ADD COLUMN ttfb Nullable(Decimal(10, 1)) AFTER fcp;
|
||||
|
||||
-- Update materialized view to exclude performance events from view counts
|
||||
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,
|
||||
hostnames as 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,
|
||||
arrayFilter(x -> x != '', groupArray(hostname)) hostnames,
|
||||
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 != '' and x != hostname, 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 NOT IN (2, 5)) 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);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Create session_replay
|
||||
CREATE TABLE umami.session_replay
|
||||
(
|
||||
replay_id UUID,
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
visit_id UUID,
|
||||
chunk_index UInt32,
|
||||
events String CODEC(ZSTD(3)),
|
||||
event_count UInt32,
|
||||
started_at DateTime64(6),
|
||||
ended_at DateTime64(6),
|
||||
created_at DateTime64(6) DEFAULT now64(6)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (replay_id, website_id, session_id, visit_id, chunk_index)
|
||||
SETTINGS index_granularity = 8192;
|
||||
@@ -0,0 +1,75 @@
|
||||
CREATE TABLE IF NOT EXISTS umami.event_data_pivot
|
||||
(
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
event_id UUID,
|
||||
event_name LowCardinality(String),
|
||||
url_path String,
|
||||
created_at DateTime('UTC'),
|
||||
property_keys AggregateFunction(groupArray, String),
|
||||
property_values AggregateFunction(groupArray, String),
|
||||
property_types AggregateFunction(groupArray, UInt32)
|
||||
)
|
||||
ENGINE = AggregatingMergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, event_name, created_at, event_id)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS umami.event_data_pivot_mv
|
||||
TO umami.event_data_pivot
|
||||
AS SELECT
|
||||
website_id,
|
||||
session_id,
|
||||
event_id,
|
||||
event_name,
|
||||
url_path,
|
||||
created_at,
|
||||
groupArrayState(data_key) AS property_keys,
|
||||
groupArrayState(multiIf(
|
||||
data_type IN (1, 3, 5), ifNull(string_value, ''),
|
||||
data_type = 2, toString(ifNull(number_value, 0)),
|
||||
data_type = 4, toString(ifNull(date_value, toDateTime(0))),
|
||||
''
|
||||
)) AS property_values,
|
||||
groupArrayState(data_type) AS property_types
|
||||
FROM umami.event_data
|
||||
GROUP BY website_id, session_id, event_id, event_name, url_path, created_at;
|
||||
|
||||
-- Backfill existing event data
|
||||
INSERT INTO umami.event_data_pivot
|
||||
SELECT
|
||||
website_id,
|
||||
session_id,
|
||||
event_id,
|
||||
event_name,
|
||||
url_path,
|
||||
created_at,
|
||||
groupArrayState(data_key),
|
||||
groupArrayState(multiIf(
|
||||
data_type IN (1, 3, 5), ifNull(string_value, ''),
|
||||
data_type = 2, toString(ifNull(number_value, 0)),
|
||||
data_type = 4, toString(ifNull(date_value, toDateTime(0))),
|
||||
''
|
||||
)),
|
||||
groupArrayState(data_type)
|
||||
FROM umami.event_data
|
||||
GROUP BY website_id, session_id, event_id, event_name, url_path, created_at;
|
||||
|
||||
ALTER TABLE umami.session_data
|
||||
MODIFY SETTING deduplicate_merge_projection_mode = 'drop';
|
||||
|
||||
ALTER TABLE umami.session_data
|
||||
ADD PROJECTION session_data_property_filter_projection (
|
||||
SELECT *
|
||||
ORDER BY (
|
||||
website_id,
|
||||
data_key,
|
||||
data_type,
|
||||
string_value,
|
||||
number_value,
|
||||
date_value,
|
||||
session_id
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE umami.session_data MATERIALIZE PROJECTION session_data_property_filter_projection;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Create heatmap_event
|
||||
CREATE TABLE umami.heatmap_event
|
||||
(
|
||||
heatmap_event_id UUID,
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
visit_id UUID,
|
||||
url_path String,
|
||||
event_type UInt8,
|
||||
node_id Nullable(Int32),
|
||||
x Nullable(Int32),
|
||||
y Nullable(Int32),
|
||||
viewport_w Nullable(Int32),
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
scroll_pct Nullable(UInt8),
|
||||
replay_chunk_index Nullable(UInt32),
|
||||
replay_event_index Nullable(UInt32),
|
||||
replay_time_ms Nullable(Int64),
|
||||
created_at DateTime('UTC')
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, url_path, event_type, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
+145
-1
@@ -34,6 +34,12 @@ CREATE TABLE umami.website_event
|
||||
ttclid String,
|
||||
li_fat_id String,
|
||||
twclid String,
|
||||
--performance
|
||||
lcp Nullable(Decimal(10, 1)),
|
||||
inp Nullable(Decimal(10, 1)),
|
||||
cls Nullable(Decimal(10, 4)),
|
||||
fcp Nullable(Decimal(10, 1)),
|
||||
ttfb Nullable(Decimal(10, 1)),
|
||||
--events
|
||||
event_type UInt32,
|
||||
event_name String,
|
||||
@@ -84,6 +90,25 @@ ENGINE = ReplacingMergeTree
|
||||
ORDER BY (website_id, session_id, data_key)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
ALTER TABLE umami.session_data
|
||||
MODIFY SETTING deduplicate_merge_projection_mode = 'drop';
|
||||
|
||||
ALTER TABLE umami.session_data
|
||||
ADD PROJECTION session_data_property_filter_projection (
|
||||
SELECT *
|
||||
ORDER BY (
|
||||
website_id,
|
||||
data_key,
|
||||
data_type,
|
||||
string_value,
|
||||
number_value,
|
||||
date_value,
|
||||
session_id
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE umami.session_data MATERIALIZE PROJECTION session_data_property_filter_projection;
|
||||
|
||||
-- stats hourly
|
||||
CREATE TABLE umami.website_event_stats_hourly
|
||||
(
|
||||
@@ -209,7 +234,7 @@ FROM (SELECT
|
||||
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
|
||||
event_type,
|
||||
if(event_type = 2, groupArray(event_name), []) event_name,
|
||||
sumIf(1, event_type != 2) views,
|
||||
sumIf(1, event_type NOT IN (2, 5)) views,
|
||||
min(created_at) min_time,
|
||||
max(created_at) max_time,
|
||||
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
||||
@@ -281,3 +306,122 @@ JOIN (SELECT event_id, string_value as currency
|
||||
WHERE positionCaseInsensitive(data_key, 'currency') > 0) c
|
||||
ON c.event_id = ed.event_id
|
||||
WHERE positionCaseInsensitive(data_key, 'revenue') > 0;
|
||||
|
||||
-- Create session_replay
|
||||
CREATE TABLE umami.session_replay
|
||||
(
|
||||
replay_id UUID,
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
visit_id UUID,
|
||||
chunk_index UInt32,
|
||||
events String CODEC(ZSTD(3)),
|
||||
event_count UInt32,
|
||||
started_at DateTime64(6),
|
||||
ended_at DateTime64(6),
|
||||
created_at DateTime64(6) DEFAULT now64(6)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (replay_id, website_id, session_id, visit_id, chunk_index)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Create event_data_pivot
|
||||
CREATE TABLE IF NOT EXISTS umami.event_data_pivot
|
||||
(
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
event_id UUID,
|
||||
event_name LowCardinality(String),
|
||||
url_path String,
|
||||
created_at DateTime('UTC'),
|
||||
property_keys AggregateFunction(groupArray, String),
|
||||
property_values AggregateFunction(groupArray, String),
|
||||
property_types AggregateFunction(groupArray, UInt32)
|
||||
)
|
||||
ENGINE = AggregatingMergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, event_name, created_at, event_id)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS umami.event_data_pivot_mv
|
||||
TO umami.event_data_pivot
|
||||
AS SELECT
|
||||
website_id,
|
||||
session_id,
|
||||
event_id,
|
||||
event_name,
|
||||
url_path,
|
||||
created_at,
|
||||
groupArrayState(data_key) AS property_keys,
|
||||
groupArrayState(multiIf(
|
||||
data_type IN (1, 3, 5), ifNull(string_value, ''),
|
||||
data_type = 2, toString(ifNull(number_value, 0)),
|
||||
data_type = 4, toString(ifNull(date_value, toDateTime(0))),
|
||||
''
|
||||
)) AS property_values,
|
||||
groupArrayState(data_type) AS property_types
|
||||
FROM umami.event_data
|
||||
GROUP BY website_id, session_id, event_id, event_name, url_path, created_at;
|
||||
|
||||
-- Create session_data_pivot
|
||||
CREATE TABLE IF NOT EXISTS umami.session_data_pivot
|
||||
(
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
distinct_id String,
|
||||
created_year_month UInt32,
|
||||
created_at AggregateFunction(max, DateTime('UTC')),
|
||||
property_keys AggregateFunction(groupArray, String),
|
||||
property_values AggregateFunction(groupArray, String),
|
||||
property_types AggregateFunction(groupArray, UInt32)
|
||||
)
|
||||
ENGINE = AggregatingMergeTree()
|
||||
PARTITION BY created_year_month
|
||||
ORDER BY (website_id, session_id, distinct_id)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS umami.session_data_pivot_mv
|
||||
TO umami.session_data_pivot
|
||||
AS SELECT
|
||||
website_id,
|
||||
session_id,
|
||||
ifNull(distinct_id, '') AS distinct_id,
|
||||
toYYYYMM(max(session_data.created_at)) AS created_year_month,
|
||||
maxState(session_data.created_at) AS created_at,
|
||||
groupArrayState(data_key) AS property_keys,
|
||||
groupArrayState(multiIf(
|
||||
data_type IN (1, 3, 5), ifNull(string_value, ''),
|
||||
data_type = 2, toString(ifNull(number_value, 0)),
|
||||
data_type = 4, toString(ifNull(date_value, toDateTime(0))),
|
||||
''
|
||||
)) AS property_values,
|
||||
groupArrayState(data_type) AS property_types
|
||||
FROM umami.session_data
|
||||
GROUP BY website_id, session_id, distinct_id;
|
||||
|
||||
-- Create heatmap_event
|
||||
CREATE TABLE umami.heatmap_event
|
||||
(
|
||||
heatmap_event_id UUID,
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
visit_id UUID,
|
||||
url_path String,
|
||||
event_type UInt8,
|
||||
node_id Nullable(Int32),
|
||||
x Nullable(Int32),
|
||||
y Nullable(Int32),
|
||||
viewport_w Nullable(Int32),
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
scroll_pct Nullable(UInt8),
|
||||
replay_chunk_index Nullable(UInt32),
|
||||
replay_event_index Nullable(UInt32),
|
||||
replay_time_ms Nullable(Int64),
|
||||
created_at DateTime('UTC')
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, url_path, event_type, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { matchesConfiguredPath } from '@/lib/match-configured-path';
|
||||
|
||||
export const config = {
|
||||
matcher: '/:path*',
|
||||
@@ -7,6 +8,7 @@ export const config = {
|
||||
const TRACKER_PATH = '/script.js';
|
||||
const COLLECT_PATH = '/api/send';
|
||||
const LOGIN_PATH = '/login';
|
||||
const BASE_PATH = process.env.BASE_PATH || '';
|
||||
|
||||
const apiHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -27,7 +29,7 @@ function customCollectEndpoint(request: NextRequest) {
|
||||
if (collectEndpoint) {
|
||||
const url = request.nextUrl.clone();
|
||||
|
||||
if (url.pathname.endsWith(collectEndpoint)) {
|
||||
if (matchesConfiguredPath(url.pathname, collectEndpoint, BASE_PATH)) {
|
||||
url.pathname = COLLECT_PATH;
|
||||
return NextResponse.rewrite(url, { headers: apiHeaders });
|
||||
}
|
||||
@@ -41,7 +43,7 @@ function customScriptName(request: NextRequest) {
|
||||
const url = request.nextUrl.clone();
|
||||
const names = scriptName.split(',').map(name => name.trim().replace(/^\/+/, ''));
|
||||
|
||||
if (names.find(name => url.pathname.endsWith(name))) {
|
||||
if (names.find(name => matchesConfiguredPath(url.pathname, name, BASE_PATH))) {
|
||||
url.pathname = TRACKER_PATH;
|
||||
return NextResponse.rewrite(url, { headers: trackerHeaders });
|
||||
}
|
||||
@@ -51,7 +53,7 @@ function customScriptName(request: NextRequest) {
|
||||
function customScriptUrl(request: NextRequest) {
|
||||
const scriptUrl = process.env.TRACKER_SCRIPT_URL;
|
||||
|
||||
if (scriptUrl && request.nextUrl.pathname.endsWith(TRACKER_PATH)) {
|
||||
if (scriptUrl && matchesConfiguredPath(request.nextUrl.pathname, TRACKER_PATH, BASE_PATH)) {
|
||||
return NextResponse.rewrite(scriptUrl, { headers: trackerHeaders });
|
||||
}
|
||||
}
|
||||
@@ -59,7 +61,7 @@ function customScriptUrl(request: NextRequest) {
|
||||
function disableLogin(request: NextRequest) {
|
||||
const loginDisabled = process.env.DISABLE_LOGIN;
|
||||
|
||||
if (loginDisabled && request.nextUrl.pathname.endsWith(LOGIN_PATH)) {
|
||||
if (loginDisabled && matchesConfiguredPath(request.nextUrl.pathname, LOGIN_PATH, BASE_PATH)) {
|
||||
return new NextResponse('Access denied', { status: 403 });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
roots: ['./src'],
|
||||
testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'],
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx)$': 'ts-jest',
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
};
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
+70
-10
@@ -1,25 +1,54 @@
|
||||
import 'dotenv/config';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import pkg from './package.json' with { type: 'json' };
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
const TRACKER_SCRIPT = '/script.js';
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
const apiUrl = process.env.API_URL || '';
|
||||
const basePath = process.env.BASE_PATH || '';
|
||||
const cloudMode = process.env.CLOUD_MODE || '';
|
||||
const cloudUrl = process.env.CLOUD_URL || '';
|
||||
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || '';
|
||||
const corsMaxAge = process.env.CORS_MAX_AGE || '';
|
||||
const defaultCurrency = process.env.DEFAULT_CURRENCY || '';
|
||||
const defaultLocale = process.env.DEFAULT_LOCALE || '';
|
||||
const forceSSL = process.env.FORCE_SSL || '';
|
||||
const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
|
||||
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME || '';
|
||||
const trackerScriptURL = process.env.TRACKER_SCRIPT_URL || '';
|
||||
const selfTrack = process.env.UMAMI_SELF_TRACK || '';
|
||||
const selfRecord = process.env.UMAMI_SELF_RECORD || '';
|
||||
|
||||
function getUrlOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isRelativeUrl(url: string) {
|
||||
return Boolean(url && !/^https?:\/\//i.test(url));
|
||||
}
|
||||
|
||||
function normalizePath(url: string) {
|
||||
return `/${url.replace(/^\/+|\/+$/g, '')}`;
|
||||
}
|
||||
|
||||
const apiUrlOrigin = getUrlOrigin(apiUrl);
|
||||
const connectSrc = ["'self'", 'https:', apiUrlOrigin].filter(Boolean).join(' ');
|
||||
|
||||
const contentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
img-src 'self' https: data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
connect-src 'self' https:;
|
||||
connect-src ${connectSrc};
|
||||
frame-src 'self' http: https:;
|
||||
frame-ancestors 'self' ${frameAncestors};
|
||||
`;
|
||||
|
||||
@@ -84,11 +113,14 @@ const headers = [
|
||||
source: '/:path*',
|
||||
headers: defaultHeaders,
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (isProd) {
|
||||
headers.push({
|
||||
source: TRACKER_SCRIPT,
|
||||
headers: trackerHeaders,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const rewrites = [];
|
||||
|
||||
@@ -111,7 +143,33 @@ if (collectApiEndpoint) {
|
||||
});
|
||||
}
|
||||
|
||||
if (isRelativeUrl(apiUrl)) {
|
||||
const normalizedApiUrl = normalizePath(apiUrl);
|
||||
|
||||
if (normalizedApiUrl !== '/' && normalizedApiUrl !== '/api') {
|
||||
headers.push({
|
||||
source: `${normalizedApiUrl}/:path*`,
|
||||
headers: apiHeaders,
|
||||
});
|
||||
|
||||
rewrites.push({
|
||||
source: `${normalizedApiUrl}/:path*`,
|
||||
destination: '/api/:path*',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const redirects = [
|
||||
{
|
||||
source: '/teams/:id/dashboard/edit',
|
||||
destination: '/dashboard/edit',
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/teams/:id/dashboard',
|
||||
destination: '/dashboard',
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/settings',
|
||||
destination: '/settings/preferences',
|
||||
@@ -155,7 +213,7 @@ if (trackerScriptName) {
|
||||
}
|
||||
}
|
||||
|
||||
if (cloudMode) {
|
||||
if (isProd && cloudMode) {
|
||||
rewrites.push({
|
||||
source: '/script.js',
|
||||
destination: 'https://cloud.umami.is/script.js',
|
||||
@@ -163,23 +221,25 @@ if (cloudMode) {
|
||||
}
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
export default {
|
||||
export default withNextIntl({
|
||||
reactStrictMode: false,
|
||||
env: {
|
||||
apiUrl,
|
||||
basePath,
|
||||
cloudMode,
|
||||
cloudUrl,
|
||||
currentVersion: pkg.version,
|
||||
defaultCurrency,
|
||||
defaultLocale,
|
||||
selfTrack,
|
||||
selfRecord,
|
||||
},
|
||||
basePath,
|
||||
output: 'standalone',
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
devIndicators: false,
|
||||
async headers() {
|
||||
return headers;
|
||||
},
|
||||
@@ -199,4 +259,4 @@ export default {
|
||||
async redirects() {
|
||||
return [...redirects];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+89
-98
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "umami",
|
||||
"version": "3.0.3",
|
||||
"version": "3.1.0",
|
||||
"description": "A modern, privacy-focused alternative to Google Analytics.",
|
||||
"author": "Umami Software, Inc. <hello@umami.is>",
|
||||
"license": "MIT",
|
||||
@@ -11,19 +11,20 @@
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001 --turbo",
|
||||
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
|
||||
"dev": "dotenv next dev --turbo",
|
||||
"build": "npm-run-all check-env build-db check-db build-tracker build-recorder build-geo build-app",
|
||||
"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-recorder build-geo build-app",
|
||||
"start-docker": "npm-run-all check-db update-tracker start-server",
|
||||
"start-env": "node scripts/start-env.js",
|
||||
"start-server": "node server.js",
|
||||
"build-app": "next build --turbo",
|
||||
"build-icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
|
||||
"build-components": "tsup",
|
||||
"build-components": "node scripts/bump-components.js && tsup",
|
||||
"build-tracker": "rollup -c rollup.tracker.config.js",
|
||||
"build-recorder": "rollup -c rollup.recorder.config.js",
|
||||
"build-prisma-client": "node scripts/build-prisma-client.js",
|
||||
"build-lang": "npm-run-all format-lang compile-lang download-country-names download-language-names clean-lang",
|
||||
"build-lang": "npm-run-all download-country-names download-language-names",
|
||||
"build-geo": "node scripts/build-geo.js",
|
||||
"build-db": "npm-run-all build-db-client build-prisma-client",
|
||||
"build-db-schema": "prisma db pull",
|
||||
@@ -32,143 +33,133 @@
|
||||
"update-db": "prisma migrate deploy",
|
||||
"check-db": "node scripts/check-db.js",
|
||||
"check-env": "node scripts/check-env.js",
|
||||
"check-missing-messages": "node scripts/check-missing-messages.js",
|
||||
"copy-db-files": "node scripts/copy-db-files.js",
|
||||
"extract-messages": "formatjs extract \"src/components/messages.ts\" --out-file build/extracted-messages.json",
|
||||
"merge-messages": "node scripts/merge-messages.js",
|
||||
"generate-lang": "npm-run-all extract-messages merge-messages",
|
||||
"format-lang": "node scripts/format-lang.js",
|
||||
"compile-lang": "formatjs compile-folder --ast build/messages public/intl/messages",
|
||||
"clean-lang": "prettier --write ./public/intl/**/*.json",
|
||||
"download-country-names": "node scripts/download-country-names.js",
|
||||
"download-language-names": "node scripts/download-language-names.js",
|
||||
"change-password": "node scripts/change-password.js",
|
||||
"prepare": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky install",
|
||||
"postbuild": "node scripts/postbuild.js",
|
||||
"test": "jest",
|
||||
"cypress-open": "cypress open cypress run",
|
||||
"cypress-run": "cypress run cypress run",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"seed-data": "tsx scripts/seed-data.ts",
|
||||
"lint": "biome lint .",
|
||||
"format": "biome format --write .",
|
||||
"check": "biome check --write"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||
]
|
||||
},
|
||||
"cacheDirectories": [
|
||||
".next/cache"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.12.0",
|
||||
"@date-fns/utc": "^1.2.0",
|
||||
"@dicebear/collection": "^9.2.3",
|
||||
"@dicebear/core": "^9.2.3",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@prisma/adapter-pg": "^6.18.0",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"@react-spring/web": "^10.0.3",
|
||||
"@clickhouse/client": "^1.18.5",
|
||||
"@date-fns/utc": "^2.1.1",
|
||||
"@dicebear/collection": "^9.4.2",
|
||||
"@dicebear/core": "^9.4.2",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@prisma/extension-read-replicas": "^0.5.0",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@umami/react-zen": "^0.211.0",
|
||||
"@umami/redis-client": "^0.29.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@umami/react-zen": "^0.245.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chalk": "^5.6.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"classnames": "^2.3.1",
|
||||
"colord": "^2.9.2",
|
||||
"cors": "^2.8.5",
|
||||
"cors": "^2.8.6",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"date-fns": "^2.23.0",
|
||||
"date-fns-tz": "^1.1.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"debug": "^4.4.3",
|
||||
"del": "^6.0.0",
|
||||
"del": "^8.0.1",
|
||||
"detect-browser": "^5.2.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.25.11",
|
||||
"fs-extra": "^11.3.2",
|
||||
"immer": "^10.2.0",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"is-ci": "^3.0.1",
|
||||
"is-docker": "^3.0.0",
|
||||
"is-localhost-ip": "^2.0.0",
|
||||
"isbot": "^5.1.31",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"immer": "^11.1.8",
|
||||
"ipaddr.js": "^2.4.0",
|
||||
"is-ci": "^4.1.0",
|
||||
"is-docker": "^4.0.0",
|
||||
"is-localhost-ip": "^3.0.1",
|
||||
"isbot": "^5.1.40",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"kafkajs": "^2.1.0",
|
||||
"lucide-react": "^0.543.0",
|
||||
"maxmind": "^5.0.0",
|
||||
"next": "^15.5.9",
|
||||
"lucide-react": "^1.16.0",
|
||||
"maxmind": "^5.0.5",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.6",
|
||||
"next-intl": "4.9.2",
|
||||
"node-fetch": "^3.2.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^6.18.0",
|
||||
"pure-rand": "^7.0.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-error-boundary": "^4.0.4",
|
||||
"react-intl": "^7.1.14",
|
||||
"react-simple-maps": "^2.3.0",
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.8.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"pure-rand": "^8.4.0",
|
||||
"react": "^19.2.6",
|
||||
"react-aria-components": "^1.17.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-resizable-panels": "^4.11.1",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"react-use-measure": "^2.0.4",
|
||||
"react-window": "^1.8.6",
|
||||
"react-window": "^2.2.7",
|
||||
"redis": "^5.12.1",
|
||||
"request-ip": "^3.3.0",
|
||||
"semver": "^7.7.3",
|
||||
"serialize-error": "^12.0.0",
|
||||
"thenby": "^1.3.4",
|
||||
"ua-parser-js": "^2.0.6",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
"rrweb": "2.0.0-alpha.4",
|
||||
"rrweb-player": "1.0.0-alpha.4",
|
||||
"semver": "^7.8.0",
|
||||
"serialize-error": "^13.0.1",
|
||||
"thenby": "^1.4.1",
|
||||
"ua-parser-js": "^2.0.9",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@formatjs/cli": "^4.2.29",
|
||||
"@netlify/plugin-nextjs": "^5.15.1",
|
||||
"@rollup/plugin-alias": "^5.0.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.4",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@netlify/plugin-nextjs": "^5.15.11",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@rollup/plugin-alias": "^6.0.0",
|
||||
"@rollup/plugin-commonjs": "^29.0.2",
|
||||
"@rollup/plugin-json": "^6.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.0",
|
||||
"@rollup/plugin-replace": "^5.0.2",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@rollup/plugin-replace": "^6.0.3",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
"@rollup/plugin-typescript": "^12.3.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"cypress": "^13.6.6",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"husky": "^9.1.7",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"lint-staged": "^16.2.6",
|
||||
"postcss": "^8.5.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"msw": "^2.14.6",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-flexbugs-fixes": "^5.0.2",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-preset-env": "7.8.3",
|
||||
"postcss-import": "^16.1.1",
|
||||
"postcss-preset-env": "11.2.1",
|
||||
"prompts": "2.4.2",
|
||||
"rollup": "^4.52.5",
|
||||
"rollup": "^4.60.3",
|
||||
"rollup-plugin-copy": "^3.4.0",
|
||||
"rollup-plugin-delete": "^3.0.1",
|
||||
"rollup-plugin-dts": "^6.3.0",
|
||||
"rollup-plugin-node-externals": "^8.1.1",
|
||||
"rollup-plugin-delete": "^3.0.2",
|
||||
"rollup-plugin-dts": "^6.4.1",
|
||||
"rollup-plugin-node-externals": "^9.0.1",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"stylelint": "^15.10.1",
|
||||
"stylelint-config-css-modules": "^4.5.1",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-config-recommended": "^14.0.0",
|
||||
"tar": "^6.1.2",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tar": "^7.5.15",
|
||||
"ts-morph": "^28.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3"
|
||||
"tsx": "^4.22.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = process.env.PORT ?? '3000';
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
testIdAttribute: 'data-test',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: process.env.PLAYWRIGHT_SKIP_WEB_SERVER
|
||||
? undefined
|
||||
: {
|
||||
command: process.env.PLAYWRIGHT_WEB_SERVER_COMMAND ?? 'pnpm dev',
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
Generated
+5173
-6837
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
packages:
|
||||
- '**'
|
||||
ignoredBuiltDependencies:
|
||||
- cypress
|
||||
- esbuild
|
||||
- sharp
|
||||
onlyBuiltDependencies:
|
||||
|
||||
@@ -10,6 +10,11 @@ DATABASE_TYPE=postgresql
|
||||
# A secret string used by Umami (replace with a strong random string)
|
||||
APP_SECRET=replace-me-with-a-random-string
|
||||
|
||||
# Optional API base URL for internal UI API calls. Relative paths are served under BASE_PATH;
|
||||
# absolute URLs are called directly by the browser.
|
||||
# Examples: /internal-api or https://api.example.com/api
|
||||
API_URL=
|
||||
|
||||
# Postgres container defaults.
|
||||
POSTGRES_DB=umami
|
||||
POSTGRES_USER=umami
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "share" (
|
||||
"share_id" UUID NOT NULL,
|
||||
"entity_id" UUID NOT NULL,
|
||||
"name" VARCHAR(200) NOT NULL,
|
||||
"share_type" INTEGER NOT NULL,
|
||||
"slug" VARCHAR(100) NOT NULL,
|
||||
"parameters" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "share_pkey" PRIMARY KEY ("share_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "share_entity_id_idx" ON "share"("entity_id");
|
||||
|
||||
-- MigrateData
|
||||
INSERT INTO "share" (share_id, entity_id, name, share_type, slug, parameters, created_at)
|
||||
SELECT gen_random_uuid(),
|
||||
website_id,
|
||||
name,
|
||||
1,
|
||||
share_id,
|
||||
'{"overview":true}'::jsonb,
|
||||
now()
|
||||
FROM "website"
|
||||
WHERE share_id IS NOT NULL;
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "website_share_id_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "website_share_id_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "website" DROP COLUMN "share_id";
|
||||
@@ -0,0 +1,30 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "board" (
|
||||
"board_id" UUID NOT NULL,
|
||||
"type" VARCHAR(50) NOT NULL,
|
||||
"name" VARCHAR(200) NOT NULL,
|
||||
"description" VARCHAR(500) NOT NULL,
|
||||
"parameters" JSONB NOT NULL,
|
||||
"slug" VARCHAR(100) NOT NULL,
|
||||
"user_id" UUID,
|
||||
"team_id" UUID,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "board_pkey" PRIMARY KEY ("board_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "board_slug_key" ON "board"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "board_slug_idx" ON "board"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "board_user_id_idx" ON "board"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "board_team_id_idx" ON "board"("team_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "board_created_at_idx" ON "board"("created_at");
|
||||
@@ -0,0 +1,29 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "link_link_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "pixel_pixel_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "report_report_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "revenue_revenue_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "segment_segment_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "session_session_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "team_team_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "team_user_team_user_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "user_user_id_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "website_website_id_key";
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "website_event" ADD COLUMN "cls" DECIMAL(10,4),
|
||||
ADD COLUMN "fcp" DECIMAL(10,1),
|
||||
ADD COLUMN "inp" DECIMAL(10,1),
|
||||
ADD COLUMN "lcp" DECIMAL(10,1),
|
||||
ADD COLUMN "ttfb" DECIMAL(10,1);
|
||||
@@ -0,0 +1,50 @@
|
||||
-- AlterTable board: drop slug
|
||||
DROP INDEX IF EXISTS "board_slug_key";
|
||||
DROP INDEX IF EXISTS "board_slug_idx";
|
||||
ALTER TABLE "board" DROP COLUMN IF EXISTS "slug";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "website" ADD COLUMN "replay_enabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "website" ADD COLUMN "replay_config" JSONB;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session_replay" (
|
||||
"replay_id" UUID NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"session_id" UUID NOT NULL,
|
||||
"visit_id" UUID NOT NULL,
|
||||
"chunk_index" INTEGER NOT NULL,
|
||||
"events" BYTEA NOT NULL,
|
||||
"event_count" INTEGER NOT NULL,
|
||||
"started_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
"ended_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "session_replay_pkey" PRIMARY KEY ("replay_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_replay_website_id_idx" ON "session_replay"("website_id");
|
||||
CREATE INDEX "session_replay_session_id_idx" ON "session_replay"("session_id");
|
||||
CREATE INDEX "session_replay_website_id_session_id_idx" ON "session_replay"("website_id", "session_id");
|
||||
CREATE INDEX "session_replay_website_id_visit_id_idx" ON "session_replay"("website_id", "visit_id");
|
||||
CREATE INDEX "session_replay_website_id_created_at_idx" ON "session_replay"("website_id", "created_at");
|
||||
CREATE INDEX "session_replay_session_id_chunk_index_idx" ON "session_replay"("session_id", "chunk_index");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session_replay_saved" (
|
||||
"saved_replay_id" UUID NOT NULL,
|
||||
"name" VARCHAR(100) NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"visit_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "session_replay_saved_pkey" PRIMARY KEY ("saved_replay_id"),
|
||||
CONSTRAINT "session_replay_saved_website_id_visit_id_key" UNIQUE ("website_id", "visit_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_replay_saved_website_id_idx" ON "session_replay_saved"("website_id");
|
||||
CREATE INDEX "session_replay_saved_visit_id_idx" ON "session_replay_saved"("visit_id");
|
||||
CREATE INDEX "session_replay_saved_website_id_created_at_idx" ON "session_replay_saved"("website_id", "created_at");
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "heatmap_event" (
|
||||
"heatmap_event_id" UUID NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"session_id" UUID NOT NULL,
|
||||
"visit_id" UUID NOT NULL,
|
||||
"url_path" VARCHAR(500) NOT NULL,
|
||||
"event_type" INTEGER NOT NULL,
|
||||
"node_id" INTEGER,
|
||||
"x" INTEGER,
|
||||
"y" INTEGER,
|
||||
"viewport_w" INTEGER,
|
||||
"viewport_h" INTEGER,
|
||||
"page_h" INTEGER,
|
||||
"scroll_pct" INTEGER,
|
||||
"replay_chunk_index" INTEGER,
|
||||
"replay_event_index" INTEGER,
|
||||
"replay_time_ms" BIGINT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "heatmap_event_pkey" PRIMARY KEY ("heatmap_event_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "heatmap_event_website_id_idx" ON "heatmap_event"("website_id");
|
||||
CREATE INDEX "heatmap_event_visit_id_idx" ON "heatmap_event"("visit_id");
|
||||
CREATE INDEX "heatmap_event_website_id_created_at_idx" ON "heatmap_event"("website_id", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_url_path_event_type_created_at_idx" ON "heatmap_event"("website_id", "url_path", "event_type", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_visit_id_replay_chunk_index_replay_event_index_idx" ON "heatmap_event"("website_id", "visit_id", "replay_chunk_index", "replay_event_index");
|
||||
+134
-21
@@ -6,12 +6,11 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
relationMode = "prisma"
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @unique @map("user_id") @db.Uuid
|
||||
id String @id() @map("user_id") @db.Uuid
|
||||
username String @unique @db.VarChar(255)
|
||||
password String @db.VarChar(60)
|
||||
role String @map("role") @db.VarChar(50)
|
||||
@@ -27,12 +26,13 @@ model User {
|
||||
pixels Pixel[] @relation("user")
|
||||
teams TeamUser[]
|
||||
reports Report[]
|
||||
boards Board[] @relation("user")
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @unique @map("session_id") @db.Uuid
|
||||
id String @id() @map("session_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
browser String? @db.VarChar(20)
|
||||
os String? @db.VarChar(20)
|
||||
@@ -64,10 +64,9 @@ model Session {
|
||||
}
|
||||
|
||||
model Website {
|
||||
id String @id @unique @map("website_id") @db.Uuid
|
||||
id String @id() @map("website_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
domain String? @db.VarChar(500)
|
||||
shareId String? @unique @map("share_id") @db.VarChar(50)
|
||||
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
teamId String? @map("team_id") @db.Uuid
|
||||
@@ -76,19 +75,24 @@ model Website {
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
|
||||
user User? @relation("user", fields: [userId], references: [id])
|
||||
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
eventData EventData[]
|
||||
reports Report[]
|
||||
revenue Revenue[]
|
||||
segments Segment[]
|
||||
sessionData SessionData[]
|
||||
replayEnabled Boolean @default(false) @map("replay_enabled")
|
||||
replayConfig Json? @map("replay_config")
|
||||
|
||||
user User? @relation("user", fields: [userId], references: [id])
|
||||
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
eventData EventData[]
|
||||
reports Report[]
|
||||
revenue Revenue[]
|
||||
segments Segment[]
|
||||
sessionData SessionData[]
|
||||
sessionReplays SessionReplay[]
|
||||
sessionReplaysSaved SessionReplaySaved[]
|
||||
heatmapEvents HeatmapEvent[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([createdAt])
|
||||
@@index([shareId])
|
||||
@@index([createdBy])
|
||||
@@map("website")
|
||||
}
|
||||
@@ -120,6 +124,11 @@ model WebsiteEvent {
|
||||
eventName String? @map("event_name") @db.VarChar(50)
|
||||
tag String? @db.VarChar(50)
|
||||
hostname String? @db.VarChar(100)
|
||||
lcp Decimal? @db.Decimal(10, 1)
|
||||
inp Decimal? @db.Decimal(10, 1)
|
||||
cls Decimal? @db.Decimal(10, 4)
|
||||
fcp Decimal? @db.Decimal(10, 1)
|
||||
ttfb Decimal? @db.Decimal(10, 1)
|
||||
|
||||
eventData EventData[]
|
||||
session Session @relation(fields: [sessionId], references: [id])
|
||||
@@ -187,7 +196,7 @@ model SessionData {
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id() @unique() @map("team_id") @db.Uuid
|
||||
id String @id() @map("team_id") @db.Uuid
|
||||
name String @db.VarChar(50)
|
||||
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
||||
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
||||
@@ -199,13 +208,14 @@ model Team {
|
||||
members TeamUser[]
|
||||
links Link[]
|
||||
pixels Pixel[]
|
||||
boards Board[]
|
||||
|
||||
@@index([accessCode])
|
||||
@@map("team")
|
||||
}
|
||||
|
||||
model TeamUser {
|
||||
id String @id() @unique() @map("team_user_id") @db.Uuid
|
||||
id String @id() @map("team_user_id") @db.Uuid
|
||||
teamId String @map("team_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
role String @db.VarChar(50)
|
||||
@@ -221,7 +231,7 @@ model TeamUser {
|
||||
}
|
||||
|
||||
model Report {
|
||||
id String @id() @unique() @map("report_id") @db.Uuid
|
||||
id String @id() @map("report_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
type String @db.VarChar(50)
|
||||
@@ -242,7 +252,7 @@ model Report {
|
||||
}
|
||||
|
||||
model Segment {
|
||||
id String @id() @unique() @map("segment_id") @db.Uuid
|
||||
id String @id() @map("segment_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
type String @db.VarChar(50)
|
||||
name String @db.VarChar(200)
|
||||
@@ -257,7 +267,7 @@ model Segment {
|
||||
}
|
||||
|
||||
model Revenue {
|
||||
id String @id() @unique() @map("revenue_id") @db.Uuid
|
||||
id String @id() @map("revenue_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
eventId String @map("event_id") @db.Uuid
|
||||
@@ -277,7 +287,7 @@ model Revenue {
|
||||
}
|
||||
|
||||
model Link {
|
||||
id String @id() @unique() @map("link_id") @db.Uuid
|
||||
id String @id() @map("link_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
url String @db.VarChar(500)
|
||||
slug String @unique() @db.VarChar(100)
|
||||
@@ -298,7 +308,7 @@ model Link {
|
||||
}
|
||||
|
||||
model Pixel {
|
||||
id String @id() @unique() @map("pixel_id") @db.Uuid
|
||||
id String @id() @map("pixel_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
slug String @unique() @db.VarChar(100)
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
@@ -316,3 +326,106 @@ model Pixel {
|
||||
@@index([createdAt])
|
||||
@@map("pixel")
|
||||
}
|
||||
|
||||
model Board {
|
||||
id String @id() @map("board_id") @db.Uuid
|
||||
type String @db.VarChar(50)
|
||||
name String @db.VarChar(200)
|
||||
description String @db.VarChar(500)
|
||||
parameters Json
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
teamId String? @map("team_id") @db.Uuid
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
user User? @relation("user", fields: [userId], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([createdAt])
|
||||
@@map("board")
|
||||
}
|
||||
model Share {
|
||||
id String @id() @map("share_id") @db.Uuid
|
||||
entityId String @map("entity_id") @db.Uuid
|
||||
name String @db.VarChar(200)
|
||||
shareType Int @map("share_type") @db.Integer
|
||||
slug String @unique() @db.VarChar(100)
|
||||
parameters Json
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@index([entityId])
|
||||
@@map("share")
|
||||
}
|
||||
|
||||
model SessionReplay {
|
||||
id String @id() @map("replay_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
visitId String @map("visit_id") @db.Uuid
|
||||
chunkIndex Int @map("chunk_index") @db.Integer
|
||||
events Bytes @map("events")
|
||||
eventCount Int @map("event_count") @db.Integer
|
||||
startedAt DateTime @map("started_at") @db.Timestamptz(6)
|
||||
endedAt DateTime @map("ended_at") @db.Timestamptz(6)
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@index([websiteId])
|
||||
@@index([sessionId])
|
||||
@@index([visitId])
|
||||
@@index([websiteId, sessionId])
|
||||
@@index([websiteId, visitId])
|
||||
@@index([websiteId, createdAt])
|
||||
@@index([sessionId, chunkIndex])
|
||||
@@map("session_replay")
|
||||
}
|
||||
|
||||
model SessionReplaySaved {
|
||||
id String @id() @map("saved_replay_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
visitId String @map("visit_id") @db.Uuid
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@unique([websiteId, visitId])
|
||||
@@index([websiteId])
|
||||
@@index([visitId])
|
||||
@@index([websiteId, createdAt])
|
||||
@@map("session_replay_saved")
|
||||
}
|
||||
|
||||
model HeatmapEvent {
|
||||
id String @id() @map("heatmap_event_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
visitId String @map("visit_id") @db.Uuid
|
||||
urlPath String @map("url_path") @db.VarChar(500)
|
||||
eventType Int @map("event_type") @db.Integer
|
||||
nodeId Int? @map("node_id") @db.Integer
|
||||
x Int? @db.Integer
|
||||
y Int? @db.Integer
|
||||
viewportW Int? @map("viewport_w") @db.Integer
|
||||
viewportH Int? @map("viewport_h") @db.Integer
|
||||
pageH Int? @map("page_h") @db.Integer
|
||||
scrollPct Int? @map("scroll_pct") @db.Integer
|
||||
replayChunkIndex Int? @map("replay_chunk_index") @db.Integer
|
||||
replayEventIndex Int? @map("replay_event_index") @db.Integer
|
||||
replayTimeMs BigInt? @map("replay_time_ms") @db.BigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@index([websiteId])
|
||||
@@index([visitId])
|
||||
@@index([websiteId, createdAt])
|
||||
@@index([websiteId, urlPath, eventType, createdAt])
|
||||
@@index([websiteId, visitId, replayChunkIndex, replayEventIndex])
|
||||
@@map("heatmap_event")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 152 B After Width: | Height: | Size: 323 B |
@@ -227,7 +227,7 @@
|
||||
"TO": "Tonga",
|
||||
"TT": "Trinidad & Tobago",
|
||||
"TN": "Tunisia",
|
||||
"TR": "Turkey",
|
||||
"TR": "T\u00fcrkiye",
|
||||
"TM": "Turkmenistan",
|
||||
"TC": "Turks & Caicos Islands",
|
||||
"TV": "Tuvalu",
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
"TO": "Tonga",
|
||||
"TT": "Trinidad & Tobago",
|
||||
"TN": "Tunisia",
|
||||
"TR": "Turkey",
|
||||
"TR": "T\u00fcrkiye",
|
||||
"TM": "Turkmenistan",
|
||||
"TC": "Turks & Caicos Islands",
|
||||
"TV": "Tuvalu",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+421
-2212
File diff suppressed because it is too large
Load Diff
+421
-2220
File diff suppressed because it is too large
Load Diff
+421
-2226
File diff suppressed because it is too large
Load Diff
+421
-2228
File diff suppressed because it is too large
Load Diff
+433
-2226
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+433
-2212
File diff suppressed because it is too large
Load Diff
+433
-2208
File diff suppressed because it is too large
Load Diff
+421
-2220
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+442
-2226
File diff suppressed because it is too large
Load Diff
+435
-2226
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+421
-2200
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+433
-2204
File diff suppressed because it is too large
Load Diff
+433
-2228
File diff suppressed because it is too large
Load Diff
+421
-2204
File diff suppressed because it is too large
Load Diff
+421
-2212
File diff suppressed because it is too large
Load Diff
+433
-2234
File diff suppressed because it is too large
Load Diff
+433
-2224
File diff suppressed because it is too large
Load Diff
+433
-2184
File diff suppressed because it is too large
Load Diff
+433
-2224
File diff suppressed because it is too large
Load Diff
+421
-2210
File diff suppressed because it is too large
Load Diff
+421
-2210
File diff suppressed because it is too large
Load Diff
+421
-2166
File diff suppressed because it is too large
Load Diff
+433
-2345
File diff suppressed because it is too large
Load Diff
+421
-2234
File diff suppressed because it is too large
Load Diff
+433
-2212
File diff suppressed because it is too large
Load Diff
+421
-2200
File diff suppressed because it is too large
Load Diff
+433
-2224
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+433
-2204
File diff suppressed because it is too large
Load Diff
+433
-2224
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+421
-2188
File diff suppressed because it is too large
Load Diff
+421
-2226
File diff suppressed because it is too large
Load Diff
+433
-2220
File diff suppressed because it is too large
Load Diff
+437
-2094
File diff suppressed because it is too large
Load Diff
+433
-2224
File diff suppressed because it is too large
Load Diff
+421
-2216
File diff suppressed because it is too large
Load Diff
+421
-2216
File diff suppressed because it is too large
Load Diff
+433
-2176
File diff suppressed because it is too large
Load Diff
+421
-2196
File diff suppressed because it is too large
Load Diff
+421
-2220
File diff suppressed because it is too large
Load Diff
+439
-1856
File diff suppressed because it is too large
Load Diff
+427
-1858
File diff suppressed because it is too large
Load Diff
+421
-2212
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user