mirror of
https://github.com/umami-software/umami.git
synced 2026-02-12 00:27:11 +01:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
aaf92058d4
119 changed files with 5043 additions and 3493 deletions
2
.github/workflows/cd-cloud.yml
vendored
2
.github/workflows/cd-cloud.yml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: Create docker images
|
name: Create docker images (cloud)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
|
||||||
58
.github/workflows/cd-manual.yml
vendored
58
.github/workflows/cd-manual.yml
vendored
|
|
@ -1,58 +0,0 @@
|
||||||
name: Create docker images (manual)
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
type: string
|
|
||||||
description: Version
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build, push, and deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
db-type: [postgresql, mysql]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Extract version parts from input
|
|
||||||
id: extract_version
|
|
||||||
run: |
|
|
||||||
echo "version=$(echo ${{ github.event.inputs.version }})" >> $GITHUB_ENV
|
|
||||||
echo "major=$(echo ${{ github.event.inputs.version }} | cut -d. -f1)" >> $GITHUB_ENV
|
|
||||||
echo "minor=$(echo ${{ github.event.inputs.version }} | cut -d. -f2)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Generate tags
|
|
||||||
id: generate_tags
|
|
||||||
run: |
|
|
||||||
echo "tag_major=$(echo ${{ matrix.db-type }}-${{ env.major }})" >> $GITHUB_ENV
|
|
||||||
echo "tag_minor=$(echo ${{ matrix.db-type }}-${{ env.major }}.${{ env.minor }})" >> $GITHUB_ENV
|
|
||||||
echo "tag_patch=$(echo ${{ matrix.db-type }}-${{ env.version }})" >> $GITHUB_ENV
|
|
||||||
echo "tag_latest=$(echo ${{ matrix.db-type }}-latest)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: mr-smithers-excellent/docker-build-push@v6
|
|
||||||
name: Build & push Docker image to ghcr.io for ${{ matrix.db-type }}
|
|
||||||
with:
|
|
||||||
image: umami
|
|
||||||
tags: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }}
|
|
||||||
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
|
|
||||||
registry: ghcr.io
|
|
||||||
multiPlatform: true
|
|
||||||
platform: linux/amd64,linux/arm64
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: mr-smithers-excellent/docker-build-push@v6
|
|
||||||
name: Build & push Docker image to docker.io for ${{ matrix.db-type }}
|
|
||||||
with:
|
|
||||||
image: umamisoftware/umami
|
|
||||||
tags: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }}
|
|
||||||
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
|
|
||||||
registry: docker.io
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
109
.github/workflows/cd.yml
vendored
109
.github/workflows/cd.yml
vendored
|
|
@ -1,50 +1,101 @@
|
||||||
name: Create docker images
|
name: Create docker images
|
||||||
|
|
||||||
on: [create]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
# Publish semver tags as releases.
|
||||||
|
tags: [ 'v*.*.*' ]
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build, push, and deploy
|
name: Build, push, and deploy
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
# This is used to complete the identity challenge
|
||||||
|
# with sigstore/fulcio when running outside of PRs.
|
||||||
|
id-token: write
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
db-type: [postgresql, mysql]
|
db-type: [postgresql, mysql]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set env
|
# Install the cosign tool except on PR
|
||||||
run: |
|
# https://github.com/sigstore/cosign-installer
|
||||||
echo "NOW=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
- name: Install cosign
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
- name: Generate tags
|
uses: sigstore/cosign-installer@v3
|
||||||
id: generate_tags
|
|
||||||
run: |
|
|
||||||
echo "tag_patch=$(echo ${{ matrix.db-type }})-${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
|
||||||
echo "tag_minor=$(echo ${{ matrix.db-type }})-$(echo ${GITHUB_REF#refs/tags/} | cut -d. -f1,2)" >> $GITHUB_ENV
|
|
||||||
echo "tag_major=$(echo ${{ matrix.db-type }})-$(echo ${GITHUB_REF#refs/tags/} | cut -d. -f1)" >> $GITHUB_ENV
|
|
||||||
echo "tag_latest=$(echo ${{ matrix.db-type }})-latest" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: mr-smithers-excellent/docker-build-push@v6
|
- name: Set up Docker Buildx
|
||||||
name: Build & push Docker image to ghcr.io for ${{ matrix.db-type }}
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log into registry docker.io
|
||||||
|
if: github.event_name != 'pull_request' && github.repository == 'umami-software/umami'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.io
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Log into ghcr registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
image: umami
|
|
||||||
tags: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }}
|
|
||||||
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
|
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
multiPlatform: true
|
|
||||||
platform: linux/amd64,linux/arm64
|
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: mr-smithers-excellent/docker-build-push@v6
|
- name: Extract Docker metadata
|
||||||
name: Build & push Docker image to docker.io for ${{ matrix.db-type }}
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
image: umamisoftware/umami
|
images: |
|
||||||
tags: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }}
|
umamisoftware/umami,enable=${{ github.repository == 'umami-software/umami' }}
|
||||||
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
|
ghcr.io/${{ github.repository }}
|
||||||
registry: docker.io
|
flavor: |
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
latest=auto
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
prefix=${{ matrix.db-type }}-
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
|
||||||
|
# output 1.1.2
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
# output 1.1
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
# output 1
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
build-args: DATABASE_TYPE=${{ matrix.db-type }}
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
|
- name: Sign the published Docker image
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
env:
|
||||||
|
TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||||
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
|
||||||
|
|
|
||||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
|
@ -19,20 +19,26 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- node-version: 18.18
|
- node-version: 18.18
|
||||||
|
pnpm-version: 10
|
||||||
db-type: postgresql
|
db-type: postgresql
|
||||||
- node-version: 18.18
|
- node-version: 18.18
|
||||||
|
pnpm-version: 10
|
||||||
db-type: mysql
|
db-type: mysql
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4 # required so that setup-node will work
|
||||||
|
with:
|
||||||
|
version: ${{ matrix.pnpm-version }}
|
||||||
|
run_install: false
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: 'yarn'
|
cache: 'pnpm'
|
||||||
env:
|
env:
|
||||||
DATABASE_TYPE: ${{ matrix.db-type }}
|
DATABASE_TYPE: ${{ matrix.db-type }}
|
||||||
- run: npm install --global yarn
|
- run: npm install --global pnpm
|
||||||
- run: yarn install
|
- run: pnpm install
|
||||||
- run: yarn test
|
- run: pnpm test
|
||||||
- run: yarn build
|
- run: pnpm build
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@
|
||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -43,7 +43,7 @@ A detailed getting started guide can be found at [umami.is/docs](https://umami.i
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/umami-software/umami.git
|
git clone https://github.com/umami-software/umami.git
|
||||||
cd umami
|
cd umami
|
||||||
npm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure Umami
|
### Configure Umami
|
||||||
|
|
@ -64,7 +64,7 @@ mysql://username:mypassword@localhost:3306/mydb
|
||||||
### Build the Application
|
### Build the Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
_The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**._
|
_The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**._
|
||||||
|
|
@ -72,7 +72,7 @@ _The build step will create tables in your database if you are installing for th
|
||||||
### Start the Application
|
### Start the Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run start
|
pnpm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
_By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly._
|
_By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly._
|
||||||
|
|
@ -107,8 +107,8 @@ To get the latest features, simply do a pull, install any new dependencies, and
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
npm install
|
pnpm install
|
||||||
npm run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
To update the Docker image, simply pull the new images and rebuild:
|
To update the Docker image, simply pull the new images and rebuild:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { uuid } from '../../src/lib/crypto';
|
||||||
|
|
||||||
describe('Website API tests', () => {
|
describe('Website API tests', () => {
|
||||||
Cypress.session.clearAllSavedSessions();
|
Cypress.session.clearAllSavedSessions();
|
||||||
|
|
||||||
|
|
@ -65,6 +67,37 @@ describe('Website API tests', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.', () => {
|
it('Returns all tracked websites.', () => {
|
||||||
cy.request({
|
cy.request({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -116,6 +149,21 @@ describe('Website API tests', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.', () => {
|
it('Resets a website by removing all data related to the website.', () => {
|
||||||
cy.request({
|
cy.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
253
db/clickhouse/migrations/08_update_hostname_view.sql
Normal file
253
db/clickhouse/migrations/08_update_hostname_view.sql
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
-- create new hourly table
|
||||||
|
CREATE TABLE umami.website_event_stats_hourly_new
|
||||||
|
(
|
||||||
|
website_id UUID,
|
||||||
|
session_id UUID,
|
||||||
|
visit_id UUID,
|
||||||
|
hostname SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
browser LowCardinality(String),
|
||||||
|
os LowCardinality(String),
|
||||||
|
device LowCardinality(String),
|
||||||
|
screen LowCardinality(String),
|
||||||
|
language LowCardinality(String),
|
||||||
|
country LowCardinality(String),
|
||||||
|
region LowCardinality(String),
|
||||||
|
city String,
|
||||||
|
entry_url AggregateFunction(argMin, String, DateTime('UTC')),
|
||||||
|
exit_url AggregateFunction(argMax, String, DateTime('UTC')),
|
||||||
|
url_path SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
url_query SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_source SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_medium SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_campaign SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_content SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
utm_term SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
page_title SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
gclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
fbclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
msclkid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
ttclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
li_fat_id SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
twclid SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
event_type UInt32,
|
||||||
|
event_name SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
views SimpleAggregateFunction(sum, UInt64),
|
||||||
|
min_time SimpleAggregateFunction(min, DateTime('UTC')),
|
||||||
|
max_time SimpleAggregateFunction(max, DateTime('UTC')),
|
||||||
|
tag SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
|
distinct_id String,
|
||||||
|
created_at Datetime('UTC')
|
||||||
|
)
|
||||||
|
ENGINE = AggregatingMergeTree
|
||||||
|
PARTITION BY toYYYYMM(created_at)
|
||||||
|
ORDER BY (
|
||||||
|
website_id,
|
||||||
|
event_type,
|
||||||
|
toStartOfHour(created_at),
|
||||||
|
cityHash64(visit_id),
|
||||||
|
visit_id
|
||||||
|
)
|
||||||
|
SAMPLE BY cityHash64(visit_id);
|
||||||
|
|
||||||
|
-- create view
|
||||||
|
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv_new
|
||||||
|
TO umami.website_event_stats_hourly_new
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
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 = 1) views,
|
||||||
|
min(created_at) min_time,
|
||||||
|
max(created_at) max_time,
|
||||||
|
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
||||||
|
distinct_id,
|
||||||
|
toStartOfHour(created_at) timestamp
|
||||||
|
FROM umami.website_event
|
||||||
|
GROUP BY website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
event_type,
|
||||||
|
distinct_id,
|
||||||
|
timestamp);
|
||||||
|
|
||||||
|
-- rename tables
|
||||||
|
RENAME TABLE umami.website_event_stats_hourly TO umami.website_event_stats_hourly_old;
|
||||||
|
RENAME TABLE umami.website_event_stats_hourly_new TO umami.website_event_stats_hourly;
|
||||||
|
|
||||||
|
-- drop views
|
||||||
|
DROP TABLE umami.website_event_stats_hourly_mv;
|
||||||
|
DROP TABLE umami.website_event_stats_hourly_mv_new;
|
||||||
|
|
||||||
|
-- recreate view
|
||||||
|
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv
|
||||||
|
TO umami.website_event_stats_hourly
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
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 = 1) views,
|
||||||
|
min(created_at) min_time,
|
||||||
|
max(created_at) max_time,
|
||||||
|
arrayFilter(x -> x != '', groupArray(tag)) tag,
|
||||||
|
distinct_id,
|
||||||
|
toStartOfHour(created_at) timestamp
|
||||||
|
FROM umami.website_event
|
||||||
|
GROUP BY website_id,
|
||||||
|
session_id,
|
||||||
|
visit_id,
|
||||||
|
hostname,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
screen,
|
||||||
|
language,
|
||||||
|
country,
|
||||||
|
region,
|
||||||
|
city,
|
||||||
|
event_type,
|
||||||
|
distinct_id,
|
||||||
|
timestamp);
|
||||||
|
|
@ -90,7 +90,7 @@ CREATE TABLE umami.website_event_stats_hourly
|
||||||
website_id UUID,
|
website_id UUID,
|
||||||
session_id UUID,
|
session_id UUID,
|
||||||
visit_id UUID,
|
visit_id UUID,
|
||||||
hostname LowCardinality(String),
|
hostname SimpleAggregateFunction(groupArrayArray, Array(String)),
|
||||||
browser LowCardinality(String),
|
browser LowCardinality(String),
|
||||||
os LowCardinality(String),
|
os LowCardinality(String),
|
||||||
device LowCardinality(String),
|
device LowCardinality(String),
|
||||||
|
|
@ -143,7 +143,7 @@ SELECT
|
||||||
website_id,
|
website_id,
|
||||||
session_id,
|
session_id,
|
||||||
visit_id,
|
visit_id,
|
||||||
hostname,
|
hostnames as hostname,
|
||||||
browser,
|
browser,
|
||||||
os,
|
os,
|
||||||
device,
|
device,
|
||||||
|
|
@ -181,7 +181,7 @@ FROM (SELECT
|
||||||
website_id,
|
website_id,
|
||||||
session_id,
|
session_id,
|
||||||
visit_id,
|
visit_id,
|
||||||
hostname,
|
arrayFilter(x -> x != '', groupArray(hostname)) hostnames,
|
||||||
browser,
|
browser,
|
||||||
os,
|
os,
|
||||||
device,
|
device,
|
||||||
|
|
@ -199,7 +199,7 @@ FROM (SELECT
|
||||||
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
|
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
|
||||||
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
|
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
|
||||||
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
|
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
|
||||||
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
|
arrayFilter(x -> x != '' and x != hostname, groupArray(referrer_domain)) referrer_domain,
|
||||||
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
|
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
|
||||||
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
|
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
|
||||||
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
|
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
|
||||||
|
|
@ -246,3 +246,38 @@ SELECT * ORDER BY toStartOfDay(created_at), website_id, referrer_domain, created
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE umami.website_event MATERIALIZE PROJECTION website_event_referrer_domain_projection;
|
ALTER TABLE umami.website_event MATERIALIZE PROJECTION website_event_referrer_domain_projection;
|
||||||
|
|
||||||
|
-- revenue
|
||||||
|
CREATE TABLE umami.website_revenue
|
||||||
|
(
|
||||||
|
website_id UUID,
|
||||||
|
session_id UUID,
|
||||||
|
event_id UUID,
|
||||||
|
event_name String,
|
||||||
|
currency String,
|
||||||
|
revenue DECIMAL(18,4),
|
||||||
|
created_at DateTime('UTC')
|
||||||
|
)
|
||||||
|
ENGINE = MergeTree
|
||||||
|
PARTITION BY toYYYYMM(created_at)
|
||||||
|
ORDER BY (website_id, session_id, created_at)
|
||||||
|
SETTINGS index_granularity = 8192;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW umami.website_revenue_mv
|
||||||
|
TO umami.website_revenue
|
||||||
|
AS
|
||||||
|
SELECT DISTINCT
|
||||||
|
ed.website_id,
|
||||||
|
ed.session_id,
|
||||||
|
ed.event_id,
|
||||||
|
ed.event_name,
|
||||||
|
c.currency,
|
||||||
|
coalesce(toDecimal64(ed.number_value, 2), toDecimal64(ed.string_value, 2)) revenue,
|
||||||
|
ed.created_at
|
||||||
|
FROM umami.event_data ed
|
||||||
|
JOIN (SELECT event_id, string_value as currency
|
||||||
|
FROM umami.event_data
|
||||||
|
WHERE positionCaseInsensitive(data_key, 'currency') > 0) c
|
||||||
|
ON c.event_id = ed.event_id
|
||||||
|
WHERE positionCaseInsensitive(data_key, 'revenue') > 0;
|
||||||
|
|
|
||||||
14
db/mysql/migrations/11_add_segment/migration.sql
Normal file
14
db/mysql/migrations/11_add_segment/migration.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `segment` (
|
||||||
|
`segment_id` VARCHAR(36) NOT NULL,
|
||||||
|
`website_id` VARCHAR(36) NOT NULL,
|
||||||
|
`type` VARCHAR(200) NOT NULL,
|
||||||
|
`name` VARCHAR(200) NOT NULL,
|
||||||
|
`parameters` JSON NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` TIMESTAMP(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `segment_segment_id_key`(`segment_id`),
|
||||||
|
INDEX `segment_website_id_idx`(`website_id`),
|
||||||
|
PRIMARY KEY (`segment_id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `report` MODIFY `parameters` JSON NOT NULL;
|
||||||
18
db/mysql/migrations/13_add_revenue/migration.sql
Normal file
18
db/mysql/migrations/13_add_revenue/migration.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `revenue` (
|
||||||
|
`revenue_id` VARCHAR(36) NOT NULL,
|
||||||
|
`website_id` VARCHAR(36) NOT NULL,
|
||||||
|
`session_id` VARCHAR(36) NOT NULL,
|
||||||
|
`event_id` VARCHAR(36) NOT NULL,
|
||||||
|
`event_name` VARCHAR(50) NOT NULL,
|
||||||
|
`currency` VARCHAR(100) NOT NULL,
|
||||||
|
`revenue` DECIMAL(19, 4) NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
|
||||||
|
UNIQUE INDEX `revenue_revenue_id_key`(`revenue_id`),
|
||||||
|
INDEX `revenue_website_id_idx`(`website_id`),
|
||||||
|
INDEX `revenue_session_id_idx`(`session_id`),
|
||||||
|
INDEX `revenue_website_id_created_at_idx`(`website_id`, `created_at`),
|
||||||
|
INDEX `revenue_website_id_session_id_created_at_idx`(`website_id`, `session_id`, `created_at`),
|
||||||
|
PRIMARY KEY (`revenue_id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
@ -43,6 +43,7 @@ model Session {
|
||||||
|
|
||||||
websiteEvent WebsiteEvent[]
|
websiteEvent WebsiteEvent[]
|
||||||
sessionData SessionData[]
|
sessionData SessionData[]
|
||||||
|
revenue Revenue[]
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
|
|
@ -76,7 +77,9 @@ model Website {
|
||||||
team Team? @relation(fields: [teamId], references: [id])
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
eventData EventData[]
|
eventData EventData[]
|
||||||
report Report[]
|
report Report[]
|
||||||
|
revenue Revenue[]
|
||||||
sessionData SessionData[]
|
sessionData SessionData[]
|
||||||
|
segment Segment[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
|
|
@ -215,10 +218,10 @@ model Report {
|
||||||
id String @id() @unique() @map("report_id") @db.VarChar(36)
|
id String @id() @unique() @map("report_id") @db.VarChar(36)
|
||||||
userId String @map("user_id") @db.VarChar(36)
|
userId String @map("user_id") @db.VarChar(36)
|
||||||
websiteId String @map("website_id") @db.VarChar(36)
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
type String @map("type") @db.VarChar(200)
|
type String @db.VarChar(200)
|
||||||
name String @map("name") @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
description String @map("description") @db.VarChar(500)
|
description String @db.VarChar(500)
|
||||||
parameters String @map("parameters") @db.VarChar(6000)
|
parameters Json
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
|
@ -231,3 +234,38 @@ model Report {
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@map("report")
|
@@map("report")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Segment {
|
||||||
|
id String @id() @unique() @map("segment_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
type String @db.VarChar(200)
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
parameters Json
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@map("segment")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Revenue {
|
||||||
|
id String @id() @unique() @map("revenue_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
sessionId String @map("session_id") @db.VarChar(36)
|
||||||
|
eventId String @map("event_id") @db.VarChar(36)
|
||||||
|
eventName String @map("event_name") @db.VarChar(50)
|
||||||
|
currency String @db.VarChar(100)
|
||||||
|
revenue Decimal? @db.Decimal(19, 4)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, sessionId, createdAt])
|
||||||
|
@@map("revenue")
|
||||||
|
}
|
||||||
18
db/postgresql/migrations/11_add_segment/migration.sql
Normal file
18
db/postgresql/migrations/11_add_segment/migration.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "segment" (
|
||||||
|
"segment_id" UUID NOT NULL,
|
||||||
|
"website_id" UUID NOT NULL,
|
||||||
|
"type" VARCHAR(200) NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"parameters" JSONB NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6),
|
||||||
|
|
||||||
|
CONSTRAINT "segment_pkey" PRIMARY KEY ("segment_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "segment_segment_id_key" ON "segment"("segment_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "segment_website_id_idx" ON "segment"("website_id");
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "report"
|
||||||
|
ALTER COLUMN "parameters" SET DATA TYPE JSONB USING parameters::JSONB;
|
||||||
28
db/postgresql/migrations/13_add_revenue/migration.sql
Normal file
28
db/postgresql/migrations/13_add_revenue/migration.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "revenue" (
|
||||||
|
"revenue_id" UUID NOT NULL,
|
||||||
|
"website_id" UUID NOT NULL,
|
||||||
|
"session_id" UUID NOT NULL,
|
||||||
|
"event_id" UUID NOT NULL,
|
||||||
|
"event_name" VARCHAR(50) NOT NULL,
|
||||||
|
"currency" VARCHAR(100) NOT NULL,
|
||||||
|
"revenue" DECIMAL(19,4),
|
||||||
|
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "revenue_pkey" PRIMARY KEY ("revenue_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "revenue_revenue_id_key" ON "revenue"("revenue_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "revenue_website_id_idx" ON "revenue"("website_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "revenue_session_id_idx" ON "revenue"("session_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "revenue_website_id_created_at_idx" ON "revenue"("website_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "revenue_website_id_session_id_created_at_idx" ON "revenue"("website_id", "session_id", "created_at");
|
||||||
|
|
@ -43,6 +43,7 @@ model Session {
|
||||||
|
|
||||||
websiteEvent WebsiteEvent[]
|
websiteEvent WebsiteEvent[]
|
||||||
sessionData SessionData[]
|
sessionData SessionData[]
|
||||||
|
revenue Revenue[]
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
|
|
@ -76,7 +77,9 @@ model Website {
|
||||||
team Team? @relation(fields: [teamId], references: [id])
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
eventData EventData[]
|
eventData EventData[]
|
||||||
report Report[]
|
report Report[]
|
||||||
|
revenue Revenue[]
|
||||||
sessionData SessionData[]
|
sessionData SessionData[]
|
||||||
|
segment Segment[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
|
|
@ -103,12 +106,12 @@ model WebsiteEvent {
|
||||||
referrerQuery String? @map("referrer_query") @db.VarChar(500)
|
referrerQuery String? @map("referrer_query") @db.VarChar(500)
|
||||||
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
|
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
|
||||||
pageTitle String? @map("page_title") @db.VarChar(500)
|
pageTitle String? @map("page_title") @db.VarChar(500)
|
||||||
gclid String? @map("gclid") @db.VarChar(255)
|
gclid String? @db.VarChar(255)
|
||||||
fbclid String? @map("fbclid") @db.VarChar(255)
|
fbclid String? @db.VarChar(255)
|
||||||
msclkid String? @map("msclkid") @db.VarChar(255)
|
msclkid String? @db.VarChar(255)
|
||||||
ttclid String? @map("ttclid") @db.VarChar(255)
|
ttclid String? @db.VarChar(255)
|
||||||
lifatid String? @map("li_fat_id") @db.VarChar(255)
|
lifatid String? @map("li_fat_id") @db.VarChar(255)
|
||||||
twclid String? @map("twclid") @db.VarChar(255)
|
twclid String? @db.VarChar(255)
|
||||||
eventType Int @default(1) @map("event_type") @db.Integer
|
eventType Int @default(1) @map("event_type") @db.Integer
|
||||||
eventName String? @map("event_name") @db.VarChar(50)
|
eventName String? @map("event_name") @db.VarChar(50)
|
||||||
tag String? @db.VarChar(50)
|
tag String? @db.VarChar(50)
|
||||||
|
|
@ -199,7 +202,7 @@ model TeamUser {
|
||||||
id String @id() @unique() @map("team_user_id") @db.Uuid
|
id String @id() @unique() @map("team_user_id") @db.Uuid
|
||||||
teamId String @map("team_id") @db.Uuid
|
teamId String @map("team_id") @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
role String @map("role") @db.VarChar(50)
|
role String @db.VarChar(50)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
|
@ -215,10 +218,10 @@ model Report {
|
||||||
id String @id() @unique() @map("report_id") @db.Uuid
|
id String @id() @unique() @map("report_id") @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
type String @map("type") @db.VarChar(200)
|
type String @db.VarChar(200)
|
||||||
name String @map("name") @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
description String @map("description") @db.VarChar(500)
|
description String @db.VarChar(500)
|
||||||
parameters String @map("parameters") @db.VarChar(6000)
|
parameters Json
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
|
@ -231,3 +234,38 @@ model Report {
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@map("report")
|
@@map("report")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Segment {
|
||||||
|
id String @id() @unique() @map("segment_id") @db.Uuid
|
||||||
|
websiteId String @map("website_id") @db.Uuid
|
||||||
|
type String @db.VarChar(200)
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
parameters Json
|
||||||
|
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])
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@map("segment")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Revenue {
|
||||||
|
id String @id() @unique() @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
|
||||||
|
eventName String @map("event_name") @db.VarChar(50)
|
||||||
|
currency String @db.VarChar(100)
|
||||||
|
revenue Decimal? @db.Decimal(19, 4)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, sessionId, createdAt])
|
||||||
|
@@map("revenue")
|
||||||
|
}
|
||||||
|
|
@ -12,12 +12,8 @@ const cloudMode = process.env.CLOUD_MODE;
|
||||||
const cloudUrl = process.env.CLOUD_URL;
|
const cloudUrl = process.env.CLOUD_URL;
|
||||||
const corsMaxAge = process.env.CORS_MAX_AGE;
|
const corsMaxAge = process.env.CORS_MAX_AGE;
|
||||||
const defaultLocale = process.env.DEFAULT_LOCALE;
|
const defaultLocale = process.env.DEFAULT_LOCALE;
|
||||||
const disableLogin = process.env.DISABLE_LOGIN;
|
|
||||||
const disableUI = process.env.DISABLE_UI;
|
|
||||||
const faviconURL = process.env.FAVICON_URL;
|
|
||||||
const forceSSL = process.env.FORCE_SSL;
|
const forceSSL = process.env.FORCE_SSL;
|
||||||
const frameAncestors = process.env.ALLOWED_FRAME_URLS ?? '';
|
const frameAncestors = process.env.ALLOWED_FRAME_URLS ?? '';
|
||||||
const privateMode = process.env.PRIVATE_MODE;
|
|
||||||
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME;
|
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME;
|
||||||
const trackerScriptURL = process.env.TRACKER_SCRIPT_URL;
|
const trackerScriptURL = process.env.TRACKER_SCRIPT_URL;
|
||||||
|
|
||||||
|
|
@ -173,13 +169,11 @@ if (cloudMode && cloudUrl) {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (disableLogin) {
|
redirects.push({
|
||||||
redirects.push({
|
source: '/login',
|
||||||
source: '/login',
|
destination: cloudUrl,
|
||||||
destination: cloudUrl,
|
permanent: false,
|
||||||
permanent: false,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
@ -191,10 +185,6 @@ export default {
|
||||||
cloudUrl,
|
cloudUrl,
|
||||||
currentVersion: pkg.version,
|
currentVersion: pkg.version,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
disableLogin,
|
|
||||||
disableUI,
|
|
||||||
faviconURL,
|
|
||||||
privateMode,
|
|
||||||
},
|
},
|
||||||
basePath,
|
basePath,
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "umami",
|
"name": "umami",
|
||||||
"version": "2.18.1",
|
"version": "2.19.0",
|
||||||
"description": "A modern, privacy-focused alternative to Google Analytics.",
|
"description": "A modern, privacy-focused alternative to Google Analytics.",
|
||||||
"author": "Umami Software, Inc. <hello@umami.is>",
|
"author": "Umami Software, Inc. <hello@umami.is>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"dev-turbo": "next dev -p 3001 --turbopack",
|
"dev-turbo": "next dev -p 3000 --turbopack",
|
||||||
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
|
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
|
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
|
||||||
|
|
@ -100,12 +100,14 @@
|
||||||
"is-localhost-ip": "^1.4.0",
|
"is-localhost-ip": "^1.4.0",
|
||||||
"isbot": "^5.1.16",
|
"isbot": "^5.1.16",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"kafkajs": "^2.1.0",
|
"kafkajs": "^2.1.0",
|
||||||
"maxmind": "^4.3.24",
|
"maxmind": "^4.3.24",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"next": "15.3.3",
|
"next": "15.4.7",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"prisma": "6.7.0",
|
"prisma": "6.7.0",
|
||||||
"pure-rand": "^6.1.0",
|
"pure-rand": "^6.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|
|
||||||
4578
pnpm-lock.yaml
generated
4578
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
|
@ -38,7 +38,7 @@
|
||||||
"label.add-step": [
|
"label.add-step": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Add step"
|
"value": "إضافة خطوة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.add-website": [
|
"label.add-website": [
|
||||||
|
|
@ -77,6 +77,18 @@
|
||||||
"value": "تحليلات"
|
"value": "تحليلات"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.attribution": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "الإسناد"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.attribution-description": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "شاهد كيف يتفاعل المستخدمون مع حملاتك التسويقية وما الذي يحفز التحويلات."
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.average": [
|
"label.average": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -122,7 +134,7 @@
|
||||||
"label.cancel": [
|
"label.cancel": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "ألغِ"
|
"value": "إلغاء"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.change-password": [
|
"label.change-password": [
|
||||||
|
|
@ -152,7 +164,7 @@
|
||||||
"label.compare": [
|
"label.compare": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Compare"
|
"value": "المقارنة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.confirm": [
|
"label.confirm": [
|
||||||
|
|
@ -170,7 +182,7 @@
|
||||||
"label.contains": [
|
"label.contains": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "يحتوي"
|
"value": "يحتوي على"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.continue": [
|
"label.continue": [
|
||||||
|
|
@ -182,7 +194,7 @@
|
||||||
"label.count": [
|
"label.count": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Count"
|
"value": "العدد"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.countries": [
|
"label.countries": [
|
||||||
|
|
@ -236,7 +248,7 @@
|
||||||
"label.current": [
|
"label.current": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Current"
|
"value": "الحالي"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.current-password": [
|
"label.current-password": [
|
||||||
|
|
@ -254,7 +266,7 @@
|
||||||
"label.dashboard": [
|
"label.dashboard": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "الشاشة الرئيسية"
|
"value": "لوحة التحكم"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.data": [
|
"label.data": [
|
||||||
|
|
@ -356,7 +368,7 @@
|
||||||
"label.does-not-contain": [
|
"label.does-not-contain": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "لا يحتوي"
|
"value": "لا يحتوي على"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.domain": [
|
"label.domain": [
|
||||||
|
|
@ -374,7 +386,7 @@
|
||||||
"label.edit": [
|
"label.edit": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "عدّل"
|
"value": "تعديل"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.edit-dashboard": [
|
"label.edit-dashboard": [
|
||||||
|
|
@ -398,13 +410,13 @@
|
||||||
"label.end-step": [
|
"label.end-step": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "End Step"
|
"value": "الخطوة الأخيرة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.entry": [
|
"label.entry": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Entry URL"
|
"value": "رابط الدخول"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.event": [
|
"label.event": [
|
||||||
|
|
@ -428,7 +440,7 @@
|
||||||
"label.exit": [
|
"label.exit": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Exit URL"
|
"value": "رابط المغادرة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.false": [
|
"label.false": [
|
||||||
|
|
@ -476,7 +488,7 @@
|
||||||
"label.first-seen": [
|
"label.first-seen": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "First seen"
|
"value": "أول ظهور"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.funnel": [
|
"label.funnel": [
|
||||||
|
|
@ -494,19 +506,19 @@
|
||||||
"label.goal": [
|
"label.goal": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Goal"
|
"value": "الهدف"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.goals": [
|
"label.goals": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Goals"
|
"value": "الأهداف"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.goals-description": [
|
"label.goals-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Track your goals for pageviews and events."
|
"value": "تابع تحقق أهدافك المرتبطة بمشاهدات الصفحات والأحداث."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.greater-than": [
|
"label.greater-than": [
|
||||||
|
|
@ -548,13 +560,13 @@
|
||||||
"label.is": [
|
"label.is": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "هو"
|
"value": "يساوي"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.is-not": [
|
"label.is-not": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "لم"
|
"value": "لا يساوي"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.is-not-set": [
|
"label.is-not-set": [
|
||||||
|
|
@ -584,13 +596,13 @@
|
||||||
"label.journey": [
|
"label.journey": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Journey"
|
"value": "رحلة المستخدم"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.journey-description": [
|
"label.journey-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Understand how users navigate through your website."
|
"value": "تعرّف على كيفية تنقّل المستخدمين داخل موقعك."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.language": [
|
"label.language": [
|
||||||
|
|
@ -642,7 +654,7 @@
|
||||||
"label.last-months": [
|
"label.last-months": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Last "
|
"value": "آخر "
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 1,
|
"type": 1,
|
||||||
|
|
@ -650,13 +662,13 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": " months"
|
"value": " شهر/أشهر"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.last-seen": [
|
"label.last-seen": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Last seen"
|
"value": "آخر ظهور"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.leave": [
|
"label.leave": [
|
||||||
|
|
@ -704,7 +716,7 @@
|
||||||
"label.manager": [
|
"label.manager": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Manager"
|
"value": "مدير"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.max": [
|
"label.max": [
|
||||||
|
|
@ -876,13 +888,13 @@
|
||||||
"label.path": [
|
"label.path": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Path"
|
"value": "المسار"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.paths": [
|
"label.paths": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Paths"
|
"value": "المسارات"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.powered-by": [
|
"label.powered-by": [
|
||||||
|
|
@ -898,19 +910,19 @@
|
||||||
"label.previous": [
|
"label.previous": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Previous"
|
"value": "السابق"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.previous-period": [
|
"label.previous-period": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Previous period"
|
"value": "الفترة السابقة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.previous-year": [
|
"label.previous-year": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Previous year"
|
"value": "العام السابق"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.profile": [
|
"label.profile": [
|
||||||
|
|
@ -922,13 +934,13 @@
|
||||||
"label.properties": [
|
"label.properties": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Properties"
|
"value": "الخصائص"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.property": [
|
"label.property": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Property"
|
"value": "الخاصية"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.queries": [
|
"label.queries": [
|
||||||
|
|
@ -1042,19 +1054,19 @@
|
||||||
"label.revenue": [
|
"label.revenue": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Revenue"
|
"value": "الإيرادات"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.revenue-description": [
|
"label.revenue-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Look into your revenue across time."
|
"value": "قم بإلقاء نظرة على بيانات إيراداتك وكيفية إنفاق المستخدمين."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.revenue-property": [
|
"label.revenue-property": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Revenue Property"
|
"value": "خاصية الإيرادات"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.role": [
|
"label.role": [
|
||||||
|
|
@ -1114,7 +1126,7 @@
|
||||||
"label.session": [
|
"label.session": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Session"
|
"value": "الزيارة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.sessions": [
|
"label.sessions": [
|
||||||
|
|
@ -1144,13 +1156,13 @@
|
||||||
"label.start-step": [
|
"label.start-step": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Start Step"
|
"value": "الخطوة الأولى"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.steps": [
|
"label.steps": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Steps"
|
"value": "الخطوات"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.sum": [
|
"label.sum": [
|
||||||
|
|
@ -1165,6 +1177,18 @@
|
||||||
"value": "تابلت"
|
"value": "تابلت"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.tag": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "الوسم"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.tags": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "الوسوم"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.team": [
|
"label.team": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1180,7 +1204,7 @@
|
||||||
"label.team-manager": [
|
"label.team-manager": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Team manager"
|
"value": "مدير الفريق"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.team-member": [
|
"label.team-member": [
|
||||||
|
|
@ -1204,7 +1228,7 @@
|
||||||
"label.team-view-only": [
|
"label.team-view-only": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Team view only"
|
"value": "عرض الفريق فقط"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.team-websites": [
|
"label.team-websites": [
|
||||||
|
|
@ -1288,13 +1312,13 @@
|
||||||
"label.transactions": [
|
"label.transactions": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Transactions"
|
"value": "المعاملات"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.transfer": [
|
"label.transfer": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Transfer"
|
"value": "نقل"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.transfer-website": [
|
"label.transfer-website": [
|
||||||
|
|
@ -1330,7 +1354,7 @@
|
||||||
"label.uniqueCustomers": [
|
"label.uniqueCustomers": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Unique Customers"
|
"value": "العملاء الفريدون"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.unknown": [
|
"label.unknown": [
|
||||||
|
|
@ -1348,19 +1372,19 @@
|
||||||
"label.update": [
|
"label.update": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Update"
|
"value": "تحديث"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.url": [
|
"label.url": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "URL"
|
"value": "الرابط"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.urls": [
|
"label.urls": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "URLs"
|
"value": "الروابط"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.user": [
|
"label.user": [
|
||||||
|
|
@ -1372,7 +1396,7 @@
|
||||||
"label.user-property": [
|
"label.user-property": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "User Property"
|
"value": "سمات المستخدم"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.username": [
|
"label.username": [
|
||||||
|
|
@ -1396,7 +1420,7 @@
|
||||||
"label.utm-description": [
|
"label.utm-description": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Track your campaigns through UTM parameters."
|
"value": "تابع حملاتك التسويقية باستخدام معلمات UTM."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.value": [
|
"label.value": [
|
||||||
|
|
@ -1432,7 +1456,7 @@
|
||||||
"label.views-per-visit": [
|
"label.views-per-visit": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Views per visit"
|
"value": "مشاهدات لكل زيارة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.visit-duration": [
|
"label.visit-duration": [
|
||||||
|
|
@ -1450,7 +1474,7 @@
|
||||||
"label.visits": [
|
"label.visits": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Visits"
|
"value": "الزيارات"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.website": [
|
"label.website": [
|
||||||
|
|
@ -1534,7 +1558,7 @@
|
||||||
"message.collected-data": [
|
"message.collected-data": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Collected data"
|
"value": "البيانات المجمعة"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.confirm-delete": [
|
"message.confirm-delete": [
|
||||||
|
|
@ -1754,15 +1778,7 @@
|
||||||
"message.share-url": [
|
"message.share-url": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "هذا الرابط الذي تم مشاركته بشكل عام لـ "
|
"value": "إحصائيات موقعك متاحة للجميع على الرابط التالي:"
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 1,
|
|
||||||
"value": "target"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 0,
|
|
||||||
"value": "."
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.team-already-member": [
|
"message.team-already-member": [
|
||||||
|
|
|
||||||
41
scripts/data-migrations/populate-revenue-table.sql
Normal file
41
scripts/data-migrations/populate-revenue-table.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
-----------------------------------------------------
|
||||||
|
-- PostgreSQL
|
||||||
|
-----------------------------------------------------
|
||||||
|
INSERT INTO "revenue"
|
||||||
|
SELECT gen_random_uuid() revenue_id,
|
||||||
|
ed.website_id,
|
||||||
|
we.session_id,
|
||||||
|
we.event_id,
|
||||||
|
we.event_name,
|
||||||
|
currency.string_value currency,
|
||||||
|
coalesce(ed.number_value, cast(ed.string_value as numeric(19,4))) revenue,
|
||||||
|
ed.created_at
|
||||||
|
FROM event_data ed
|
||||||
|
JOIN website_event we
|
||||||
|
ON we.event_id = ed.website_event_id
|
||||||
|
JOIN (SELECT website_event_id, string_value
|
||||||
|
FROM event_data
|
||||||
|
WHERE data_key ilike '%currency%') currency
|
||||||
|
ON currency.website_event_id = ed.website_event_id
|
||||||
|
WHERE ed.data_key ilike '%revenue%';
|
||||||
|
|
||||||
|
-----------------------------------------------------
|
||||||
|
-- MySQL
|
||||||
|
-----------------------------------------------------
|
||||||
|
INSERT INTO `revenue`
|
||||||
|
SELECT UUID() revenue_id,
|
||||||
|
ed.website_id,
|
||||||
|
we.session_id,
|
||||||
|
we.event_id,
|
||||||
|
we.event_name,
|
||||||
|
currency.string_value currency,
|
||||||
|
coalesce(ed.number_value, cast(ed.string_value as decimal(19,4))) revenue,
|
||||||
|
ed.created_at
|
||||||
|
FROM event_data ed
|
||||||
|
JOIN website_event we
|
||||||
|
ON we.event_id = ed.website_event_id
|
||||||
|
JOIN (SELECT website_event_id, string_value
|
||||||
|
FROM event_data
|
||||||
|
WHERE data_key like '%currency%') currency
|
||||||
|
ON currency.website_event_id = ed.website_event_id
|
||||||
|
WHERE ed.data_key like '%revenue%';
|
||||||
|
|
@ -22,10 +22,6 @@ export function App({ children }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.uiDisabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,14 @@ export function UpdateNotice({ user, config }) {
|
||||||
const { latest, checked, hasUpdate, releaseUrl } = useStore();
|
const { latest, checked, hasUpdate, releaseUrl } = useStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [dismissed, setDismissed] = useState(checked);
|
const [dismissed, setDismissed] = useState(checked);
|
||||||
|
|
||||||
const allowUpdate =
|
const allowUpdate =
|
||||||
process.env.NODE_ENV === 'production' &&
|
process.env.NODE_ENV === 'production' &&
|
||||||
user?.isAdmin &&
|
user?.isAdmin &&
|
||||||
!config?.updatesDisabled &&
|
!config?.updatesDisabled &&
|
||||||
|
!config?.privateMode &&
|
||||||
!pathname.includes('/share/') &&
|
!pathname.includes('/share/') &&
|
||||||
!process.env.cloudMode &&
|
!process.env.cloudMode &&
|
||||||
!process.env.privateMode &&
|
|
||||||
!dismissed;
|
!dismissed;
|
||||||
|
|
||||||
const updateCheck = useCallback(() => {
|
const updateCheck = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export function LanguageSetting() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { locale, saveLocale } = useLocale();
|
const { locale, saveLocale } = useLocale();
|
||||||
|
|
||||||
const options = search
|
const options = search
|
||||||
? Object.keys(languages).filter(n => {
|
? Object.keys(languages).filter(n => {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { ReportContext } from './Report';
|
import { ReportContext } from './Report';
|
||||||
import styles from './ReportBody.module.css';
|
import styles from './ReportBody.module.css';
|
||||||
|
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||||
|
|
||||||
export function ReportBody({ children }) {
|
export function ReportBody({ children }) {
|
||||||
const { report } = useContext(ReportContext);
|
const { report } = useContext(ReportContext);
|
||||||
|
|
@ -9,7 +10,14 @@ export function ReportBody({ children }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={styles.body}>{children}</div>;
|
return (
|
||||||
|
<div className={styles.body}>
|
||||||
|
{report.type !== 'revenue' && report.type !== 'attribution' && (
|
||||||
|
<DownloadButton filename={report.name} data={report.data} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ReportBody;
|
export default ReportBody;
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,9 @@ export default function ReportPage({ reportId }: { reportId: string }) {
|
||||||
|
|
||||||
const ReportComponent = reports[report.type];
|
const ReportComponent = reports[report.type];
|
||||||
|
|
||||||
|
if (!ReportComponent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <ReportComponent reportId={reportId} />;
|
return <ReportComponent reportId={reportId} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,31 @@ function toArray(data: { [key: string]: number } = {}) {
|
||||||
.sort(firstBy('value', -1));
|
.sort(firstBy('value', -1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseParameters(data: any[]) {
|
||||||
|
return data.reduce((obj, { url_query, num }) => {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams(url_query);
|
||||||
|
|
||||||
|
for (const [key, value] of searchParams) {
|
||||||
|
if (key.match(/^utm_(\w+)$/)) {
|
||||||
|
const name = value;
|
||||||
|
if (!obj[key]) {
|
||||||
|
obj[key] = { [name]: Number(num) };
|
||||||
|
} else if (!obj[key][name]) {
|
||||||
|
obj[key][name] = Number(num);
|
||||||
|
} else {
|
||||||
|
obj[key][name] += Number(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
export default function UTMView() {
|
export default function UTMView() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { report } = useContext(ReportContext);
|
const { report } = useContext(ReportContext);
|
||||||
|
|
@ -27,7 +52,7 @@ export default function UTMView() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{UTM_PARAMS.map(param => {
|
{UTM_PARAMS.map(param => {
|
||||||
const items = toArray(data[param]);
|
const items = toArray(parseParameters(data)[param]);
|
||||||
const chartData = {
|
const chartData = {
|
||||||
labels: items.map(({ name }) => name),
|
labels: items.map(({ name }) => name),
|
||||||
datasets: [
|
datasets: [
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import WebsiteExpandedView from './WebsiteExpandedView';
|
||||||
import WebsiteHeader from './WebsiteHeader';
|
import WebsiteHeader from './WebsiteHeader';
|
||||||
import WebsiteMetricsBar from './WebsiteMetricsBar';
|
import WebsiteMetricsBar from './WebsiteMetricsBar';
|
||||||
import WebsiteTableView from './WebsiteTableView';
|
import WebsiteTableView from './WebsiteTableView';
|
||||||
import { FILTER_COLUMNS } from '@/lib/constants';
|
import { FILTER_COLUMNS, FILTER_GROUPS } from '@/lib/constants';
|
||||||
|
|
||||||
export default function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
|
export default function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
@ -17,7 +17,7 @@ export default function WebsiteDetailsPage({ websiteId }: { websiteId: string })
|
||||||
const { view } = query;
|
const { view } = query;
|
||||||
|
|
||||||
const params = Object.keys(query).reduce((obj, key) => {
|
const params = Object.keys(query).reduce((obj, key) => {
|
||||||
if (FILTER_COLUMNS[key]) {
|
if (FILTER_COLUMNS[key] || FILTER_GROUPS[key]) {
|
||||||
obj[key] = query[key];
|
obj[key] = query[key];
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Dropdown, Item } from 'react-basics';
|
import { Dropdown, Item } from 'react-basics';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useDateRange, useMessages, useSticky } from '@/components/hooks';
|
import { useDateRange, useMessages, useNavigation, useSticky } from '@/components/hooks';
|
||||||
import WebsiteDateFilter from '@/components/input/WebsiteDateFilter';
|
import WebsiteDateFilter from '@/components/input/WebsiteDateFilter';
|
||||||
import MetricCard from '@/components/metrics/MetricCard';
|
import MetricCard from '@/components/metrics/MetricCard';
|
||||||
import MetricsBar from '@/components/metrics/MetricsBar';
|
import MetricsBar from '@/components/metrics/MetricsBar';
|
||||||
|
|
@ -8,6 +8,7 @@ import { formatShortTime, formatLongNumber } from '@/lib/format';
|
||||||
import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats';
|
import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats';
|
||||||
import useStore, { setWebsiteDateCompare } from '@/store/websites';
|
import useStore, { setWebsiteDateCompare } from '@/store/websites';
|
||||||
import WebsiteFilterButton from './WebsiteFilterButton';
|
import WebsiteFilterButton from './WebsiteFilterButton';
|
||||||
|
import { ExportButton } from '@/components/input/ExportButton';
|
||||||
import styles from './WebsiteMetricsBar.module.css';
|
import styles from './WebsiteMetricsBar.module.css';
|
||||||
|
|
||||||
export function WebsiteMetricsBar({
|
export function WebsiteMetricsBar({
|
||||||
|
|
@ -31,6 +32,9 @@ export function WebsiteMetricsBar({
|
||||||
websiteId,
|
websiteId,
|
||||||
compareMode && dateCompare,
|
compareMode && dateCompare,
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
|
query: { view },
|
||||||
|
} = useNavigation();
|
||||||
const isAllTime = dateRange.value === 'all';
|
const isAllTime = dateRange.value === 'all';
|
||||||
|
|
||||||
const { pageviews, visitors, visits, bounces, totaltime } = data || {};
|
const { pageviews, visitors, visits, bounces, totaltime } = data || {};
|
||||||
|
|
@ -109,7 +113,10 @@ export function WebsiteMetricsBar({
|
||||||
</MetricsBar>
|
</MetricsBar>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
<div>
|
||||||
|
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||||
|
{!view && <ExportButton websiteId={websiteId} />}
|
||||||
|
</div>
|
||||||
<WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} />
|
<WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} />
|
||||||
{compareMode && (
|
{compareMode && (
|
||||||
<div className={styles.vs}>
|
<div className={styles.vs}>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const tableProps = {
|
const tableProps = {
|
||||||
websiteId,
|
websiteId,
|
||||||
|
allowDownload: false,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
};
|
};
|
||||||
const isSharePage = pathname.includes('/share/');
|
const isSharePage = pathname.includes('/share/');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import WebsiteHeader from '../WebsiteHeader';
|
||||||
import WebsiteMetricsBar from '../WebsiteMetricsBar';
|
import WebsiteMetricsBar from '../WebsiteMetricsBar';
|
||||||
import FilterTags from '@/components/metrics/FilterTags';
|
import FilterTags from '@/components/metrics/FilterTags';
|
||||||
import { useNavigation } from '@/components/hooks';
|
import { useNavigation } from '@/components/hooks';
|
||||||
import { FILTER_COLUMNS } from '@/lib/constants';
|
import { FILTER_COLUMNS, FILTER_GROUPS } from '@/lib/constants';
|
||||||
import WebsiteChart from '../WebsiteChart';
|
import WebsiteChart from '../WebsiteChart';
|
||||||
import WebsiteCompareTables from './WebsiteCompareTables';
|
import WebsiteCompareTables from './WebsiteCompareTables';
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function WebsiteComparePage({ websiteId }) {
|
||||||
const { query } = useNavigation();
|
const { query } = useNavigation();
|
||||||
|
|
||||||
const params = Object.keys(query).reduce((obj, key) => {
|
const params = Object.keys(query).reduce((obj, key) => {
|
||||||
if (FILTER_COLUMNS[key]) {
|
if (FILTER_COLUMNS[key] || FILTER_GROUPS[key]) {
|
||||||
obj[key] = query[key];
|
obj[key] = query[key];
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,14 @@
|
||||||
color: var(--primary400);
|
color: var(--primary400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.header {
|
||||||
text-align: center;
|
margin-bottom: 40px;
|
||||||
font-weight: bold;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data {
|
||||||
min-height: 620px;
|
min-height: 620px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { GridColumn, GridTable } from 'react-basics';
|
import { useMemo } from 'react';
|
||||||
|
import { GridColumn, GridTable, Flexbox, Button, ButtonGroup, Loading } from 'react-basics';
|
||||||
import { useEventDataProperties, useEventDataValues, useMessages } from '@/components/hooks';
|
import { useEventDataProperties, useEventDataValues, useMessages } from '@/components/hooks';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import PieChart from '@/components/charts/PieChart';
|
import PieChart from '@/components/charts/PieChart';
|
||||||
|
import ListTable from '@/components/metrics/ListTable';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CHART_COLORS } from '@/lib/constants';
|
import { CHART_COLORS } from '@/lib/constants';
|
||||||
import styles from './EventProperties.module.css';
|
import styles from './EventProperties.module.css';
|
||||||
|
|
@ -9,22 +11,38 @@ import styles from './EventProperties.module.css';
|
||||||
export function EventProperties({ websiteId }: { websiteId: string }) {
|
export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||||
const [propertyName, setPropertyName] = useState('');
|
const [propertyName, setPropertyName] = useState('');
|
||||||
const [eventName, setEventName] = useState('');
|
const [eventName, setEventName] = useState('');
|
||||||
|
const [propertyView, setPropertyView] = useState('table');
|
||||||
|
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { data, isLoading, isFetched, error } = useEventDataProperties(websiteId);
|
const { data, isLoading, isFetched, error } = useEventDataProperties(websiteId);
|
||||||
const { data: values } = useEventDataValues(websiteId, eventName, propertyName);
|
const { data: values } = useEventDataValues(websiteId, eventName, propertyName);
|
||||||
const chartData =
|
|
||||||
propertyName && values
|
const propertySum = useMemo(() => {
|
||||||
? {
|
return values?.reduce((sum, { total }) => sum + total, 0) ?? 0;
|
||||||
labels: values.map(({ value }) => value),
|
}, [values]);
|
||||||
datasets: [
|
|
||||||
{
|
const chartData = useMemo(() => {
|
||||||
data: values.map(({ total }) => total),
|
if (!propertyName || !values) return null;
|
||||||
backgroundColor: CHART_COLORS,
|
return {
|
||||||
borderWidth: 0,
|
labels: values.map(({ value }) => value),
|
||||||
},
|
datasets: [
|
||||||
],
|
{
|
||||||
}
|
data: values.map(({ total }) => total),
|
||||||
: null;
|
backgroundColor: CHART_COLORS,
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [propertyName, values]);
|
||||||
|
|
||||||
|
const tableData = useMemo(() => {
|
||||||
|
if (!propertyName || !values || propertySum === 0) return [];
|
||||||
|
return values.map(({ value, total }) => ({
|
||||||
|
x: value,
|
||||||
|
y: total,
|
||||||
|
z: 100 * (total / propertySum),
|
||||||
|
}));
|
||||||
|
}, [propertyName, values, propertySum]);
|
||||||
|
|
||||||
const handleRowClick = row => {
|
const handleRowClick = row => {
|
||||||
setEventName(row.eventName);
|
setEventName(row.eventName);
|
||||||
|
|
@ -52,9 +70,25 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||||
<GridColumn name="total" label={formatMessage(labels.count)} alignment="end" />
|
<GridColumn name="total" label={formatMessage(labels.count)} alignment="end" />
|
||||||
</GridTable>
|
</GridTable>
|
||||||
{propertyName && (
|
{propertyName && (
|
||||||
<div className={styles.chart}>
|
<div className={styles.data}>
|
||||||
<div className={styles.title}>{propertyName}</div>
|
<Flexbox className={styles.header} gap={12} justifyContent="space-between">
|
||||||
<PieChart key={propertyName + eventName} type="doughnut" data={chartData} />
|
<div className={styles.title}>{`${eventName}: ${propertyName}`}</div>
|
||||||
|
<ButtonGroup
|
||||||
|
selectedKey={propertyView}
|
||||||
|
onSelect={key => setPropertyView(key as string)}
|
||||||
|
>
|
||||||
|
<Button key="table">{formatMessage(labels.table)}</Button>
|
||||||
|
<Button key="chart">{formatMessage(labels.chart)}</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Flexbox>
|
||||||
|
|
||||||
|
{!values ? (
|
||||||
|
<Loading icon="dots" />
|
||||||
|
) : propertyView === 'table' ? (
|
||||||
|
<ListTable data={tableData} />
|
||||||
|
) : (
|
||||||
|
<PieChart key={propertyName + eventName} type="doughnut" data={chartData} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,22 @@ import { useMessages } from '@/components/hooks';
|
||||||
import { Item, Tabs } from 'react-basics';
|
import { Item, Tabs } from 'react-basics';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import EventProperties from './EventProperties';
|
import EventProperties from './EventProperties';
|
||||||
|
import { getItem, setItem } from '@/lib/storage';
|
||||||
|
|
||||||
export default function EventsPage({ websiteId }) {
|
export default function EventsPage({ websiteId }) {
|
||||||
const [label, setLabel] = useState(null);
|
const [label, setLabel] = useState(null);
|
||||||
const [tab, setTab] = useState('activity');
|
const [tab, setTab] = useState(getItem('eventTab') || 'activity');
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
const handleLabelClick = (value: string) => {
|
const handleLabelClick = (value: string) => {
|
||||||
setLabel(value !== label ? value : '');
|
setLabel(value !== label ? value : '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSelect = (value: 'activity' | 'properties') => {
|
||||||
|
setItem('eventTab', value);
|
||||||
|
setTab(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WebsiteHeader websiteId={websiteId} />
|
<WebsiteHeader websiteId={websiteId} />
|
||||||
|
|
@ -34,11 +40,7 @@ export default function EventsPage({ websiteId }) {
|
||||||
/>
|
/>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
<div>
|
<div>
|
||||||
<Tabs
|
<Tabs selectedKey={tab} onSelect={onSelect} style={{ marginBottom: 30 }}>
|
||||||
selectedKey={tab}
|
|
||||||
onSelect={(value: any) => setTab(value)}
|
|
||||||
style={{ marginBottom: 30 }}
|
|
||||||
>
|
|
||||||
<Item key="activity">{formatMessage(labels.activity)}</Item>
|
<Item key="activity">{formatMessage(labels.activity)}</Item>
|
||||||
<Item key="properties">{formatMessage(labels.properties)}</Item>
|
<Item key="properties">{formatMessage(labels.properties)}</Item>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
export async function getConfig() {
|
export type Config = {
|
||||||
|
faviconUrl: string | undefined;
|
||||||
|
privateMode: boolean;
|
||||||
|
telemetryDisabled: boolean;
|
||||||
|
trackerScriptName: string | undefined;
|
||||||
|
updatesDisabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getConfig(): Promise<Config> {
|
||||||
return {
|
return {
|
||||||
|
faviconUrl: process.env.FAVICON_URL,
|
||||||
|
privateMode: !!process.env.PRIVATE_MODE,
|
||||||
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
||||||
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
||||||
uiDisabled: !!process.env.DISABLE_UI,
|
|
||||||
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
report.parameters = JSON.parse(report.parameters);
|
|
||||||
|
|
||||||
return json(report);
|
return json(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +60,7 @@ export async function POST(
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
parameters: JSON.stringify(parameters),
|
parameters: parameters,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
return json(result);
|
return json(result);
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export async function POST(request: Request) {
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
parameters: JSON.stringify(parameters),
|
parameters: parameters,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
return json(result);
|
return json(result);
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,25 @@ import { CURRENT_VERSION, TELEMETRY_PIXEL } from '@/lib/constants';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (
|
if (
|
||||||
process.env.NODE_ENV !== 'production' &&
|
process.env.NODE_ENV !== 'production' ||
|
||||||
process.env.DISABLE_TELEMETRY &&
|
process.env.DISABLE_TELEMETRY ||
|
||||||
process.env.PRIVATE_MODE
|
process.env.PRIVATE_MODE
|
||||||
) {
|
) {
|
||||||
const script = `
|
return new Response('/* telemetry disabled */', {
|
||||||
(()=>{const i=document.createElement('img');
|
|
||||||
i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}');
|
|
||||||
i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;');
|
|
||||||
document.body.appendChild(i);})();
|
|
||||||
`;
|
|
||||||
|
|
||||||
return new Response(script.replace(/\s\s+/g, ''), {
|
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'text/javascript',
|
'content-type': 'text/javascript',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response('/* telemetry disabled */', {
|
const script = `
|
||||||
|
(()=>{const i=document.createElement('img');
|
||||||
|
i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}');
|
||||||
|
i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;');
|
||||||
|
document.body.appendChild(i);})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Response(script.replace(/\s\s+/g, ''), {
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'text/javascript',
|
'content-type': 'text/javascript',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { startOfHour, startOfMonth } from 'date-fns';
|
||||||
import clickhouse from '@/lib/clickhouse';
|
import clickhouse from '@/lib/clickhouse';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { badRequest, json, forbidden, serverError } from '@/lib/response';
|
import { badRequest, json, forbidden, serverError } from '@/lib/response';
|
||||||
import { fetchSession, fetchWebsite } from '@/lib/load';
|
import { fetchWebsite } from '@/lib/load';
|
||||||
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
||||||
import { createToken, parseToken } from '@/lib/jwt';
|
import { createToken, parseToken } from '@/lib/jwt';
|
||||||
import { secret, uuid, hash } from '@/lib/crypto';
|
import { secret, uuid, hash } from '@/lib/crypto';
|
||||||
|
|
@ -103,32 +103,24 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
|
const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
|
||||||
|
|
||||||
// Find session
|
// Create a session if not found
|
||||||
if (!clickhouse.enabled && !cache?.sessionId) {
|
if (!clickhouse.enabled && !cache?.sessionId) {
|
||||||
const session = await fetchSession(websiteId, sessionId);
|
await createSession(
|
||||||
|
{
|
||||||
// Create a session if not found
|
id: sessionId,
|
||||||
if (!session) {
|
websiteId,
|
||||||
try {
|
browser,
|
||||||
await createSession({
|
os,
|
||||||
id: sessionId,
|
device,
|
||||||
websiteId,
|
screen,
|
||||||
browser,
|
language,
|
||||||
os,
|
country,
|
||||||
device,
|
region,
|
||||||
screen,
|
city,
|
||||||
language,
|
distinctId: id,
|
||||||
country,
|
},
|
||||||
region,
|
{ skipDuplicates: true },
|
||||||
city,
|
);
|
||||||
distinctId: id,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!e.message.toLowerCase().includes('unique constraint')) {
|
|
||||||
return serverError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visit info
|
// Visit info
|
||||||
|
|
@ -145,7 +137,8 @@ export async function POST(request: Request) {
|
||||||
const base = hostname ? `https://${hostname}` : 'https://localhost';
|
const base = hostname ? `https://${hostname}` : 'https://localhost';
|
||||||
const currentUrl = new URL(url, base);
|
const currentUrl = new URL(url, base);
|
||||||
|
|
||||||
let urlPath = currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash;
|
let urlPath =
|
||||||
|
currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash;
|
||||||
const urlQuery = currentUrl.search.substring(1);
|
const urlQuery = currentUrl.search.substring(1);
|
||||||
const urlDomain = currentUrl.hostname.replace(/^www./, '');
|
const urlDomain = currentUrl.hostname.replace(/^www./, '');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { parseRequest, getRequestDateRange, getRequestFilters } from '@/lib/requ
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
|
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
|
||||||
import { getEventMetrics } from '@/queries';
|
import { getEventStats } from '@/queries';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -32,14 +32,14 @@ export async function GET(
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
...getRequestFilters(query),
|
...(await getRequestFilters(query)),
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
timezone,
|
timezone,
|
||||||
unit,
|
unit,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await getEventMetrics(websiteId, filters);
|
const data = await getEventStats(websiteId, filters);
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
src/app/api/websites/[websiteId]/export/route.ts
Normal file
73
src/app/api/websites/[websiteId]/export/route.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import { getRequestFilters, parseRequest } from '@/lib/request';
|
||||||
|
import { unauthorized, json } from '@/lib/response';
|
||||||
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
|
import { pagingParams } from '@/lib/schema';
|
||||||
|
import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
startAt: z.coerce.number().int(),
|
||||||
|
endAt: z.coerce.number().int(),
|
||||||
|
...pagingParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { startAt, endAt } = query;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(+startAt);
|
||||||
|
const endDate = new Date(+endAt);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
...(await getRequestFilters(query)),
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
|
||||||
|
getEventMetrics(websiteId, 'event', filters),
|
||||||
|
getPageviewMetrics(websiteId, 'url', filters),
|
||||||
|
getPageviewMetrics(websiteId, 'referrer', filters),
|
||||||
|
getSessionMetrics(websiteId, 'browser', filters),
|
||||||
|
getSessionMetrics(websiteId, 'os', filters),
|
||||||
|
getSessionMetrics(websiteId, 'device', filters),
|
||||||
|
getSessionMetrics(websiteId, 'country', filters),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
const parse = (data: any) => {
|
||||||
|
return Papa.unparse(data, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
zip.file('events.csv', parse(events));
|
||||||
|
zip.file('pages.csv', parse(pages));
|
||||||
|
zip.file('referrers.csv', parse(referrers));
|
||||||
|
zip.file('browsers.csv', parse(browsers));
|
||||||
|
zip.file('os.csv', parse(os));
|
||||||
|
zip.file('devices.csv', parse(devices));
|
||||||
|
zip.file('countries.csv', parse(countries));
|
||||||
|
|
||||||
|
const content = await zip.generateAsync({ type: 'nodebuffer' });
|
||||||
|
const base64 = content.toString('base64');
|
||||||
|
|
||||||
|
return json({ zip: base64 });
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,12 @@ import {
|
||||||
} from '@/lib/constants';
|
} from '@/lib/constants';
|
||||||
import { getRequestFilters, getRequestDateRange, parseRequest } from '@/lib/request';
|
import { getRequestFilters, getRequestDateRange, parseRequest } from '@/lib/request';
|
||||||
import { json, unauthorized, badRequest } from '@/lib/response';
|
import { json, unauthorized, badRequest } from '@/lib/response';
|
||||||
import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries';
|
import {
|
||||||
|
getPageviewMetrics,
|
||||||
|
getSessionMetrics,
|
||||||
|
getEventMetrics,
|
||||||
|
getChannelMetrics,
|
||||||
|
} from '@/queries';
|
||||||
import { filterParams } from '@/lib/schema';
|
import { filterParams } from '@/lib/schema';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
|
|
@ -48,7 +53,7 @@ export async function GET(
|
||||||
const { startDate, endDate } = await getRequestDateRange(query);
|
const { startDate, endDate } = await getRequestDateRange(query);
|
||||||
const column = FILTER_COLUMNS[type] || type;
|
const column = FILTER_COLUMNS[type] || type;
|
||||||
const filters = {
|
const filters = {
|
||||||
...getRequestFilters(query),
|
...(await getRequestFilters(query)),
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
|
|
@ -85,7 +90,13 @@ export async function GET(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (EVENT_COLUMNS.includes(type)) {
|
if (EVENT_COLUMNS.includes(type)) {
|
||||||
const data = await getPageviewMetrics(websiteId, type, filters, limit, offset);
|
let data;
|
||||||
|
|
||||||
|
if (type === 'event') {
|
||||||
|
data = await getEventMetrics(websiteId, type, filters, limit, offset);
|
||||||
|
} else {
|
||||||
|
data = await getPageviewMetrics(websiteId, type, filters, limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export async function GET(
|
||||||
const { startDate, endDate, unit } = await getRequestDateRange(query);
|
const { startDate, endDate, unit } = await getRequestDateRange(query);
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
...getRequestFilters(query),
|
...(await getRequestFilters(query)),
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
timezone,
|
timezone,
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export async function POST(
|
||||||
{ params }: { params: Promise<{ websiteId: string }> },
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
) {
|
) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string(),
|
name: z.string().optional(),
|
||||||
domain: z.string(),
|
domain: z.string().optional(),
|
||||||
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
|
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/lib/auth';
|
||||||
|
import { parseRequest } from '@/lib/request';
|
||||||
|
import { json, notFound, ok, unauthorized } from '@/lib/response';
|
||||||
|
import { segmentTypeParam } from '@/lib/schema';
|
||||||
|
import { deleteSegment, getSegment, updateSegment } from '@/queries';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId, segmentId } = await params;
|
||||||
|
|
||||||
|
const segment = await getSegment(segmentId);
|
||||||
|
|
||||||
|
if (websiteId && !(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
type: segmentTypeParam,
|
||||||
|
name: z.string().max(200),
|
||||||
|
parameters: z.object({}).passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId, segmentId } = await params;
|
||||||
|
const { type, name, parameters } = body;
|
||||||
|
|
||||||
|
const segment = await getSegment(segmentId);
|
||||||
|
|
||||||
|
if (!segment) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateSegment(segmentId, {
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
parameters,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
|
||||||
|
) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId, segmentId } = await params;
|
||||||
|
|
||||||
|
const segment = await getSegment(segmentId);
|
||||||
|
|
||||||
|
if (!segment) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await canDeleteWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteSegment(segmentId);
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
67
src/app/api/websites/[websiteId]/segments/route.ts
Normal file
67
src/app/api/websites/[websiteId]/segments/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { canUpdateWebsite, canViewWebsite } from '@/lib/auth';
|
||||||
|
import { uuid } from '@/lib/crypto';
|
||||||
|
import { parseRequest } from '@/lib/request';
|
||||||
|
import { json, unauthorized } from '@/lib/response';
|
||||||
|
import { segmentTypeParam } from '@/lib/schema';
|
||||||
|
import { createSegment, getWebsiteSegments } from '@/queries';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
type: segmentTypeParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { type } = query;
|
||||||
|
|
||||||
|
if (websiteId && !(await canViewWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = await getWebsiteSegments(websiteId, type);
|
||||||
|
|
||||||
|
return json(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
type: segmentTypeParam,
|
||||||
|
name: z.string().max(200),
|
||||||
|
parameters: z.object({}).passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteId } = await params;
|
||||||
|
const { type, name, parameters } = body;
|
||||||
|
|
||||||
|
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createSegment({
|
||||||
|
id: uuid(),
|
||||||
|
websiteId,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
parameters,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
||||||
|
|
||||||
const { startDate, endDate } = await getRequestDateRange(query);
|
const { startDate, endDate } = await getRequestDateRange(query);
|
||||||
|
|
||||||
const filters = getRequestFilters(query);
|
const filters = await getRequestFilters(query);
|
||||||
|
|
||||||
const metrics = await getWebsiteSessionStats(websiteId, {
|
const metrics = await getWebsiteSessionStats(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export async function GET(
|
||||||
endDate,
|
endDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
const filters = getRequestFilters(query);
|
const filters = await getRequestFilters(query);
|
||||||
|
|
||||||
const metrics = await getWebsiteStats(websiteId, {
|
const metrics = await getWebsiteStats(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
import { EVENT_COLUMNS, FILTER_COLUMNS, FILTER_GROUPS, SESSION_COLUMNS } from '@/lib/constants';
|
||||||
import { getValues } from '@/queries';
|
import { getRequestDateRange, parseRequest } from '@/lib/request';
|
||||||
import { parseRequest, getRequestDateRange } from '@/lib/request';
|
|
||||||
import { badRequest, json, unauthorized } from '@/lib/response';
|
import { badRequest, json, unauthorized } from '@/lib/response';
|
||||||
|
import { getWebsiteSegments, getValues } from '@/queries';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -30,11 +30,17 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type)) {
|
if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !FILTER_GROUPS[type]) {
|
||||||
return badRequest('Invalid type.');
|
return badRequest('Invalid type.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search);
|
let values;
|
||||||
|
|
||||||
|
if (FILTER_GROUPS[type]) {
|
||||||
|
values = (await getWebsiteSegments(websiteId, type)).map(segment => ({ value: segment.name }));
|
||||||
|
} else {
|
||||||
|
values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search);
|
||||||
|
}
|
||||||
|
|
||||||
return json(values.filter(n => n).sort());
|
return json(values.filter(n => n).sort());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export async function POST(request: Request) {
|
||||||
domain: z.string().max(500),
|
domain: z.string().max(500),
|
||||||
shareId: z.string().max(50).nullable().optional(),
|
shareId: z.string().max(50).nullable().optional(),
|
||||||
teamId: z.string().nullable().optional(),
|
teamId: z.string().nullable().optional(),
|
||||||
|
id: z.string().uuid().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
@ -34,14 +35,14 @@ export async function POST(request: Request) {
|
||||||
return error();
|
return error();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, domain, shareId, teamId } = body;
|
const { id, name, domain, shareId, teamId } = body;
|
||||||
|
|
||||||
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
|
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: any = {
|
const data: any = {
|
||||||
id: uuid(),
|
id: id ?? uuid(),
|
||||||
createdBy: auth.user.id,
|
createdBy: auth.user.id,
|
||||||
name,
|
name,
|
||||||
domain,
|
domain,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ import '@/styles/index.css';
|
||||||
import '@/styles/variables.css';
|
import '@/styles/variables.css';
|
||||||
|
|
||||||
export default function ({ children }) {
|
export default function ({ children }) {
|
||||||
|
if (process.env.DISABLE_UI) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-scroll="0">
|
<html lang="en" data-scroll="0">
|
||||||
<head>
|
<head>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,6 @@ import LoginForm from './LoginForm';
|
||||||
import styles from './LoginPage.module.css';
|
import styles from './LoginPage.module.css';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
if (process.env.disableLogin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ import { Metadata } from 'next';
|
||||||
import LoginPage from './LoginPage';
|
import LoginPage from './LoginPage';
|
||||||
|
|
||||||
export default async function () {
|
export default async function () {
|
||||||
|
if (process.env.DISABLE_LOGIN) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { setUser } from '@/store/app';
|
||||||
import { removeClientAuthToken } from '@/lib/client';
|
import { removeClientAuthToken } from '@/lib/client';
|
||||||
|
|
||||||
export function LogoutPage() {
|
export function LogoutPage() {
|
||||||
const disabled = !!(process.env.disableLogin || process.env.cloudMode);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { post } = useApi();
|
const { post } = useApi();
|
||||||
|
const disabled = process.env.cloudMode;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ import LogoutPage from './LogoutPage';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
|
if (process.env.DISABLE_LOGIN) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <LogoutPage />;
|
return <LogoutPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1
src/assets/download.svg
Normal file
1
src/assets/download.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg id="Layer_1" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m97.4999924 82.6562576.0000076-11.298912c0-1.957756-1.5870743-3.544838-3.544838-3.544838h-4.785324c-1.9577637 0-3.544838 1.5870743-3.544838 3.5448303l-.0000076 11.2989121c0 1.639595-1.329155 2.96875-2.96875 2.96875l-65.3124924-.0000229c-1.639596 0-2.96875-1.329155-2.968749-2.96875l.0000038-11.298912c0-1.957756-1.5870762-3.544838-3.544836-3.544838h-4.7853256c-1.9577594 0-3.5448372 1.5870743-3.544838 3.544838l-.0000036 11.298912c-.0000026 8.1979752 6.6457672 14.84375 14.8437443 14.84375l65.3124965.0000229c8.1979751 0 14.84375-6.6457672 14.84375-14.8437424z"/><path d="m29.6809349 44.1050034-3.3884087 3.3884048c-1.3843441 1.384346-1.384346 3.6288109-.0000019 5.0131569l19.5066929 19.5067101c2.3174515 2.3200302 6.0768623 2.3221207 8.3968925.0046768.0015564-.0015564.0031128-.0031204.0046692-.0046768l19.5067177-19.5066948c1.384346-1.3843422 1.384346-3.6288109 0-5.0131569l-3.3884125-3.3884048c-1.3843384-1.384346-3.6288071-1.384346-5.0131531-.0000038l-9.3684235 9.3684196.0000153-47.4285965c0-1.9577589-1.5870781-3.544837-3.5448341-3.5448377l-4.7853279-.0000014c-1.9577599-.0000007-3.544838 1.5870759-3.544838 3.5448353l-.0000153 47.4285965-9.3684158-9.3684235c-1.3843459-1.384346-3.6288127-1.384346-5.0131568-.0000038z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/assets/export.svg
Normal file
1
src/assets/export.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg id="Layer_1" enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><switch><g><path d="m8.7 7.7 2.3-2.3v9.6c0 .6.4 1 1 1s1-.4 1-1v-9.6l2.3 2.3c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0zm12.3 6.3c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1h-14c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1z"/></g></switch></svg>
|
||||||
|
After Width: | Height: | Size: 493 B |
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useConfig } from '@/components/hooks';
|
||||||
import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants';
|
import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants';
|
||||||
|
|
||||||
function getHostName(url: string) {
|
function getHostName(url: string) {
|
||||||
|
|
@ -6,11 +7,13 @@ function getHostName(url: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Favicon({ domain, ...props }) {
|
export function Favicon({ domain, ...props }) {
|
||||||
if (process.env.privateMode) {
|
const config = useConfig();
|
||||||
|
|
||||||
|
if (config?.privateMode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = process.env.faviconURL || FAVICON_URL;
|
const url = config?.faviconUrl || FAVICON_URL;
|
||||||
const hostName = domain ? getHostName(domain) : null;
|
const hostName = domain ? getHostName(domain) : null;
|
||||||
const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName;
|
const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName;
|
||||||
const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null;
|
const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import useStore, { setConfig } from '@/store/app';
|
import useStore, { setConfig } from '@/store/app';
|
||||||
import { getConfig } from '@/app/actions/getConfig';
|
import { getConfig, Config } from '@/app/actions/getConfig';
|
||||||
|
|
||||||
export function useConfig() {
|
export function useConfig(): Config {
|
||||||
const { config } = useStore();
|
const { config } = useStore();
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function useReport(
|
||||||
data.parameters = {
|
data.parameters = {
|
||||||
...defaultParameters?.parameters,
|
...defaultParameters?.parameters,
|
||||||
...data.parameters,
|
...data.parameters,
|
||||||
dateRange: parseDateRange(dateRange.value),
|
dateRange: dateRange ? parseDateRange(dateRange?.value) : {},
|
||||||
};
|
};
|
||||||
|
|
||||||
setReport(data);
|
setReport(data);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ export function useFields() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
|
// { name: 'cohort', type: 'string', label: formatMessage(labels.cohort) },
|
||||||
|
// { name: 'segment', type: 'string', label: formatMessage(labels.segment) },
|
||||||
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
|
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
|
||||||
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
|
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
|
||||||
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
|
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export function useFilterParams(websiteId: string) {
|
||||||
city,
|
city,
|
||||||
event,
|
event,
|
||||||
tag,
|
tag,
|
||||||
|
segment,
|
||||||
|
cohort,
|
||||||
},
|
},
|
||||||
} = useNavigation();
|
} = useNavigation();
|
||||||
|
|
||||||
|
|
@ -42,5 +44,7 @@ export function useFilterParams(websiteId: string) {
|
||||||
city,
|
city,
|
||||||
event,
|
event,
|
||||||
tag,
|
tag,
|
||||||
|
segment,
|
||||||
|
cohort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import Change from '@/assets/change.svg';
|
||||||
import Clock from '@/assets/clock.svg';
|
import Clock from '@/assets/clock.svg';
|
||||||
import Compare from '@/assets/compare.svg';
|
import Compare from '@/assets/compare.svg';
|
||||||
import Dashboard from '@/assets/dashboard.svg';
|
import Dashboard from '@/assets/dashboard.svg';
|
||||||
|
import Download from '@/assets/download.svg';
|
||||||
|
import Export from '@/assets/export.svg';
|
||||||
import Eye from '@/assets/eye.svg';
|
import Eye from '@/assets/eye.svg';
|
||||||
import Gear from '@/assets/gear.svg';
|
import Gear from '@/assets/gear.svg';
|
||||||
import Globe from '@/assets/globe.svg';
|
import Globe from '@/assets/globe.svg';
|
||||||
|
|
@ -37,6 +39,8 @@ const icons = {
|
||||||
Clock,
|
Clock,
|
||||||
Compare,
|
Compare,
|
||||||
Dashboard,
|
Dashboard,
|
||||||
|
Download,
|
||||||
|
Export,
|
||||||
Eye,
|
Eye,
|
||||||
Gear,
|
Gear,
|
||||||
Globe,
|
Globe,
|
||||||
|
|
|
||||||
41
src/components/input/DownloadButton.tsx
Normal file
41
src/components/input/DownloadButton.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import { Button, Icon, TooltipPopup } from 'react-basics';
|
||||||
|
import Icons from '@/components/icons';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function DownloadButton({
|
||||||
|
filename = 'data',
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
filename?: string;
|
||||||
|
data?: any;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
downloadCsv(`${filename}.csv`, Papa.unparse(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipPopup label={formatMessage(labels.download)} position="top">
|
||||||
|
<Button variant="quiet" onClick={handleClick} disabled={!data}>
|
||||||
|
<Icon>
|
||||||
|
<Icons.Download />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</TooltipPopup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv(filename: string, data: any) {
|
||||||
|
const blob = new Blob([data], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
47
src/components/input/ExportButton.tsx
Normal file
47
src/components/input/ExportButton.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Icon, TooltipPopup, LoadingButton } from 'react-basics';
|
||||||
|
import Icons from '@/components/icons';
|
||||||
|
import { useMessages, useApi } from '@/components/hooks';
|
||||||
|
import { useFilterParams } from '@/components/hooks/useFilterParams';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export function ExportButton({ websiteId }: { websiteId: string }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const params = useFilterParams(websiteId);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { get } = useApi();
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const { zip } = await get(`/websites/${websiteId}/export`, { ...params, ...searchParams });
|
||||||
|
|
||||||
|
const binary = atob(zip);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([bytes], { type: 'application/zip' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'download.zip';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipPopup label={formatMessage(labels.download)} position="top">
|
||||||
|
<LoadingButton variant="quiet" isLoading={isLoading} onClick={handleClick}>
|
||||||
|
<Icon>
|
||||||
|
<Icons.Download />
|
||||||
|
</Icon>
|
||||||
|
</LoadingButton>
|
||||||
|
</TooltipPopup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,8 @@ export const labels = defineMessages({
|
||||||
countries: { id: 'label.countries', defaultMessage: 'Countries' },
|
countries: { id: 'label.countries', defaultMessage: 'Countries' },
|
||||||
languages: { id: 'label.languages', defaultMessage: 'Languages' },
|
languages: { id: 'label.languages', defaultMessage: 'Languages' },
|
||||||
tags: { id: 'label.tags', defaultMessage: 'Tags' },
|
tags: { id: 'label.tags', defaultMessage: 'Tags' },
|
||||||
|
segments: { id: 'label.segments', defaultMessage: 'Segments' },
|
||||||
|
cohorts: { id: 'label.cohorts', defaultMessage: 'Cohorts' },
|
||||||
count: { id: 'label.count', defaultMessage: 'Count' },
|
count: { id: 'label.count', defaultMessage: 'Count' },
|
||||||
average: { id: 'label.average', defaultMessage: 'Average' },
|
average: { id: 'label.average', defaultMessage: 'Average' },
|
||||||
sum: { id: 'label.sum', defaultMessage: 'Sum' },
|
sum: { id: 'label.sum', defaultMessage: 'Sum' },
|
||||||
|
|
@ -229,6 +231,8 @@ export const labels = defineMessages({
|
||||||
device: { id: 'label.device', defaultMessage: 'Device' },
|
device: { id: 'label.device', defaultMessage: 'Device' },
|
||||||
pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
|
pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
|
||||||
tag: { id: 'label.tag', defaultMessage: 'Tag' },
|
tag: { id: 'label.tag', defaultMessage: 'Tag' },
|
||||||
|
segment: { id: 'label.segment', defaultMessage: 'Segment' },
|
||||||
|
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
|
||||||
day: { id: 'label.day', defaultMessage: 'Day' },
|
day: { id: 'label.day', defaultMessage: 'Day' },
|
||||||
date: { id: 'label.date', defaultMessage: 'Date' },
|
date: { id: 'label.date', defaultMessage: 'Date' },
|
||||||
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
|
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
|
||||||
|
|
@ -310,6 +314,9 @@ export const labels = defineMessages({
|
||||||
paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' },
|
paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' },
|
||||||
grouped: { id: 'label.grouped', defaultMessage: 'Grouped' },
|
grouped: { id: 'label.grouped', defaultMessage: 'Grouped' },
|
||||||
other: { id: 'label.other', defaultMessage: 'Other' },
|
other: { id: 'label.other', defaultMessage: 'Other' },
|
||||||
|
chart: { id: 'label.chart', defaultMessage: 'Chart' },
|
||||||
|
table: { id: 'label.table', defaultMessage: 'Table' },
|
||||||
|
download: { id: 'label.download', defaultMessage: 'Download' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
|
||||||
metric={formatMessage(labels.actions)}
|
metric={formatMessage(labels.actions)}
|
||||||
onDataLoad={handleDataLoad}
|
onDataLoad={handleDataLoad}
|
||||||
renderLabel={renderLabel}
|
renderLabel={renderLabel}
|
||||||
|
allowDownload={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import Icons from '@/components/icons';
|
import Icons from '@/components/icons';
|
||||||
import ListTable, { ListTableProps } from './ListTable';
|
import ListTable, { ListTableProps } from './ListTable';
|
||||||
import styles from './MetricsTable.module.css';
|
import styles from './MetricsTable.module.css';
|
||||||
|
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||||
|
|
||||||
export interface MetricsTableProps extends ListTableProps {
|
export interface MetricsTableProps extends ListTableProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
|
|
@ -29,6 +30,7 @@ export interface MetricsTableProps extends ListTableProps {
|
||||||
searchFormattedValues?: boolean;
|
searchFormattedValues?: boolean;
|
||||||
showMore?: boolean;
|
showMore?: boolean;
|
||||||
params?: { [key: string]: any };
|
params?: { [key: string]: any };
|
||||||
|
allowDownload?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +46,7 @@ export function MetricsTable({
|
||||||
searchFormattedValues = false,
|
searchFormattedValues = false,
|
||||||
showMore = true,
|
showMore = true,
|
||||||
params,
|
params,
|
||||||
|
allowDownload = true,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: MetricsTableProps) {
|
}: MetricsTableProps) {
|
||||||
|
|
@ -104,7 +107,10 @@ export function MetricsTable({
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{children}
|
<div className={styles.buttons}>
|
||||||
|
{children}
|
||||||
|
{allowDownload && <DownloadButton filename={type} data={filteredData} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{data && !error && (
|
{data && !error && (
|
||||||
<ListTable {...(props as ListTableProps)} data={filteredData} className={className} />
|
<ListTable {...(props as ListTableProps)} data={filteredData} className={className} />
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function PagesTable({ allowFilter, ...props }: PagesTableProps) {
|
||||||
{...props}
|
{...props}
|
||||||
title={formatMessage(labels.pages)}
|
title={formatMessage(labels.pages)}
|
||||||
type={view}
|
type={view}
|
||||||
metric={formatMessage(labels.views)}
|
metric={formatMessage(labels.visitors)}
|
||||||
dataFilter={emptyFilter}
|
dataFilter={emptyFilter}
|
||||||
renderLabel={renderLink}
|
renderLabel={renderLink}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@
|
||||||
"label.add": "أضِف",
|
"label.add": "أضِف",
|
||||||
"label.add-description": "أضِف وصف",
|
"label.add-description": "أضِف وصف",
|
||||||
"label.add-member": "أضِف عضو",
|
"label.add-member": "أضِف عضو",
|
||||||
"label.add-step": "Add step",
|
"label.add-step": "إضافة خطوة",
|
||||||
"label.add-website": "إضافة موقع",
|
"label.add-website": "إضافة موقع",
|
||||||
"label.admin": "مدير",
|
"label.admin": "مدير",
|
||||||
"label.after": "يعد",
|
"label.after": "يعد",
|
||||||
"label.all": "الكل",
|
"label.all": "الكل",
|
||||||
"label.all-time": "كل الوقت",
|
"label.all-time": "كل الوقت",
|
||||||
"label.analytics": "تحليلات",
|
"label.analytics": "تحليلات",
|
||||||
|
"label.attribution": "الإسناد",
|
||||||
|
"label.attribution-description": "شاهد كيف يتفاعل المستخدمون مع حملاتك التسويقية وما الذي يحفز التحويلات.",
|
||||||
"label.average": "المتوسط",
|
"label.average": "المتوسط",
|
||||||
"label.back": "للخلف",
|
"label.back": "للخلف",
|
||||||
"label.before": "قبل",
|
"label.before": "قبل",
|
||||||
|
|
@ -19,17 +21,17 @@
|
||||||
"label.breakdown": "التصنيف",
|
"label.breakdown": "التصنيف",
|
||||||
"label.browser": "المتصفح",
|
"label.browser": "المتصفح",
|
||||||
"label.browsers": "المتصفحات",
|
"label.browsers": "المتصفحات",
|
||||||
"label.cancel": "ألغِ",
|
"label.cancel": "إلغاء",
|
||||||
"label.change-password": "تغيير كلمة المرور",
|
"label.change-password": "تغيير كلمة المرور",
|
||||||
"label.cities": "المدن",
|
"label.cities": "المدن",
|
||||||
"label.city": "المدينة",
|
"label.city": "المدينة",
|
||||||
"label.clear-all": "مسح الكل",
|
"label.clear-all": "مسح الكل",
|
||||||
"label.compare": "Compare",
|
"label.compare": "المقارنة",
|
||||||
"label.confirm": "تأكيد",
|
"label.confirm": "تأكيد",
|
||||||
"label.confirm-password": "تأكيد كلمة المرور",
|
"label.confirm-password": "تأكيد كلمة المرور",
|
||||||
"label.contains": "يحتوي",
|
"label.contains": "يحتوي على",
|
||||||
"label.continue": "تابع",
|
"label.continue": "تابع",
|
||||||
"label.count": "Count",
|
"label.count": "العدد",
|
||||||
"label.countries": "الدول",
|
"label.countries": "الدول",
|
||||||
"label.country": "الدولة",
|
"label.country": "الدولة",
|
||||||
"label.create": "أنشِئ",
|
"label.create": "أنشِئ",
|
||||||
|
|
@ -38,10 +40,10 @@
|
||||||
"label.create-user": "أنشِئ مستخدم",
|
"label.create-user": "أنشِئ مستخدم",
|
||||||
"label.created": "أُنشئت",
|
"label.created": "أُنشئت",
|
||||||
"label.created-by": "أُنشئ من قبل",
|
"label.created-by": "أُنشئ من قبل",
|
||||||
"label.current": "Current",
|
"label.current": "الحالي",
|
||||||
"label.current-password": "كلمة المرور الحالية",
|
"label.current-password": "كلمة المرور الحالية",
|
||||||
"label.custom-range": "فترة مخصّصة",
|
"label.custom-range": "فترة مخصّصة",
|
||||||
"label.dashboard": "الشاشة الرئيسية",
|
"label.dashboard": "لوحة التحكم",
|
||||||
"label.data": "البيانات",
|
"label.data": "البيانات",
|
||||||
"label.date": "التاريخ",
|
"label.date": "التاريخ",
|
||||||
"label.date-range": "فترة مخصّصة",
|
"label.date-range": "فترة مخصّصة",
|
||||||
|
|
@ -58,19 +60,19 @@
|
||||||
"label.device": "الجهاز",
|
"label.device": "الجهاز",
|
||||||
"label.devices": "الأجهزة",
|
"label.devices": "الأجهزة",
|
||||||
"label.dismiss": "تجاهل",
|
"label.dismiss": "تجاهل",
|
||||||
"label.does-not-contain": "لا يحتوي",
|
"label.does-not-contain": "لا يحتوي على",
|
||||||
"label.domain": "النطاق",
|
"label.domain": "النطاق",
|
||||||
"label.dropoff": "إنزال",
|
"label.dropoff": "إنزال",
|
||||||
"label.edit": "عدّل",
|
"label.edit": "تعديل",
|
||||||
"label.edit-dashboard": "عدّل لوحة التحكم",
|
"label.edit-dashboard": "عدّل لوحة التحكم",
|
||||||
"label.edit-member": "عدّل العضو",
|
"label.edit-member": "عدّل العضو",
|
||||||
"label.enable-share-url": "فعّل مشاركة الرابط",
|
"label.enable-share-url": "فعّل مشاركة الرابط",
|
||||||
"label.end-step": "End Step",
|
"label.end-step": "الخطوة الأخيرة",
|
||||||
"label.entry": "Entry URL",
|
"label.entry": "رابط الدخول",
|
||||||
"label.event": "الحدث",
|
"label.event": "الحدث",
|
||||||
"label.event-data": "تاريخ الحدث",
|
"label.event-data": "تاريخ الحدث",
|
||||||
"label.events": "الأحداث",
|
"label.events": "الأحداث",
|
||||||
"label.exit": "Exit URL",
|
"label.exit": "رابط المغادرة",
|
||||||
"label.false": "خطأ",
|
"label.false": "خطأ",
|
||||||
"label.field": "الحقل",
|
"label.field": "الحقل",
|
||||||
"label.fields": "الحقول",
|
"label.fields": "الحقول",
|
||||||
|
|
@ -78,33 +80,33 @@
|
||||||
"label.filter-combined": "مُجمّعة",
|
"label.filter-combined": "مُجمّعة",
|
||||||
"label.filter-raw": "خام",
|
"label.filter-raw": "خام",
|
||||||
"label.filters": "التصفيات",
|
"label.filters": "التصفيات",
|
||||||
"label.first-seen": "First seen",
|
"label.first-seen": "أول ظهور",
|
||||||
"label.funnel": "قمع",
|
"label.funnel": "قمع",
|
||||||
"label.funnel-description": "فهم معدل التحويل والانقطاع عن المستخدمين.",
|
"label.funnel-description": "فهم معدل التحويل والانقطاع عن المستخدمين.",
|
||||||
"label.goal": "Goal",
|
"label.goal": "الهدف",
|
||||||
"label.goals": "Goals",
|
"label.goals": "الأهداف",
|
||||||
"label.goals-description": "Track your goals for pageviews and events.",
|
"label.goals-description": "تابع تحقق أهدافك المرتبطة بمشاهدات الصفحات والأحداث.",
|
||||||
"label.greater-than": "أكبَر مِن",
|
"label.greater-than": "أكبَر مِن",
|
||||||
"label.greater-than-equals": "أكبَر مِن أو يساوي",
|
"label.greater-than-equals": "أكبَر مِن أو يساوي",
|
||||||
"label.host": "Host",
|
"label.host": "Host",
|
||||||
"label.hosts": "Hosts",
|
"label.hosts": "Hosts",
|
||||||
"label.insights": "نتائج التحليلات",
|
"label.insights": "نتائج التحليلات",
|
||||||
"label.insights-description": "تعمق في بياناتك باستخدام الشرائح والتصفيات.",
|
"label.insights-description": "تعمق في بياناتك باستخدام الشرائح والتصفيات.",
|
||||||
"label.is": "هو",
|
"label.is": "يساوي",
|
||||||
"label.is-not": "لم",
|
"label.is-not": "لا يساوي",
|
||||||
"label.is-not-set": "لم ضُبط",
|
"label.is-not-set": "لم ضُبط",
|
||||||
"label.is-set": "ضُبط",
|
"label.is-set": "ضُبط",
|
||||||
"label.join": "انضم",
|
"label.join": "انضم",
|
||||||
"label.join-team": "انضم للفريق",
|
"label.join-team": "انضم للفريق",
|
||||||
"label.journey": "Journey",
|
"label.journey": "رحلة المستخدم",
|
||||||
"label.journey-description": "Understand how users navigate through your website.",
|
"label.journey-description": "تعرّف على كيفية تنقّل المستخدمين داخل موقعك.",
|
||||||
"label.language": "اللغة",
|
"label.language": "اللغة",
|
||||||
"label.languages": "اللغات",
|
"label.languages": "اللغات",
|
||||||
"label.laptop": "لابتوب",
|
"label.laptop": "لابتوب",
|
||||||
"label.last-days": "آخر {x} يوم/ايام",
|
"label.last-days": "آخر {x} يوم/ايام",
|
||||||
"label.last-hours": "آخر {x} ساعة",
|
"label.last-hours": "آخر {x} ساعة",
|
||||||
"label.last-months": "Last {x} months",
|
"label.last-months": "آخر {x} شهر/أشهر",
|
||||||
"label.last-seen": "Last seen",
|
"label.last-seen": "آخر ظهور",
|
||||||
"label.leave": "غادر",
|
"label.leave": "غادر",
|
||||||
"label.leave-team": "مغادرة المجموعة",
|
"label.leave-team": "مغادرة المجموعة",
|
||||||
"label.less-than": "أقل مِن",
|
"label.less-than": "أقل مِن",
|
||||||
|
|
@ -112,7 +114,7 @@
|
||||||
"label.login": "تسجيل الدخول",
|
"label.login": "تسجيل الدخول",
|
||||||
"label.logout": "تسجيل الخروج",
|
"label.logout": "تسجيل الخروج",
|
||||||
"label.manage": "التحكم",
|
"label.manage": "التحكم",
|
||||||
"label.manager": "Manager",
|
"label.manager": "مدير",
|
||||||
"label.max": "الحد الأقصى",
|
"label.max": "الحد الأقصى",
|
||||||
"label.member": "عضو",
|
"label.member": "عضو",
|
||||||
"label.members": "الأعضاء",
|
"label.members": "الأعضاء",
|
||||||
|
|
@ -134,15 +136,15 @@
|
||||||
"label.pageTitle": "عنوان الصفحة",
|
"label.pageTitle": "عنوان الصفحة",
|
||||||
"label.pages": "الصفحات",
|
"label.pages": "الصفحات",
|
||||||
"label.password": "كلمة المرور",
|
"label.password": "كلمة المرور",
|
||||||
"label.path": "Path",
|
"label.path": "المسار",
|
||||||
"label.paths": "Paths",
|
"label.paths": "المسارات",
|
||||||
"label.powered-by": "مشغل بواسطة {name}",
|
"label.powered-by": "مشغل بواسطة {name}",
|
||||||
"label.previous": "Previous",
|
"label.previous": "السابق",
|
||||||
"label.previous-period": "Previous period",
|
"label.previous-period": "الفترة السابقة",
|
||||||
"label.previous-year": "Previous year",
|
"label.previous-year": "العام السابق",
|
||||||
"label.profile": "الملف الشخصي",
|
"label.profile": "الملف الشخصي",
|
||||||
"label.properties": "Properties",
|
"label.properties": "الخصائص",
|
||||||
"label.property": "Property",
|
"label.property": "الخاصية",
|
||||||
"label.queries": "استعلامات",
|
"label.queries": "استعلامات",
|
||||||
"label.query": "استعلام",
|
"label.query": "استعلام",
|
||||||
"label.query-parameters": "متغيرات الرابط",
|
"label.query-parameters": "متغيرات الرابط",
|
||||||
|
|
@ -161,9 +163,9 @@
|
||||||
"label.reset-website": "اعادة تعيين الإحصائيات",
|
"label.reset-website": "اعادة تعيين الإحصائيات",
|
||||||
"label.retention": "الاحتفاظ",
|
"label.retention": "الاحتفاظ",
|
||||||
"label.retention-description": "قس مدى ثبات موقعك على الويب من خلال تتبع عدد مرات عودة المستخدمين.",
|
"label.retention-description": "قس مدى ثبات موقعك على الويب من خلال تتبع عدد مرات عودة المستخدمين.",
|
||||||
"label.revenue": "Revenue",
|
"label.revenue": "الإيرادات",
|
||||||
"label.revenue-description": "Look into your revenue across time.",
|
"label.revenue-description": "قم بإلقاء نظرة على بيانات إيراداتك وكيفية إنفاق المستخدمين.",
|
||||||
"label.revenue-property": "Revenue Property",
|
"label.revenue-property": "خاصية الإيرادات",
|
||||||
"label.role": "الصلاحية",
|
"label.role": "الصلاحية",
|
||||||
"label.run-query": "شغّل الاستعلام",
|
"label.run-query": "شغّل الاستعلام",
|
||||||
"label.save": "حفظ",
|
"label.save": "حفظ",
|
||||||
|
|
@ -173,22 +175,24 @@
|
||||||
"label.select-date": "حدد التاريخ",
|
"label.select-date": "حدد التاريخ",
|
||||||
"label.select-role": "حدد الدور",
|
"label.select-role": "حدد الدور",
|
||||||
"label.select-website": "حدد موقع",
|
"label.select-website": "حدد موقع",
|
||||||
"label.session": "Session",
|
"label.session": "الزيارة",
|
||||||
"label.sessions": "الزيارات",
|
"label.sessions": "الزيارات",
|
||||||
"label.settings": "الإعدادات",
|
"label.settings": "الإعدادات",
|
||||||
"label.share-url": "مشاركة الرابط",
|
"label.share-url": "مشاركة الرابط",
|
||||||
"label.single-day": "يوم واحد",
|
"label.single-day": "يوم واحد",
|
||||||
"label.start-step": "Start Step",
|
"label.start-step": "الخطوة الأولى",
|
||||||
"label.steps": "Steps",
|
"label.steps": "الخطوات",
|
||||||
"label.sum": "المجموع",
|
"label.sum": "المجموع",
|
||||||
"label.tablet": "تابلت",
|
"label.tablet": "تابلت",
|
||||||
|
"label.tag": "الوسم",
|
||||||
|
"label.tags": "الوسوم",
|
||||||
"label.team": "الفريق",
|
"label.team": "الفريق",
|
||||||
"label.team-id": "معرّف الفريق",
|
"label.team-id": "معرّف الفريق",
|
||||||
"label.team-manager": "Team manager",
|
"label.team-manager": "مدير الفريق",
|
||||||
"label.team-member": "عضو الفريق",
|
"label.team-member": "عضو الفريق",
|
||||||
"label.team-name": "اسم الفريق",
|
"label.team-name": "اسم الفريق",
|
||||||
"label.team-owner": "مدير الفريق",
|
"label.team-owner": "مدير الفريق",
|
||||||
"label.team-view-only": "Team view only",
|
"label.team-view-only": "عرض الفريق فقط",
|
||||||
"label.team-websites": "مواقع الفريق",
|
"label.team-websites": "مواقع الفريق",
|
||||||
"label.teams": "الفرق",
|
"label.teams": "الفرق",
|
||||||
"label.theme": "السمة",
|
"label.theme": "السمة",
|
||||||
|
|
@ -202,34 +206,34 @@
|
||||||
"label.total": "الإجمالي",
|
"label.total": "الإجمالي",
|
||||||
"label.total-records": "إجمالي السجلات",
|
"label.total-records": "إجمالي السجلات",
|
||||||
"label.tracking-code": "كود التتبع",
|
"label.tracking-code": "كود التتبع",
|
||||||
"label.transactions": "Transactions",
|
"label.transactions": "المعاملات",
|
||||||
"label.transfer": "Transfer",
|
"label.transfer": "نقل",
|
||||||
"label.transfer-website": "انقل الموقع",
|
"label.transfer-website": "انقل الموقع",
|
||||||
"label.true": "حقيقي",
|
"label.true": "حقيقي",
|
||||||
"label.type": "النوع",
|
"label.type": "النوع",
|
||||||
"label.unique": "فريد",
|
"label.unique": "فريد",
|
||||||
"label.unique-visitors": "زائرون فريدون",
|
"label.unique-visitors": "زائرون فريدون",
|
||||||
"label.uniqueCustomers": "Unique Customers",
|
"label.uniqueCustomers": "العملاء الفريدون",
|
||||||
"label.unknown": "غير معروف",
|
"label.unknown": "غير معروف",
|
||||||
"label.untitled": "بدون عنوان",
|
"label.untitled": "بدون عنوان",
|
||||||
"label.update": "Update",
|
"label.update": "تحديث",
|
||||||
"label.url": "URL",
|
"label.url": "الرابط",
|
||||||
"label.urls": "URLs",
|
"label.urls": "الروابط",
|
||||||
"label.user": "المستخدم",
|
"label.user": "المستخدم",
|
||||||
"label.user-property": "User Property",
|
"label.user-property": "سمات المستخدم",
|
||||||
"label.username": "اسم المستخدم",
|
"label.username": "اسم المستخدم",
|
||||||
"label.users": "المستخدمين",
|
"label.users": "المستخدمين",
|
||||||
"label.utm": "UTM",
|
"label.utm": "UTM",
|
||||||
"label.utm-description": "Track your campaigns through UTM parameters.",
|
"label.utm-description": "تابع حملاتك التسويقية باستخدام معلمات UTM.",
|
||||||
"label.value": "القيمة",
|
"label.value": "القيمة",
|
||||||
"label.view": "عرض",
|
"label.view": "عرض",
|
||||||
"label.view-details": "عرض التفاصيل",
|
"label.view-details": "عرض التفاصيل",
|
||||||
"label.view-only": "عرض فقط",
|
"label.view-only": "عرض فقط",
|
||||||
"label.views": "المشاهدات",
|
"label.views": "المشاهدات",
|
||||||
"label.views-per-visit": "Views per visit",
|
"label.views-per-visit": "مشاهدات لكل زيارة",
|
||||||
"label.visit-duration": "متوسط وقت الزيارة",
|
"label.visit-duration": "متوسط وقت الزيارة",
|
||||||
"label.visitors": "الزوار",
|
"label.visitors": "الزوار",
|
||||||
"label.visits": "Visits",
|
"label.visits": "الزيارات",
|
||||||
"label.website": "الموقع",
|
"label.website": "الموقع",
|
||||||
"label.website-id": "معرّف الموقع",
|
"label.website-id": "معرّف الموقع",
|
||||||
"label.websites": "المواقع",
|
"label.websites": "المواقع",
|
||||||
|
|
@ -237,7 +241,7 @@
|
||||||
"label.yesterday": "الأمس",
|
"label.yesterday": "الأمس",
|
||||||
"message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.",
|
"message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.",
|
||||||
"message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}",
|
"message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}",
|
||||||
"message.collected-data": "Collected data",
|
"message.collected-data": "البيانات المجمعة",
|
||||||
"message.confirm-delete": "هل أنت متأكد من حذف {target}?",
|
"message.confirm-delete": "هل أنت متأكد من حذف {target}?",
|
||||||
"message.confirm-leave": "هل أنت متأكد من مغادرة {target}?",
|
"message.confirm-leave": "هل أنت متأكد من مغادرة {target}?",
|
||||||
"message.confirm-remove": "هل انت متأكد من حذف {target}?",
|
"message.confirm-remove": "هل انت متأكد من حذف {target}?",
|
||||||
|
|
@ -261,9 +265,9 @@
|
||||||
"message.no-websites-configured": "لم تقم بإعداد اي موقع.",
|
"message.no-websites-configured": "لم تقم بإعداد اي موقع.",
|
||||||
"message.page-not-found": "الصفحة غير موجودة.",
|
"message.page-not-found": "الصفحة غير موجودة.",
|
||||||
"message.reset-website": "لإعادة ضبط موقع الويب هذا، اكتب {confirmation} في المربع أدناه للتأكيد.",
|
"message.reset-website": "لإعادة ضبط موقع الويب هذا، اكتب {confirmation} في المربع أدناه للتأكيد.",
|
||||||
"message.reset-website-warning": "سيتم اعادة تعيين كافة الإحصائيات لهذا الموقع، لكن لن يتم تعيير كود التتبع",
|
"message.reset-website-warning": "سيتم اعادة تعيين كافة الإحصائيات لهذا الموقع، لكن لن يتم تغيير كود التتبع",
|
||||||
"message.saved": "تم الحفظ بنجاح.",
|
"message.saved": "تم الحفظ بنجاح.",
|
||||||
"message.share-url": "هذا الرابط الذي تم مشاركته بشكل عام لـ {target}.",
|
"message.share-url": "إحصائيات موقعك متاحة للجميع على الرابط التالي:",
|
||||||
"message.team-already-member": "أنت عضو في الفريق",
|
"message.team-already-member": "أنت عضو في الفريق",
|
||||||
"message.team-not-found": "لم يتم العثور على الفريق",
|
"message.team-not-found": "لم يتم العثور على الفريق",
|
||||||
"message.team-websites-info": "يمكن مشاهدة الموقع من اي عضو في الفريق.",
|
"message.team-websites-info": "يمكن مشاهدة الموقع من اي عضو في الفريق.",
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
"label.filter-combined": "Combinado",
|
"label.filter-combined": "Combinado",
|
||||||
"label.filter-raw": "En crudo",
|
"label.filter-raw": "En crudo",
|
||||||
"label.filters": "Filtros",
|
"label.filters": "Filtros",
|
||||||
"label.first-seen": "First seen",
|
"label.first-seen": "Visto por primera vez",
|
||||||
"label.funnel": "Embudo",
|
"label.funnel": "Embudo",
|
||||||
"label.funnel-description": "Comprender conversión y abandono de usuarios.",
|
"label.funnel-description": "Comprender conversión y abandono de usuarios.",
|
||||||
"label.goal": "Objetivo",
|
"label.goal": "Objetivo",
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
"label.last-days": "Últimos {x} días",
|
"label.last-days": "Últimos {x} días",
|
||||||
"label.last-hours": "Últimas {x} horas",
|
"label.last-hours": "Últimas {x} horas",
|
||||||
"label.last-months": "Últimos {x} meses",
|
"label.last-months": "Últimos {x} meses",
|
||||||
"label.last-seen": "Last seen",
|
"label.last-seen": "Visto por última vez",
|
||||||
"label.leave": "Abandonar",
|
"label.leave": "Abandonar",
|
||||||
"label.leave-team": "Abandonar equipo",
|
"label.leave-team": "Abandonar equipo",
|
||||||
"label.less-than": "Menor que",
|
"label.less-than": "Menor que",
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
"label.name": "Nombre",
|
"label.name": "Nombre",
|
||||||
"label.new-password": "Nueva contraseña",
|
"label.new-password": "Nueva contraseña",
|
||||||
"label.none": "Ninguno",
|
"label.none": "Ninguno",
|
||||||
"label.number-of-records": "{x} {x, plural, one {record} other {records}}",
|
"label.number-of-records": "{x} {x, plural, one {registro} other {registros}}",
|
||||||
"label.ok": "OK",
|
"label.ok": "OK",
|
||||||
"label.os": "Sistema",
|
"label.os": "Sistema",
|
||||||
"label.overview": "Resumen",
|
"label.overview": "Resumen",
|
||||||
|
|
@ -141,7 +141,7 @@
|
||||||
"label.previous-period": "Periodo anterior",
|
"label.previous-period": "Periodo anterior",
|
||||||
"label.previous-year": "Año anterior",
|
"label.previous-year": "Año anterior",
|
||||||
"label.profile": "Perfil",
|
"label.profile": "Perfil",
|
||||||
"label.properties": "Properties",
|
"label.properties": "Propiedades",
|
||||||
"label.property": "Propiedad",
|
"label.property": "Propiedad",
|
||||||
"label.queries": "Consultas",
|
"label.queries": "Consultas",
|
||||||
"label.query": "Consulta",
|
"label.query": "Consulta",
|
||||||
|
|
@ -161,9 +161,9 @@
|
||||||
"label.reset-website": "Reiniciar analíticas",
|
"label.reset-website": "Reiniciar analíticas",
|
||||||
"label.retention": "Retención",
|
"label.retention": "Retención",
|
||||||
"label.retention-description": "Medir la frecuencia con la que los usuarios vuelven a tu sitio web.",
|
"label.retention-description": "Medir la frecuencia con la que los usuarios vuelven a tu sitio web.",
|
||||||
"label.revenue": "Revenue",
|
"label.revenue": "Ganancias",
|
||||||
"label.revenue-description": "Look into your revenue across time.",
|
"label.revenue-description": "Analice sus ganancias a lo largo del tiempo.",
|
||||||
"label.revenue-property": "Revenue Property",
|
"label.revenue-property": "Propiedad de ganancias",
|
||||||
"label.role": "Rol",
|
"label.role": "Rol",
|
||||||
"label.run-query": "Ejecutar consulta",
|
"label.run-query": "Ejecutar consulta",
|
||||||
"label.save": "Guardar",
|
"label.save": "Guardar",
|
||||||
|
|
@ -173,7 +173,7 @@
|
||||||
"label.select-date": "Seleccionar fecha",
|
"label.select-date": "Seleccionar fecha",
|
||||||
"label.select-role": "Seleccionar rol",
|
"label.select-role": "Seleccionar rol",
|
||||||
"label.select-website": "Seleccionar sitio web",
|
"label.select-website": "Seleccionar sitio web",
|
||||||
"label.session": "Session",
|
"label.session": "Sesión",
|
||||||
"label.sessions": "Sesiones",
|
"label.sessions": "Sesiones",
|
||||||
"label.settings": "Ajustes",
|
"label.settings": "Ajustes",
|
||||||
"label.share-url": "Compartir URL",
|
"label.share-url": "Compartir URL",
|
||||||
|
|
@ -202,21 +202,21 @@
|
||||||
"label.total": "Total",
|
"label.total": "Total",
|
||||||
"label.total-records": "Total de registros",
|
"label.total-records": "Total de registros",
|
||||||
"label.tracking-code": "Código de rastreo",
|
"label.tracking-code": "Código de rastreo",
|
||||||
"label.transactions": "Transactions",
|
"label.transactions": "Transacciones",
|
||||||
"label.transfer": "Transferir",
|
"label.transfer": "Transferir",
|
||||||
"label.transfer-website": "Transferir sitio web",
|
"label.transfer-website": "Transferir sitio web",
|
||||||
"label.true": "Verdadero",
|
"label.true": "Verdadero",
|
||||||
"label.type": "Tipo",
|
"label.type": "Tipo",
|
||||||
"label.unique": "Único",
|
"label.unique": "Único",
|
||||||
"label.unique-visitors": "Visitantes únicos",
|
"label.unique-visitors": "Visitantes únicos",
|
||||||
"label.uniqueCustomers": "Unique Customers",
|
"label.uniqueCustomers": "Clientes únicos",
|
||||||
"label.unknown": "Desconocida",
|
"label.unknown": "Desconocida",
|
||||||
"label.untitled": "Sin título",
|
"label.untitled": "Sin título",
|
||||||
"label.update": "Actualizar",
|
"label.update": "Actualizar",
|
||||||
"label.url": "URL",
|
"label.url": "URL",
|
||||||
"label.urls": "URLs",
|
"label.urls": "URLs",
|
||||||
"label.user": "Usuario",
|
"label.user": "Usuario",
|
||||||
"label.user-property": "User Property",
|
"label.user-property": "Propiedad de usuario",
|
||||||
"label.username": "Nombre de usuario",
|
"label.username": "Nombre de usuario",
|
||||||
"label.users": "Usuarios",
|
"label.users": "Usuarios",
|
||||||
"label.utm": "UTM",
|
"label.utm": "UTM",
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
"label.activity": "Dnevnik dejavnosti",
|
"label.activity": "Dnevnik dejavnosti",
|
||||||
"label.add": "Dodaj",
|
"label.add": "Dodaj",
|
||||||
"label.add-description": "Dodaj opis",
|
"label.add-description": "Dodaj opis",
|
||||||
"label.add-member": "Add member",
|
"label.add-member": "Dodaj člana",
|
||||||
"label.add-step": "Add step",
|
"label.add-step": "Dodaj korak",
|
||||||
"label.add-website": "Dodaj spletno mesto",
|
"label.add-website": "Dodaj spletno mesto",
|
||||||
"label.admin": "Administrator",
|
"label.admin": "Administrator",
|
||||||
"label.after": "Po",
|
"label.after": "Po",
|
||||||
|
|
@ -24,21 +24,21 @@
|
||||||
"label.cities": "Mesta",
|
"label.cities": "Mesta",
|
||||||
"label.city": "Mesto",
|
"label.city": "Mesto",
|
||||||
"label.clear-all": "Počisti vse",
|
"label.clear-all": "Počisti vse",
|
||||||
"label.compare": "Compare",
|
"label.compare": "Primerjaj",
|
||||||
"label.confirm": "Potrdi",
|
"label.confirm": "Potrdi",
|
||||||
"label.confirm-password": "Potrdi geslo",
|
"label.confirm-password": "Potrdi geslo",
|
||||||
"label.contains": "Vsebuje",
|
"label.contains": "Vsebuje",
|
||||||
"label.continue": "Nadaljuj",
|
"label.continue": "Nadaljuj",
|
||||||
"label.count": "Count",
|
"label.count": "Število",
|
||||||
"label.countries": "Države",
|
"label.countries": "Države",
|
||||||
"label.country": "Država",
|
"label.country": "Država",
|
||||||
"label.create": "Create",
|
"label.create": "Ustvari",
|
||||||
"label.create-report": "Ustvari poročilo",
|
"label.create-report": "Ustvari poročilo",
|
||||||
"label.create-team": "Ustvari ekipo",
|
"label.create-team": "Ustvari ekipo",
|
||||||
"label.create-user": "Ustvari uporabnika",
|
"label.create-user": "Ustvari uporabnika",
|
||||||
"label.created": "Ustvarjeno",
|
"label.created": "Ustvarjeno",
|
||||||
"label.created-by": "Created By",
|
"label.created-by": "Ustvaril",
|
||||||
"label.current": "Current",
|
"label.current": "Trenutno",
|
||||||
"label.current-password": "Trenutno geslo",
|
"label.current-password": "Trenutno geslo",
|
||||||
"label.custom-range": "Obdobje po meri",
|
"label.custom-range": "Obdobje po meri",
|
||||||
"label.dashboard": "Nadzorna plošča",
|
"label.dashboard": "Nadzorna plošča",
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
"label.day": "Dan",
|
"label.day": "Dan",
|
||||||
"label.default-date-range": "Privzeto časovno obdobje",
|
"label.default-date-range": "Privzeto časovno obdobje",
|
||||||
"label.delete": "Izbriši",
|
"label.delete": "Izbriši",
|
||||||
"label.delete-report": "Delete report",
|
"label.delete-report": "Izbriši poročilo",
|
||||||
"label.delete-team": "Izbriši ekipo",
|
"label.delete-team": "Izbriši ekipo",
|
||||||
"label.delete-user": "Izbriši uporabnika",
|
"label.delete-user": "Izbriši uporabnika",
|
||||||
"label.delete-website": "Izbriši spletno mesto",
|
"label.delete-website": "Izbriši spletno mesto",
|
||||||
|
|
@ -63,14 +63,14 @@
|
||||||
"label.dropoff": "Zapustitev",
|
"label.dropoff": "Zapustitev",
|
||||||
"label.edit": "Uredi",
|
"label.edit": "Uredi",
|
||||||
"label.edit-dashboard": "Uredi nadzorno ploščo",
|
"label.edit-dashboard": "Uredi nadzorno ploščo",
|
||||||
"label.edit-member": "Edit member",
|
"label.edit-member": "Uredi člana",
|
||||||
"label.enable-share-url": "Uredi povezavo za deljenje",
|
"label.enable-share-url": "Omogoči povezavo za deljenje",
|
||||||
"label.end-step": "End Step",
|
"label.end-step": "Končni korak",
|
||||||
"label.entry": "Entry URL",
|
"label.entry": "Vstopni URL",
|
||||||
"label.event": "Dogodek",
|
"label.event": "Dogodek",
|
||||||
"label.event-data": "Podatki dogodka",
|
"label.event-data": "Podatki dogodka",
|
||||||
"label.events": "Dogodki",
|
"label.events": "Dogodki",
|
||||||
"label.exit": "Exit URL",
|
"label.exit": "Izhodni URL",
|
||||||
"label.false": "Napačno",
|
"label.false": "Napačno",
|
||||||
"label.field": "Polje",
|
"label.field": "Polje",
|
||||||
"label.fields": "Polja",
|
"label.fields": "Polja",
|
||||||
|
|
@ -78,48 +78,48 @@
|
||||||
"label.filter-combined": "Skupaj",
|
"label.filter-combined": "Skupaj",
|
||||||
"label.filter-raw": "Neobdelano",
|
"label.filter-raw": "Neobdelano",
|
||||||
"label.filters": "Filtri",
|
"label.filters": "Filtri",
|
||||||
"label.first-seen": "First seen",
|
"label.first-seen": "Prvič viden",
|
||||||
"label.funnel": "Prodajni lijak",
|
"label.funnel": "Prodajni lijak",
|
||||||
"label.funnel-description": "Understand the conversion and drop-off rate of users.",
|
"label.funnel-description": "Razumite stopnjo konverzije in osipa uporabnikov.",
|
||||||
"label.goal": "Goal",
|
"label.goal": "Cilj",
|
||||||
"label.goals": "Goals",
|
"label.goals": "Cilji",
|
||||||
"label.goals-description": "Track your goals for pageviews and events.",
|
"label.goals-description": "Spremljajte svoje cilje za oglede strani in dogodke.",
|
||||||
"label.greater-than": "Večje od",
|
"label.greater-than": "Večje od",
|
||||||
"label.greater-than-equals": "Večje ali enako kot",
|
"label.greater-than-equals": "Večje ali enako kot",
|
||||||
"label.host": "Host",
|
"label.host": "Gostitelj",
|
||||||
"label.hosts": "Hosts",
|
"label.hosts": "Gostitelji",
|
||||||
"label.insights": "Vpogled",
|
"label.insights": "Vpogled",
|
||||||
"label.insights-description": "Dive deeper into your data by using segments and filters.",
|
"label.insights-description": "Poglobite se v podatke z uporabo segmentov in filtrov.",
|
||||||
"label.is": "Je",
|
"label.is": "Je",
|
||||||
"label.is-not": "Ni",
|
"label.is-not": "Ni",
|
||||||
"label.is-not-set": "Ni nastavljeno",
|
"label.is-not-set": "Ni nastavljeno",
|
||||||
"label.is-set": "Je nastavljeno",
|
"label.is-set": "Je nastavljeno",
|
||||||
"label.join": "Pridruži se",
|
"label.join": "Pridruži se",
|
||||||
"label.join-team": "Pridruži se ekipi",
|
"label.join-team": "Pridruži se ekipi",
|
||||||
"label.journey": "Journey",
|
"label.journey": "Uporabniška pot",
|
||||||
"label.journey-description": "Understand how users navigate through your website.",
|
"label.journey-description": "Razumite, kako uporabniki krmarijo po vašem spletnem mestu.",
|
||||||
"label.language": "Jezik",
|
"label.language": "Jezik",
|
||||||
"label.languages": "Jeziki",
|
"label.languages": "Jeziki",
|
||||||
"label.laptop": "Prenosni računalnik",
|
"label.laptop": "Prenosni računalnik",
|
||||||
"label.last-days": "Zadnjih {x} dni",
|
"label.last-days": "Zadnjih {x} dni",
|
||||||
"label.last-hours": "Zadnjih {x} ur",
|
"label.last-hours": "Zadnjih {x} ur",
|
||||||
"label.last-months": "Last {x} months",
|
"label.last-months": "Zadnjih {x} mesecev",
|
||||||
"label.last-seen": "Last seen",
|
"label.last-seen": "Nazadnje viden",
|
||||||
"label.leave": "Zapusti",
|
"label.leave": "Zapusti",
|
||||||
"label.leave-team": "Zapusti ekipo",
|
"label.leave-team": "Zapusti ekipo",
|
||||||
"label.less-than": "Manjše kot",
|
"label.less-than": "Manjše kot",
|
||||||
"label.less-than-equals": "Manjše ali enako kot",
|
"label.less-than-equals": "Manjše ali enako kot",
|
||||||
"label.login": "Prijava",
|
"label.login": "Prijava",
|
||||||
"label.logout": "Odjava",
|
"label.logout": "Odjava",
|
||||||
"label.manage": "Manage",
|
"label.manage": "Upravljaj",
|
||||||
"label.manager": "Manager",
|
"label.manager": "Upravitelj",
|
||||||
"label.max": "Največ",
|
"label.max": "Največ",
|
||||||
"label.member": "Member",
|
"label.member": "Član",
|
||||||
"label.members": "Člani",
|
"label.members": "Člani",
|
||||||
"label.min": "Najmanj",
|
"label.min": "Najmanj",
|
||||||
"label.mobile": "Mobilne naprave",
|
"label.mobile": "Mobilne naprave",
|
||||||
"label.more": "Več",
|
"label.more": "Več",
|
||||||
"label.my-account": "My account",
|
"label.my-account": "Moj račun",
|
||||||
"label.my-websites": "Moja spletna mesta",
|
"label.my-websites": "Moja spletna mesta",
|
||||||
"label.name": "Ime",
|
"label.name": "Ime",
|
||||||
"label.new-password": "Novo geslo",
|
"label.new-password": "Novo geslo",
|
||||||
|
|
@ -134,15 +134,15 @@
|
||||||
"label.pageTitle": "Naslov strani",
|
"label.pageTitle": "Naslov strani",
|
||||||
"label.pages": "Strani",
|
"label.pages": "Strani",
|
||||||
"label.password": "Geslo",
|
"label.password": "Geslo",
|
||||||
"label.path": "Path",
|
"label.path": "Pot",
|
||||||
"label.paths": "Paths",
|
"label.paths": "Poti",
|
||||||
"label.powered-by": "Poganja {name}",
|
"label.powered-by": "Poganja {name}",
|
||||||
"label.previous": "Previous",
|
"label.previous": "Prejšnji",
|
||||||
"label.previous-period": "Previous period",
|
"label.previous-period": "Prejšnje obdobje",
|
||||||
"label.previous-year": "Previous year",
|
"label.previous-year": "Prejšnje leto",
|
||||||
"label.profile": "Profil",
|
"label.profile": "Profil",
|
||||||
"label.properties": "Properties",
|
"label.properties": "Lastnosti",
|
||||||
"label.property": "Property",
|
"label.property": "Lastnost",
|
||||||
"label.queries": "Poizvedbe",
|
"label.queries": "Poizvedbe",
|
||||||
"label.query": "Poizvedba",
|
"label.query": "Poizvedba",
|
||||||
"label.query-parameters": "Parametri poizvedbe",
|
"label.query-parameters": "Parametri poizvedbe",
|
||||||
|
|
@ -154,41 +154,41 @@
|
||||||
"label.region": "Regija",
|
"label.region": "Regija",
|
||||||
"label.regions": "Regije",
|
"label.regions": "Regije",
|
||||||
"label.remove": "Odstrani",
|
"label.remove": "Odstrani",
|
||||||
"label.remove-member": "Remove member",
|
"label.remove-member": "Odstrani člana",
|
||||||
"label.reports": "Poročila",
|
"label.reports": "Poročila",
|
||||||
"label.required": "Zahtevano",
|
"label.required": "Zahtevano",
|
||||||
"label.reset": "Ponastavi",
|
"label.reset": "Ponastavi",
|
||||||
"label.reset-website": "Ponastavi statistiko",
|
"label.reset-website": "Ponastavi statistiko",
|
||||||
"label.retention": "Ohranjanje uporabnikov",
|
"label.retention": "Ohranjanje uporabnikov",
|
||||||
"label.retention-description": "Measure your website stickiness by tracking how often users return.",
|
"label.retention-description": "Merite uporabnikovo zadržanost s sledenjem, kako pogosto se vračajo.",
|
||||||
"label.revenue": "Revenue",
|
"label.revenue": "Prihodki",
|
||||||
"label.revenue-description": "Look into your revenue across time.",
|
"label.revenue-description": "Preglejte svoje prihodke skozi čas.",
|
||||||
"label.revenue-property": "Revenue Property",
|
"label.revenue-property": "Lastnost prihodkov",
|
||||||
"label.role": "Vloga",
|
"label.role": "Vloga",
|
||||||
"label.run-query": "Izvedi poizvedbo",
|
"label.run-query": "Izvedi poizvedbo",
|
||||||
"label.save": "Shrani",
|
"label.save": "Shrani",
|
||||||
"label.screens": "Zasloni",
|
"label.screens": "Zasloni",
|
||||||
"label.search": "Search",
|
"label.search": "Išči",
|
||||||
"label.select": "Select",
|
"label.select": "Izberi",
|
||||||
"label.select-date": "Izberi datum",
|
"label.select-date": "Izberi datum",
|
||||||
"label.select-role": "Select role",
|
"label.select-role": "Izberi vlogo",
|
||||||
"label.select-website": "Izberi spletno mesto",
|
"label.select-website": "Izberi spletno mesto",
|
||||||
"label.session": "Session",
|
"label.session": "Seja",
|
||||||
"label.sessions": "Seje",
|
"label.sessions": "Seje",
|
||||||
"label.settings": "Nastavitve",
|
"label.settings": "Nastavitve",
|
||||||
"label.share-url": "Deli povezavo",
|
"label.share-url": "Deli povezavo",
|
||||||
"label.single-day": "En dan",
|
"label.single-day": "En dan",
|
||||||
"label.start-step": "Start Step",
|
"label.start-step": "Začetni korak",
|
||||||
"label.steps": "Steps",
|
"label.steps": "Koraki",
|
||||||
"label.sum": "Seštevek",
|
"label.sum": "Seštevek",
|
||||||
"label.tablet": "Tablični računalnik",
|
"label.tablet": "Tablični računalnik",
|
||||||
"label.team": "Ekipa",
|
"label.team": "Ekipa",
|
||||||
"label.team-id": "ID ekipe",
|
"label.team-id": "ID ekipe",
|
||||||
"label.team-manager": "Team manager",
|
"label.team-manager": "Upravitelj ekipe",
|
||||||
"label.team-member": "Član ekipe",
|
"label.team-member": "Član ekipe",
|
||||||
"label.team-name": "Ime ekipe",
|
"label.team-name": "Ime ekipe",
|
||||||
"label.team-owner": "Lastnik ekipe",
|
"label.team-owner": "Lastnik ekipe",
|
||||||
"label.team-view-only": "Team view only",
|
"label.team-view-only": "Ekipa samo za ogled",
|
||||||
"label.team-websites": "Spletna mesta ekipe",
|
"label.team-websites": "Spletna mesta ekipe",
|
||||||
"label.teams": "Ekipe",
|
"label.teams": "Ekipe",
|
||||||
"label.theme": "Tema",
|
"label.theme": "Tema",
|
||||||
|
|
@ -232,17 +232,17 @@
|
||||||
"label.visits": "Visits",
|
"label.visits": "Visits",
|
||||||
"label.website": "Spletno mesto",
|
"label.website": "Spletno mesto",
|
||||||
"label.website-id": "ID spletnega mesta",
|
"label.website-id": "ID spletnega mesta",
|
||||||
"label.websites": "Spletnih mest",
|
"label.websites": "Spletna mesta",
|
||||||
"label.window": "Okno",
|
"label.window": "Okno",
|
||||||
"label.yesterday": "Včeraj",
|
"label.yesterday": "Včeraj",
|
||||||
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
|
"message.action-confirmation": "Za potrditev v spodnje polje vnesite {confirmation}.",
|
||||||
"message.active-users": "{x} trenutni {x, plural, one {obiskovalec} other {obiskovalcev}}",
|
"message.active-users": "{x} trenutni {x, plural, one {obiskovalec} other {obiskovalcev}}",
|
||||||
"message.collected-data": "Collected data",
|
"message.collected-data": "Zbrani podatki",
|
||||||
"message.confirm-delete": "Ste prepričani, da želite izbrisati {target}?",
|
"message.confirm-delete": "Ste prepričani, da želite izbrisati {target}?",
|
||||||
"message.confirm-leave": "Ste prepričani, da želite zapustiti {target}?",
|
"message.confirm-leave": "Ste prepričani, da želite zapustiti {target}?",
|
||||||
"message.confirm-remove": "Are you sure you want to remove {target}?",
|
"message.confirm-remove": "Ali ste prepričani, da želite odstraniti {target}?",
|
||||||
"message.confirm-reset": "Ste prepričani, da želite ponastaviti statistiko {target}?",
|
"message.confirm-reset": "Ste prepričani, da želite ponastaviti statistiko {target}?",
|
||||||
"message.delete-team-warning": "Deleting a team will also delete all team websites.",
|
"message.delete-team-warning": "Brisanje ekipe bo izbrisalo tudi vsa spletna mesta ekipe.",
|
||||||
"message.delete-website-warning": "Izbrisani bodo tudi vsi pripadajoči podatki.",
|
"message.delete-website-warning": "Izbrisani bodo tudi vsi pripadajoči podatki.",
|
||||||
"message.error": "Nekaj je šlo narobe.",
|
"message.error": "Nekaj je šlo narobe.",
|
||||||
"message.event-log": "{event} na {url}",
|
"message.event-log": "{event} na {url}",
|
||||||
|
|
@ -268,12 +268,12 @@
|
||||||
"message.team-not-found": "Ekipa ni bila najdena.",
|
"message.team-not-found": "Ekipa ni bila najdena.",
|
||||||
"message.team-websites-info": "Spletne strani si lahko ogleda vsak član ekipe.",
|
"message.team-websites-info": "Spletne strani si lahko ogleda vsak član ekipe.",
|
||||||
"message.tracking-code": "Koda za sledenje",
|
"message.tracking-code": "Koda za sledenje",
|
||||||
"message.transfer-team-website-to-user": "Transfer this website to your account?",
|
"message.transfer-team-website-to-user": "Želite prenesti to spletno mesto v svoj račun?",
|
||||||
"message.transfer-user-website-to-team": "Select the team to transfer this website to.",
|
"message.transfer-user-website-to-team": "Izberite ekipo, na katero želite prenesti to spletno mesto.",
|
||||||
"message.transfer-website": "Transfer website ownership to your account or another team.",
|
"message.transfer-website": "Prenesite lastništvo spletnega mesta na svoj račun ali drugo ekipo.",
|
||||||
"message.triggered-event": "Triggered event",
|
"message.triggered-event": "Sprožen dogodek",
|
||||||
"message.user-deleted": "Uporabnik je izbrisan.",
|
"message.user-deleted": "Uporabnik je izbrisan.",
|
||||||
"message.viewed-page": "Viewed page",
|
"message.viewed-page": "Ogledana stran",
|
||||||
"message.visitor-log": "Obiskovalec iz {country} uporablja {browser} na {os} {device}",
|
"message.visitor-log": "Obiskovalec iz {country} uporablja {browser} na {os} {device}",
|
||||||
"message.visitors-dropped-off": "Visitors dropped off"
|
"message.visitors-dropped-off": "Osip obiskovalcev"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,279 +1,279 @@
|
||||||
{
|
{
|
||||||
"label.access-code": "Access code",
|
"label.access-code": "Mã truy cập",
|
||||||
"label.actions": "Hành động",
|
"label.actions": "Hành động",
|
||||||
"label.activity": "Activity log",
|
"label.activity": "Nhật ký hoạt động",
|
||||||
"label.add": "Add",
|
"label.add": "Thêm",
|
||||||
"label.add-description": "Add description",
|
"label.add-description": "Thêm mô tả",
|
||||||
"label.add-member": "Add member",
|
"label.add-member": "Thêm thành viên",
|
||||||
"label.add-step": "Add step",
|
"label.add-step": "Thêm bước",
|
||||||
"label.add-website": "Thêm website",
|
"label.add-website": "Thêm website",
|
||||||
"label.admin": "Quản trị",
|
"label.admin": "Quản trị",
|
||||||
"label.after": "After",
|
"label.after": "Sau đó",
|
||||||
"label.all": "Tất cả",
|
"label.all": "Tất cả",
|
||||||
"label.all-time": "Toàn thời gian",
|
"label.all-time": "Toàn thời gian",
|
||||||
"label.analytics": "Analytics",
|
"label.analytics": "Phân tích",
|
||||||
"label.average": "Average",
|
"label.average": "Trung bình",
|
||||||
"label.back": "Quay về",
|
"label.back": "Quay lại",
|
||||||
"label.before": "Before",
|
"label.before": "Trước đó",
|
||||||
"label.bounce-rate": "Tỷ lệ thoát trang",
|
"label.bounce-rate": "Tỷ lệ thoát trang",
|
||||||
"label.breakdown": "Breakdown",
|
"label.breakdown": "Phân tích chi tiết",
|
||||||
"label.browser": "Browser",
|
"label.browser": "Trình duyệt",
|
||||||
"label.browsers": "Trình duyệt",
|
"label.browsers": "Các trình duyệt",
|
||||||
"label.cancel": "Huỷ bỏ",
|
"label.cancel": "Hủy bỏ",
|
||||||
"label.change-password": "Đổi mật khẩu",
|
"label.change-password": "Đổi mật khẩu",
|
||||||
"label.cities": "Cities",
|
"label.cities": "Các thành phố",
|
||||||
"label.city": "City",
|
"label.city": "Thành phố",
|
||||||
"label.clear-all": "Clear all",
|
"label.clear-all": "Xóa tất cả",
|
||||||
"label.compare": "Compare",
|
"label.compare": "So sánh",
|
||||||
"label.confirm": "Confirm",
|
"label.confirm": "Xác nhận",
|
||||||
"label.confirm-password": "Xác nhận mật khẩu",
|
"label.confirm-password": "Xác nhận mật khẩu",
|
||||||
"label.contains": "Contains",
|
"label.contains": "Chứa",
|
||||||
"label.continue": "Continue",
|
"label.continue": "Tiếp tục",
|
||||||
"label.count": "Count",
|
"label.count": "Số lượng",
|
||||||
"label.countries": "Quốc gia",
|
"label.countries": "Các quốc gia",
|
||||||
"label.country": "Country",
|
"label.country": "Quốc gia",
|
||||||
"label.create": "Create",
|
"label.create": "Tạo",
|
||||||
"label.create-report": "Create report",
|
"label.create-report": "Tạo báo cáo",
|
||||||
"label.create-team": "Create team",
|
"label.create-team": "Tạo nhóm",
|
||||||
"label.create-user": "Create user",
|
"label.create-user": "Tạo người dùng",
|
||||||
"label.created": "Created",
|
"label.created": "Đã tạo",
|
||||||
"label.created-by": "Created By",
|
"label.created-by": "Được tạo bởi",
|
||||||
"label.current": "Current",
|
"label.current": "Hiện tại",
|
||||||
"label.current-password": "Mật khẩu hiện tại",
|
"label.current-password": "Mật khẩu hiện tại",
|
||||||
"label.custom-range": "Phạm vi ngày tuỳ chọn",
|
"label.custom-range": "Phạm vi tùy chỉnh",
|
||||||
"label.dashboard": "Bảng điều khiển",
|
"label.dashboard": "Bảng điều khiển",
|
||||||
"label.data": "Data",
|
"label.data": "Dữ liệu",
|
||||||
"label.date": "Date",
|
"label.date": "Ngày",
|
||||||
"label.date-range": "Phạm vi ngày",
|
"label.date-range": "Phạm vi ngày",
|
||||||
"label.day": "Day",
|
"label.day": "Ngày",
|
||||||
"label.default-date-range": "Khoảng thời gian mặc định",
|
"label.default-date-range": "Khoảng thời gian mặc định",
|
||||||
"label.delete": "Xoá",
|
"label.delete": "Xóa",
|
||||||
"label.delete-report": "Delete report",
|
"label.delete-report": "Xóa báo cáo",
|
||||||
"label.delete-team": "Delete team",
|
"label.delete-team": "Xóa nhóm",
|
||||||
"label.delete-user": "Delete user",
|
"label.delete-user": "Xóa người dùng",
|
||||||
"label.delete-website": "Xóa website",
|
"label.delete-website": "Xóa website",
|
||||||
"label.description": "Description",
|
"label.description": "Mô tả",
|
||||||
"label.desktop": "Máy bàn",
|
"label.desktop": "Máy tính để bàn",
|
||||||
"label.details": "Details",
|
"label.details": "Chi tiết",
|
||||||
"label.device": "Device",
|
"label.device": "Thiết bị",
|
||||||
"label.devices": "Thiết bị",
|
"label.devices": "Các thiết bị",
|
||||||
"label.dismiss": "Loại trừ",
|
"label.dismiss": "Bỏ qua",
|
||||||
"label.does-not-contain": "Does not contain",
|
"label.does-not-contain": "Không chứa",
|
||||||
"label.domain": "Tên miền",
|
"label.domain": "Tên miền",
|
||||||
"label.dropoff": "Dropoff",
|
"label.dropoff": "Tỷ lệ bỏ qua",
|
||||||
"label.edit": "Chỉnh sửa",
|
"label.edit": "Chỉnh sửa",
|
||||||
"label.edit-dashboard": "Edit dashboard",
|
"label.edit-dashboard": "Chỉnh sửa bảng điều khiển",
|
||||||
"label.edit-member": "Edit member",
|
"label.edit-member": "Chỉnh sửa thành viên",
|
||||||
"label.enable-share-url": "Bật khả năng chia sẻ URL",
|
"label.enable-share-url": "Bật chia sẻ URL",
|
||||||
"label.end-step": "End Step",
|
"label.end-step": "Bước kết thúc",
|
||||||
"label.entry": "Entry URL",
|
"label.entry": "URL truy cập",
|
||||||
"label.event": "Event",
|
"label.event": "Sự kiện",
|
||||||
"label.event-data": "Event data",
|
"label.event-data": "Dữ liệu sự kiện",
|
||||||
"label.events": "Sự kiện",
|
"label.events": "Các sự kiện",
|
||||||
"label.exit": "Exit URL",
|
"label.exit": "URL thoát",
|
||||||
"label.false": "False",
|
"label.false": "Sai",
|
||||||
"label.field": "Field",
|
"label.field": "Trường",
|
||||||
"label.fields": "Fields",
|
"label.fields": "Các trường",
|
||||||
"label.filter": "Filter",
|
"label.filter": "Lọc",
|
||||||
"label.filter-combined": "Kết hợp",
|
"label.filter-combined": "Kết hợp lọc",
|
||||||
"label.filter-raw": "Gốc",
|
"label.filter-raw": "Lọc thô",
|
||||||
"label.filters": "Filters",
|
"label.filters": "Bộ lọc",
|
||||||
"label.first-seen": "First seen",
|
"label.first-seen": "Lần đầu tiên nhìn thấy",
|
||||||
"label.funnel": "Funnel",
|
"label.funnel": "Phễu",
|
||||||
"label.funnel-description": "Understand the conversion and drop-off rate of users.",
|
"label.funnel-description": "Tìm hiểu tỷ lệ chuyển đổi và bỏ qua của người dùng.",
|
||||||
"label.goal": "Goal",
|
"label.goal": "Mục tiêu",
|
||||||
"label.goals": "Goals",
|
"label.goals": "Các mục tiêu",
|
||||||
"label.goals-description": "Track your goals for pageviews and events.",
|
"label.goals-description": "Theo dõi các mục tiêu của bạn cho lượt xem trang và sự kiện.",
|
||||||
"label.greater-than": "Greater than",
|
"label.greater-than": "Lớn hơn",
|
||||||
"label.greater-than-equals": "Greater than or equals",
|
"label.greater-than-equals": "Lớn hơn hoặc bằng",
|
||||||
"label.host": "Host",
|
"label.host": "Máy chủ",
|
||||||
"label.hosts": "Hosts",
|
"label.hosts": "Các máy chủ",
|
||||||
"label.insights": "Insights",
|
"label.insights": "Thông tin chi tiết",
|
||||||
"label.insights-description": "Dive deeper into your data by using segments and filters.",
|
"label.insights-description": "Tìm hiểu sâu hơn về dữ liệu của bạn bằng cách sử dụng phân đoạn và bộ lọc.",
|
||||||
"label.is": "Is",
|
"label.is": "Là",
|
||||||
"label.is-not": "Is not",
|
"label.is-not": "Không phải là",
|
||||||
"label.is-not-set": "Is not set",
|
"label.is-not-set": "Chưa được đặt",
|
||||||
"label.is-set": "Is set",
|
"label.is-set": "Đã đặt",
|
||||||
"label.join": "Join",
|
"label.join": "Tham gia",
|
||||||
"label.join-team": "Join team",
|
"label.join-team": "Tham gia nhóm",
|
||||||
"label.journey": "Journey",
|
"label.journey": "Hành trình",
|
||||||
"label.journey-description": "Understand how users navigate through your website.",
|
"label.journey-description": "Hiểu cách người dùng điều hướng qua website của bạn.",
|
||||||
"label.language": "Language",
|
"label.language": "Ngôn ngữ",
|
||||||
"label.languages": "Ngôn ngữ",
|
"label.languages": "Các ngôn ngữ",
|
||||||
"label.laptop": "Laptop",
|
"label.laptop": "Máy tính xách tay",
|
||||||
"label.last-days": "{x} ngày gần nhất",
|
"label.last-days": "{x} ngày gần nhất",
|
||||||
"label.last-hours": "{x} giờ gần nhất",
|
"label.last-hours": "{x} giờ gần nhất",
|
||||||
"label.last-months": "Last {x} months",
|
"label.last-months": "{x} tháng gần nhất",
|
||||||
"label.last-seen": "Last seen",
|
"label.last-seen": "Lần cuối cùng nhìn thấy",
|
||||||
"label.leave": "Leave",
|
"label.leave": "Rời khỏi",
|
||||||
"label.leave-team": "Leave team",
|
"label.leave-team": "Rời nhóm",
|
||||||
"label.less-than": "Less than",
|
"label.less-than": "Nhỏ hơn",
|
||||||
"label.less-than-equals": "Less than or equals",
|
"label.less-than-equals": "Nhỏ hơn hoặc bằng",
|
||||||
"label.login": "Đăng nhập",
|
"label.login": "Đăng nhập",
|
||||||
"label.logout": "Đăng xuất",
|
"label.logout": "Đăng xuất",
|
||||||
"label.manage": "Manage",
|
"label.manage": "Quản lý",
|
||||||
"label.manager": "Manager",
|
"label.manager": "Quản lý",
|
||||||
"label.max": "Max",
|
"label.max": "Tối đa",
|
||||||
"label.member": "Member",
|
"label.member": "Thành viên",
|
||||||
"label.members": "Members",
|
"label.members": "Các thành viên",
|
||||||
"label.min": "Min",
|
"label.min": "Tối thiểu",
|
||||||
"label.mobile": "Di động",
|
"label.mobile": "Di động",
|
||||||
"label.more": "Thêm",
|
"label.more": "Thêm",
|
||||||
"label.my-account": "My account",
|
"label.my-account": "Tài khoản của tôi",
|
||||||
"label.my-websites": "My websites",
|
"label.my-websites": "Các website của tôi",
|
||||||
"label.name": "Tên",
|
"label.name": "Tên",
|
||||||
"label.new-password": "Mật khẩu mới",
|
"label.new-password": "Mật khẩu mới",
|
||||||
"label.none": "None",
|
"label.none": "Không",
|
||||||
"label.number-of-records": "{x} {x, plural, one {record} other {records}}",
|
"label.number-of-records": "{x} {x, plural, one {bản ghi} other {bản ghi}}",
|
||||||
"label.ok": "OK",
|
"label.ok": "OK",
|
||||||
"label.os": "OS",
|
"label.os": "Hệ điều hành",
|
||||||
"label.overview": "Overview",
|
"label.overview": "Tổng quan",
|
||||||
"label.owner": "Chủ sở hữu",
|
"label.owner": "Chủ sở hữu",
|
||||||
"label.page-of": "Page {current} of {total}",
|
"label.page-of": "Trang {current} trên {total}",
|
||||||
"label.page-views": "Lượt xem",
|
"label.page-views": "Lượt xem trang",
|
||||||
"label.pageTitle": "Page title",
|
"label.pageTitle": "Tiêu đề trang",
|
||||||
"label.pages": "Trang",
|
"label.pages": "Các trang",
|
||||||
"label.password": "Mật khẩu",
|
"label.password": "Mật khẩu",
|
||||||
"label.path": "Path",
|
"label.path": "Đường dẫn",
|
||||||
"label.paths": "Paths",
|
"label.paths": "Các đường dẫn",
|
||||||
"label.powered-by": "Bản quyền thuộc về {name}",
|
"label.powered-by": "Được cung cấp bởi {name}",
|
||||||
"label.previous": "Previous",
|
"label.previous": "Trước",
|
||||||
"label.previous-period": "Previous period",
|
"label.previous-period": "Kỳ trước",
|
||||||
"label.previous-year": "Previous year",
|
"label.previous-year": "Năm trước",
|
||||||
"label.profile": "Hồ sơ",
|
"label.profile": "Hồ sơ",
|
||||||
"label.properties": "Properties",
|
"label.properties": "Thuộc tính",
|
||||||
"label.property": "Property",
|
"label.property": "Thuộc tính",
|
||||||
"label.queries": "Queries",
|
"label.queries": "Truy vấn",
|
||||||
"label.query": "Query",
|
"label.query": "Truy vấn",
|
||||||
"label.query-parameters": "Query parameters",
|
"label.query-parameters": "Tham số truy vấn",
|
||||||
"label.realtime": "Thời gian thực",
|
"label.realtime": "Thời gian thực",
|
||||||
"label.referrer": "Referrer",
|
"label.referrer": "Nguồn giới thiệu",
|
||||||
"label.referrers": "Liên kết giới thiệu",
|
"label.referrers": "Các nguồn giới thiệu",
|
||||||
"label.refresh": "Làm mới",
|
"label.refresh": "Làm mới",
|
||||||
"label.regenerate": "Regenerate",
|
"label.regenerate": "Tạo lại",
|
||||||
"label.region": "Region",
|
"label.region": "Vùng",
|
||||||
"label.regions": "Regions",
|
"label.regions": "Các vùng",
|
||||||
"label.remove": "Remove",
|
"label.remove": "Xóa",
|
||||||
"label.remove-member": "Remove member",
|
"label.remove-member": "Xóa thành viên",
|
||||||
"label.reports": "Reports",
|
"label.reports": "Báo cáo",
|
||||||
"label.required": "Yêu cầu",
|
"label.required": "Yêu cầu",
|
||||||
"label.reset": "Tái thiết lập",
|
"label.reset": "Đặt lại",
|
||||||
"label.reset-website": "Tái thiết lập thống kê",
|
"label.reset-website": "Đặt lại thống kê website",
|
||||||
"label.retention": "Retention",
|
"label.retention": "Tỷ lệ giữ chân",
|
||||||
"label.retention-description": "Measure your website stickiness by tracking how often users return.",
|
"label.retention-description": "Đo lường mức độ gắn bó của website bằng cách theo dõi tần suất người dùng quay lại.",
|
||||||
"label.revenue": "Revenue",
|
"label.revenue": "Doanh thu",
|
||||||
"label.revenue-description": "Look into your revenue across time.",
|
"label.revenue-description": "Xem xét doanh thu của bạn theo thời gian.",
|
||||||
"label.revenue-property": "Revenue Property",
|
"label.revenue-property": "Thuộc tính doanh thu",
|
||||||
"label.role": "Role",
|
"label.role": "Vai trò",
|
||||||
"label.run-query": "Run query",
|
"label.run-query": "Chạy truy vấn",
|
||||||
"label.save": "Lưu",
|
"label.save": "Lưu",
|
||||||
"label.screens": "Screens",
|
"label.screens": "Màn hình",
|
||||||
"label.search": "Search",
|
"label.search": "Tìm kiếm",
|
||||||
"label.select": "Select",
|
"label.select": "Chọn",
|
||||||
"label.select-date": "Select date",
|
"label.select-date": "Chọn ngày",
|
||||||
"label.select-role": "Select role",
|
"label.select-role": "Chọn vai trò",
|
||||||
"label.select-website": "Select website",
|
"label.select-website": "Chọn website",
|
||||||
"label.session": "Session",
|
"label.session": "Phiên",
|
||||||
"label.sessions": "Sessions",
|
"label.sessions": "Các phiên",
|
||||||
"label.settings": "Cài đặt",
|
"label.settings": "Cài đặt",
|
||||||
"label.share-url": "Chia sẻ URL",
|
"label.share-url": "Chia sẻ URL",
|
||||||
"label.single-day": "Trong ngày",
|
"label.single-day": "Một ngày",
|
||||||
"label.start-step": "Start Step",
|
"label.start-step": "Bước bắt đầu",
|
||||||
"label.steps": "Steps",
|
"label.steps": "Các bước",
|
||||||
"label.sum": "Sum",
|
"label.sum": "Tổng",
|
||||||
"label.tablet": "Máy tính bảng",
|
"label.tablet": "Máy tính bảng",
|
||||||
"label.team": "Team",
|
"label.team": "Nhóm",
|
||||||
"label.team-id": "Team ID",
|
"label.team-id": "ID nhóm",
|
||||||
"label.team-manager": "Team manager",
|
"label.team-manager": "Quản lý nhóm",
|
||||||
"label.team-member": "Team member",
|
"label.team-member": "Thành viên nhóm",
|
||||||
"label.team-name": "Team name",
|
"label.team-name": "Tên nhóm",
|
||||||
"label.team-owner": "Team owner",
|
"label.team-owner": "Chủ sở hữu nhóm",
|
||||||
"label.team-view-only": "Team view only",
|
"label.team-view-only": "Chỉ xem nhóm",
|
||||||
"label.team-websites": "Team websites",
|
"label.team-websites": "Các website của nhóm",
|
||||||
"label.teams": "Teams",
|
"label.teams": "Các nhóm",
|
||||||
"label.theme": "Giao diện",
|
"label.theme": "Chủ đề",
|
||||||
"label.this-month": "Tháng này",
|
"label.this-month": "Tháng này",
|
||||||
"label.this-week": "Tuần này",
|
"label.this-week": "Tuần này",
|
||||||
"label.this-year": "Năm nay",
|
"label.this-year": "Năm nay",
|
||||||
"label.timezone": "Múi giờ",
|
"label.timezone": "Múi giờ",
|
||||||
"label.title": "Title",
|
"label.title": "Tiêu đề",
|
||||||
"label.today": "Hôm nay",
|
"label.today": "Hôm nay",
|
||||||
"label.toggle-charts": "Bật/tắt biểu đồ",
|
"label.toggle-charts": "Bật/tắt biểu đồ",
|
||||||
"label.total": "Total",
|
"label.total": "Tổng",
|
||||||
"label.total-records": "Total records",
|
"label.total-records": "Tổng số bản ghi",
|
||||||
"label.tracking-code": "Mã theo dõi",
|
"label.tracking-code": "Mã theo dõi",
|
||||||
"label.transactions": "Transactions",
|
"label.transactions": "Giao dịch",
|
||||||
"label.transfer": "Transfer",
|
"label.transfer": "Chuyển giao",
|
||||||
"label.transfer-website": "Transfer website",
|
"label.transfer-website": "Chuyển giao website",
|
||||||
"label.true": "True",
|
"label.true": "Đúng",
|
||||||
"label.type": "Type",
|
"label.type": "Loại",
|
||||||
"label.unique": "Unique",
|
"label.unique": "Duy nhất",
|
||||||
"label.unique-visitors": "Khách truy cập một lần",
|
"label.unique-visitors": "Khách truy cập duy nhất",
|
||||||
"label.uniqueCustomers": "Unique Customers",
|
"label.uniqueCustomers": "Khách hàng duy nhất",
|
||||||
"label.unknown": "Không rõ",
|
"label.unknown": "Không rõ",
|
||||||
"label.untitled": "Untitled",
|
"label.untitled": "Không có tiêu đề",
|
||||||
"label.update": "Update",
|
"label.update": "Cập nhật",
|
||||||
"label.url": "URL",
|
"label.url": "URL",
|
||||||
"label.urls": "URLs",
|
"label.urls": "Các URL",
|
||||||
"label.user": "User",
|
"label.user": "Người dùng",
|
||||||
"label.user-property": "User Property",
|
"label.user-property": "Thuộc tính người dùng",
|
||||||
"label.username": "Tên đăng nhập",
|
"label.username": "Tên đăng nhập",
|
||||||
"label.users": "Users",
|
"label.users": "Người dùng",
|
||||||
"label.utm": "UTM",
|
"label.utm": "UTM",
|
||||||
"label.utm-description": "Track your campaigns through UTM parameters.",
|
"label.utm-description": "Theo dõi các chiến dịch của bạn thông qua các tham số UTM.",
|
||||||
"label.value": "Value",
|
"label.value": "Giá trị",
|
||||||
"label.view": "View",
|
"label.view": "Xem",
|
||||||
"label.view-details": "Xem chi tiết",
|
"label.view-details": "Xem chi tiết",
|
||||||
"label.view-only": "View only",
|
"label.view-only": "Chỉ xem",
|
||||||
"label.views": "Xem",
|
"label.views": "Lượt xem",
|
||||||
"label.views-per-visit": "Views per visit",
|
"label.views-per-visit": "Lượt xem trên mỗi lượt truy cập",
|
||||||
"label.visit-duration": "Thời gian truy cập trung bình",
|
"label.visit-duration": "Thời lượng truy cập",
|
||||||
"label.visitors": "Khách",
|
"label.visitors": "Khách truy cập",
|
||||||
"label.visits": "Visits",
|
"label.visits": "Lượt truy cập",
|
||||||
"label.website": "Website",
|
"label.website": "Website",
|
||||||
"label.website-id": "Website ID",
|
"label.website-id": "ID website",
|
||||||
"label.websites": "Websites",
|
"label.websites": "Các website",
|
||||||
"label.window": "Window",
|
"label.window": "Cửa sổ",
|
||||||
"label.yesterday": "Yesterday",
|
"label.yesterday": "Hôm qua",
|
||||||
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
|
"message.action-confirmation": "Nhập {confirmation} vào ô bên dưới để xác nhận.",
|
||||||
"message.active-users": "{x} hiện tại {x, plural, one {một} other {trên}}",
|
"message.active-users": "{x} {x, plural, one {người dùng} other {người dùng}} đang hoạt động",
|
||||||
"message.collected-data": "Collected data",
|
"message.collected-data": "Dữ liệu đã thu thập",
|
||||||
"message.confirm-delete": "Bạn có chắc chắn muốn xoá {target}?",
|
"message.confirm-delete": "Bạn có chắc chắn muốn xóa {target}?",
|
||||||
"message.confirm-leave": "Are you sure you want to leave {target}?",
|
"message.confirm-leave": "Bạn có chắc chắn muốn rời {target}?",
|
||||||
"message.confirm-remove": "Are you sure you want to remove {target}?",
|
"message.confirm-remove": "Bạn có chắc chắn muốn xóa {target}?",
|
||||||
"message.confirm-reset": "Bạn có chắc chắn muốn tái thiết lập thống kê {target}?",
|
"message.confirm-reset": "Bạn có chắc chắn muốn đặt lại thống kê {target}?",
|
||||||
"message.delete-team-warning": "Deleting a team will also delete all team websites.",
|
"message.delete-team-warning": "Việc xóa một nhóm cũng sẽ xóa tất cả các website của nhóm.",
|
||||||
"message.delete-website-warning": "Tất cả các dữ liệu liên quan cũng sẽ bị xoá.",
|
"message.delete-website-warning": "Tất cả dữ liệu liên quan cũng sẽ bị xóa.",
|
||||||
"message.error": "Đã xảy ra lỗi.",
|
"message.error": "Đã xảy ra lỗi.",
|
||||||
"message.event-log": "{event} on {url}",
|
"message.event-log": "{event} trên {url}",
|
||||||
"message.go-to-settings": "Chuyển tới cài đặt",
|
"message.go-to-settings": "Chuyển đến cài đặt",
|
||||||
"message.incorrect-username-password": "Sai tên đăng nhập/mật khẩu.",
|
"message.incorrect-username-password": "Sai tên đăng nhập/mật khẩu.",
|
||||||
"message.invalid-domain": "Tên miền không hợp lệ",
|
"message.invalid-domain": "Tên miền không hợp lệ",
|
||||||
"message.min-password-length": "Minimum length of {n} characters",
|
"message.min-password-length": "Độ dài tối thiểu {n} ký tự",
|
||||||
"message.new-version-available": "A new version of Umami {version} is available!",
|
"message.new-version-available": "Có phiên bản mới của Umami {version}!",
|
||||||
"message.no-data-available": "Không có dữ liệu.",
|
"message.no-data-available": "Không có dữ liệu.",
|
||||||
"message.no-event-data": "No event data is available.",
|
"message.no-event-data": "Không có dữ liệu sự kiện.",
|
||||||
"message.no-match-password": "Mật khẩu không đồng nhất",
|
"message.no-match-password": "Mật khẩu không khớp",
|
||||||
"message.no-results-found": "No results were found.",
|
"message.no-results-found": "Không tìm thấy kết quả nào.",
|
||||||
"message.no-team-websites": "This team does not have any websites.",
|
"message.no-team-websites": "Nhóm này không có bất kỳ website nào.",
|
||||||
"message.no-teams": "You have not created any teams.",
|
"message.no-teams": "Bạn chưa tạo nhóm nào.",
|
||||||
"message.no-users": "There are no users.",
|
"message.no-users": "Không có người dùng nào.",
|
||||||
"message.no-websites-configured": "Bạn chưa có bất cứ website nào.",
|
"message.no-websites-configured": "Bạn chưa cấu hình bất kỳ website nào.",
|
||||||
"message.page-not-found": "Trang không tìm thấy.",
|
"message.page-not-found": "Không tìm thấy trang.",
|
||||||
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
|
"message.reset-website": "Để đặt lại website này, nhập {confirmation} vào ô bên dưới để xác nhận.",
|
||||||
"message.reset-website-warning": "Tất cả số liệu thống kê của website này sẽ bị xoá, nhưng mã theo dõi sẽ vẫn giữ nguyên.",
|
"message.reset-website-warning": "Tất cả số liệu thống kê của website này sẽ bị xóa, nhưng mã theo dõi sẽ vẫn giữ nguyên.",
|
||||||
"message.saved": "Đã lưu thành công.",
|
"message.saved": "Đã lưu thành công.",
|
||||||
"message.share-url": "Đây là đường dẫn URL cho {target}.",
|
"message.share-url": "Đây là đường dẫn URL cho {target}.",
|
||||||
"message.team-already-member": "You are already a member of the team.",
|
"message.team-already-member": "Bạn đã là thành viên của nhóm.",
|
||||||
"message.team-not-found": "Team not found.",
|
"message.team-not-found": "Không tìm thấy nhóm.",
|
||||||
"message.team-websites-info": "Websites can be viewed by anyone on the team.",
|
"message.team-websites-info": "Bất kỳ ai trong nhóm đều có thể xem các website.",
|
||||||
"message.tracking-code": "Mã theo dõi",
|
"message.tracking-code": "Mã theo dõi",
|
||||||
"message.transfer-team-website-to-user": "Transfer this website to your account?",
|
"message.transfer-team-website-to-user": "Chuyển website này sang tài khoản của bạn?",
|
||||||
"message.transfer-user-website-to-team": "Select the team to transfer this website to.",
|
"message.transfer-user-website-to-team": "Chọn nhóm để chuyển website này đến.",
|
||||||
"message.transfer-website": "Transfer website ownership to your account or another team.",
|
"message.transfer-website": "Chuyển quyền sở hữu website sang tài khoản của bạn hoặc một nhóm khác.",
|
||||||
"message.triggered-event": "Triggered event",
|
"message.triggered-event": "Sự kiện được kích hoạt",
|
||||||
"message.user-deleted": "User deleted.",
|
"message.user-deleted": "Người dùng đã bị xóa.",
|
||||||
"message.viewed-page": "Viewed page",
|
"message.viewed-page": "Đã xem trang",
|
||||||
"message.visitor-log": "Khách từ {country} đang dùng {browser} trên {os} {device}",
|
"message.visitor-log": "Khách từ {country} đang sử dụng {browser} trên {os} {device}",
|
||||||
"message.visitors-dropped-off": "Visitors dropped off"
|
"message.visitors-dropped-off": "Khách truy cập đã rời đi"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import * as detect from '../detect';
|
||||||
import { expect } from '@jest/globals';
|
import { expect } from '@jest/globals';
|
||||||
|
|
||||||
const IP = '127.0.0.1';
|
const IP = '127.0.0.1';
|
||||||
|
const BAD_IP = '127.127.127.127';
|
||||||
|
|
||||||
test('getIpAddress: Custom header', () => {
|
test('getIpAddress: Custom header', () => {
|
||||||
process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
|
process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
|
||||||
|
|
@ -17,6 +18,12 @@ test('getIpAddress: Standard header', () => {
|
||||||
expect(detect.getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP);
|
expect(detect.getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getIpAddress: CloudFlare header is lower priority than standard header', () => {
|
||||||
|
expect(
|
||||||
|
detect.getIpAddress(new Headers({ 'cf-connecting-ip': BAD_IP, 'x-forwarded-for': IP })),
|
||||||
|
).toEqual(IP);
|
||||||
|
});
|
||||||
|
|
||||||
test('getIpAddress: No header', () => {
|
test('getIpAddress: No header', () => {
|
||||||
expect(detect.getIpAddress(new Headers())).toEqual(null);
|
expect(detect.getIpAddress(new Headers())).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,21 @@ function mapFilter(column: string, operator: string, name: string, type: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapCohortFilter(column: string, operator: string, value: string) {
|
||||||
|
switch (operator) {
|
||||||
|
case OPERATORS.equals:
|
||||||
|
return `${column} = '${value}'`;
|
||||||
|
case OPERATORS.notEquals:
|
||||||
|
return `${column} != '${value}'`;
|
||||||
|
case OPERATORS.contains:
|
||||||
|
return `positionCaseInsensitive(${column}, '${value}') > 0`;
|
||||||
|
case OPERATORS.doesNotContain:
|
||||||
|
return `positionCaseInsensitive(${column}, '${value}') = 0`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) {
|
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) {
|
||||||
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
|
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
|
||||||
if (column) {
|
if (column) {
|
||||||
|
|
@ -105,6 +120,42 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {})
|
||||||
return query.join('\n');
|
return query.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) {
|
||||||
|
const query = filtersToArray(filters, options).reduce(
|
||||||
|
(arr, { name, column, operator, value }) => {
|
||||||
|
if (column) {
|
||||||
|
arr.push(
|
||||||
|
`${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (name === 'referrer') {
|
||||||
|
arr.push(`and referrer_domain != hostname`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (query.length > 0) {
|
||||||
|
// add website and date range filters
|
||||||
|
query.push(`and website_id = '${websiteId}'`);
|
||||||
|
query.push(
|
||||||
|
`and created_at between parseDateTimeBestEffort('${filters.startDate}') and parseDateTimeBestEffort('${filters.endDate}')`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `join
|
||||||
|
(select distinct session_id
|
||||||
|
from website_event
|
||||||
|
${query.join('\n')}) cohort
|
||||||
|
on cohort.session_id = website_event.session_id
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function getDateQuery(filters: QueryFilters = {}) {
|
function getDateQuery(filters: QueryFilters = {}) {
|
||||||
const { startDate, endDate, timezone } = filters;
|
const { startDate, endDate, timezone } = filters;
|
||||||
|
|
||||||
|
|
@ -146,6 +197,7 @@ async function parseFilters(websiteId: string, filters: QueryFilters = {}, optio
|
||||||
websiteId,
|
websiteId,
|
||||||
startDate: maxDate(filters.startDate, new Date(website?.resetAt)),
|
startDate: maxDate(filters.startDate, new Date(website?.resetAt)),
|
||||||
},
|
},
|
||||||
|
cohortQuery: getCohortQuery(websiteId, filters?.cohort),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const UPDATES_URL = 'https://api.umami.is/v1/updates';
|
||||||
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
|
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
|
||||||
export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
|
export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
|
||||||
|
|
||||||
export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US';
|
export const DEFAULT_LOCALE = 'en-US';
|
||||||
export const DEFAULT_THEME = 'light';
|
export const DEFAULT_THEME = 'light';
|
||||||
export const DEFAULT_ANIMATION_DURATION = 300;
|
export const DEFAULT_ANIMATION_DURATION = 300;
|
||||||
export const DEFAULT_DATE_RANGE = '24hour';
|
export const DEFAULT_DATE_RANGE = '24hour';
|
||||||
|
|
@ -33,7 +33,17 @@ export const FILTER_REFERRERS = 'filter-referrers';
|
||||||
export const FILTER_PAGES = 'filter-pages';
|
export const FILTER_PAGES = 'filter-pages';
|
||||||
|
|
||||||
export const UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute'];
|
export const UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute'];
|
||||||
export const EVENT_COLUMNS = ['url', 'entry', 'exit', 'referrer', 'title', 'query', 'event', 'tag'];
|
export const EVENT_COLUMNS = [
|
||||||
|
'url',
|
||||||
|
'entry',
|
||||||
|
'exit',
|
||||||
|
'referrer',
|
||||||
|
'title',
|
||||||
|
'query',
|
||||||
|
'event',
|
||||||
|
'tag',
|
||||||
|
'host',
|
||||||
|
];
|
||||||
|
|
||||||
export const SESSION_COLUMNS = [
|
export const SESSION_COLUMNS = [
|
||||||
'browser',
|
'browser',
|
||||||
|
|
@ -44,9 +54,13 @@ export const SESSION_COLUMNS = [
|
||||||
'country',
|
'country',
|
||||||
'city',
|
'city',
|
||||||
'region',
|
'region',
|
||||||
'host',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const FILTER_GROUPS = {
|
||||||
|
segment: 'segment',
|
||||||
|
cohort: 'cohort',
|
||||||
|
};
|
||||||
|
|
||||||
export const FILTER_COLUMNS = {
|
export const FILTER_COLUMNS = {
|
||||||
url: 'url_path',
|
url: 'url_path',
|
||||||
entry: 'url_path',
|
entry: 'url_path',
|
||||||
|
|
@ -140,12 +154,12 @@ export const KAFKA_TOPIC = {
|
||||||
|
|
||||||
export const ROLES = {
|
export const ROLES = {
|
||||||
admin: 'admin',
|
admin: 'admin',
|
||||||
user: 'user',
|
|
||||||
viewOnly: 'view-only',
|
|
||||||
teamOwner: 'team-owner',
|
|
||||||
teamManager: 'team-manager',
|
teamManager: 'team-manager',
|
||||||
teamMember: 'team-member',
|
teamMember: 'team-member',
|
||||||
|
teamOwner: 'team-owner',
|
||||||
teamViewOnly: 'team-view-only',
|
teamViewOnly: 'team-view-only',
|
||||||
|
user: 'user',
|
||||||
|
viewOnly: 'view-only',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
|
|
@ -253,7 +267,7 @@ export const URL_LENGTH = 500;
|
||||||
export const PAGE_TITLE_LENGTH = 500;
|
export const PAGE_TITLE_LENGTH = 500;
|
||||||
export const EVENT_NAME_LENGTH = 50;
|
export const EVENT_NAME_LENGTH = 50;
|
||||||
|
|
||||||
export const UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
export const UTM_PARAMS = ['utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term'];
|
||||||
|
|
||||||
export const DESKTOP_OS = [
|
export const DESKTOP_OS = [
|
||||||
'BeOS',
|
'BeOS',
|
||||||
|
|
@ -291,8 +305,8 @@ export const OS_NAMES = {
|
||||||
export const BROWSERS = {
|
export const BROWSERS = {
|
||||||
android: 'Android',
|
android: 'Android',
|
||||||
aol: 'AOL',
|
aol: 'AOL',
|
||||||
beaker: 'Beaker',
|
|
||||||
bb10: 'BlackBerry 10',
|
bb10: 'BlackBerry 10',
|
||||||
|
beaker: 'Beaker',
|
||||||
chrome: 'Chrome',
|
chrome: 'Chrome',
|
||||||
'chromium-webview': 'Chrome (webview)',
|
'chromium-webview': 'Chrome (webview)',
|
||||||
crios: 'Chrome (iOS)',
|
crios: 'Chrome (iOS)',
|
||||||
|
|
@ -314,15 +328,17 @@ export const BROWSERS = {
|
||||||
phantomjs: 'PhantomJS',
|
phantomjs: 'PhantomJS',
|
||||||
safari: 'Safari',
|
safari: 'Safari',
|
||||||
samsung: 'Samsung',
|
samsung: 'Samsung',
|
||||||
silk: 'Silk',
|
|
||||||
searchbot: 'Searchbot',
|
searchbot: 'Searchbot',
|
||||||
|
silk: 'Silk',
|
||||||
yandexbrowser: 'Yandex',
|
yandexbrowser: 'Yandex',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The order here is important and influences how IPs are detected by lib/detect.ts
|
||||||
|
// Please do not change the order unless you know exactly what you're doing - read https://developers.cloudflare.com/fundamentals/reference/http-headers/
|
||||||
export const IP_ADDRESS_HEADERS = [
|
export const IP_ADDRESS_HEADERS = [
|
||||||
'cf-connecting-ip',
|
|
||||||
'x-client-ip',
|
'x-client-ip',
|
||||||
'x-forwarded-for',
|
'x-forwarded-for',
|
||||||
|
'cf-connecting-ip', // This should be *after* x-forwarded-for, so that x-forwarded-for is respected if present
|
||||||
'do-connecting-ip',
|
'do-connecting-ip',
|
||||||
'fastly-client-ip',
|
'fastly-client-ip',
|
||||||
'true-client-ip',
|
'true-client-ip',
|
||||||
|
|
@ -331,349 +347,348 @@ export const IP_ADDRESS_HEADERS = [
|
||||||
'x-forwarded',
|
'x-forwarded',
|
||||||
'forwarded',
|
'forwarded',
|
||||||
'x-appengine-user-ip',
|
'x-appengine-user-ip',
|
||||||
|
'x-nf-client-connection-ip',
|
||||||
|
'x-real-ip',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SOCIAL_DOMAINS = [
|
export const SOCIAL_DOMAINS = [
|
||||||
|
'bsky.app',
|
||||||
'facebook.com',
|
'facebook.com',
|
||||||
'fb.com',
|
'fb.com',
|
||||||
'instagram.com',
|
|
||||||
'ig.com',
|
'ig.com',
|
||||||
'twitter.com',
|
'instagram.com',
|
||||||
't.co',
|
|
||||||
'x.com',
|
|
||||||
'linkedin.',
|
'linkedin.',
|
||||||
'tiktok.',
|
|
||||||
'reddit.',
|
|
||||||
'threads.net',
|
|
||||||
'bsky.app',
|
|
||||||
'news.ycombinator.com',
|
'news.ycombinator.com',
|
||||||
'snapchat.',
|
|
||||||
'pinterest.',
|
'pinterest.',
|
||||||
|
'reddit.',
|
||||||
|
'snapchat.',
|
||||||
|
't.co',
|
||||||
|
'threads.net',
|
||||||
|
'tiktok.',
|
||||||
|
'twitter.com',
|
||||||
|
'x.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SEARCH_DOMAINS = [
|
export const SEARCH_DOMAINS = [
|
||||||
'google.',
|
'baidu.com',
|
||||||
'bing.com',
|
'bing.com',
|
||||||
'msn.com',
|
'chatgpt.com',
|
||||||
'duckduckgo.com',
|
'duckduckgo.com',
|
||||||
|
'ecosia.org',
|
||||||
|
'google.',
|
||||||
|
'msn.com',
|
||||||
|
'perplexity.ai',
|
||||||
'search.brave.com',
|
'search.brave.com',
|
||||||
'yandex.',
|
'yandex.',
|
||||||
'baidu.com',
|
|
||||||
'ecosia.org',
|
|
||||||
'chatgpt.com',
|
|
||||||
'perplexity.ai',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SHOPPING_DOMAINS = [
|
export const SHOPPING_DOMAINS = [
|
||||||
'amazon.',
|
'alibaba.com',
|
||||||
'ebay.com',
|
|
||||||
'walmart.com',
|
|
||||||
'alibab.com',
|
|
||||||
'aliexpress.com',
|
'aliexpress.com',
|
||||||
'etsy.com',
|
'amazon.',
|
||||||
'bestbuy.com',
|
'bestbuy.com',
|
||||||
'target.com',
|
'ebay.com',
|
||||||
|
'etsy.com',
|
||||||
'newegg.com',
|
'newegg.com',
|
||||||
|
'target.com',
|
||||||
|
'walmart.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const EMAIL_DOMAINS = [
|
export const EMAIL_DOMAINS = [
|
||||||
'gmail.',
|
'gmail.',
|
||||||
|
'hotmail.',
|
||||||
'mail.yahoo.',
|
'mail.yahoo.',
|
||||||
'outlook.',
|
'outlook.',
|
||||||
'hotmail.',
|
|
||||||
'protonmail.',
|
|
||||||
'proton.me',
|
'proton.me',
|
||||||
|
'protonmail.',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const VIDEO_DOMAINS = ['youtube.', 'twitch.'];
|
export const VIDEO_DOMAINS = ['twitch.', 'youtube.'];
|
||||||
|
|
||||||
export const PAID_AD_PARAMS = [
|
export const PAID_AD_PARAMS = [
|
||||||
'utm_source=google',
|
|
||||||
'gclid=',
|
|
||||||
'fbclid=',
|
|
||||||
'msclkid=',
|
|
||||||
'dclid=',
|
|
||||||
'twclid=',
|
|
||||||
'li_fat_id=',
|
|
||||||
'epik=',
|
|
||||||
'ttclid=',
|
|
||||||
'scid=',
|
|
||||||
'aid=',
|
|
||||||
'pc_id=',
|
|
||||||
'ad_id=',
|
'ad_id=',
|
||||||
'rdt_cid=',
|
'aid=',
|
||||||
|
'dclid=',
|
||||||
|
'epik=',
|
||||||
|
'fbclid=',
|
||||||
|
'gclid=',
|
||||||
|
'li_fat_id=',
|
||||||
|
'msclkid=',
|
||||||
'ob_click_id=',
|
'ob_click_id=',
|
||||||
|
'pc_id=',
|
||||||
|
'rdt_cid=',
|
||||||
|
'scid=',
|
||||||
|
'ttclid=',
|
||||||
|
'twclid=',
|
||||||
'utm_medium=cpc',
|
'utm_medium=cpc',
|
||||||
'utm_medium=paid',
|
'utm_medium=paid',
|
||||||
'utm_medium=paid_social',
|
'utm_medium=paid_social',
|
||||||
|
'utm_source=google',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GROUPED_DOMAINS = [
|
export const GROUPED_DOMAINS = [
|
||||||
{ name: 'Google', domain: 'google.com', match: 'google.' },
|
|
||||||
{ name: 'Facebook', domain: 'facebook.com', match: 'facebook.' },
|
|
||||||
{ name: 'Reddit', domain: 'reddit.com', match: 'reddit.' },
|
|
||||||
{ name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' },
|
|
||||||
{ name: 'GitHub', domain: 'github.com', match: 'github.' },
|
|
||||||
{ name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' },
|
|
||||||
{ name: 'Bing', domain: 'bing.com', match: 'bing.' },
|
{ name: 'Bing', domain: 'bing.com', match: 'bing.' },
|
||||||
{ name: 'Brave', domain: 'brave.com', match: 'brave.' },
|
{ name: 'Brave', domain: 'brave.com', match: 'brave.' },
|
||||||
{ name: 'DuckDuckGo', domain: 'duckduckgo.com', match: 'duckduckgo.' },
|
|
||||||
{ name: 'Twitter', domain: 'twitter.com', match: ['twitter.', 't.co', 'x.com'] },
|
|
||||||
{ name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] },
|
|
||||||
{ name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' },
|
|
||||||
{ name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' },
|
|
||||||
{ name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' },
|
{ name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' },
|
||||||
|
{ name: 'DuckDuckGo', domain: 'duckduckgo.com', match: 'duckduckgo.' },
|
||||||
|
{ name: 'Facebook', domain: 'facebook.com', match: 'facebook.' },
|
||||||
|
{ name: 'GitHub', domain: 'github.com', match: 'github.' },
|
||||||
|
{ name: 'Google', domain: 'google.com', match: 'google.' },
|
||||||
|
{ name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' },
|
||||||
|
{ name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] },
|
||||||
|
{ name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' },
|
||||||
|
{ name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' },
|
||||||
|
{ name: 'Reddit', domain: 'reddit.com', match: 'reddit.' },
|
||||||
|
{ name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' },
|
||||||
|
{ name: 'Twitter', domain: 'twitter.com', match: ['twitter.', 't.co', 'x.com'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MAP_FILE = '/datamaps.world.json';
|
export const MAP_FILE = '/datamaps.world.json';
|
||||||
|
|
||||||
export const ISO_COUNTRIES = {
|
export const ISO_COUNTRIES = {
|
||||||
|
ABW: 'AW',
|
||||||
AFG: 'AF',
|
AFG: 'AF',
|
||||||
ALA: 'AX',
|
|
||||||
ALB: 'AL',
|
|
||||||
DZA: 'DZ',
|
|
||||||
ASM: 'AS',
|
|
||||||
AND: 'AD',
|
|
||||||
AGO: 'AO',
|
AGO: 'AO',
|
||||||
AIA: 'AI',
|
AIA: 'AI',
|
||||||
ATA: 'AQ',
|
ALA: 'AX',
|
||||||
ATG: 'AG',
|
ALB: 'AL',
|
||||||
|
AND: 'AD',
|
||||||
|
ANT: 'AN',
|
||||||
|
ARE: 'AE',
|
||||||
ARG: 'AR',
|
ARG: 'AR',
|
||||||
ARM: 'AM',
|
ARM: 'AM',
|
||||||
ABW: 'AW',
|
ASM: 'AS',
|
||||||
|
ATF: 'TF',
|
||||||
|
ATG: 'AG',
|
||||||
AUS: 'AU',
|
AUS: 'AU',
|
||||||
AUT: 'AT',
|
AUT: 'AT',
|
||||||
AZE: 'AZ',
|
AZE: 'AZ',
|
||||||
BHS: 'BS',
|
|
||||||
BHR: 'BH',
|
|
||||||
BGD: 'BD',
|
|
||||||
BRB: 'BB',
|
|
||||||
BLR: 'BY',
|
|
||||||
BEL: 'BE',
|
|
||||||
BLZ: 'BZ',
|
|
||||||
BEN: 'BJ',
|
|
||||||
BMU: 'BM',
|
|
||||||
BTN: 'BT',
|
|
||||||
BOL: 'BO',
|
|
||||||
BIH: 'BA',
|
|
||||||
BWA: 'BW',
|
|
||||||
BVT: 'BV',
|
|
||||||
BRA: 'BR',
|
|
||||||
VGB: 'VG',
|
|
||||||
IOT: 'IO',
|
|
||||||
BRN: 'BN',
|
|
||||||
BGR: 'BG',
|
|
||||||
BFA: 'BF',
|
|
||||||
BDI: 'BI',
|
BDI: 'BI',
|
||||||
KHM: 'KH',
|
BEL: 'BE',
|
||||||
CMR: 'CM',
|
BEN: 'BJ',
|
||||||
CAN: 'CA',
|
BFA: 'BF',
|
||||||
CPV: 'CV',
|
BGD: 'BD',
|
||||||
CYM: 'KY',
|
BGR: 'BG',
|
||||||
|
BHR: 'BH',
|
||||||
|
BHS: 'BS',
|
||||||
|
BIH: 'BA',
|
||||||
|
BLR: 'BY',
|
||||||
|
BLZ: 'BZ',
|
||||||
|
BLM: 'BL',
|
||||||
|
BMU: 'BM',
|
||||||
|
BOL: 'BO',
|
||||||
|
BRA: 'BR',
|
||||||
|
BRB: 'BB',
|
||||||
|
BRN: 'BN',
|
||||||
|
BTN: 'BT',
|
||||||
|
BVT: 'BV',
|
||||||
|
BWA: 'BW',
|
||||||
CAF: 'CF',
|
CAF: 'CF',
|
||||||
TCD: 'TD',
|
CAN: 'CA',
|
||||||
|
CCK: 'CC',
|
||||||
|
CHE: 'CH',
|
||||||
CHL: 'CL',
|
CHL: 'CL',
|
||||||
CHN: 'CN',
|
CHN: 'CN',
|
||||||
HKG: 'HK',
|
CIV: 'CI',
|
||||||
MAC: 'MO',
|
CMR: 'CM',
|
||||||
CXR: 'CX',
|
COD: 'CD',
|
||||||
CCK: 'CC',
|
COG: 'CG',
|
||||||
|
COK: 'CK',
|
||||||
COL: 'CO',
|
COL: 'CO',
|
||||||
COM: 'KM',
|
COM: 'KM',
|
||||||
COG: 'CG',
|
CPV: 'CV',
|
||||||
COD: 'CD',
|
|
||||||
COK: 'CK',
|
|
||||||
CRI: 'CR',
|
CRI: 'CR',
|
||||||
CIV: 'CI',
|
|
||||||
HRV: 'HR',
|
|
||||||
CUB: 'CU',
|
CUB: 'CU',
|
||||||
|
CXR: 'CX',
|
||||||
|
CYM: 'KY',
|
||||||
CYP: 'CY',
|
CYP: 'CY',
|
||||||
CZE: 'CZ',
|
CZE: 'CZ',
|
||||||
DNK: 'DK',
|
DEU: 'DE',
|
||||||
DJI: 'DJ',
|
DJI: 'DJ',
|
||||||
DMA: 'DM',
|
DMA: 'DM',
|
||||||
|
DNK: 'DK',
|
||||||
DOM: 'DO',
|
DOM: 'DO',
|
||||||
|
DZA: 'DZ',
|
||||||
ECU: 'EC',
|
ECU: 'EC',
|
||||||
EGY: 'EG',
|
EGY: 'EG',
|
||||||
SLV: 'SV',
|
|
||||||
GNQ: 'GQ',
|
|
||||||
ERI: 'ER',
|
ERI: 'ER',
|
||||||
|
ESH: 'EH',
|
||||||
|
ESP: 'ES',
|
||||||
EST: 'EE',
|
EST: 'EE',
|
||||||
ETH: 'ET',
|
ETH: 'ET',
|
||||||
FLK: 'FK',
|
|
||||||
FRO: 'FO',
|
|
||||||
FJI: 'FJ',
|
|
||||||
FIN: 'FI',
|
FIN: 'FI',
|
||||||
|
FJI: 'FJ',
|
||||||
|
FLK: 'FK',
|
||||||
FRA: 'FR',
|
FRA: 'FR',
|
||||||
GUF: 'GF',
|
FRO: 'FO',
|
||||||
PYF: 'PF',
|
FSM: 'FM',
|
||||||
ATF: 'TF',
|
|
||||||
GAB: 'GA',
|
GAB: 'GA',
|
||||||
GMB: 'GM',
|
GBR: 'GB',
|
||||||
GEO: 'GE',
|
GEO: 'GE',
|
||||||
DEU: 'DE',
|
GGY: 'GG',
|
||||||
GHA: 'GH',
|
GHA: 'GH',
|
||||||
GIB: 'GI',
|
GIB: 'GI',
|
||||||
GRC: 'GR',
|
|
||||||
GRL: 'GL',
|
|
||||||
GRD: 'GD',
|
|
||||||
GLP: 'GP',
|
|
||||||
GUM: 'GU',
|
|
||||||
GTM: 'GT',
|
|
||||||
GGY: 'GG',
|
|
||||||
GIN: 'GN',
|
GIN: 'GN',
|
||||||
|
GLP: 'GP',
|
||||||
|
GMB: 'GM',
|
||||||
GNB: 'GW',
|
GNB: 'GW',
|
||||||
|
GNQ: 'GQ',
|
||||||
|
GRC: 'GR',
|
||||||
|
GRD: 'GD',
|
||||||
|
GRL: 'GL',
|
||||||
|
GTM: 'GT',
|
||||||
|
GUF: 'GF',
|
||||||
|
GUM: 'GU',
|
||||||
GUY: 'GY',
|
GUY: 'GY',
|
||||||
HTI: 'HT',
|
HKG: 'HK',
|
||||||
HMD: 'HM',
|
HMD: 'HM',
|
||||||
VAT: 'VA',
|
|
||||||
HND: 'HN',
|
HND: 'HN',
|
||||||
|
HRV: 'HR',
|
||||||
|
HTI: 'HT',
|
||||||
HUN: 'HU',
|
HUN: 'HU',
|
||||||
ISL: 'IS',
|
|
||||||
IND: 'IN',
|
|
||||||
IDN: 'ID',
|
IDN: 'ID',
|
||||||
|
IMN: 'IM',
|
||||||
|
IND: 'IN',
|
||||||
|
IOT: 'IO',
|
||||||
|
IRL: 'IE',
|
||||||
IRN: 'IR',
|
IRN: 'IR',
|
||||||
IRQ: 'IQ',
|
IRQ: 'IQ',
|
||||||
IRL: 'IE',
|
ISL: 'IS',
|
||||||
IMN: 'IM',
|
|
||||||
ISR: 'IL',
|
ISR: 'IL',
|
||||||
ITA: 'IT',
|
ITA: 'IT',
|
||||||
JAM: 'JM',
|
JAM: 'JM',
|
||||||
JPN: 'JP',
|
|
||||||
JEY: 'JE',
|
JEY: 'JE',
|
||||||
JOR: 'JO',
|
JOR: 'JO',
|
||||||
|
JPN: 'JP',
|
||||||
KAZ: 'KZ',
|
KAZ: 'KZ',
|
||||||
KEN: 'KE',
|
KEN: 'KE',
|
||||||
|
KGZ: 'KG',
|
||||||
|
KHM: 'KH',
|
||||||
KIR: 'KI',
|
KIR: 'KI',
|
||||||
PRK: 'KP',
|
KNA: 'KN',
|
||||||
KOR: 'KR',
|
KOR: 'KR',
|
||||||
KWT: 'KW',
|
KWT: 'KW',
|
||||||
KGZ: 'KG',
|
|
||||||
LAO: 'LA',
|
LAO: 'LA',
|
||||||
LVA: 'LV',
|
|
||||||
LBN: 'LB',
|
LBN: 'LB',
|
||||||
LSO: 'LS',
|
|
||||||
LBR: 'LR',
|
LBR: 'LR',
|
||||||
LBY: 'LY',
|
LBY: 'LY',
|
||||||
|
LCA: 'LC',
|
||||||
LIE: 'LI',
|
LIE: 'LI',
|
||||||
|
LKA: 'LK',
|
||||||
|
LSO: 'LS',
|
||||||
LTU: 'LT',
|
LTU: 'LT',
|
||||||
LUX: 'LU',
|
LUX: 'LU',
|
||||||
MKD: 'MK',
|
LVA: 'LV',
|
||||||
|
MAF: 'MF',
|
||||||
|
MAR: 'MA',
|
||||||
|
MCO: 'MC',
|
||||||
|
MDA: 'MD',
|
||||||
MDG: 'MG',
|
MDG: 'MG',
|
||||||
MWI: 'MW',
|
|
||||||
MYS: 'MY',
|
|
||||||
MDV: 'MV',
|
MDV: 'MV',
|
||||||
|
MEX: 'MX',
|
||||||
|
MHL: 'MH',
|
||||||
|
MKD: 'MK',
|
||||||
MLI: 'ML',
|
MLI: 'ML',
|
||||||
MLT: 'MT',
|
MLT: 'MT',
|
||||||
MHL: 'MH',
|
|
||||||
MTQ: 'MQ',
|
|
||||||
MRT: 'MR',
|
|
||||||
MUS: 'MU',
|
|
||||||
MYT: 'YT',
|
|
||||||
MEX: 'MX',
|
|
||||||
FSM: 'FM',
|
|
||||||
MDA: 'MD',
|
|
||||||
MCO: 'MC',
|
|
||||||
MNG: 'MN',
|
|
||||||
MNE: 'ME',
|
|
||||||
MSR: 'MS',
|
|
||||||
MAR: 'MA',
|
|
||||||
MOZ: 'MZ',
|
|
||||||
MMR: 'MM',
|
MMR: 'MM',
|
||||||
NAM: 'NA',
|
MNE: 'ME',
|
||||||
NRU: 'NR',
|
MNG: 'MN',
|
||||||
NPL: 'NP',
|
|
||||||
NLD: 'NL',
|
|
||||||
ANT: 'AN',
|
|
||||||
NCL: 'NC',
|
|
||||||
NZL: 'NZ',
|
|
||||||
NIC: 'NI',
|
|
||||||
NER: 'NE',
|
|
||||||
NGA: 'NG',
|
|
||||||
NIU: 'NU',
|
|
||||||
NFK: 'NF',
|
|
||||||
MNP: 'MP',
|
MNP: 'MP',
|
||||||
|
MOZ: 'MZ',
|
||||||
|
MRT: 'MR',
|
||||||
|
MSR: 'MS',
|
||||||
|
MTQ: 'MQ',
|
||||||
|
MUS: 'MU',
|
||||||
|
MWI: 'MW',
|
||||||
|
MYS: 'MY',
|
||||||
|
MYT: 'YT',
|
||||||
|
NAM: 'NA',
|
||||||
|
NCL: 'NC',
|
||||||
|
NER: 'NE',
|
||||||
|
NFK: 'NF',
|
||||||
|
NGA: 'NG',
|
||||||
|
NIC: 'NI',
|
||||||
|
NIU: 'NU',
|
||||||
|
NLD: 'NL',
|
||||||
NOR: 'NO',
|
NOR: 'NO',
|
||||||
|
NPL: 'NP',
|
||||||
|
NRU: 'NR',
|
||||||
|
NZL: 'NZ',
|
||||||
OMN: 'OM',
|
OMN: 'OM',
|
||||||
PAK: 'PK',
|
PAK: 'PK',
|
||||||
PLW: 'PW',
|
|
||||||
PSE: 'PS',
|
|
||||||
PAN: 'PA',
|
PAN: 'PA',
|
||||||
PNG: 'PG',
|
PCN: 'PN',
|
||||||
PRY: 'PY',
|
|
||||||
PER: 'PE',
|
PER: 'PE',
|
||||||
PHL: 'PH',
|
PHL: 'PH',
|
||||||
PCN: 'PN',
|
PLW: 'PW',
|
||||||
|
PNG: 'PG',
|
||||||
POL: 'PL',
|
POL: 'PL',
|
||||||
PRT: 'PT',
|
|
||||||
PRI: 'PR',
|
PRI: 'PR',
|
||||||
|
PRK: 'KP',
|
||||||
|
PRT: 'PT',
|
||||||
|
PRY: 'PY',
|
||||||
|
PSE: 'PS',
|
||||||
|
PYF: 'PF',
|
||||||
QAT: 'QA',
|
QAT: 'QA',
|
||||||
REU: 'RE',
|
REU: 'RE',
|
||||||
ROU: 'RO',
|
ROU: 'RO',
|
||||||
RUS: 'RU',
|
RUS: 'RU',
|
||||||
RWA: 'RW',
|
RWA: 'RW',
|
||||||
BLM: 'BL',
|
|
||||||
SHN: 'SH',
|
|
||||||
KNA: 'KN',
|
|
||||||
LCA: 'LC',
|
|
||||||
MAF: 'MF',
|
|
||||||
SPM: 'PM',
|
|
||||||
VCT: 'VC',
|
|
||||||
WSM: 'WS',
|
|
||||||
SMR: 'SM',
|
|
||||||
STP: 'ST',
|
|
||||||
SAU: 'SA',
|
SAU: 'SA',
|
||||||
|
SDN: 'SD',
|
||||||
SEN: 'SN',
|
SEN: 'SN',
|
||||||
SRB: 'RS',
|
|
||||||
SYC: 'SC',
|
|
||||||
SLE: 'SL',
|
|
||||||
SGP: 'SG',
|
SGP: 'SG',
|
||||||
|
SGS: 'GS',
|
||||||
|
SHN: 'SH',
|
||||||
|
SJM: 'SJ',
|
||||||
|
SLB: 'SB',
|
||||||
|
SLE: 'SL',
|
||||||
|
SLV: 'SV',
|
||||||
|
SMR: 'SM',
|
||||||
|
SOM: 'SO',
|
||||||
|
SPM: 'PM',
|
||||||
|
SRB: 'RS',
|
||||||
|
SUR: 'SR',
|
||||||
|
STP: 'ST',
|
||||||
SVK: 'SK',
|
SVK: 'SK',
|
||||||
SVN: 'SI',
|
SVN: 'SI',
|
||||||
SLB: 'SB',
|
|
||||||
SOM: 'SO',
|
|
||||||
ZAF: 'ZA',
|
|
||||||
SGS: 'GS',
|
|
||||||
SSD: 'SS',
|
|
||||||
ESP: 'ES',
|
|
||||||
LKA: 'LK',
|
|
||||||
SDN: 'SD',
|
|
||||||
SUR: 'SR',
|
|
||||||
SJM: 'SJ',
|
|
||||||
SWZ: 'SZ',
|
|
||||||
SWE: 'SE',
|
SWE: 'SE',
|
||||||
CHE: 'CH',
|
SWZ: 'SZ',
|
||||||
|
SYC: 'SC',
|
||||||
SYR: 'SY',
|
SYR: 'SY',
|
||||||
TWN: 'TW',
|
TCA: 'TC',
|
||||||
TJK: 'TJ',
|
TCD: 'TD',
|
||||||
TZA: 'TZ',
|
|
||||||
THA: 'TH',
|
|
||||||
TLS: 'TL',
|
|
||||||
TGO: 'TG',
|
TGO: 'TG',
|
||||||
|
THA: 'TH',
|
||||||
|
TJK: 'TJ',
|
||||||
TKL: 'TK',
|
TKL: 'TK',
|
||||||
|
TKM: 'TM',
|
||||||
|
TLS: 'TL',
|
||||||
TON: 'TO',
|
TON: 'TO',
|
||||||
TTO: 'TT',
|
TTO: 'TT',
|
||||||
TUN: 'TN',
|
TUN: 'TN',
|
||||||
TUR: 'TR',
|
TUR: 'TR',
|
||||||
TKM: 'TM',
|
|
||||||
TCA: 'TC',
|
|
||||||
TUV: 'TV',
|
TUV: 'TV',
|
||||||
|
TWN: 'TW',
|
||||||
|
TZA: 'TZ',
|
||||||
UGA: 'UG',
|
UGA: 'UG',
|
||||||
UKR: 'UA',
|
UKR: 'UA',
|
||||||
ARE: 'AE',
|
|
||||||
GBR: 'GB',
|
|
||||||
USA: 'US',
|
|
||||||
UMI: 'UM',
|
UMI: 'UM',
|
||||||
URY: 'UY',
|
URY: 'UY',
|
||||||
|
USA: 'US',
|
||||||
UZB: 'UZ',
|
UZB: 'UZ',
|
||||||
VUT: 'VU',
|
VAT: 'VA',
|
||||||
|
VCT: 'VC',
|
||||||
VEN: 'VE',
|
VEN: 'VE',
|
||||||
VNM: 'VN',
|
VGB: 'VG',
|
||||||
VIR: 'VI',
|
VIR: 'VI',
|
||||||
|
VNM: 'VN',
|
||||||
|
VUT: 'VU',
|
||||||
WLF: 'WF',
|
WLF: 'WF',
|
||||||
ESH: 'EH',
|
WSM: 'WS',
|
||||||
|
XKX: 'XK',
|
||||||
YEM: 'YE',
|
YEM: 'YE',
|
||||||
|
ZAF: 'ZA',
|
||||||
ZMB: 'ZM',
|
ZMB: 'ZM',
|
||||||
ZWE: 'ZW',
|
ZWE: 'ZW',
|
||||||
XKX: 'XK',
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,18 @@ const DATE_FUNCTIONS = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TIMEZONE_MAPPINGS: Record<string, string> = {
|
||||||
|
'Asia/Calcutta': 'Asia/Kolkata',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeTimezone(timezone: string): string {
|
||||||
|
return TIMEZONE_MAPPINGS[timezone] || timezone;
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidTimezone(timezone: string) {
|
export function isValidTimezone(timezone: string) {
|
||||||
try {
|
try {
|
||||||
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
const normalizedTimezone = normalizeTimezone(timezone);
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: normalizedTimezone });
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,37 @@ import ipaddr from 'ipaddr.js';
|
||||||
import maxmind from 'maxmind';
|
import maxmind from 'maxmind';
|
||||||
import {
|
import {
|
||||||
DESKTOP_OS,
|
DESKTOP_OS,
|
||||||
MOBILE_OS,
|
|
||||||
DESKTOP_SCREEN_WIDTH,
|
DESKTOP_SCREEN_WIDTH,
|
||||||
LAPTOP_SCREEN_WIDTH,
|
|
||||||
MOBILE_SCREEN_WIDTH,
|
|
||||||
IP_ADDRESS_HEADERS,
|
IP_ADDRESS_HEADERS,
|
||||||
|
LAPTOP_SCREEN_WIDTH,
|
||||||
|
MOBILE_OS,
|
||||||
|
MOBILE_SCREEN_WIDTH,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
import { safeDecodeURIComponent } from '@/lib/url';
|
||||||
|
|
||||||
const MAXMIND = 'maxmind';
|
const MAXMIND = 'maxmind';
|
||||||
|
|
||||||
|
const PROVIDER_HEADERS = [
|
||||||
|
// Cloudflare headers
|
||||||
|
{
|
||||||
|
countryHeader: 'cf-ipcountry',
|
||||||
|
regionHeader: 'cf-region-code',
|
||||||
|
cityHeader: 'cf-ipcity',
|
||||||
|
},
|
||||||
|
// Vercel headers
|
||||||
|
{
|
||||||
|
countryHeader: 'x-vercel-ip-country',
|
||||||
|
regionHeader: 'x-vercel-ip-country-region',
|
||||||
|
cityHeader: 'x-vercel-ip-city',
|
||||||
|
},
|
||||||
|
// CloudFront headers
|
||||||
|
{
|
||||||
|
countryHeader: 'cloudfront-viewer-country',
|
||||||
|
regionHeader: 'cloudfront-viewer-country-region',
|
||||||
|
cityHeader: 'cloudfront-viewer-city',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function getIpAddress(headers: Headers) {
|
export function getIpAddress(headers: Headers) {
|
||||||
const customHeader = process.env.CLIENT_IP_HEADER;
|
const customHeader = process.env.CLIENT_IP_HEADER;
|
||||||
|
|
||||||
|
|
@ -86,6 +108,14 @@ function decodeHeader(s: string | undefined | null): string | undefined | null {
|
||||||
return Buffer.from(s, 'latin1').toString('utf-8');
|
return Buffer.from(s, 'latin1').toString('utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removePortFromIP(ip: string = "") {
|
||||||
|
const split = ip.split(":");
|
||||||
|
|
||||||
|
// Assuming ip is a valid IPv4/IPv6 address, 3 colons is the minumum for IPv6
|
||||||
|
const ipv4 = split.length - 1 < 3;
|
||||||
|
return ipv4 ? split[0] : ip;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) {
|
export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) {
|
||||||
// Ignore local ips
|
// Ignore local ips
|
||||||
if (await isLocalhost(ip)) {
|
if (await isLocalhost(ip)) {
|
||||||
|
|
@ -93,30 +123,19 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) {
|
if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) {
|
||||||
// Cloudflare headers
|
for (const provider of PROVIDER_HEADERS) {
|
||||||
if (headers.get('cf-ipcountry')) {
|
const countryHeader = headers.get(provider.countryHeader);
|
||||||
const country = decodeHeader(headers.get('cf-ipcountry'));
|
if (countryHeader) {
|
||||||
const region = decodeHeader(headers.get('cf-region-code'));
|
const country = decodeHeader(countryHeader);
|
||||||
const city = decodeHeader(headers.get('cf-ipcity'));
|
const region = decodeHeader(headers.get(provider.regionHeader));
|
||||||
|
const city = decodeHeader(headers.get(provider.cityHeader));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
country,
|
country,
|
||||||
region: getRegionCode(country, region),
|
region: getRegionCode(country, region),
|
||||||
city,
|
city,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vercel headers
|
|
||||||
if (headers.get('x-vercel-ip-country')) {
|
|
||||||
const country = decodeHeader(headers.get('x-vercel-ip-country'));
|
|
||||||
const region = decodeHeader(headers.get('x-vercel-ip-country-region'));
|
|
||||||
const city = decodeHeader(headers.get('x-vercel-ip-city'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
country,
|
|
||||||
region: getRegionCode(country, region),
|
|
||||||
city,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,12 +143,14 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
|
||||||
if (!global[MAXMIND]) {
|
if (!global[MAXMIND]) {
|
||||||
const dir = path.join(process.cwd(), 'geo');
|
const dir = path.join(process.cwd(), 'geo');
|
||||||
|
|
||||||
global[MAXMIND] = await maxmind.open(path.resolve(dir, 'GeoLite2-City.mmdb'));
|
global[MAXMIND] = await maxmind.open(
|
||||||
|
process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the client IP is extracted from headers, sometimes the value includes a port
|
// When the client IP is extracted from headers, sometimes the value includes a port
|
||||||
const cleanIp = ip?.split(':')[0];
|
const cleanIp = removePortFromIP(ip);
|
||||||
const result = global[MAXMIND].get(cleanIp);
|
const result = global[MAXMIND].get(cleanIp);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
|
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
|
||||||
|
|
@ -148,9 +169,9 @@ export async function getClientInfo(request: Request, payload: Record<string, an
|
||||||
const userAgent = payload?.userAgent || request.headers.get('user-agent');
|
const userAgent = payload?.userAgent || request.headers.get('user-agent');
|
||||||
const ip = payload?.ip || getIpAddress(request.headers);
|
const ip = payload?.ip || getIpAddress(request.headers);
|
||||||
const location = await getLocation(ip, request.headers, !!payload?.ip);
|
const location = await getLocation(ip, request.headers, !!payload?.ip);
|
||||||
const country = location?.country;
|
const country = safeDecodeURIComponent(location?.country);
|
||||||
const region = location?.region;
|
const region = safeDecodeURIComponent(location?.region);
|
||||||
const city = location?.city;
|
const city = safeDecodeURIComponent(location?.city);
|
||||||
const browser = browserName(userAgent);
|
const browser = browserName(userAgent);
|
||||||
const os = detectOS(userAgent) as string;
|
const os = detectOS(userAgent) as string;
|
||||||
const device = getDevice(payload?.screen, os);
|
const device = getDevice(payload?.screen, os);
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,24 @@ function mapFilter(column: string, operator: string, name: string, type: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapCohortFilter(column: string, operator: string, value: string) {
|
||||||
|
const db = getDatabaseType();
|
||||||
|
const like = db === POSTGRESQL ? 'ilike' : 'like';
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case OPERATORS.equals:
|
||||||
|
return `${column} = '${value}'`;
|
||||||
|
case OPERATORS.notEquals:
|
||||||
|
return `${column} != '${value}'`;
|
||||||
|
case OPERATORS.contains:
|
||||||
|
return `${column} ${like} '${value}'`;
|
||||||
|
case OPERATORS.doesNotContain:
|
||||||
|
return `${column} not ${like} '${value}'`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string {
|
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string {
|
||||||
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
|
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
|
||||||
if (column) {
|
if (column) {
|
||||||
|
|
@ -173,6 +191,43 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}):
|
||||||
return query.join('\n');
|
return query.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) {
|
||||||
|
const query = filtersToArray(filters, options).reduce(
|
||||||
|
(arr, { name, column, operator, value }) => {
|
||||||
|
if (column) {
|
||||||
|
arr.push(
|
||||||
|
`${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (name === 'referrer') {
|
||||||
|
arr.push(`and referrer_domain != hostname`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (query.length > 0) {
|
||||||
|
// add website and date range filters
|
||||||
|
query.push(`and website_event.website_id = '${websiteId}'`);
|
||||||
|
query.push(
|
||||||
|
`and website_event.created_at between '${filters.startDate}'::timestamptz and '${filters.endDate}'::timestamptz`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `join
|
||||||
|
(select distinct website_event.session_id
|
||||||
|
from website_event
|
||||||
|
join session on session.session_id = website_event.session_id
|
||||||
|
${query.join('\n')}) cohort
|
||||||
|
on cohort.session_id = website_event.session_id
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function getDateQuery(filters: QueryFilters = {}) {
|
function getDateQuery(filters: QueryFilters = {}) {
|
||||||
const { startDate, endDate } = filters;
|
const { startDate, endDate } = filters;
|
||||||
|
|
||||||
|
|
@ -219,6 +274,7 @@ async function parseFilters(
|
||||||
websiteId,
|
websiteId,
|
||||||
startDate: maxDate(filters.startDate, website?.resetAt),
|
startDate: maxDate(filters.startDate, website?.resetAt),
|
||||||
},
|
},
|
||||||
|
cohortQuery: getCohortQuery(websiteId, filters?.cohort),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { z, ZodSchema } from 'zod';
|
import { z, ZodSchema } from 'zod';
|
||||||
import { FILTER_COLUMNS } from '@/lib/constants';
|
import { FILTER_COLUMNS, FILTER_GROUPS } from '@/lib/constants';
|
||||||
import { badRequest, unauthorized } from '@/lib/response';
|
import { badRequest, unauthorized } from '@/lib/response';
|
||||||
import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
|
import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
|
||||||
import { checkAuth } from '@/lib/auth';
|
import { checkAuth } from '@/lib/auth';
|
||||||
import { getWebsiteDateRange } from '@/queries';
|
import { getWebsiteSegment, getWebsiteDateRange } from '@/queries';
|
||||||
|
|
||||||
export async function getJsonBody(request: Request) {
|
export async function getJsonBody(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -85,14 +85,28 @@ export async function getRequestDateRange(query: Record<string, any>) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRequestFilters(query: Record<string, any>) {
|
export async function getRequestFilters(query: Record<string, any>, websiteId?: string) {
|
||||||
return Object.keys(FILTER_COLUMNS).reduce((obj, key) => {
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(FILTER_COLUMNS)) {
|
||||||
const value = query[key];
|
const value = query[key];
|
||||||
|
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
obj[key] = value;
|
result[key] = value;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return obj;
|
for (const key of Object.keys(FILTER_GROUPS)) {
|
||||||
}, {});
|
const value = query[key];
|
||||||
|
if (value !== undefined) {
|
||||||
|
const segment = await getWebsiteSegment(websiteId, key, value);
|
||||||
|
if (key === 'segment') {
|
||||||
|
// merge filters into result
|
||||||
|
Object.assign(result, segment.parameters);
|
||||||
|
} else {
|
||||||
|
result[key] = segment.parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isValidTimezone } from '@/lib/date';
|
import { isValidTimezone, normalizeTimezone } from '@/lib/date';
|
||||||
import { UNIT_TYPES } from './constants';
|
import { UNIT_TYPES } from './constants';
|
||||||
|
|
||||||
export const filterParams = {
|
export const filterParams = {
|
||||||
|
|
@ -17,6 +17,8 @@ export const filterParams = {
|
||||||
host: z.string().optional(),
|
host: z.string().optional(),
|
||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
event: z.string().optional(),
|
event: z.string().optional(),
|
||||||
|
segment: z.string().optional(),
|
||||||
|
cohort: z.string().optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pagingParams = {
|
export const pagingParams = {
|
||||||
|
|
@ -26,9 +28,9 @@ export const pagingParams = {
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const timezoneParam = z.string().refine(value => isValidTimezone(value), {
|
export const timezoneParam = z.string().refine((value: string) => isValidTimezone(value), {
|
||||||
message: 'Invalid timezone',
|
message: 'Invalid timezone',
|
||||||
});
|
}).transform((value: string) => normalizeTimezone(value));
|
||||||
|
|
||||||
export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), {
|
export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), {
|
||||||
message: 'Invalid unit',
|
message: 'Invalid unit',
|
||||||
|
|
@ -74,3 +76,5 @@ export const reportParms = {
|
||||||
value: z.string().optional(),
|
value: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const segmentTypeParam = z.enum(['segment', 'cohort']);
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ export interface QueryFilters {
|
||||||
event?: string;
|
event?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
cohort?: { [key: string]: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryOptions {
|
export interface QueryOptions {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export * from '@/queries/prisma/report';
|
export * from '@/queries/prisma/report';
|
||||||
|
export * from '@/queries/prisma/segment';
|
||||||
export * from '@/queries/prisma/team';
|
export * from '@/queries/prisma/team';
|
||||||
export * from '@/queries/prisma/teamUser';
|
export * from '@/queries/prisma/teamUser';
|
||||||
export * from '@/queries/prisma/user';
|
export * from '@/queries/prisma/user';
|
||||||
|
|
@ -10,6 +11,7 @@ export * from '@/queries/sql/events/getEventDataValues';
|
||||||
export * from '@/queries/sql/events/getEventDataStats';
|
export * from '@/queries/sql/events/getEventDataStats';
|
||||||
export * from '@/queries/sql/events/getEventDataUsage';
|
export * from '@/queries/sql/events/getEventDataUsage';
|
||||||
export * from '@/queries/sql/events/getEventMetrics';
|
export * from '@/queries/sql/events/getEventMetrics';
|
||||||
|
export * from '@/queries/sql/events/getEventStats';
|
||||||
export * from '@/queries/sql/events/getWebsiteEvents';
|
export * from '@/queries/sql/events/getWebsiteEvents';
|
||||||
export * from '@/queries/sql/events/getEventUsage';
|
export * from '@/queries/sql/events/getEventUsage';
|
||||||
export * from '@/queries/sql/events/saveEvent';
|
export * from '@/queries/sql/events/saveEvent';
|
||||||
|
|
|
||||||
45
src/queries/prisma/segment.ts
Normal file
45
src/queries/prisma/segment.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { Prisma, Segment } from '@prisma/client';
|
||||||
|
|
||||||
|
async function findSegment(criteria: Prisma.SegmentFindUniqueArgs): Promise<Segment> {
|
||||||
|
return prisma.client.Segment.findUnique(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSegment(segmentId: string): Promise<Segment> {
|
||||||
|
return findSegment({
|
||||||
|
where: {
|
||||||
|
id: segmentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebsiteSegment(
|
||||||
|
websiteId: string,
|
||||||
|
type: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<Segment> {
|
||||||
|
return prisma.client.segment.findFirst({
|
||||||
|
where: { websiteId, type, name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebsiteSegments(websiteId: string, type: string): Promise<Segment[]> {
|
||||||
|
return prisma.client.Segment.findMany({
|
||||||
|
where: { websiteId, type },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSegment(data: Prisma.SegmentUncheckedCreateInput): Promise<Segment> {
|
||||||
|
return prisma.client.Segment.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSegment(
|
||||||
|
SegmentId: string,
|
||||||
|
data: Prisma.SegmentUpdateInput,
|
||||||
|
): Promise<Segment> {
|
||||||
|
return prisma.client.Segment.update({ where: { id: SegmentId }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSegment(SegmentId: string): Promise<Segment> {
|
||||||
|
return prisma.client.Segment.delete({ where: { id: SegmentId } });
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ export async function getEventDataFields(
|
||||||
|
|
||||||
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
const { rawQuery, parseFilters, getDateSQL } = prisma;
|
const { rawQuery, parseFilters, getDateSQL } = prisma;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, filters);
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -29,6 +29,9 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
count(*) as "total"
|
count(*) as "total"
|
||||||
from event_data
|
from event_data
|
||||||
join website_event on website_event.event_id = event_data.website_event_id
|
join website_event on website_event.event_id = event_data.website_event_id
|
||||||
|
and website_event.website_id = {{websiteId::uuid}}
|
||||||
|
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||||
|
${cohortQuery}
|
||||||
where event_data.website_id = {{websiteId::uuid}}
|
where event_data.website_id = {{websiteId::uuid}}
|
||||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
|
|
@ -45,7 +48,7 @@ async function clickhouseQuery(
|
||||||
filters: QueryFilters,
|
filters: QueryFilters,
|
||||||
): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> {
|
): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> {
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, filters);
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -56,7 +59,8 @@ async function clickhouseQuery(
|
||||||
data_type = 4, toString(date_trunc('hour', date_value)),
|
data_type = 4, toString(date_trunc('hour', date_value)),
|
||||||
string_value) as "value",
|
string_value) as "value",
|
||||||
count(*) as "total"
|
count(*) as "total"
|
||||||
from event_data
|
from event_data website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ async function relationalQuery(
|
||||||
filters: QueryFilters & { propertyName?: string },
|
filters: QueryFilters & { propertyName?: string },
|
||||||
) {
|
) {
|
||||||
const { rawQuery, parseFilters } = prisma;
|
const { rawQuery, parseFilters } = prisma;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, filters, {
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters, {
|
||||||
columns: { propertyName: 'data_key' },
|
columns: { propertyName: 'data_key' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -29,6 +29,9 @@ async function relationalQuery(
|
||||||
count(*) as "total"
|
count(*) as "total"
|
||||||
from event_data
|
from event_data
|
||||||
join website_event on website_event.event_id = event_data.website_event_id
|
join website_event on website_event.event_id = event_data.website_event_id
|
||||||
|
and website_event.website_id = {{websiteId::uuid}}
|
||||||
|
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||||
|
${cohortQuery}
|
||||||
where event_data.website_id = {{websiteId::uuid}}
|
where event_data.website_id = {{websiteId::uuid}}
|
||||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
|
|
@ -45,7 +48,7 @@ async function clickhouseQuery(
|
||||||
filters: QueryFilters & { propertyName?: string },
|
filters: QueryFilters & { propertyName?: string },
|
||||||
): Promise<{ eventName: string; propertyName: string; total: number }[]> {
|
): Promise<{ eventName: string; propertyName: string; total: number }[]> {
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, filters, {
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters, {
|
||||||
columns: { propertyName: 'data_key' },
|
columns: { propertyName: 'data_key' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,7 +58,8 @@ async function clickhouseQuery(
|
||||||
event_name as eventName,
|
event_name as eventName,
|
||||||
data_key as propertyName,
|
data_key as propertyName,
|
||||||
count(*) as total
|
count(*) as total
|
||||||
from event_data
|
from event_data website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export async function getEventDataStats(
|
||||||
|
|
||||||
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
const { rawQuery, parseFilters } = prisma;
|
const { rawQuery, parseFilters } = prisma;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, filters);
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -32,8 +32,12 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
data_key,
|
data_key,
|
||||||
count(*) as "total"
|
count(*) as "total"
|
||||||
from event_data
|
from event_data
|
||||||
where website_id = {{websiteId::uuid}}
|
join website_event on website_event.event_id = event_data.website_event_id
|
||||||
and created_at between {{startDate}} and {{endDate}}
|
and website_event.website_id = {{websiteId::uuid}}
|
||||||
|
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||||
|
${cohortQuery}
|
||||||
|
where event_data.website_id = {{websiteId::uuid}}
|
||||||
|
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
group by website_event_id, data_key
|
group by website_event_id, data_key
|
||||||
) as t
|
) as t
|
||||||
|
|
@ -47,7 +51,7 @@ async function clickhouseQuery(
|
||||||
filters: QueryFilters,
|
filters: QueryFilters,
|
||||||
): Promise<{ events: number; properties: number; records: number }[]> {
|
): Promise<{ events: number; properties: number; records: number }[]> {
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, filters);
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -60,7 +64,8 @@ async function clickhouseQuery(
|
||||||
event_id,
|
event_id,
|
||||||
data_key,
|
data_key,
|
||||||
count(*) as "total"
|
count(*) as "total"
|
||||||
from event_data
|
from event_data website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ async function relationalQuery(
|
||||||
filters: QueryFilters & { eventName?: string; propertyName?: string },
|
filters: QueryFilters & { eventName?: string; propertyName?: string },
|
||||||
) {
|
) {
|
||||||
const { rawQuery, parseFilters, getDateSQL } = prisma;
|
const { rawQuery, parseFilters, getDateSQL } = prisma;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, filters);
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -33,6 +33,9 @@ async function relationalQuery(
|
||||||
count(*) as "total"
|
count(*) as "total"
|
||||||
from event_data
|
from event_data
|
||||||
join website_event on website_event.event_id = event_data.website_event_id
|
join website_event on website_event.event_id = event_data.website_event_id
|
||||||
|
and website_event.website_id = {{websiteId::uuid}}
|
||||||
|
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||||
|
${cohortQuery}
|
||||||
where event_data.website_id = {{websiteId::uuid}}
|
where event_data.website_id = {{websiteId::uuid}}
|
||||||
and event_data.created_at between {{startDate}} and {{endDate}}
|
and event_data.created_at between {{startDate}} and {{endDate}}
|
||||||
and event_data.data_key = {{propertyName}}
|
and event_data.data_key = {{propertyName}}
|
||||||
|
|
@ -51,7 +54,7 @@ async function clickhouseQuery(
|
||||||
filters: QueryFilters & { eventName?: string; propertyName?: string },
|
filters: QueryFilters & { eventName?: string; propertyName?: string },
|
||||||
): Promise<{ value: string; total: number }[]> {
|
): Promise<{ value: string; total: number }[]> {
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, filters);
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -60,7 +63,8 @@ async function clickhouseQuery(
|
||||||
data_type = 4, toString(date_trunc('hour', date_value)),
|
data_type = 4, toString(date_trunc('hour', date_value)),
|
||||||
string_value) as "value",
|
string_value) as "value",
|
||||||
count(*) as "total"
|
count(*) as "total"
|
||||||
from event_data
|
from event_data website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
and data_key = {propertyName:String}
|
and data_key = {propertyName:String}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,57 @@
|
||||||
import clickhouse from '@/lib/clickhouse';
|
import clickhouse from '@/lib/clickhouse';
|
||||||
import { EVENT_TYPE } from '@/lib/constants';
|
import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
||||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import { QueryFilters, WebsiteEventMetric } from '@/lib/types';
|
import { QueryFilters } from '@/lib/types';
|
||||||
|
|
||||||
export async function getEventMetrics(
|
export async function getEventMetrics(
|
||||||
...args: [websiteId: string, filters: QueryFilters]
|
...args: [
|
||||||
): Promise<WebsiteEventMetric[]> {
|
websiteId: string,
|
||||||
|
type: string,
|
||||||
|
filters: QueryFilters,
|
||||||
|
limit?: number | string,
|
||||||
|
offset?: number | string,
|
||||||
|
]
|
||||||
|
) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
async function relationalQuery(
|
||||||
const { timezone = 'utc', unit = 'day' } = filters;
|
websiteId: string,
|
||||||
const { rawQuery, getDateSQL, parseFilters } = prisma;
|
type: string,
|
||||||
const { filterQuery, joinSession, params } = await parseFilters(websiteId, {
|
filters: QueryFilters,
|
||||||
...filters,
|
limit: number | string = 500,
|
||||||
eventType: EVENT_TYPE.customEvent,
|
offset: number | string = 0,
|
||||||
});
|
) {
|
||||||
|
const column = FILTER_COLUMNS[type] || type;
|
||||||
|
const { rawQuery, parseFilters } = prisma;
|
||||||
|
const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(
|
||||||
|
websiteId,
|
||||||
|
{
|
||||||
|
...filters,
|
||||||
|
eventType: EVENT_TYPE.customEvent,
|
||||||
|
},
|
||||||
|
{ joinSession: SESSION_COLUMNS.includes(type) },
|
||||||
|
);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
select
|
select ${column} x,
|
||||||
event_name x,
|
count(*) as y
|
||||||
${getDateSQL('website_event.created_at', unit, timezone)} t,
|
|
||||||
count(*) y
|
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
${joinSession}
|
${joinSession}
|
||||||
where website_event.website_id = {{websiteId::uuid}}
|
where website_event.website_id = {{websiteId::uuid}}
|
||||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||||
and event_type = {{eventType}}
|
and event_type = {{eventType}}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
group by 1, 2
|
group by 1
|
||||||
order by 2
|
order by 2 desc
|
||||||
|
limit ${limit}
|
||||||
|
offset ${offset}
|
||||||
`,
|
`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
@ -42,49 +59,32 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
|
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
|
type: string,
|
||||||
filters: QueryFilters,
|
filters: QueryFilters,
|
||||||
): Promise<{ x: string; t: string; y: number }[]> {
|
limit: number | string = 500,
|
||||||
const { timezone = 'UTC', unit = 'day' } = filters;
|
offset: number | string = 0,
|
||||||
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
|
): Promise<{ x: string; y: number }[]> {
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, {
|
const column = FILTER_COLUMNS[type] || type;
|
||||||
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
eventType: EVENT_TYPE.customEvent,
|
eventType: EVENT_TYPE.customEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
let sql = '';
|
return rawQuery(
|
||||||
|
`select ${column} x,
|
||||||
if (filterQuery) {
|
count(*) as y
|
||||||
sql = `
|
|
||||||
select
|
|
||||||
event_name x,
|
|
||||||
${getDateSQL('created_at', unit, timezone)} t,
|
|
||||||
count(*) y
|
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
and event_type = {eventType:UInt32}
|
and event_type = {eventType:UInt32}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
group by x, t
|
group by x
|
||||||
order by t
|
order by y desc
|
||||||
`;
|
limit ${limit}
|
||||||
} else {
|
offset ${offset}
|
||||||
sql = `
|
`,
|
||||||
select
|
params,
|
||||||
event_name x,
|
);
|
||||||
${getDateSQL('created_at', unit, timezone)} t,
|
|
||||||
count(*) y
|
|
||||||
from (
|
|
||||||
select arrayJoin(event_name) as event_name,
|
|
||||||
created_at
|
|
||||||
from website_event_stats_hourly website_event
|
|
||||||
where website_id = {websiteId:UUID}
|
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
|
||||||
and event_type = {eventType:UInt32}
|
|
||||||
) as g
|
|
||||||
group by x, t
|
|
||||||
order by t
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawQuery(sql, params);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
src/queries/sql/events/getEventStats.ts
Normal file
92
src/queries/sql/events/getEventStats.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import clickhouse from '@/lib/clickhouse';
|
||||||
|
import { EVENT_TYPE } from '@/lib/constants';
|
||||||
|
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { QueryFilters, WebsiteEventMetric } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function getEventStats(
|
||||||
|
...args: [websiteId: string, filters: QueryFilters]
|
||||||
|
): Promise<WebsiteEventMetric[]> {
|
||||||
|
return runQuery({
|
||||||
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
|
const { timezone = 'utc', unit = 'day' } = filters;
|
||||||
|
const { rawQuery, getDateSQL, parseFilters } = prisma;
|
||||||
|
const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(websiteId, {
|
||||||
|
...filters,
|
||||||
|
eventType: EVENT_TYPE.customEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rawQuery(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
event_name x,
|
||||||
|
${getDateSQL('website_event.created_at', unit, timezone)} t,
|
||||||
|
count(*) y
|
||||||
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
|
${joinSession}
|
||||||
|
where website_event.website_id = {{websiteId::uuid}}
|
||||||
|
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||||
|
and event_type = {{eventType}}
|
||||||
|
${filterQuery}
|
||||||
|
group by 1, 2
|
||||||
|
order by 2
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickhouseQuery(
|
||||||
|
websiteId: string,
|
||||||
|
filters: QueryFilters,
|
||||||
|
): Promise<{ x: string; t: string; y: number }[]> {
|
||||||
|
const { timezone = 'UTC', unit = 'day' } = filters;
|
||||||
|
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
|
||||||
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
|
||||||
|
...filters,
|
||||||
|
eventType: EVENT_TYPE.customEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
let sql = '';
|
||||||
|
|
||||||
|
if (filterQuery || cohortQuery) {
|
||||||
|
sql = `
|
||||||
|
select
|
||||||
|
event_name x,
|
||||||
|
${getDateSQL('created_at', unit, timezone)} t,
|
||||||
|
count(*) y
|
||||||
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
and event_type = {eventType:UInt32}
|
||||||
|
${filterQuery}
|
||||||
|
group by x, t
|
||||||
|
order by t
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
sql = `
|
||||||
|
select
|
||||||
|
event_name x,
|
||||||
|
${getDateSQL('created_at', unit, timezone)} t,
|
||||||
|
count(*) y
|
||||||
|
from (
|
||||||
|
select arrayJoin(event_name) as event_name,
|
||||||
|
created_at
|
||||||
|
from website_event_stats_hourly website_event
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
and event_type = {eventType:UInt32}
|
||||||
|
) as g
|
||||||
|
group by x, t
|
||||||
|
order by t
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawQuery(sql, params);
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ export function getWebsiteEvents(
|
||||||
async function relationalQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) {
|
async function relationalQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) {
|
||||||
const { pagedRawQuery, parseFilters } = prisma;
|
const { pagedRawQuery, parseFilters } = prisma;
|
||||||
const { search } = pageParams;
|
const { search } = pageParams;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, {
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -24,7 +24,6 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
|
||||||
|
|
||||||
return pagedRawQuery(
|
return pagedRawQuery(
|
||||||
`
|
`
|
||||||
with events as (
|
|
||||||
select
|
select
|
||||||
event_id as "id",
|
event_id as "id",
|
||||||
website_id as "websiteId",
|
website_id as "websiteId",
|
||||||
|
|
@ -39,6 +38,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
|
||||||
event_type as "eventType",
|
event_type as "eventType",
|
||||||
event_name as "eventName"
|
event_name as "eventName"
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {{websiteId::uuid}}
|
where website_id = {{websiteId::uuid}}
|
||||||
and created_at between {{startDate}} and {{endDate}}
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
|
|
@ -49,8 +49,6 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
order by created_at desc
|
order by created_at desc
|
||||||
limit 1000)
|
|
||||||
select * from events
|
|
||||||
`,
|
`,
|
||||||
{ ...params, search: `%${search}%` },
|
{ ...params, search: `%${search}%` },
|
||||||
pageParams,
|
pageParams,
|
||||||
|
|
@ -59,12 +57,11 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
|
||||||
|
|
||||||
async function clickhouseQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) {
|
async function clickhouseQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) {
|
||||||
const { pagedQuery, parseFilters } = clickhouse;
|
const { pagedQuery, parseFilters } = clickhouse;
|
||||||
const { params, dateQuery, filterQuery } = await parseFilters(websiteId, filters);
|
const { params, dateQuery, filterQuery, cohortQuery } = await parseFilters(websiteId, filters);
|
||||||
const { search } = pageParams;
|
const { search } = pageParams;
|
||||||
|
|
||||||
return pagedQuery(
|
return pagedQuery(
|
||||||
`
|
`
|
||||||
with events as (
|
|
||||||
select
|
select
|
||||||
event_id as id,
|
event_id as id,
|
||||||
website_id as websiteId,
|
website_id as websiteId,
|
||||||
|
|
@ -79,6 +76,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
|
||||||
event_type as eventType,
|
event_type as eventType,
|
||||||
event_name as eventName
|
event_name as eventName
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
${dateQuery}
|
${dateQuery}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
|
|
@ -89,8 +87,6 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
order by created_at desc
|
order by created_at desc
|
||||||
limit 1000)
|
|
||||||
select * from events
|
|
||||||
`,
|
`,
|
||||||
{ ...params, search },
|
{ ...params, search },
|
||||||
pageParams,
|
pageParams,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import kafka from '@/lib/kafka';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import { uuid } from '@/lib/crypto';
|
import { uuid } from '@/lib/crypto';
|
||||||
import { saveEventData } from './saveEventData';
|
import { saveEventData } from './saveEventData';
|
||||||
|
import { saveRevenue } from './saveRevenue';
|
||||||
|
|
||||||
export interface SaveEventArgs {
|
export interface SaveEventArgs {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
|
|
@ -130,6 +131,20 @@ async function relationalQuery({
|
||||||
eventData,
|
eventData,
|
||||||
createdAt,
|
createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { revenue, currency } = eventData;
|
||||||
|
|
||||||
|
if (revenue > 0 && currency) {
|
||||||
|
await saveRevenue({
|
||||||
|
websiteId,
|
||||||
|
sessionId,
|
||||||
|
eventId: websiteEventId,
|
||||||
|
eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
|
||||||
|
currency,
|
||||||
|
revenue,
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
36
src/queries/sql/events/saveRevenue.ts
Normal file
36
src/queries/sql/events/saveRevenue.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { uuid } from '@/lib/crypto';
|
||||||
|
import { PRISMA, runQuery } from '@/lib/db';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export interface SaveRevenueArgs {
|
||||||
|
websiteId: string;
|
||||||
|
sessionId: string;
|
||||||
|
eventId: string;
|
||||||
|
eventName: string;
|
||||||
|
currency: string;
|
||||||
|
revenue: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRevenue(data: SaveRevenueArgs) {
|
||||||
|
return runQuery({
|
||||||
|
[PRISMA]: () => relationalQuery(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relationalQuery(data: SaveRevenueArgs) {
|
||||||
|
const { websiteId, sessionId, eventId, eventName, currency, revenue, createdAt } = data;
|
||||||
|
|
||||||
|
await prisma.client.revenue.create({
|
||||||
|
data: {
|
||||||
|
id: uuid(),
|
||||||
|
websiteId,
|
||||||
|
sessionId,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
currency,
|
||||||
|
revenue,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ export async function getChannelMetrics(...args: [websiteId: string, filters?: Q
|
||||||
|
|
||||||
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
const { rawQuery, parseFilters } = prisma;
|
const { rawQuery, parseFilters } = prisma;
|
||||||
const { params, filterQuery, dateQuery } = await parseFilters(websiteId, filters);
|
const { params, filterQuery, cohortQuery, dateQuery } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -21,6 +21,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
url_query as query,
|
url_query as query,
|
||||||
count(distinct session_id) as visitors
|
count(distinct session_id) as visitors
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {{websiteId::uuid}}
|
where website_id = {{websiteId::uuid}}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
${dateQuery}
|
${dateQuery}
|
||||||
|
|
@ -36,7 +37,7 @@ async function clickhouseQuery(
|
||||||
filters: QueryFilters,
|
filters: QueryFilters,
|
||||||
): Promise<{ x: string; y: number }[]> {
|
): Promise<{ x: string; y: number }[]> {
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const { params, filterQuery, dateQuery } = await parseFilters(websiteId, filters);
|
const { params, filterQuery, cohortQuery, dateQuery } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
select
|
select
|
||||||
|
|
@ -44,6 +45,7 @@ async function clickhouseQuery(
|
||||||
url_query as query,
|
url_query as query,
|
||||||
uniq(session_id) as visitors
|
uniq(session_id) as visitors
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
${dateQuery}
|
${dateQuery}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export async function getRealtimeActivity(...args: [websiteId: string, filters:
|
||||||
|
|
||||||
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
const { rawQuery, parseFilters } = prisma;
|
const { rawQuery, parseFilters } = prisma;
|
||||||
const { params, filterQuery, dateQuery } = await parseFilters(websiteId, filters);
|
const { params, filterQuery, cohortQuery, dateQuery } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -27,6 +27,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
website_event.url_path as "urlPath",
|
website_event.url_path as "urlPath",
|
||||||
website_event.referrer_domain as "referrerDomain"
|
website_event.referrer_domain as "referrerDomain"
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
inner join session
|
inner join session
|
||||||
on session.session_id = website_event.session_id
|
on session.session_id = website_event.session_id
|
||||||
where website_event.website_id = {{websiteId::uuid}}
|
where website_event.website_id = {{websiteId::uuid}}
|
||||||
|
|
@ -41,7 +42,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||||
|
|
||||||
async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promise<{ x: number }> {
|
async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promise<{ x: number }> {
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const { params, filterQuery, dateQuery } = await parseFilters(websiteId, filters);
|
const { params, filterQuery, cohortQuery, dateQuery } = await parseFilters(websiteId, filters);
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
|
|
@ -56,6 +57,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promis
|
||||||
url_path as urlPath,
|
url_path as urlPath,
|
||||||
referrer_domain as referrerDomain
|
referrer_domain as referrerDomain
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
${filterQuery}
|
${filterQuery}
|
||||||
${dateQuery}
|
${dateQuery}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ async function relationalQuery(
|
||||||
const { rawQuery, getSearchSQL } = prisma;
|
const { rawQuery, getSearchSQL } = prisma;
|
||||||
const params = {};
|
const params = {};
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
|
let excludeDomain = '';
|
||||||
|
|
||||||
|
if (column === 'referrer_domain') {
|
||||||
|
excludeDomain = `and website_event.referrer_domain != website_event.hostname
|
||||||
|
and website_event.referrer_domain != ''`;
|
||||||
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
if (decodeURIComponent(search).includes(',')) {
|
if (decodeURIComponent(search).includes(',')) {
|
||||||
|
|
@ -49,6 +55,7 @@ async function relationalQuery(
|
||||||
where website_event.website_id = {{websiteId::uuid}}
|
where website_event.website_id = {{websiteId::uuid}}
|
||||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||||
${searchQuery}
|
${searchQuery}
|
||||||
|
${excludeDomain}
|
||||||
group by 1
|
group by 1
|
||||||
order by 2 desc
|
order by 2 desc
|
||||||
limit 10
|
limit 10
|
||||||
|
|
@ -73,6 +80,11 @@ async function clickhouseQuery(
|
||||||
const { rawQuery, getSearchSQL } = clickhouse;
|
const { rawQuery, getSearchSQL } = clickhouse;
|
||||||
const params = {};
|
const params = {};
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
|
let excludeDomain = '';
|
||||||
|
|
||||||
|
if (column === 'referrer_domain') {
|
||||||
|
excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`;
|
||||||
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`;
|
searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`;
|
||||||
|
|
@ -103,6 +115,7 @@ async function clickhouseQuery(
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
${searchQuery}
|
${searchQuery}
|
||||||
|
${excludeDomain}
|
||||||
group by 1
|
group by 1
|
||||||
order by 2 desc
|
order by 2 desc
|
||||||
limit 10
|
limit 10
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ async function relationalQuery(
|
||||||
{ pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }[]
|
{ pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }[]
|
||||||
> {
|
> {
|
||||||
const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma;
|
const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma;
|
||||||
const { filterQuery, joinSession, params } = await parseFilters(websiteId, {
|
const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
eventType: EVENT_TYPE.pageView,
|
eventType: EVENT_TYPE.pageView,
|
||||||
});
|
});
|
||||||
|
|
@ -44,6 +44,7 @@ async function relationalQuery(
|
||||||
min(website_event.created_at) as "min_time",
|
min(website_event.created_at) as "min_time",
|
||||||
max(website_event.created_at) as "max_time"
|
max(website_event.created_at) as "max_time"
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
${joinSession}
|
${joinSession}
|
||||||
where website_event.website_id = {{websiteId::uuid}}
|
where website_event.website_id = {{websiteId::uuid}}
|
||||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||||
|
|
@ -63,7 +64,7 @@ async function clickhouseQuery(
|
||||||
{ pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }[]
|
{ pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }[]
|
||||||
> {
|
> {
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const { filterQuery, params } = await parseFilters(websiteId, {
|
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
eventType: EVENT_TYPE.pageView,
|
eventType: EVENT_TYPE.pageView,
|
||||||
});
|
});
|
||||||
|
|
@ -86,6 +87,7 @@ async function clickhouseQuery(
|
||||||
min(created_at) min_time,
|
min(created_at) min_time,
|
||||||
max(created_at) max_time
|
max(created_at) max_time
|
||||||
from website_event
|
from website_event
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
and event_type = {eventType:UInt32}
|
and event_type = {eventType:UInt32}
|
||||||
|
|
@ -107,7 +109,8 @@ async function clickhouseQuery(
|
||||||
sum(views) c,
|
sum(views) c,
|
||||||
min(min_time) min_time,
|
min(min_time) min_time,
|
||||||
max(max_time) max_time
|
max(max_time) max_time
|
||||||
from umami.website_event_stats_hourly "website_event"
|
from website_event_stats_hourly "website_event"
|
||||||
|
${cohortQuery}
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
and event_type = {eventType:UInt32}
|
and event_type = {eventType:UInt32}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue