Merge dev.

This commit is contained in:
Mike Cao
2025-04-28 20:09:58 -07:00
88 changed files with 4120 additions and 21010 deletions

1
.gitignore vendored
View File

@@ -18,6 +18,7 @@ node_modules
/public/script.js /public/script.js
/geo /geo
/dist /dist
src/generated/prisma/
# misc # misc
.DS_Store .DS_Store

View File

@@ -3,27 +3,25 @@ FROM node:22-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json pnpm-lock.yaml ./
# Add yarn timeout to handle slow CPU when Github Actions RUN npm install -g pnpm
RUN yarn config set network-timeout 300000 RUN pnpm install --frozen-lockfile
RUN yarn install --frozen-lockfile
# Rebuild the source code only when needed # Rebuild the source code only when needed
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
COPY docker/middleware.ts ./src
ARG DATABASE_TYPE ARG DATABASE_TYPE
ARG BASE_PATH ARG BASE_PATH
ENV DATABASE_TYPE $DATABASE_TYPE ENV DATABASE_TYPE=$DATABASE_TYPE
ENV BASE_PATH $BASE_PATH ENV BASE_PATH=$BASE_PATH
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn build-docker RUN npm run build-docker
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
@@ -31,21 +29,24 @@ WORKDIR /app
ARG NODE_OPTIONS ARG NODE_OPTIONS
ENV NODE_ENV production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_OPTIONS $NODE_OPTIONS ENV NODE_OPTIONS=$NODE_OPTIONS
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
RUN npm install -g pnpm
RUN set -x \ RUN set -x \
&& apk add --no-cache curl \ && apk add --no-cache curl
&& yarn add npm-run-all dotenv semver prisma@6.1.0
# Script dependencies
RUN pnpm add npm-run-all dotenv prisma@6.1.0
# Permissions for prisma
RUN chown -R nextjs:nodejs node_modules/.pnpm/
# You only need to copy next.config.js if you are NOT using the default configuration
COPY --from=builder /app/next.config.js .
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/scripts ./scripts COPY --from=builder /app/scripts ./scripts
@@ -54,11 +55,14 @@ COPY --from=builder /app/scripts ./scripts
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Custom routes
RUN mv ./.next/routes-manifest.json ./.next/routes-manifest-orig.json
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME 0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV PORT 3000 ENV PORT=3000
CMD ["yarn", "start-docker"] CMD ["pnpm", "start-docker"]

View File

@@ -38,18 +38,12 @@ A detailed getting started guide can be found at [umami.is/docs](https://umami.i
- A server with Node.js version 18.18 or newer - A server with Node.js version 18.18 or newer
- A database. Umami supports [MariaDB](https://www.mariadb.org/) (minimum v10.5), [MySQL](https://www.mysql.com/) (minimum v8.0) and [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases. - A database. Umami supports [MariaDB](https://www.mariadb.org/) (minimum v10.5), [MySQL](https://www.mysql.com/) (minimum v8.0) and [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases.
### Install Yarn
```bash
npm install -g yarn
```
### Get the Source Code and Install Packages ### Get the Source Code and Install Packages
```bash ```bash
git clone https://github.com/umami-software/umami.git git clone https://github.com/umami-software/umami.git
cd umami cd umami
yarn install npm install
``` ```
### Configure Umami ### Configure Umami
@@ -70,7 +64,7 @@ mysql://username:mypassword@localhost:3306/mydb
### Build the Application ### Build the Application
```bash ```bash
yarn build npm build
``` ```
*The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.* *The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.*
@@ -78,7 +72,7 @@ yarn build
### Start the Application ### Start the Application
```bash ```bash
yarn start npm run start
``` ```
*By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.* *By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.*
@@ -113,8 +107,8 @@ To get the latest features, simply do a pull, install any new dependencies, and
```bash ```bash
git pull git pull
yarn install npm install
yarn build npm run build
``` ```
To update the Docker image, simply pull the new images and rebuild: To update the Docker image, simply pull the new images and rebuild:

View File

@@ -0,0 +1,332 @@
-- Create Event
CREATE TABLE umami.website_event_new
(
website_id UUID,
session_id UUID,
visit_id UUID,
event_id UUID,
--sessions
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String),
subdivision1 LowCardinality(String),
subdivision2 LowCardinality(String),
city String,
--pageviews
url_path String,
url_query String,
utm_source String,
utm_medium String,
utm_campaign String,
utm_content String,
utm_term String,
referrer_path String,
referrer_query String,
referrer_domain String,
page_title String,
--clickIDs
gclid String,
fbclid String,
msclkid String,
ttclid String,
li_fat_id String,
twclid String,
--events
event_type UInt32,
event_name String,
tag String,
created_at DateTime('UTC'),
job_id Nullable(UUID)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (toStartOfHour(created_at), website_id, session_id, visit_id, created_at)
PRIMARY KEY (toStartOfHour(created_at), website_id, session_id, visit_id)
SETTINGS index_granularity = 8192;
-- stats hourly
CREATE TABLE umami.website_event_stats_hourly_new
(
website_id UUID,
session_id UUID,
visit_id UUID,
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String),
subdivision1 LowCardinality(String),
city String,
entry_url AggregateFunction(argMin, String, DateTime('UTC')),
exit_url AggregateFunction(argMax, String, DateTime('UTC')),
url_path SimpleAggregateFunction(groupArrayArray, Array(String)),
url_query SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_source SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_medium SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_campaign SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_content SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_term SimpleAggregateFunction(groupArrayArray, Array(String)),
referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)),
page_title SimpleAggregateFunction(groupArrayArray, Array(String)),
gclid SimpleAggregateFunction(groupArrayArray, Array(String)),
fbclid SimpleAggregateFunction(groupArrayArray, Array(String)),
msclkid SimpleAggregateFunction(groupArrayArray, Array(String)),
ttclid SimpleAggregateFunction(groupArrayArray, Array(String)),
li_fat_id SimpleAggregateFunction(groupArrayArray, Array(String)),
twclid SimpleAggregateFunction(groupArrayArray, Array(String)),
event_type UInt32,
event_name SimpleAggregateFunction(groupArrayArray, Array(String)),
views SimpleAggregateFunction(sum, UInt64),
min_time SimpleAggregateFunction(min, DateTime('UTC')),
max_time SimpleAggregateFunction(max, DateTime('UTC')),
tag SimpleAggregateFunction(groupArrayArray, Array(String)),
created_at Datetime('UTC')
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (
website_id,
event_type,
toStartOfHour(created_at),
cityHash64(visit_id),
visit_id
)
SAMPLE BY cityHash64(visit_id);
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv_new
TO umami.website_event_stats_hourly_new
AS
SELECT
website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
city,
entry_url,
exit_url,
url_paths as url_path,
url_query,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
referrer_domain,
page_title,
gclid,
fbclid,
msclkid,
ttclid,
li_fat_id,
twclid,
event_type,
event_name,
views,
min_time,
max_time,
tag,
timestamp as created_at
FROM (SELECT
website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
city,
argMinState(url_path, created_at) entry_url,
argMaxState(url_path, created_at) exit_url,
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
event_type,
if(event_type = 2, groupArray(event_name), []) event_name,
sumIf(1, event_type = 1) views,
min(created_at) min_time,
max(created_at) max_time,
arrayFilter(x -> x != '', groupArray(tag)) tag,
toStartOfHour(created_at) timestamp
FROM umami.website_event_new
GROUP BY website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
city,
event_type,
timestamp);
-- projections
ALTER TABLE umami.website_event_new
ADD PROJECTION website_event_url_path_projection (
SELECT * ORDER BY toStartOfDay(created_at), website_id, url_path, created_at
);
ALTER TABLE umami.website_event_new MATERIALIZE PROJECTION website_event_url_path_projection;
ALTER TABLE umami.website_event_new
ADD PROJECTION website_event_referrer_domain_projection (
SELECT * ORDER BY toStartOfDay(created_at), website_id, referrer_domain, created_at
);
ALTER TABLE umami.website_event_new MATERIALIZE PROJECTION website_event_referrer_domain_projection;
-- migration
INSERT INTO umami.website_event_new
SELECT website_id, session_id, visit_id, event_id, hostname, browser, os, device, screen, language, country, subdivision1, subdivision2, city, url_path, url_query,
extract(url_query, 'utm_source=([^&]*)') AS utm_source,
extract(url_query, 'utm_medium=([^&]*)') AS utm_medium,
extract(url_query, 'utm_campaign=([^&]*)') AS utm_campaign,
extract(url_query, 'utm_content=([^&]*)') AS utm_content,
extract(url_query, 'utm_term=([^&]*)') AS utm_term,referrer_path, referrer_query, referrer_domain,
page_title,
extract(url_query, 'gclid=([^&]*)') gclid,
extract(url_query, 'fbclid=([^&]*)') fbclid,
extract(url_query, 'msclkid=([^&]*)') msclkid,
extract(url_query, 'ttclid=([^&]*)') ttclid,
extract(url_query, 'li_fat_id=([^&]*)') li_fat_id,
extract(url_query, 'twclid=([^&]*)') twclid,
event_type, event_name, tag, created_at, job_id
FROM umami.website_event
-- rename tables
RENAME TABLE umami.website_event TO umami.website_event_old;
RENAME TABLE umami.website_event_new TO umami.website_event;
RENAME TABLE umami.website_event_stats_hourly TO umami.website_event_stats_hourly_old;
RENAME TABLE umami.website_event_stats_hourly_new TO umami.website_event_stats_hourly;
RENAME TABLE umami.website_event_stats_hourly_mv TO umami.website_event_stats_hourly_mv_old;
RENAME TABLE umami.website_event_stats_hourly_mv_new TO umami.website_event_stats_hourly_mv;
-- recreate view
DROP TABLE umami.website_event_stats_hourly_mv;
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv
TO umami.website_event_stats_hourly
AS
SELECT
website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
city,
entry_url,
exit_url,
url_paths as url_path,
url_query,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
referrer_domain,
page_title,
gclid,
fbclid,
msclkid,
ttclid,
li_fat_id,
twclid,
event_type,
event_name,
views,
min_time,
max_time,
tag,
timestamp as created_at
FROM (SELECT
website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
city,
argMinState(url_path, created_at) entry_url,
argMaxState(url_path, created_at) exit_url,
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
event_type,
if(event_type = 2, groupArray(event_name), []) event_name,
sumIf(1, event_type = 1) views,
min(created_at) min_time,
max(created_at) max_time,
arrayFilter(x -> x != '', groupArray(tag)) tag,
toStartOfHour(created_at) timestamp
FROM umami.website_event
GROUP BY website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
city,
event_type,
timestamp);

View File

@@ -0,0 +1,122 @@
-- drop projections
ALTER TABLE umami.website_event DROP PROJECTION website_event_url_path_projection;
ALTER TABLE umami.website_event DROP PROJECTION website_event_referrer_domain_projection;
--drop view
DROP TABLE umami.website_event_stats_hourly_mv;
-- rename columns
ALTER TABLE umami.website_event RENAME COLUMN "subdivision1" TO "region";
ALTER TABLE umami.website_event_stats_hourly RENAME COLUMN "subdivision1" TO "region";
-- drop columns
ALTER TABLE umami.website_event DROP COLUMN "subdivision2";
-- recreate projections
ALTER TABLE umami.website_event
ADD PROJECTION website_event_url_path_projection (
SELECT * ORDER BY toStartOfDay(created_at), website_id, url_path, created_at
);
ALTER TABLE umami.website_event MATERIALIZE PROJECTION website_event_url_path_projection;
ALTER TABLE umami.website_event
ADD PROJECTION website_event_referrer_domain_projection (
SELECT * ORDER BY toStartOfDay(created_at), website_id, referrer_domain, created_at
);
ALTER TABLE umami.website_event MATERIALIZE PROJECTION website_event_referrer_domain_projection;
-- recreate view
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv
TO umami.website_event_stats_hourly
AS
SELECT
website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
region,
city,
entry_url,
exit_url,
url_paths as url_path,
url_query,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
referrer_domain,
page_title,
gclid,
fbclid,
msclkid,
ttclid,
li_fat_id,
twclid,
event_type,
event_name,
views,
min_time,
max_time,
tag,
timestamp as created_at
FROM (SELECT
website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
region,
city,
argMinState(url_path, created_at) entry_url,
argMaxState(url_path, created_at) exit_url,
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
event_type,
if(event_type = 2, groupArray(event_name), []) event_name,
sumIf(1, event_type = 1) views,
min(created_at) min_time,
max(created_at) max_time,
arrayFilter(x -> x != '', groupArray(tag)) tag,
toStartOfHour(created_at) timestamp
FROM umami.website_event
GROUP BY website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
region,
city,
event_type,
timestamp);

View File

@@ -13,16 +13,27 @@ CREATE TABLE umami.website_event
screen LowCardinality(String), screen LowCardinality(String),
language LowCardinality(String), language LowCardinality(String),
country LowCardinality(String), country LowCardinality(String),
subdivision1 LowCardinality(String), region LowCardinality(String),
subdivision2 LowCardinality(String),
city String, city String,
--pageviews --pageviews
url_path String, url_path String,
url_query String, url_query String,
utm_source String,
utm_medium String,
utm_campaign String,
utm_content String,
utm_term String,
referrer_path String, referrer_path String,
referrer_query String, referrer_query String,
referrer_domain String, referrer_domain String,
page_title String, page_title String,
--clickIDs
gclid String,
fbclid String,
msclkid String,
ttclid String,
li_fat_id String,
twclid String,
--events --events
event_type UInt32, event_type UInt32,
event_name String, event_name String,
@@ -84,14 +95,25 @@ CREATE TABLE umami.website_event_stats_hourly
screen LowCardinality(String), screen LowCardinality(String),
language LowCardinality(String), language LowCardinality(String),
country LowCardinality(String), country LowCardinality(String),
subdivision1 LowCardinality(String), region LowCardinality(String),
city String, city String,
entry_url AggregateFunction(argMin, String, DateTime('UTC')), entry_url AggregateFunction(argMin, String, DateTime('UTC')),
exit_url AggregateFunction(argMax, String, DateTime('UTC')), exit_url AggregateFunction(argMax, String, DateTime('UTC')),
url_path SimpleAggregateFunction(groupArrayArray, Array(String)), url_path SimpleAggregateFunction(groupArrayArray, Array(String)),
url_query SimpleAggregateFunction(groupArrayArray, Array(String)), url_query SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_source SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_medium SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_campaign SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_content SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_term SimpleAggregateFunction(groupArrayArray, Array(String)),
referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)), referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)),
page_title SimpleAggregateFunction(groupArrayArray, Array(String)), page_title SimpleAggregateFunction(groupArrayArray, Array(String)),
gclid SimpleAggregateFunction(groupArrayArray, Array(String)),
fbclid SimpleAggregateFunction(groupArrayArray, Array(String)),
msclkid SimpleAggregateFunction(groupArrayArray, Array(String)),
ttclid SimpleAggregateFunction(groupArrayArray, Array(String)),
li_fat_id SimpleAggregateFunction(groupArrayArray, Array(String)),
twclid SimpleAggregateFunction(groupArrayArray, Array(String)),
event_type UInt32, event_type UInt32,
event_name SimpleAggregateFunction(groupArrayArray, Array(String)), event_name SimpleAggregateFunction(groupArrayArray, Array(String)),
views SimpleAggregateFunction(sum, UInt64), views SimpleAggregateFunction(sum, UInt64),
@@ -125,14 +147,25 @@ SELECT
screen, screen,
language, language,
country, country,
subdivision1, region,
city, city,
entry_url, entry_url,
exit_url, exit_url,
url_paths as url_path, url_paths as url_path,
url_query, url_query,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
referrer_domain, referrer_domain,
page_title, page_title,
gclid,
fbclid,
msclkid,
ttclid,
li_fat_id,
twclid,
event_type, event_type,
event_name, event_name,
views, views,
@@ -151,14 +184,25 @@ FROM (SELECT
screen, screen,
language, language,
country, country,
subdivision1, region,
city, city,
argMinState(url_path, created_at) entry_url, argMinState(url_path, created_at) entry_url,
argMaxState(url_path, created_at) exit_url, argMaxState(url_path, created_at) exit_url,
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths, arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
arrayFilter(x -> x != '', groupArray(url_query)) url_query, arrayFilter(x -> x != '', groupArray(url_query)) url_query,
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain, arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain,
arrayFilter(x -> x != '', groupArray(page_title)) page_title, arrayFilter(x -> x != '', groupArray(page_title)) page_title,
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
event_type, event_type,
if(event_type = 2, groupArray(event_name), []) event_name, if(event_type = 2, groupArray(event_name), []) event_name,
sumIf(1, event_type = 1) views, sumIf(1, event_type = 1) views,
@@ -177,7 +221,7 @@ GROUP BY website_id,
screen, screen,
language, language,
country, country,
subdivision1, region,
city, city,
event_type, event_type,
timestamp); timestamp);

View File

@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE `website_event`
ADD COLUMN `fbclid` VARCHAR(255) NULL,
ADD COLUMN `gclid` VARCHAR(255) NULL,
ADD COLUMN `li_fat_id` VARCHAR(255) NULL,
ADD COLUMN `msclkid` VARCHAR(255) NULL,
ADD COLUMN `ttclid` VARCHAR(255) NULL,
ADD COLUMN `twclid` VARCHAR(255) NULL,
ADD COLUMN `utm_campaign` VARCHAR(255) NULL,
ADD COLUMN `utm_content` VARCHAR(255) NULL,
ADD COLUMN `utm_medium` VARCHAR(255) NULL,
ADD COLUMN `utm_source` VARCHAR(255) NULL,
ADD COLUMN `utm_term` VARCHAR(255) NULL;

View File

@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE `website_event` ADD COLUMN `hostname` VARCHAR(100) NULL;
-- DataMigration
UPDATE `website_event` w
JOIN `session` s
ON s.website_id = w.website_id
and s.session_id = w.session_id
SET w.hostname = s.hostname;
-- DropIndex
DROP INDEX `session_website_id_created_at_hostname_idx` ON `session`;
DROP INDEX `session_website_id_created_at_subdivision1_idx` ON `session`;
-- AlterTable
ALTER TABLE `session` RENAME COLUMN `subdivision1` TO `region`;
ALTER TABLE `session` DROP COLUMN `subdivision2`;
ALTER TABLE `session` DROP COLUMN `hostname`;
-- CreateIndex
CREATE INDEX `website_event_website_id_created_at_hostname_idx` ON `website_event`(`website_id`, `created_at`, `hostname`);
CREATE INDEX `session_website_id_created_at_region_idx` ON `session`(`website_id`, `created_at`, `region`);

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (e.g., Git)
provider = "mysql" provider = "mysql"

View File

@@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../src/generated/prisma"
binaryTargets = ["native"] binaryTargets = ["native"]
} }
@@ -29,19 +30,17 @@ model User {
} }
model Session { model Session {
id String @id @unique @map("session_id") @db.VarChar(36) id String @id @unique @map("session_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36) websiteId String @map("website_id") @db.VarChar(36)
hostname String? @db.VarChar(100) browser String? @db.VarChar(20)
browser String? @db.VarChar(20) os String? @db.VarChar(20)
os String? @db.VarChar(20) device String? @db.VarChar(20)
device String? @db.VarChar(20) screen String? @db.VarChar(11)
screen String? @db.VarChar(11) language String? @db.VarChar(35)
language String? @db.VarChar(35) country String? @db.Char(2)
country String? @db.Char(2) region String? @db.Char(20)
subdivision1 String? @db.Char(20) city String? @db.VarChar(50)
subdivision2 String? @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
city String? @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
websiteEvent WebsiteEvent[] websiteEvent WebsiteEvent[]
sessionData SessionData[] sessionData SessionData[]
@@ -49,14 +48,13 @@ model Session {
@@index([createdAt]) @@index([createdAt])
@@index([websiteId]) @@index([websiteId])
@@index([websiteId, createdAt]) @@index([websiteId, createdAt])
@@index([websiteId, createdAt, hostname])
@@index([websiteId, createdAt, browser]) @@index([websiteId, createdAt, browser])
@@index([websiteId, createdAt, os]) @@index([websiteId, createdAt, os])
@@index([websiteId, createdAt, device]) @@index([websiteId, createdAt, device])
@@index([websiteId, createdAt, screen]) @@index([websiteId, createdAt, screen])
@@index([websiteId, createdAt, language]) @@index([websiteId, createdAt, language])
@@index([websiteId, createdAt, country]) @@index([websiteId, createdAt, country])
@@index([websiteId, createdAt, subdivision1]) @@index([websiteId, createdAt, region])
@@index([websiteId, createdAt, city]) @@index([websiteId, createdAt, city])
@@map("session") @@map("session")
} }
@@ -97,13 +95,25 @@ model WebsiteEvent {
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
urlPath String @map("url_path") @db.VarChar(500) urlPath String @map("url_path") @db.VarChar(500)
urlQuery String? @map("url_query") @db.VarChar(500) urlQuery String? @map("url_query") @db.VarChar(500)
utmSource String? @map("utm_source") @db.VarChar(255)
utmMedium String? @map("utm_medium") @db.VarChar(255)
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
utmContent String? @map("utm_content") @db.VarChar(255)
utmTerm String? @map("utm_term") @db.VarChar(255)
referrerPath String? @map("referrer_path") @db.VarChar(500) referrerPath String? @map("referrer_path") @db.VarChar(500)
referrerQuery String? @map("referrer_query") @db.VarChar(500) referrerQuery String? @map("referrer_query") @db.VarChar(500)
referrerDomain String? @map("referrer_domain") @db.VarChar(500) referrerDomain String? @map("referrer_domain") @db.VarChar(500)
pageTitle String? @map("page_title") @db.VarChar(500) pageTitle String? @map("page_title") @db.VarChar(500)
gclid String? @map("gclid") @db.VarChar(255)
fbclid String? @map("fbclid") @db.VarChar(255)
msclkid String? @map("msclkid") @db.VarChar(255)
ttclid String? @map("ttclid") @db.VarChar(255)
lifatid String? @map("li_fat_id") @db.VarChar(255)
twclid String? @map("twclid") @db.VarChar(255)
eventType Int @default(1) @map("event_type") @db.UnsignedInt eventType Int @default(1) @map("event_type") @db.UnsignedInt
eventName String? @map("event_name") @db.VarChar(50) eventName String? @map("event_name") @db.VarChar(50)
tag String? @db.VarChar(50) tag String? @db.VarChar(50)
hostname String? @db.VarChar(100)
eventData EventData[] eventData EventData[]
session Session @relation(fields: [sessionId], references: [id]) session Session @relation(fields: [sessionId], references: [id])
@@ -121,6 +131,7 @@ model WebsiteEvent {
@@index([websiteId, createdAt, tag]) @@index([websiteId, createdAt, tag])
@@index([websiteId, sessionId, createdAt]) @@index([websiteId, sessionId, createdAt])
@@index([websiteId, visitId, createdAt]) @@index([websiteId, visitId, createdAt])
@@index([websiteId, createdAt, hostname])
@@map("website_event") @@map("website_event")
} }

View File

@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "website_event"
ADD COLUMN "fbclid" VARCHAR(255),
ADD COLUMN "gclid" VARCHAR(255),
ADD COLUMN "li_fat_id" VARCHAR(255),
ADD COLUMN "msclkid" VARCHAR(255),
ADD COLUMN "ttclid" VARCHAR(255),
ADD COLUMN "twclid" VARCHAR(255),
ADD COLUMN "utm_campaign" VARCHAR(255),
ADD COLUMN "utm_content" VARCHAR(255),
ADD COLUMN "utm_medium" VARCHAR(255),
ADD COLUMN "utm_source" VARCHAR(255),
ADD COLUMN "utm_term" VARCHAR(255);

View File

@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "website_event" ADD COLUMN "hostname" VARCHAR(100);
-- DataMigration
UPDATE "website_event" w
SET hostname = s.hostname
FROM "session" s
WHERE s.website_id = w.website_id
and s.session_id = w.session_id;
-- DropIndex
DROP INDEX IF EXISTS "session_website_id_created_at_hostname_idx";
DROP INDEX IF EXISTS "session_website_id_created_at_subdivision1_idx";
-- AlterTable
ALTER TABLE "session" RENAME COLUMN "subdivision1" TO "region";
ALTER TABLE "session" DROP COLUMN "subdivision2";
ALTER TABLE "session" DROP COLUMN "hostname";
-- CreateIndex
CREATE INDEX "website_event_website_id_created_at_hostname_idx" ON "website_event"("website_id", "created_at", "hostname");
CREATE INDEX "session_website_id_created_at_region_idx" ON "session"("website_id", "created_at", "region");

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (e.g., Git)
provider = "postgresql" provider = "postgresql"

View File

@@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../src/generated/prisma"
binaryTargets = ["native"] binaryTargets = ["native"]
} }
@@ -29,19 +30,17 @@ model User {
} }
model Session { model Session {
id String @id @unique @map("session_id") @db.Uuid id String @id @unique @map("session_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
hostname String? @db.VarChar(100) browser String? @db.VarChar(20)
browser String? @db.VarChar(20) os String? @db.VarChar(20)
os String? @db.VarChar(20) device String? @db.VarChar(20)
device String? @db.VarChar(20) screen String? @db.VarChar(11)
screen String? @db.VarChar(11) language String? @db.VarChar(35)
language String? @db.VarChar(35) country String? @db.Char(2)
country String? @db.Char(2) region String? @db.VarChar(20)
subdivision1 String? @db.VarChar(20) city String? @db.VarChar(50)
subdivision2 String? @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
city String? @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
websiteEvent WebsiteEvent[] websiteEvent WebsiteEvent[]
sessionData SessionData[] sessionData SessionData[]
@@ -49,14 +48,13 @@ model Session {
@@index([createdAt]) @@index([createdAt])
@@index([websiteId]) @@index([websiteId])
@@index([websiteId, createdAt]) @@index([websiteId, createdAt])
@@index([websiteId, createdAt, hostname])
@@index([websiteId, createdAt, browser]) @@index([websiteId, createdAt, browser])
@@index([websiteId, createdAt, os]) @@index([websiteId, createdAt, os])
@@index([websiteId, createdAt, device]) @@index([websiteId, createdAt, device])
@@index([websiteId, createdAt, screen]) @@index([websiteId, createdAt, screen])
@@index([websiteId, createdAt, language]) @@index([websiteId, createdAt, language])
@@index([websiteId, createdAt, country]) @@index([websiteId, createdAt, country])
@@index([websiteId, createdAt, subdivision1]) @@index([websiteId, createdAt, region])
@@index([websiteId, createdAt, city]) @@index([websiteId, createdAt, city])
@@map("session") @@map("session")
} }
@@ -97,13 +95,25 @@ model WebsiteEvent {
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
urlPath String @map("url_path") @db.VarChar(500) urlPath String @map("url_path") @db.VarChar(500)
urlQuery String? @map("url_query") @db.VarChar(500) urlQuery String? @map("url_query") @db.VarChar(500)
utmSource String? @map("utm_source") @db.VarChar(255)
utmMedium String? @map("utm_medium") @db.VarChar(255)
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
utmContent String? @map("utm_content") @db.VarChar(255)
utmTerm String? @map("utm_term") @db.VarChar(255)
referrerPath String? @map("referrer_path") @db.VarChar(500) referrerPath String? @map("referrer_path") @db.VarChar(500)
referrerQuery String? @map("referrer_query") @db.VarChar(500) referrerQuery String? @map("referrer_query") @db.VarChar(500)
referrerDomain String? @map("referrer_domain") @db.VarChar(500) referrerDomain String? @map("referrer_domain") @db.VarChar(500)
pageTitle String? @map("page_title") @db.VarChar(500) pageTitle String? @map("page_title") @db.VarChar(500)
gclid String? @map("gclid") @db.VarChar(255)
fbclid String? @map("fbclid") @db.VarChar(255)
msclkid String? @map("msclkid") @db.VarChar(255)
ttclid String? @map("ttclid") @db.VarChar(255)
lifatid String? @map("li_fat_id") @db.VarChar(255)
twclid String? @map("twclid") @db.VarChar(255)
eventType Int @default(1) @map("event_type") @db.Integer eventType Int @default(1) @map("event_type") @db.Integer
eventName String? @map("event_name") @db.VarChar(50) eventName String? @map("event_name") @db.VarChar(50)
tag String? @db.VarChar(50) tag String? @db.VarChar(50)
hostname String? @db.VarChar(100)
eventData EventData[] eventData EventData[]
session Session @relation(fields: [sessionId], references: [id]) session Session @relation(fields: [sessionId], references: [id])
@@ -121,6 +131,7 @@ model WebsiteEvent {
@@index([websiteId, createdAt, tag]) @@index([websiteId, createdAt, tag])
@@index([websiteId, sessionId, createdAt]) @@index([websiteId, sessionId, createdAt])
@@index([websiteId, visitId, createdAt]) @@index([websiteId, visitId, createdAt])
@@index([websiteId, createdAt, hostname])
@@map("website_event") @@map("website_event")
} }

View File

@@ -1,59 +0,0 @@
import { NextResponse } from 'next/server';
export const config = {
matcher: '/:path*',
};
const apiHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'GET, DELETE, POST, PUT',
'Access-Control-Max-Age': process.env.CORS_MAX_AGE || '86400',
};
const trackerHeaders = {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=86400, must-revalidate',
};
function customCollectEndpoint(req) {
const collectEndpoint = process.env.COLLECT_API_ENDPOINT;
if (collectEndpoint) {
const url = req.nextUrl.clone();
const { pathname } = url;
if (pathname.endsWith(collectEndpoint)) {
url.pathname = '/api/send';
return NextResponse.rewrite(url, { headers: apiHeaders });
}
}
}
function customScriptName(req) {
const scriptName = process.env.TRACKER_SCRIPT_NAME;
if (scriptName) {
const url = req.nextUrl.clone();
const { pathname } = url;
const names = scriptName.split(',').map(name => name.trim().replace(/^\/+/, ''));
if (names.find(name => pathname.endsWith(name))) {
url.pathname = '/script.js';
return NextResponse.rewrite(url, { headers: trackerHeaders });
}
}
}
export function middleware(req) {
const fns = [customCollectEndpoint, customScriptName];
for (const fn of fns) {
const res = fn(req);
if (res) {
return res;
}
}
return NextResponse.next();
}

View File

@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-require-imports */ import 'dotenv/config';
require('dotenv').config(); import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json'); const pkg = require('./package.json');
const TRACKER_SCRIPT = '/script.js'; const TRACKER_SCRIPT = '/script.js';
@@ -12,6 +14,7 @@ const corsMaxAge = process.env.CORS_MAX_AGE;
const defaultLocale = process.env.DEFAULT_LOCALE; const defaultLocale = process.env.DEFAULT_LOCALE;
const disableLogin = process.env.DISABLE_LOGIN; const disableLogin = process.env.DISABLE_LOGIN;
const disableUI = process.env.DISABLE_UI; const disableUI = process.env.DISABLE_UI;
const faviconURL = process.env.FAVICON_URL;
const forceSSL = process.env.FORCE_SSL; const forceSSL = process.env.FORCE_SSL;
const frameAncestors = process.env.ALLOWED_FRAME_URLS; const frameAncestors = process.env.ALLOWED_FRAME_URLS;
const privateMode = process.env.PRIVATE_MODE; const privateMode = process.env.PRIVATE_MODE;
@@ -76,6 +79,10 @@ const apiHeaders = [
key: 'Access-Control-Max-Age', key: 'Access-Control-Max-Age',
value: corsMaxAge || '86400', value: corsMaxAge || '86400',
}, },
{
key: 'Cache-Control',
value: 'no-cache',
},
]; ];
const headers = [ const headers = [
@@ -176,7 +183,7 @@ if (cloudMode && cloudUrl) {
} }
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { export default {
reactStrictMode: false, reactStrictMode: false,
env: { env: {
basePath, basePath,
@@ -186,6 +193,7 @@ const config = {
defaultLocale, defaultLocale,
disableLogin, disableLogin,
disableUI, disableUI,
faviconURL,
privateMode, privateMode,
}, },
basePath, basePath,
@@ -216,5 +224,3 @@ const config = {
return [...redirects]; return [...redirects];
}, },
}; };
module.exports = config;

View File

@@ -1,7 +1,7 @@
{ {
"name": "umami", "name": "umami",
"version": "3.0.0", "version": "2.18.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.", "description": "A modern, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>", "author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT", "license": "MIT",
"homepage": "https://umami.is", "homepage": "https://umami.is",
@@ -15,36 +15,37 @@
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app", "build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
"start": "next start", "start": "next start",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app", "build-docker": "npm-run-all build-db build-tracker build-geo build-app",
"start-docker": "npm-run-all check-db update-tracker start-server", "start-docker": "npm-run-all check-db update-tracker set-routes-manifest start-server",
"start-env": "node scripts/start-env.js", "start-env": "node scripts/start-env.mjs",
"start-server": "node server.js", "start-server": "node server.js",
"build-app": "next build", "build-app": "next build",
"build-components": "rollup -c rollup.components.config.mjs", "build-components": "rollup -c rollup.components.config.mjs",
"build-tracker": "rollup -c rollup.tracker.config.mjs", "build-tracker": "rollup -c rollup.tracker.config.mjs",
"build-db": "npm-run-all copy-db-files build-db-client", "build-db": "npm-run-all copy-db-files build-db-client",
"build-lang": "npm-run-all format-lang compile-lang clean-lang download-country-names download-language-names", "build-lang": "npm-run-all format-lang compile-lang clean-lang download-country-names download-language-names",
"build-geo": "node scripts/build-geo.js", "build-geo": "node scripts/build-geo.mjs",
"build-db-schema": "prisma db pull", "build-db-schema": "prisma db pull",
"build-db-client": "prisma generate", "build-db-client": "prisma generate",
"build-icons": "svgr ./src/assets --out-dir ./src/components/svg --typescript", "build-icons": "svgr ./src/assets --out-dir ./src/components/svg --typescript",
"update-tracker": "node scripts/update-tracker.js", "set-routes-manifest": "node scripts/set-routes-manifest.mjs",
"update-tracker": "node scripts/update-tracker.mjs",
"update-db": "prisma migrate deploy", "update-db": "prisma migrate deploy",
"check-db": "node scripts/check-db.js", "check-db": "node scripts/check-db.mjs",
"check-env": "node scripts/check-env.js", "check-env": "node scripts/check-env.mjs",
"copy-db-files": "node scripts/copy-db-files.js", "copy-db-files": "node scripts/copy-db-files.mjs",
"extract-messages": "formatjs extract \"src/components/messages.ts\" --out-file build/extracted-messages.json", "extract-messages": "formatjs extract \"src/components/messages.ts\" --out-file build/extracted-messages.json",
"merge-messages": "node scripts/merge-messages.js", "merge-messages": "node scripts/merge-messages.mjs",
"generate-lang": "npm-run-all extract-messages merge-messages", "generate-lang": "npm-run-all extract-messages merge-messages",
"format-lang": "node scripts/format-lang.js", "format-lang": "node scripts/format-lang.mjs",
"compile-lang": "formatjs compile-folder --ast build/messages public/intl/messages", "compile-lang": "formatjs compile-folder --ast build/messages public/intl/messages",
"clean-lang": "prettier --write ./public/intl/messages/*.json", "clean-lang": "prettier --write ./public/intl/messages/*.json",
"check-lang": "node scripts/check-lang.js", "check-lang": "node scripts/check-lang.mjs",
"download-country-names": "node scripts/download-country-names.js", "download-country-names": "node scripts/download-country-names.mjs",
"download-language-names": "node scripts/download-language-names.js", "download-language-names": "node scripts/download-language-names.mjs",
"change-password": "node scripts/change-password.js", "change-password": "node scripts/change-password.js",
"lint": "next lint --quiet", "lint": "next lint --quiet",
"prepare": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky install", "prepare": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky install",
"postbuild": "node scripts/postbuild.js", "postbuild": "node scripts/postbuild.mjs",
"test": "jest", "test": "jest",
"cypress-open": "cypress open cypress run", "cypress-open": "cypress open cypress run",
"cypress-run": "cypress run cypress run" "cypress-run": "cypress run cypress run"
@@ -68,131 +69,119 @@
"dependencies": { "dependencies": {
"@clickhouse/client": "^1.11.0", "@clickhouse/client": "^1.11.0",
"@date-fns/utc": "^1.2.0", "@date-fns/utc": "^1.2.0",
"@dicebear/collection": "^9.2.2", "@dicebear/collection": "^9.2.1",
"@dicebear/core": "^9.2.2", "@dicebear/core": "^9.2.1",
"@fontsource/inter": "^5.2.5", "@fontsource/inter": "^4.5.15",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^17.0.0",
"@internationalized/date": "^3.7.0", "@prisma/client": "6.6.0",
"@prisma/client": "6.5.0",
"@prisma/extension-read-replicas": "^0.4.1", "@prisma/extension-read-replicas": "^0.4.1",
"@react-spring/web": "^9.7.5", "@react-spring/web": "^9.7.3",
"@tanstack/react-query": "^5.71.10", "@tanstack/react-query": "^5.28.6",
"@umami/prisma-client": "^0.16.0", "@umami/redis-client": "^0.26.0",
"@umami/react-zen": "^0.87.0",
"@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"chalk": "^4.1.2", "chalk": "^4.1.1",
"chart.js": "^4.4.8", "chart.js": "^4.4.9",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"classnames": "^2.5.1", "classnames": "^2.3.1",
"colord": "^2.9.3", "colord": "^2.9.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.3",
"date-fns": "^2.30.0", "date-fns": "^2.23.0",
"date-fns-tz": "^1.3.8", "date-fns-tz": "^1.1.4",
"debug": "^4.4.0", "debug": "^4.3.4",
"del": "^6.1.1", "del": "^6.0.0",
"detect-browser": "^5.3.0", "detect-browser": "^5.2.0",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"eslint-plugin-promise": "^6.6.0", "eslint-plugin-promise": "^6.1.1",
"fs-extra": "^10.1.0", "fs-extra": "^10.0.1",
"immer": "^9.0.21", "immer": "^9.0.12",
"ipaddr.js": "^2.2.0", "ipaddr.js": "^2.0.1",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
"is-docker": "^3.0.0", "is-docker": "^3.0.0",
"is-localhost-ip": "^1.4.0", "is-localhost-ip": "^1.4.0",
"isbot": "^5.1.25", "isbot": "^5.1.16",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.4", "kafkajs": "^2.1.0",
"lucide-react": "^0.475.0",
"maxmind": "^4.3.24", "maxmind": "^4.3.24",
"md5": "^2.3.0", "md5": "^2.3.0",
"next": "15.3.0", "next": "15.3.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prisma": "6.5.0", "prisma": "6.6.0",
"pure-rand": "^6.1.0", "pure-rand": "^6.1.0",
"react": "^19.1.0", "react": "^19.0.0",
"react-basics": "^0.126.0", "react-basics": "^0.126.0",
"react-dom": "^19.1.0", "react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^4.0.4",
"react-intl": "^7.1.10", "react-intl": "^6.5.5",
"react-simple-maps": "^2.3.0", "react-simple-maps": "^2.3.0",
"react-use-measure": "^2.1.7", "react-use-measure": "^2.0.4",
"react-window": "^1.8.11", "react-window": "^1.8.6",
"request-ip": "^3.3.0", "request-ip": "^3.3.0",
"semver": "^7.7.1", "semver": "^7.5.4",
"serialize-error": "^12.0.0", "serialize-error": "^12.0.0",
"thenby": "^1.3.4", "thenby": "^1.3.4",
"uuid": "^9.0.1", "uuid": "^9.0.0",
"zod": "^3.24.2", "zod": "^3.24.3",
"zustand": "^5.0.3" "zustand": "^4.5.5"
}, },
"devDependencies": { "devDependencies": {
"@formatjs/cli": "^4.8.4", "@formatjs/cli": "^4.2.29",
"@netlify/plugin-nextjs": "^5.10.3", "@netlify/plugin-nextjs": "^5.10.6",
"@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-alias": "^5.0.0",
"@rollup/plugin-commonjs": "^25.0.8", "@rollup/plugin-commonjs": "^25.0.4",
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.3.1", "@rollup/plugin-node-resolve": "^15.2.0",
"@rollup/plugin-replace": "^5.0.7", "@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-terser": "^0.4.4",
"@svgr/cli": "^8.1.0",
"@svgr/rollup": "^8.1.0", "@svgr/rollup": "^8.1.0",
"@svgr/webpack": "^8.1.0",
"@types/cypress": "^1.1.3",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.14.0", "@types/node": "^22.13.4",
"@types/react": "^19.1.0", "@types/react": "^19.0.8",
"@types/react-dom": "^19.1.1", "@types/react-dom": "^19.0.2",
"@types/react-intl": "^3.0.0",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^8.29.0", "@typescript-eslint/parser": "^6.7.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cypress": "^13.17.0", "cypress": "^13.6.6",
"esbuild": "^0.25.2", "esbuild": "^0.25.0",
"eslint": "^8.57.1", "eslint": "^8.33.0",
"eslint-config-next": "^14.2.26", "eslint-config-next": "^14.0.4",
"eslint-config-prettier": "^8.10.0", "eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-css-modules": "^2.12.0", "eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-cypress": "^2.15.2", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest": "^27.9.0", "eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.0.0",
"extract-react-intl-messages": "^4.1.1", "extract-react-intl-messages": "^4.1.1",
"husky": "^8.0.3", "husky": "^8.0.3",
"jest": "^29.7.0", "jest": "^29.7.0",
"lint-staged": "^14.0.1", "lint-staged": "^14.0.1",
"postcss": "^8.5.3", "postcss": "^8.4.31",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
"postcss-preset-env": "7.8.3", "postcss-preset-env": "7.8.3",
"postcss-rtlcss": "^4.0.9", "postcss-rtlcss": "^4.0.1",
"prettier": "^2.8.8", "prettier": "^2.6.2",
"prompts": "2.4.2", "prompts": "2.4.2",
"rollup": "^3.29.5", "rollup": "^3.28.0",
"rollup-plugin-copy": "^3.5.0", "rollup-plugin-copy": "^3.4.0",
"rollup-plugin-delete": "^2.2.0", "rollup-plugin-delete": "^2.0.0",
"rollup-plugin-dts": "^5.3.1", "rollup-plugin-dts": "^5.3.1",
"rollup-plugin-esbuild": "^5.0.0", "rollup-plugin-esbuild": "^5.0.0",
"rollup-plugin-node-externals": "^6.1.2", "rollup-plugin-node-externals": "^6.1.1",
"rollup-plugin-postcss": "^4.0.2", "rollup-plugin-postcss": "^4.0.2",
"stylelint": "^15.11.0", "rollup-plugin-terser": "^7.0.2",
"stylelint": "^15.10.1",
"stylelint-config-css-modules": "^4.4.0", "stylelint-config-css-modules": "^4.4.0",
"stylelint-config-recommended": "^14.0.1", "stylelint-config-prettier": "^9.0.3",
"tar": "^6.2.1", "stylelint-config-recommended": "^14.0.0",
"ts-jest": "^29.3.1", "tar": "^6.1.2",
"ts-node": "^10.9.2", "ts-jest": "^29.1.2",
"typescript": "^5.8.3" "ts-node": "^10.9.1",
}, "typescript": "^5.5.3"
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/client",
"@prisma/engines",
"cypress",
"esbuild",
"prisma",
"sharp"
],
"overrides": {}
} }
} }

4412
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- prisma

50
podman/README.md Normal file
View File

@@ -0,0 +1,50 @@
# How to deploy umami on podman
## How to use
1. Rename `env.sample` to `.env`
2. Edit `.env` file. At the minimum set the passwords.
3. Start umami by running `podman-compose up -d`.
If you need to stop umami, you can do so by running `podman-compose down`.
### Install systemd service (optional)
If you want to install a systemd service to run umami, you can use the provided
systemd service.
Edit `umami.service` and change these two variables:
WorkingDirectory=/opt/apps/umami
EnvironmentFile=/opt/apps/umami/.env
`WorkingDirectory` should be changed to the path in which `podman-compose.yml`
is located.
`EnvironmentFile` should be changed to the path in which your `.env`file is
located.
You can run the script `install-systemd-user-service` to install the systemd
service under the current user.
./install-systemd-user-service
Note: this script will enable the service and also start it. So it will assume
that umami is not currently running. If you started it previously, bring it
down using:
podman-compose down
## Compatibility
These files should be compatible with podman 4.3+.
I have tested this on Debian GNU/Linux 12 (bookworm) and with the podman that
is distributed with the official Debian stable mirrors (podman
v4.3.1+ds1-8+deb12u1, podman-compose v1.0.3-3).

16
podman/env.sample Normal file
View File

@@ -0,0 +1,16 @@
# Rename this file to .env and modify the values
#
# Connection string for Umamis database.
# If you use the bundled DB container, "db" is the hostname.
DATABASE_URL=postgresql://umami:replace-me-with-a-random-string@db:5432/umami
# Database type (e.g. postgresql)
DATABASE_TYPE=postgresql
# A secret string used by Umami (replace with a strong random string)
APP_SECRET=replace-me-with-a-random-string
# Postgres container defaults.
POSTGRES_DB=umami
POSTGRES_USER=umami
POSTGRES_PASSWORD=replace-me-with-a-random-string

View File

@@ -0,0 +1,10 @@
#!/bin/bash
set -e
service_name="umami"
mkdir -p ~/.config/systemd/user
cp $service_name.service ~/.config/systemd/user
systemctl --user daemon-reload
systemctl --user enable $service_name.service
systemctl --user start $service_name.service

41
podman/podman-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
version: "3.8"
services:
umami:
container_name: umami
image: ghcr.io/umami-software/umami:postgresql-latest
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: ${DATABASE_URL}
DATABASE_TYPE: ${DATABASE_TYPE}
APP_SECRET: ${APP_SECRET}
depends_on:
db:
condition: service_healthy
init: true
restart: always
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/heartbeat || exit 1"]
interval: 5s
timeout: 5s
retries: 5
db:
container_name: umami-db
image: docker.io/library/postgres:15-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- umami-db-data:/var/lib/postgresql/data:Z
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
umami-db-data:

14
podman/umami.service Normal file
View File

@@ -0,0 +1,14 @@
[Unit]
Description=Umami Container Stack via Podman-Compose
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/apps/umami
EnvironmentFile=/opt/apps/umami/.env
ExecStart=/usr/bin/podman-compose -f podman-compose.yml up -d
ExecStop=/usr/bin/podman-compose -f podman-compose.yml down
RemainAfterExit=yes
[Install]
WantedBy=default.target

7
prisma.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import path from 'node:path';
import { defineConfig } from 'prisma/config';
export default defineConfig({
earlyAccess: true,
schema: path.join('prisma', 'schema.prisma'),
});

View File

@@ -53,6 +53,12 @@
"value": "Administrateur" "value": "Administrateur"
} }
], ],
"label.affiliate": [
{
"type": 0,
"value": "Affiliation"
}
],
"label.after": [ "label.after": [
{ {
"type": 0, "type": 0,
@@ -77,6 +83,18 @@
"value": "Analytics" "value": "Analytics"
} }
], ],
"label.attribution": [
{
"type": 0,
"value": "Attribution"
}
],
"label.attribution-description": [
{
"type": 0,
"value": "Découvrez comment les utilisateurs s'engagent avec votre marketing et ce qui génère des conversions."
}
],
"label.average": [ "label.average": [
{ {
"type": 0, "type": 0,
@@ -119,6 +137,12 @@
"value": "Navigateurs" "value": "Navigateurs"
} }
], ],
"label.campaigns": [
{
"type": 0,
"value": "Campagnes"
}
],
"label.cancel": [ "label.cancel": [
{ {
"type": 0, "type": 0,
@@ -131,6 +155,12 @@
"value": "Changer le mot de passe" "value": "Changer le mot de passe"
} }
], ],
"label.channels": [
{
"type": 0,
"value": "Canaux"
}
],
"label.cities": [ "label.cities": [
{ {
"type": 0, "type": 0,
@@ -152,7 +182,7 @@
"label.compare": [ "label.compare": [
{ {
"type": 0, "type": 0,
"value": "Compare" "value": "Comparer"
} }
], ],
"label.confirm": [ "label.confirm": [
@@ -173,16 +203,28 @@
"value": "Contient" "value": "Contient"
} }
], ],
"label.content": [
{
"type": 0,
"value": "Contenu"
}
],
"label.continue": [ "label.continue": [
{ {
"type": 0, "type": 0,
"value": "Continuer" "value": "Continuer"
} }
], ],
"label.conversion-step": [
{
"type": 0,
"value": "Étape de conversion"
}
],
"label.count": [ "label.count": [
{ {
"type": 0, "type": 0,
"value": "Count" "value": "Compte"
} }
], ],
"label.countries": [ "label.countries": [
@@ -230,13 +272,19 @@
"label.created-by": [ "label.created-by": [
{ {
"type": 0, "type": 0,
"value": "Crée par" "value": "Créé par"
}
],
"label.currency": [
{
"type": 0,
"value": "Devise"
} }
], ],
"label.current": [ "label.current": [
{ {
"type": 0, "type": 0,
"value": "Current" "value": "Actuel"
} }
], ],
"label.current-password": [ "label.current-password": [
@@ -347,6 +395,12 @@
"value": "Appareils" "value": "Appareils"
} }
], ],
"label.direct": [
{
"type": 0,
"value": "Direct"
}
],
"label.dismiss": [ "label.dismiss": [
{ {
"type": 0, "type": 0,
@@ -389,6 +443,12 @@
"value": "Modifier le membre" "value": "Modifier le membre"
} }
], ],
"label.email": [
{
"type": 0,
"value": "E-mail"
}
],
"label.enable-share-url": [ "label.enable-share-url": [
{ {
"type": 0, "type": 0,
@@ -398,13 +458,13 @@
"label.end-step": [ "label.end-step": [
{ {
"type": 0, "type": 0,
"value": "End Step" "value": "Étape de fin"
} }
], ],
"label.entry": [ "label.entry": [
{ {
"type": 0, "type": 0,
"value": "URL d'entrée" "value": "Chemin d'entrée"
} }
], ],
"label.event": [ "label.event": [
@@ -428,7 +488,7 @@
"label.exit": [ "label.exit": [
{ {
"type": 0, "type": 0,
"value": "Exit URL" "value": "Chemin de sortie"
} }
], ],
"label.false": [ "label.false": [
@@ -488,19 +548,19 @@
"label.funnel-description": [ "label.funnel-description": [
{ {
"type": 0, "type": 0,
"value": "Suivi des conversions et des taux d'abandons." "value": "Comprenez les taux de conversions et d'abandons des utilisateurs."
} }
], ],
"label.goal": [ "label.goal": [
{ {
"type": 0, "type": 0,
"value": "Goal" "value": "Objectif"
} }
], ],
"label.goals": [ "label.goals": [
{ {
"type": 0, "type": 0,
"value": "Goals" "value": "Objectifs"
} }
], ],
"label.goals-description": [ "label.goals-description": [
@@ -521,16 +581,22 @@
"value": "Supérieur ou égal à" "value": "Supérieur ou égal à"
} }
], ],
"label.grouped": [
{
"type": 0,
"value": "Groupé"
}
],
"label.host": [ "label.host": [
{ {
"type": 0, "type": 0,
"value": "Host" "value": "Hôte"
} }
], ],
"label.hosts": [ "label.hosts": [
{ {
"type": 0, "type": 0,
"value": "Hosts" "value": "Hôtes"
} }
], ],
"label.insights": [ "label.insights": [
@@ -542,7 +608,7 @@
"label.insights-description": [ "label.insights-description": [
{ {
"type": 0, "type": 0,
"value": "Analyse précise des données en utilisant des segments et des filtres." "value": "Analysez précisément vos données en utilisant des segments et des filtres."
} }
], ],
"label.is": [ "label.is": [
@@ -584,13 +650,13 @@
"label.journey": [ "label.journey": [
{ {
"type": 0, "type": 0,
"value": "Journey" "value": "Parcours"
} }
], ],
"label.journey-description": [ "label.journey-description": [
{ {
"type": 0, "type": 0,
"value": "Comprendre comment les utilisateurs naviguent sur votre site web." "value": "Comprennez comment les utilisateurs naviguent sur votre site."
} }
], ],
"label.language": [ "label.language": [
@@ -644,7 +710,7 @@
"label.last-seen": [ "label.last-seen": [
{ {
"type": 0, "type": 0,
"value": "Last seen" "value": "Vu pour la dernière fois"
} }
], ],
"label.leave": [ "label.leave": [
@@ -701,6 +767,12 @@
"value": "Max" "value": "Max"
} }
], ],
"label.medium": [
{
"type": 0,
"value": "Support"
}
],
"label.member": [ "label.member": [
{ {
"type": 0, "type": 0,
@@ -725,6 +797,12 @@
"value": "Téléphone" "value": "Téléphone"
} }
], ],
"label.model": [
{
"type": 0,
"value": "Modèle"
}
],
"label.more": [ "label.more": [
{ {
"type": 0, "type": 0,
@@ -801,12 +879,42 @@
"value": "OK" "value": "OK"
} }
], ],
"label.organic-search": [
{
"type": 0,
"value": "Recherche organique"
}
],
"label.organic-shopping": [
{
"type": 0,
"value": "E-commerce organique"
}
],
"label.organic-social": [
{
"type": 0,
"value": "Réseau social organique"
}
],
"label.organic-video": [
{
"type": 0,
"value": "Vidéo organique"
}
],
"label.os": [ "label.os": [
{ {
"type": 0, "type": 0,
"value": "OS" "value": "OS"
} }
], ],
"label.other": [
{
"type": 0,
"value": "Autre"
}
],
"label.overview": [ "label.overview": [
{ {
"type": 0, "type": 0,
@@ -855,6 +963,36 @@
"value": "Pages" "value": "Pages"
} }
], ],
"label.paid-ads": [
{
"type": 0,
"value": "Publicités payantes"
}
],
"label.paid-search": [
{
"type": 0,
"value": "Recherche payante"
}
],
"label.paid-shopping": [
{
"type": 0,
"value": "E-commerce payant"
}
],
"label.paid-social": [
{
"type": 0,
"value": "Réseau social payant"
}
],
"label.paid-video": [
{
"type": 0,
"value": "Vidéo payante"
}
],
"label.password": [ "label.password": [
{ {
"type": 0, "type": 0,
@@ -864,13 +1002,13 @@
"label.path": [ "label.path": [
{ {
"type": 0, "type": 0,
"value": "Path" "value": "Chemin"
} }
], ],
"label.paths": [ "label.paths": [
{ {
"type": 0, "type": 0,
"value": "Paths" "value": "Chemins"
} }
], ],
"label.powered-by": [ "label.powered-by": [
@@ -943,6 +1081,12 @@
"value": "Temps réel" "value": "Temps réel"
} }
], ],
"label.referral": [
{
"type": 0,
"value": "Référent"
}
],
"label.referrer": [ "label.referrer": [
{ {
"type": 0, "type": 0,
@@ -1024,25 +1168,19 @@
"label.retention-description": [ "label.retention-description": [
{ {
"type": 0, "type": 0,
"value": "Mesure de l'attractivité du site en visualisant les taux de visiteurs qui reviennent." "value": "Mesurez l'attractivité de votre site en suivant la fréquence de retour des utilisateurs."
} }
], ],
"label.revenue": [ "label.revenue": [
{ {
"type": 0, "type": 0,
"value": "Revenue" "value": "Recettes"
} }
], ],
"label.revenue-description": [ "label.revenue-description": [
{ {
"type": 0, "type": 0,
"value": "Examinez vos revenus au fil du temps." "value": "Examinez vos recettes et comment dépensent vos utilisateurs."
}
],
"label.revenue-property": [
{
"type": 0,
"value": "Propriétés des revenues"
} }
], ],
"label.role": [ "label.role": [
@@ -1054,7 +1192,7 @@
"label.run-query": [ "label.run-query": [
{ {
"type": 0, "type": 0,
"value": "Éxécuter la requête" "value": "Exécuter la requête"
} }
], ],
"label.save": [ "label.save": [
@@ -1078,7 +1216,7 @@
"label.select": [ "label.select": [
{ {
"type": 0, "type": 0,
"value": "Selectionner" "value": "Sélectionner"
} }
], ],
"label.select-date": [ "label.select-date": [
@@ -1105,6 +1243,12 @@
"value": "Session" "value": "Session"
} }
], ],
"label.session-data": [
{
"type": 0,
"value": "Session data"
}
],
"label.sessions": [ "label.sessions": [
{ {
"type": 0, "type": 0,
@@ -1129,10 +1273,22 @@
"value": "Journée" "value": "Journée"
} }
], ],
"label.sms": [
{
"type": 0,
"value": "SMS"
}
],
"label.sources": [
{
"type": 0,
"value": "Sources"
}
],
"label.start-step": [ "label.start-step": [
{ {
"type": 0, "type": 0,
"value": "Etape de démarrage" "value": "Étape de départ"
} }
], ],
"label.steps": [ "label.steps": [
@@ -1153,6 +1309,18 @@
"value": "Tablette" "value": "Tablette"
} }
], ],
"label.tag": [
{
"type": 0,
"value": "Tag"
}
],
"label.tags": [
{
"type": 0,
"value": "Tags"
}
],
"label.team": [ "label.team": [
{ {
"type": 0, "type": 0,
@@ -1207,6 +1375,12 @@
"value": "Équipes" "value": "Équipes"
} }
], ],
"label.terms": [
{
"type": 0,
"value": "Mots clés"
}
],
"label.theme": [ "label.theme": [
{ {
"type": 0, "type": 0,
@@ -1357,12 +1531,6 @@
"value": "Utilisateur" "value": "Utilisateur"
} }
], ],
"label.user-property": [
{
"type": 0,
"value": "Propriétés d'utilisateurs"
}
],
"label.username": [ "label.username": [
{ {
"type": 0, "type": 0,
@@ -1384,7 +1552,7 @@
"label.utm-description": [ "label.utm-description": [
{ {
"type": 0, "type": 0,
"value": "Suivi de campagnes via les paramètres UTM." "value": "Suivez vos campagnes via les paramètres UTM."
} }
], ],
"label.value": [ "label.value": [
@@ -1426,7 +1594,7 @@
"label.visit-duration": [ "label.visit-duration": [
{ {
"type": 0, "type": 0,
"value": "Temps de visite moyen" "value": "Temps de visite"
} }
], ],
"label.visitors": [ "label.visitors": [
@@ -1526,7 +1694,7 @@
"message.collected-data": [ "message.collected-data": [
{ {
"type": 0, "type": 0,
"value": "Collected data" "value": "Donnée collectée"
} }
], ],
"message.confirm-delete": [ "message.confirm-delete": [

View File

@@ -128,7 +128,13 @@
"label.change-password": [ "label.change-password": [
{ {
"type": 0, "type": 0,
"value": "更新密码" "value": "修改密码"
}
],
"label.channels": [
{
"type": 0,
"value": "渠道"
} }
], ],
"label.cities": [ "label.cities": [
@@ -236,13 +242,13 @@
"label.current": [ "label.current": [
{ {
"type": 0, "type": 0,
"value": "前" "value": "前"
} }
], ],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,
"value": "前密码" "value": "前密码"
} }
], ],
"label.custom-range": [ "label.custom-range": [
@@ -254,7 +260,7 @@
"label.dashboard": [ "label.dashboard": [
{ {
"type": 0, "type": 0,
"value": "仪表" "value": "仪表"
} }
], ],
"label.data": [ "label.data": [
@@ -380,7 +386,7 @@
"label.edit-dashboard": [ "label.edit-dashboard": [
{ {
"type": 0, "type": 0,
"value": "编辑仪表" "value": "编辑仪表"
} }
], ],
"label.edit-member": [ "label.edit-member": [
@@ -488,7 +494,7 @@
"label.funnel-description": [ "label.funnel-description": [
{ {
"type": 0, "type": 0,
"value": "了解用户的转率和退出率。" "value": "了解用户的转率和出率。"
} }
], ],
"label.goal": [ "label.goal": [
@@ -930,7 +936,7 @@
"label.properties": [ "label.properties": [
{ {
"type": 0, "type": 0,
"value": "Properties" "value": "属性"
} }
], ],
"label.property": [ "label.property": [
@@ -1044,7 +1050,7 @@
"label.retention-description": [ "label.retention-description": [
{ {
"type": 0, "type": 0,
"value": "通过踪用户返回的频率来衡量网站的用户粘性。" "value": "通过踪用户回访频率来衡量网站的用户粘性。"
} }
], ],
"label.revenue": [ "label.revenue": [
@@ -1056,7 +1062,7 @@
"label.revenue-description": [ "label.revenue-description": [
{ {
"type": 0, "type": 0,
"value": "查看您的收入随时间变化。" "value": "查看随时间变化的收入数据。"
} }
], ],
"label.revenue-property": [ "label.revenue-property": [
@@ -1104,7 +1110,7 @@
"label.select-date": [ "label.select-date": [
{ {
"type": 0, "type": 0,
"value": "选择数据" "value": "选择日期"
} }
], ],
"label.select-role": [ "label.select-role": [
@@ -1188,7 +1194,7 @@
"label.team-manager": [ "label.team-manager": [
{ {
"type": 0, "type": 0,
"value": "团队管理" "value": "团队管理"
} }
], ],
"label.team-member": [ "label.team-member": [
@@ -1404,7 +1410,7 @@
"label.utm-description": [ "label.utm-description": [
{ {
"type": 0, "type": 0,
"value": "通过UTM参数追踪您的广告活动。" "value": "通过 UTM 参数追踪您的广告活动。"
} }
], ],
"label.value": [ "label.value": [
@@ -1428,7 +1434,7 @@
"label.view-only": [ "label.view-only": [
{ {
"type": 0, "type": 0,
"value": "仅浏览" "value": "仅浏览"
} }
], ],
"label.views": [ "label.views": [
@@ -1446,7 +1452,7 @@
"label.visit-duration": [ "label.visit-duration": [
{ {
"type": 0, "type": 0,
"value": "平均访问时" "value": "平均访问时"
} }
], ],
"label.visitors": [ "label.visitors": [
@@ -1494,7 +1500,7 @@
"message.action-confirmation": [ "message.action-confirmation": [
{ {
"type": 0, "type": 0,
"value": "在下面的框中输入 " "value": "在下方输入框中输入 "
}, },
{ {
"type": 1, "type": 1,
@@ -1502,7 +1508,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": " 以确认。" "value": " 以确认操作。"
} }
], ],
"message.active-users": [ "message.active-users": [
@@ -1516,7 +1522,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": " " "value": " 位访客"
} }
], ],
"message.collected-data": [ "message.collected-data": [
@@ -1584,7 +1590,7 @@
"message.delete-team-warning": [ "message.delete-team-warning": [
{ {
"type": 0, "type": 0,
"value": "删除团队也会删除所有团队网站。" "value": "删除团队也会删除所有团队网站。"
} }
], ],
"message.delete-website-warning": [ "message.delete-website-warning": [
@@ -1596,7 +1602,7 @@
"message.error": [ "message.error": [
{ {
"type": 0, "type": 0,
"value": "出现错误。" "value": "发生错误。"
} }
], ],
"message.event-log": [ "message.event-log": [
@@ -1648,7 +1654,7 @@
"message.new-version-available": [ "message.new-version-available": [
{ {
"type": 0, "type": 0,
"value": "Umami 新版本 " "value": "Umami 新版本 "
}, },
{ {
"type": 1, "type": 1,
@@ -1656,13 +1662,13 @@
}, },
{ {
"type": 0, "type": 0,
"value": " 已推出" "value": " 已发布"
} }
], ],
"message.no-data-available": [ "message.no-data-available": [
{ {
"type": 0, "type": 0,
"value": "无可用数据。" "value": "无数据。"
} }
], ],
"message.no-event-data": [ "message.no-event-data": [
@@ -1680,25 +1686,25 @@
"message.no-results-found": [ "message.no-results-found": [
{ {
"type": 0, "type": 0,
"value": "没有找到任何结果。" "value": "未找到结果。"
} }
], ],
"message.no-team-websites": [ "message.no-team-websites": [
{ {
"type": 0, "type": 0,
"value": "这个团队没有任何网站。" "value": "该团队暂无网站。"
} }
], ],
"message.no-teams": [ "message.no-teams": [
{ {
"type": 0, "type": 0,
"value": "你还没有创建任何团队。" "value": "您尚未创建任何团队。"
} }
], ],
"message.no-users": [ "message.no-users": [
{ {
"type": 0, "type": 0,
"value": "没有任何用户。" "value": "暂无用户。"
} }
], ],
"message.no-websites-configured": [ "message.no-websites-configured": [
@@ -1710,13 +1716,13 @@
"message.page-not-found": [ "message.page-not-found": [
{ {
"type": 0, "type": 0,
"value": "页未找到。" "value": "页未找到。"
} }
], ],
"message.reset-website": [ "message.reset-website": [
{ {
"type": 0, "type": 0,
"value": "如确定重置该网站,请在下面的输入框中输入 " "value": "如确定重置该网站,请在下面输入 "
}, },
{ {
"type": 1, "type": 1,
@@ -1724,13 +1730,13 @@
}, },
{ {
"type": 0, "type": 0,
"value": " 进行二次确认。" "value": " 确认。"
} }
], ],
"message.reset-website-warning": [ "message.reset-website-warning": [
{ {
"type": 0, "type": 0,
"value": "网站的所有统计数据将被删除,但您的跟踪代码将保持不变。" "value": "网站的所有统计数据将被删除,但您的跟踪代码将保持不变。"
} }
], ],
"message.saved": [ "message.saved": [
@@ -1756,7 +1762,7 @@
"message.team-already-member": [ "message.team-already-member": [
{ {
"type": 0, "type": 0,
"value": "你已是该团队的成员。" "value": "你已是该团队的成员。"
} }
], ],
"message.team-not-found": [ "message.team-not-found": [
@@ -1768,7 +1774,7 @@
"message.team-websites-info": [ "message.team-websites-info": [
{ {
"type": 0, "type": 0,
"value": "团队中的任何人都可查看网站。" "value": "团队成员均可查看网站数据。"
} }
], ],
"message.tracking-code": [ "message.tracking-code": [
@@ -1780,13 +1786,13 @@
"message.transfer-team-website-to-user": [ "message.transfer-team-website-to-user": [
{ {
"type": 0, "type": 0,
"value": "将网站转您的账户?" "value": "将网站转移到您的账户?"
} }
], ],
"message.transfer-user-website-to-team": [ "message.transfer-user-website-to-team": [
{ {
"type": 0, "type": 0,
"value": "选择要将该网站转移到哪个团队。" "value": "选择要转移此网站的团队。"
} }
], ],
"message.transfer-website": [ "message.transfer-website": [

View File

@@ -1,11 +1,10 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable no-console */ /* eslint-disable no-console */
require('dotenv').config(); import 'dotenv/config';
const fs = require('fs'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const https = require('https'); import https from 'https';
const zlib = require('zlib'); import zlib from 'zlib';
const tar = require('tar'); import tar from 'tar';
if (process.env.VERCEL) { if (process.env.VERCEL) {
console.log('Vercel environment detected. Skipping geo setup.'); console.log('Vercel environment detected. Skipping geo setup.');

View File

@@ -1,9 +1,10 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
require('dotenv').config(); import 'dotenv/config';
const { PrismaClient } = require('@prisma/client');
const chalk = require('chalk'); import { PrismaClient } from '@prisma/client';
const { execSync } = require('child_process'); import chalk from 'chalk';
const semver = require('semver'); import { execSync } from 'child_process';
import semver from 'semver';
if (process.env.SKIP_DB_CHECK) { if (process.env.SKIP_DB_CHECK) {
console.log('Skipping database check.'); console.log('Skipping database check.');

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
require('dotenv').config(); import 'dotenv/config';
function checkMissing(vars) { function checkMissing(vars) {
const missing = vars.reduce((arr, key) => { const missing = vars.reduce((arr, key) => {

View File

@@ -1,10 +1,12 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
const fs = require('fs'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const chalk = require('chalk'); import chalk from 'chalk';
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const messages = require('../src/lang/en-US.json'); const messages = require('../src/lang/en-US.json');
const ignore = require('../lang-ignore.json'); const ignore = require('../lang-ignore.json');
const dir = path.resolve(__dirname, '../lang'); const dir = path.resolve(__dirname, '../lang');
const files = fs.readdirSync(dir); const files = fs.readdirSync(dir);
const keys = Object.keys(messages).sort(); const keys = Object.keys(messages).sort();

View File

@@ -1,8 +1,11 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
require('dotenv').config(); import 'dotenv/config';
const fse = require('fs-extra'); import fse from 'fs-extra';
const path = require('path'); import path from 'node:path';
const del = require('del'); import url from 'node:url';
import del from 'del';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
function getDatabaseType(url = process.env.DATABASE_URL) { function getDatabaseType(url = process.env.DATABASE_URL) {
const type = process.env.DATABASE_TYPE || (url && url.split(':')[0]); const type = process.env.DATABASE_TYPE || (url && url.split(':')[0]);

View File

@@ -1,8 +1,11 @@
/* eslint-disable no-console, @typescript-eslint/no-var-requires */ /* eslint-disable no-console */
const fs = require('fs-extra'); import fs from 'fs-extra';
const path = require('path'); import path from 'node:path';
const https = require('https'); import https from 'https';
const chalk = require('chalk'); import chalk from 'chalk';
import url from "node:url";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const src = path.resolve(__dirname, '../src/lang'); const src = path.resolve(__dirname, '../src/lang');
const dest = path.resolve(__dirname, '../public/intl/country'); const dest = path.resolve(__dirname, '../public/intl/country');

View File

@@ -1,8 +1,11 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
const fs = require('fs-extra'); import fs from 'fs-extra';
const path = require('path'); import path from 'node:path';
const https = require('https'); import https from 'https';
const chalk = require('chalk'); import chalk from 'chalk';
import url from "node:url";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const src = path.resolve(__dirname, '../src/lang'); const src = path.resolve(__dirname, '../src/lang');
const dest = path.resolve(__dirname, '../public/intl/language'); const dest = path.resolve(__dirname, '../public/intl/language');

View File

@@ -1,8 +1,13 @@
const fs = require('fs-extra'); import path from 'node:path';
const path = require('path'); import url from 'node:url';
const del = require('del'); import fs from 'fs-extra';
const prettier = require('prettier'); import del from 'del';
import prettier from 'prettier';
import { createRequire } from 'module';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const src = path.resolve(__dirname, '../src/lang'); const src = path.resolve(__dirname, '../src/lang');
const dest = path.resolve(__dirname, '../build/messages'); const dest = path.resolve(__dirname, '../build/messages');
const files = fs.readdirSync(src); const files = fs.readdirSync(src);

View File

@@ -1,9 +1,11 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
const fs = require('fs'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const prettier = require('prettier'); import prettier from 'prettier';
const messages = require('../build/extracted-messages.json'); import messages from '../build/extracted-messages.json';
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const dest = path.resolve(__dirname, '../src/lang'); const dest = path.resolve(__dirname, '../src/lang');
const files = fs.readdirSync(dest); const files = fs.readdirSync(dest);
const keys = Object.keys(messages).sort(); const keys = Object.keys(messages).sort();

View File

@@ -1,5 +1,5 @@
require('dotenv').config(); import 'dotenv/config';
const { sendTelemetry } = require('./telemetry'); import { sendTelemetry } from './telemetry';
async function run() { async function run() {
if (!process.env.DISABLE_TELEMETRY) { if (!process.env.DISABLE_TELEMETRY) {

View File

@@ -0,0 +1,74 @@
/* eslint-disable no-console */
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
const routesManifestPath = path.resolve(__dirname, '../.next/routes-manifest.json');
const originalPath = path.resolve(__dirname, '../.next/routes-manifest-orig.json');
const originalManifest = require(originalPath);
const API_PATH = '/api/:path*';
const TRACKER_SCRIPT = '/script.js';
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT;
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME;
const headers = [];
const rewrites = [];
if (collectApiEndpoint) {
const apiRoute = originalManifest.headers.find(route => route.source === API_PATH);
const routeRegex = new RegExp(apiRoute.regex);
rewrites.push({
source: collectApiEndpoint,
destination: '/api/send',
});
if (!routeRegex.test(collectApiEndpoint)) {
headers.push({
source: collectApiEndpoint,
headers: apiRoute.headers,
});
}
}
if (trackerScriptName) {
const trackerRoute = originalManifest.headers.find(route => route.source === TRACKER_SCRIPT);
const names = trackerScriptName?.split(',').map(name => name.trim());
if (names) {
names.forEach(name => {
const normalizedSource = `/${name.replace(/^\/+/, '')}`;
rewrites.push({
source: normalizedSource,
destination: TRACKER_SCRIPT,
});
headers.push({
source: normalizedSource,
headers: trackerRoute.headers,
});
});
}
}
const routesManifest = { ...originalManifest };
if (rewrites.length !== 0) {
const { buildCustomRoute } = require('next/dist/lib/build-custom-route');
const builtHeaders = headers.map(header => buildCustomRoute('header', header));
const builtRewrites = rewrites.map(rewrite => buildCustomRoute('rewrite', rewrite));
routesManifest.headers = [...originalManifest.headers, ...builtHeaders];
routesManifest.rewrites = [...builtRewrites, ...originalManifest.rewrites];
console.log('Using updated Next.js routes manifest');
} else {
console.log('Using original Next.js routes manifest');
}
fs.writeFileSync(routesManifestPath, JSON.stringify(routesManifest, null, 2));

View File

@@ -1,5 +1,5 @@
require('dotenv').config(); import 'dotenv/config';
const cli = require('next/dist/cli/next-start'); import cli from 'next/dist/cli/next-start';
cli.nextStart({ cli.nextStart({
port: process.env.PORT || 3000, port: process.env.PORT || 3000,

View File

@@ -1,6 +1,9 @@
const os = require('os'); import os from 'node:os';
const isCI = require('is-ci'); import isCI from 'is-ci';
const pkg = require('../package.json'); import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
const url = 'https://api.umami.is/v1/telemetry'; const url = 'https://api.umami.is/v1/telemetry';

View File

@@ -1,7 +1,10 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
require('dotenv').config(); import 'dotenv/config';
const fs = require('fs'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
import url from "node:url";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const endPoint = process.env.COLLECT_API_ENDPOINT; const endPoint = process.env.COLLECT_API_ENDPOINT;

View File

@@ -1,6 +0,0 @@
'use client';
import { TestConsole } from './TestConsole';
export function ConsolePage({ websiteId }) {
return <TestConsole websiteId={websiteId} />;
}

View File

@@ -1,5 +1,5 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { ConsolePage } from '../ConsolePage'; import { TestConsole } from '../TestConsole';
async function getEnabled() { async function getEnabled() {
return !!process.env.ENABLE_TEST_CONSOLE; return !!process.env.ENABLE_TEST_CONSOLE;
@@ -14,7 +14,7 @@ export default async function ({ params }: { params: Promise<{ websiteId: string
return null; return null;
} }
return <ConsolePage websiteId={websiteId?.[0]} />; return <TestConsole websiteId={websiteId?.[0]} />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {

View File

@@ -8,6 +8,7 @@ import { JourneyReport } from '../journey/JourneyReport';
import { RetentionReport } from '../retention/RetentionReport'; import { RetentionReport } from '../retention/RetentionReport';
import { UTMReport } from '../utm/UTMReport'; import { UTMReport } from '../utm/UTMReport';
import { RevenueReport } from '../revenue/RevenueReport'; import { RevenueReport } from '../revenue/RevenueReport';
import AttributionReport from '../attribution/AttributionReport';
const reports = { const reports = {
funnel: FunnelReport, funnel: FunnelReport,
@@ -18,6 +19,7 @@ const reports = {
goals: GoalsReport, goals: GoalsReport,
journey: JourneyReport, journey: JourneyReport,
revenue: RevenueReport, revenue: RevenueReport,
attribution: AttributionReport,
}; };
export function ReportPage({ reportId }: { reportId: string }) { export function ReportPage({ reportId }: { reportId: string }) {

View File

@@ -0,0 +1,174 @@
import { useMessages } from '@/components/hooks';
import { Icons } from '@/components/icons';
import { useContext, useState } from 'react';
import {
Button,
Select,
Form,
FormButtons,
FormField,
Icon,
ListItem,
Popover,
DialogTrigger,
Toggle,
FormSubmitButton,
} from '@umami/react-zen';
import { BaseParameters } from '../[reportId]/BaseParameters';
import { ParameterList } from '../[reportId]/ParameterList';
import { ReportContext } from '../[reportId]/Report';
import { FunnelStepAddForm } from '../funnel/FunnelStepAddForm';
import { AttributionStepAddForm } from './AttributionStepAddForm';
import { useRevenueValuesQuery } from '@/components/hooks/queries/useRevenueValuesQuery';
export function AttributionParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, steps } = parameters || {};
const queryEnabled = websiteId && dateRange && steps.length > 0;
const [model, setModel] = useState('');
const [revenueMode, setRevenueMode] = useState(false);
const { data: currencyValues = [] } = useRevenueValuesQuery(
websiteId,
dateRange?.startDate,
dateRange?.endDate,
);
const handleSubmit = (data: any, e: any) => {
if (revenueMode === false) {
delete data.currency;
}
e.stopPropagation();
e.preventDefault();
runReport(data);
};
const handleCheck = () => {
setRevenueMode(!revenueMode);
};
const handleAddStep = (step: { type: string; value: string }) => {
if (step.type === 'url') {
setRevenueMode(false);
}
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
const handleUpdateStep = (
close: () => void,
index: number,
step: { type: string; value: string },
) => {
if (step.type === 'url') {
setRevenueMode(false);
}
const steps = [...parameters.steps];
steps[index] = step;
updateReport({ parameters: { steps } });
close();
};
const handleRemoveStep = (index: number) => {
const steps = [...parameters.steps];
delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
};
const AddStepButton = () => {
return (
<DialogTrigger>
<Button isDisabled={steps.length > 0}>
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popover placement="right top">
<FunnelStepAddForm onChange={handleAddStep} />
</Popover>
</DialogTrigger>
);
};
const items = [
{ id: 'first-click', label: 'First-Click', value: 'firstClick' },
{ id: 'last-click', label: 'Last-Click', value: 'lastClick' },
];
const onModelChange = (value: any) => {
setModel(value);
updateReport({ parameters: { model } });
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
<FormField
name="model"
rules={{ required: formatMessage(labels.required) }}
label={formatMessage(labels.model)}
>
<Select items={items} value={model} onChange={onModelChange}>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
</FormField>
<FormField name="step" label={formatMessage(labels.conversionStep)}>
<ParameterList>
{steps.map((step: { type: string; value: string }, index: number) => {
return (
<DialogTrigger key={index}>
<ParameterList.Item
icon={step.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}
onRemove={() => handleRemoveStep(index)}
>
<div>{step.value}</div>
</ParameterList.Item>
<Popover placement="right top">
<AttributionStepAddForm
type={step.type}
value={step.value}
onChange={handleUpdateStep.bind(null, close, index)}
/>
</Popover>
</DialogTrigger>
);
})}
</ParameterList>
<AddStepButton />
</FormField>
<Toggle
isSelected={revenueMode}
onClick={handleCheck}
isDisabled={currencyValues.length === 0 || steps[0]?.type === 'url'}
>
<b>Revenue Mode</b>
</Toggle>
{revenueMode && (
<FormField
name="currency"
rules={{ required: formatMessage(labels.required) }}
label={formatMessage(labels.currency)}
>
<Select items={currencyValues.map(item => ({ id: item.currency, value: item.currency }))}>
{({ id, value }: any) => (
<ListItem key={id} id={id}>
{value}
</ListItem>
)}
</Select>
</FormField>
)}
<FormButtons>
<FormSubmitButton variant="primary" isDisabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View File

@@ -0,0 +1,27 @@
import { Icons } from '@/components/icons';
import { REPORT_TYPES } from '@/lib/constants';
import { Report } from '../[reportId]/Report';
import { ReportBody } from '../[reportId]/ReportBody';
import { ReportHeader } from '../[reportId]/ReportHeader';
import { ReportMenu } from '../[reportId]/ReportMenu';
import { AttributionParameters } from './AttributionParameters';
import { AttributionView } from './AttributionView';
const defaultParameters = {
type: REPORT_TYPES.attribution,
parameters: { model: 'firstClick', steps: [] },
};
export default function AttributionReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Icons.Network />} />
<ReportMenu>
<AttributionParameters />
</ReportMenu>
<ReportBody>
<AttributionView />
</ReportBody>
</Report>
);
}

View File

@@ -0,0 +1,6 @@
'use client';
import AttributionReport from './AttributionReport';
export default function AttributionReportPage() {
return <AttributionReport />;
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react';
import { useMessages } from '@/components/hooks';
import {
Button,
FormButtons,
FormField,
TextField,
Row,
Column,
Select,
ListItem,
} from '@umami/react-zen';
export interface AttributionStepAddFormProps {
type?: string;
value?: string;
onChange?: (step: { type: string; value: string }) => void;
}
export function AttributionStepAddForm({
type: defaultType = 'url',
value: defaultValue = '',
onChange,
}: AttributionStepAddFormProps) {
const [type, setType] = useState(defaultType);
const [value, setValue] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const items = [
{ id: 'url', label: formatMessage(labels.url), value: 'url' },
{ id: 'event', label: formatMessage(labels.event), value: 'event' },
];
const isDisabled = !type || !value;
const handleSave = () => {
onChange({ type, value });
setValue('');
};
const handleChange = e => {
setValue(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
return (
<Column gap>
<FormField name="steps" label={formatMessage(defaultValue ? labels.update : labels.add)}>
<Row>
<Select items={items} value={type} onChange={(value: any) => setType(value)}>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
<TextField
value={value}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Row>
</FormField>
<FormButtons>
<Button variant="primary" onClick={handleSave} isDisabled={isDisabled}>
{formatMessage(defaultValue ? labels.update : labels.add)}
</Button>
</FormButtons>
</Column>
);
}
export default AttributionStepAddForm;

View File

@@ -0,0 +1,133 @@
import { useContext } from 'react';
import { PieChart } from '@/components/charts/PieChart';
import { useMessages } from '@/components/hooks';
import { GridRow } from '@/components/common/GridRow';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { CHART_COLORS } from '@/lib/constants';
import { formatLongNumber } from '@/lib/format';
import { ReportContext } from '../[reportId]/Report';
export interface AttributionViewProps {
isLoading?: boolean;
}
export function AttributionView({ isLoading }: AttributionViewProps) {
const { formatMessage, labels } = useMessages();
const { report } = useContext(ReportContext);
const {
data,
parameters: { currency },
} = report || {};
const ATTRIBUTION_PARAMS = [
{ value: 'referrer', label: formatMessage(labels.referrers) },
{ value: 'paidAds', label: formatMessage(labels.paidAds) },
];
if (!data) {
return null;
}
const { pageviews, visitors, visits } = data.total;
const metrics = data
? [
{
value: pageviews,
label: formatMessage(labels.views),
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
formatValue: formatLongNumber,
},
{
value: visitors,
label: formatMessage(labels.visitors),
formatValue: formatLongNumber,
},
]
: [];
function UTMTable(UTMTableProps: { data: any; title: string; utm: string }) {
const { data, title, utm } = UTMTableProps;
const total = data[utm].reduce((sum, { value }) => {
return +sum + +value;
}, 0);
return (
<ListTable
title={title}
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={data[utm].map(({ name, value }) => ({
x: name,
y: Number(value),
z: (value / total) * 100,
}))}
/>
);
}
return (
<div>
<MetricsBar isFetched={data}>
{metrics?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
{ATTRIBUTION_PARAMS.map(({ value, label }) => {
const items = data[value];
const total = items.reduce((sum, { value }) => {
return +sum + +value;
}, 0);
const chartData = {
labels: items.map(({ name }) => name),
datasets: [
{
data: items.map(({ value }) => value),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};
return (
<div key={value}>
<div>
<div>{label}</div>
<ListTable
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={items.map(({ name, value }) => ({
x: name,
y: Number(value),
z: (value / total) * 100,
}))}
/>
</div>
<div>
<PieChart type="doughnut" data={chartData} isLoading={isLoading} />
</div>
</div>
);
})}
<>
<GridRow columns="two">
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
</GridRow>
<GridRow columns="three">
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
</GridRow>
</>
</div>
);
}
export default AttributionView;

View File

@@ -0,0 +1,10 @@
import AttributionReportPage from './AttributionReportPage';
import { Metadata } from 'next';
export default function () {
return <AttributionReportPage />;
}
export const metadata: Metadata = {
title: 'Attribution Report',
};

View File

@@ -51,6 +51,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
url: renderTeamUrl('/reports/revenue'), url: renderTeamUrl('/reports/revenue'),
icon: <Icons.Money />, icon: <Icons.Money />,
}, },
{
title: formatMessage(labels.attribution),
description: formatMessage(labels.attributionDescription),
url: renderTeamUrl('/reports/attribution'),
icon: <Icons.Network />,
},
]; ];
return ( return (

View File

@@ -27,8 +27,8 @@ export function FunnelStepAddForm({
const [value, setValue] = useState(defaultValue); const [value, setValue] = useState(defaultValue);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const items = [ const items = [
{ label: formatMessage(labels.url), value: 'url' }, { id: 'url', label: formatMessage(labels.url), value: 'url' },
{ label: formatMessage(labels.event), value: 'event' }, { id: 'event', label: formatMessage(labels.event), value: 'event' },
]; ];
const isDisabled = !type || !value; const isDisabled = !type || !value;

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen'; import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
import { EventsTable } from '@/components/metrics/EventsTable';
import { useState } from 'react'; import { useState } from 'react';
import { WebsiteHeader } from '../WebsiteHeader'; import { WebsiteHeader } from '../WebsiteHeader';
import { EventsDataTable } from './EventsDataTable'; import { EventsDataTable } from './EventsDataTable';
@@ -12,9 +13,14 @@ import { useMessages } from '@/components/hooks';
import { EventProperties } from './EventProperties'; import { EventProperties } from './EventProperties';
export function EventsPage({ websiteId }) { export function EventsPage({ websiteId }) {
const [label, setLabel] = useState(null);
const [tab, setTab] = useState('activity'); const [tab, setTab] = useState('activity');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const handleLabelClick = (value: string) => {
setLabel(value !== label ? value : '');
};
return ( return (
<Column gap="3"> <Column gap="3">
<WebsiteHeader websiteId={websiteId} /> <WebsiteHeader websiteId={websiteId} />
@@ -34,6 +40,17 @@ export function EventsPage({ websiteId }) {
/> />
</Panel> </Panel>
</GridRow> </GridRow>
<EventsMetricsBar websiteId={websiteId} />
<GridRow columns="two-one">
<EventsChart websiteId={websiteId} focusLabel={label} />
<EventsTable
websiteId={websiteId}
type="event"
title={formatMessage(labels.events)}
metric={formatMessage(labels.actions)}
onLabelClick={handleLabelClick}
/>
</GridRow>
<Panel marginY="6"> <Panel marginY="6">
<Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}> <Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}>
<TabList> <TabList>

View File

@@ -14,7 +14,7 @@ import { RealtimeCountries } from './RealtimeCountries';
import { WebsiteHeader } from '../WebsiteHeader'; import { WebsiteHeader } from '../WebsiteHeader';
import { percentFilter } from '@/lib/filters'; import { percentFilter } from '@/lib/filters';
export function WebsiteRealtimePage({ websiteId }) { export function WebsiteRealtimePage({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useRealtimeQuery(websiteId); const { data, isLoading, error } = useRealtimeQuery(websiteId);
if (isLoading || error) { if (isLoading || error) {

View File

@@ -35,15 +35,15 @@ export function SessionInfo({ data }) {
</Row> </Row>
</Box> </Box>
<Box> <Row>
<Label>{formatMessage(labels.region)}</Label> <Label>{formatMessage(labels.region)}</Label>
<Row gap="3"> <Row gap="3">
<Icon> <Icon>
<Icons.Location /> <Icons.Location />
</Icon> </Icon>
<Text>{getRegionName(data?.subdivision1)}</Text> {getRegionName(data?.region)}
</Row> </Row>
</Box> </Row>
<Box> <Box>
<Label>{formatMessage(labels.city)}</Label> <Label>{formatMessage(labels.city)}</Label>

View File

@@ -0,0 +1,50 @@
import { canViewWebsite } from '@/lib/auth';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { reportParms } from '@/lib/schema';
import { getAttribution } from '@/queries/sql/reports/getAttribution';
import { z } from 'zod';
export async function POST(request: Request) {
const schema = z.object({
...reportParms,
model: z.string().regex(/firstClick|lastClick/i),
steps: z
.array(
z.object({
type: z.string(),
value: z.string(),
}),
)
.min(1),
currency: z.string().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const {
websiteId,
model,
steps,
currency,
dateRange: { startDate, endDate },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getAttribution(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
model: model,
steps,
currency,
});
return json(data);
}

View File

@@ -29,6 +29,7 @@ const schema = z.object({
ip: z.string().ip().optional(), ip: z.string().ip().optional(),
userAgent: z.string().optional(), userAgent: z.string().optional(),
timestamp: z.coerce.number().int().optional(), timestamp: z.coerce.number().int().optional(),
id: z.string().optional(),
}), }),
}); });
@@ -54,6 +55,7 @@ export async function POST(request: Request) {
title, title,
tag, tag,
timestamp, timestamp,
id,
} = payload; } = payload;
// Cache check // Cache check
@@ -78,8 +80,10 @@ export async function POST(request: Request) {
} }
// Client info // Client info
const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } = const { ip, userAgent, device, browser, os, country, region, city } = await getClientInfo(
await getClientInfo(request, payload); request,
payload,
);
// Bot check // Bot check
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) { if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
@@ -97,7 +101,7 @@ export async function POST(request: Request) {
const sessionSalt = hash(startOfMonth(createdAt).toUTCString()); const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
const visitSalt = hash(startOfHour(createdAt).toUTCString()); const visitSalt = hash(startOfHour(createdAt).toUTCString());
const sessionId = uuid(websiteId, ip, userAgent, sessionSalt); const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
// Find session // Find session
if (!clickhouse.enabled && !cache?.sessionId) { if (!clickhouse.enabled && !cache?.sessionId) {
@@ -109,15 +113,13 @@ export async function POST(request: Request) {
await createSession({ await createSession({
id: sessionId, id: sessionId,
websiteId, websiteId,
hostname,
browser, browser,
os, os,
device, device,
screen, screen,
language, language,
country, country,
subdivision1, region,
subdivision2,
city, city,
}); });
} catch (e: any) { } catch (e: any) {
@@ -146,14 +148,29 @@ export async function POST(request: Request) {
const urlQuery = currentUrl.search.substring(1); const urlQuery = currentUrl.search.substring(1);
const urlDomain = currentUrl.hostname.replace(/^www./, ''); const urlDomain = currentUrl.hostname.replace(/^www./, '');
if (process.env.REMOVE_TRAILING_SLASH) {
urlPath = urlPath.replace(/(.+)\/$/, '$1');
}
let referrerPath: string; let referrerPath: string;
let referrerQuery: string; let referrerQuery: string;
let referrerDomain: string; let referrerDomain: string;
// UTM Params
const utmSource = currentUrl.searchParams.get('utm_source');
const utmMedium = currentUrl.searchParams.get('utm_medium');
const utmCampaign = currentUrl.searchParams.get('utm_campaign');
const utmContent = currentUrl.searchParams.get('utm_content');
const utmTerm = currentUrl.searchParams.get('utm_term');
// Click IDs
const gclid = currentUrl.searchParams.get('gclid');
const fbclid = currentUrl.searchParams.get('fbclid');
const msclkid = currentUrl.searchParams.get('msclkid');
const ttclid = currentUrl.searchParams.get('ttclid');
const lifatid = currentUrl.searchParams.get('li_fat_id');
const twclid = currentUrl.searchParams.get('twclid');
if (process.env.REMOVE_TRAILING_SLASH) {
urlPath = urlPath.replace(/(.+)\/$/, '$1');
}
if (referrer) { if (referrer) {
const referrerUrl = new URL(referrer, base); const referrerUrl = new URL(referrer, base);
@@ -171,10 +188,21 @@ export async function POST(request: Request) {
visitId, visitId,
urlPath: safeDecodeURI(urlPath), urlPath: safeDecodeURI(urlPath),
urlQuery, urlQuery,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
referrerPath: safeDecodeURI(referrerPath), referrerPath: safeDecodeURI(referrerPath),
referrerQuery, referrerQuery,
referrerDomain, referrerDomain,
pageTitle: safeDecodeURIComponent(title), pageTitle: safeDecodeURIComponent(title),
gclid,
fbclid,
msclkid,
ttclid,
lifatid,
twclid,
eventName: name, eventName: name,
eventData: data, eventData: data,
hostname: hostname || urlDomain, hostname: hostname || urlDomain,
@@ -184,8 +212,7 @@ export async function POST(request: Request) {
screen, screen,
language, language,
country, country,
subdivision1, region,
subdivision2,
city, city,
tag, tag,
createdAt, createdAt,

View File

@@ -1,6 +0,0 @@
import { json } from '@/lib/response';
import { CURRENT_VERSION } from '@/lib/constants';
export async function GET() {
return json({ version: CURRENT_VERSION });
}

1
src/assets/network.svg Normal file
View File

@@ -0,0 +1 @@
<svg height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg"><g id="_x30_6_network"><path d="m28 19c-.809 0-1.54.325-2.08.847l-6.011-3.01c.058-.271.091-.55.091-.837s-.033-.566-.091-.837l6.011-3.01c.54.522 1.271.847 2.08.847 1.654 0 3-1.346 3-3s-1.346-3-3-3-3 1.346-3 3c0 .123.022.24.036.359l-6.036 3.023c-.521-.597-1.21-1.035-2-1.24v-5.326c1.162-.415 2-1.514 2-2.816 0-1.654-1.346-3-3-3s-3 1.346-3 3c0 1.302.838 2.401 2 2.815v5.327c-.79.205-1.478.643-2 1.24l-6.037-3.022c.015-.12.037-.237.037-.36 0-1.654-1.346-3-3-3s-3 1.346-3 3 1.346 3 3 3c.809 0 1.54-.325 2.08-.847l6.011 3.01c-.058.271-.091.55-.091.837s.033.566.091.837l-6.011 3.01c-.54-.522-1.271-.847-2.08-.847-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3c0-.123-.022-.24-.036-.359l6.036-3.023c.521.597 1.21 1.035 2 1.24v5.326c-1.162.415-2 1.514-2 2.816 0 1.654 1.346 3 3 3s3-1.346 3-3c0-1.302-.838-2.401-2-2.816v-5.326c.79-.205 1.478-.643 2-1.24l6.037 3.022c-.015.12-.037.237-.037.36 0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3zm0-10c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1zm-24 2c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm0 12c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm12-20c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1zm0 26c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm0-11c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2zm12 5c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -81,7 +81,7 @@ export function Chart({
dataset.data = data?.datasets[index]?.data; dataset.data = data?.datasets[index]?.data;
if (chart.current.legend.legendItems[index]) { if (chart.current.legend.legendItems[index]) {
chart.current.legend.legendItems[index].text = data?.datasets[index]?.label; chart.current.legend.legendItems[index].text = data.datasets[index]?.label;
} }
} }
}); });
@@ -90,6 +90,12 @@ export function Chart({
} }
} }
if (data.focusLabel !== null) {
chart.current.data.datasets.forEach(ds => {
ds.hidden = data.focusLabel ? ds.label !== data.focusLabel : false;
});
}
chart.current.options = options; chart.current.options = options;
// Allow config changes before update // Allow config changes before update
@@ -100,16 +106,6 @@ export function Chart({
setLegendItems(chart.current.legend.legendItems); setLegendItems(chart.current.legend.legendItems);
}; };
useEffect(() => {
if (data) {
if (!chart.current) {
createChart(data);
} else {
updateChart(data);
}
}
}, [data, options]);
const handleLegendClick = (item: LegendItem) => { const handleLegendClick = (item: LegendItem) => {
if (type === 'bar') { if (type === 'bar') {
const { datasetIndex } = item; const { datasetIndex } = item;
@@ -131,6 +127,16 @@ export function Chart({
setLegendItems(chart.current.legend.legendItems); setLegendItems(chart.current.legend.legendItems);
}; };
useEffect(() => {
if (data) {
if (!chart.current) {
createChart(data);
} else {
updateChart(data);
}
}
}, [data, options]);
return ( return (
<> <>
<div {...props}> <div {...props}>

View File

@@ -1,4 +1,4 @@
import { GROUPED_DOMAINS } from '@/lib/constants'; import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants';
function getHostName(url: string) { function getHostName(url: string) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im); const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
@@ -10,10 +10,10 @@ export function Favicon({ domain, ...props }) {
return null; return null;
} }
const url = process.env.faviconURL || FAVICON_URL;
const hostName = domain ? getHostName(domain) : null; const hostName = domain ? getHostName(domain) : null;
const src = hostName const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName;
? `https://icons.duckduckgo.com/ip3/${GROUPED_DOMAINS[hostName]?.domain || hostName}.ico` const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null;
: null;
return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null; return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null;
} }

View File

@@ -27,8 +27,8 @@ export function useFormat() {
return countryNames[value] || value; return countryNames[value] || value;
}; };
const formatRegion = (value: string): string => { const formatRegion = (value?: string): string => {
const [country] = value.split('-'); const [country] = value?.split('-') || [];
return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value; return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value;
}; };

View File

@@ -164,7 +164,13 @@ export const labels = defineMessages({
id: 'label.revenue-description', id: 'label.revenue-description',
defaultMessage: 'Look into your revenue data and how users are spending.', defaultMessage: 'Look into your revenue data and how users are spending.',
}, },
attribution: { id: 'label.attribution', defaultMessage: 'Attribution' },
attributionDescription: {
id: 'label.attribution-description',
defaultMessage: 'See how users engage with your marketing and what drives conversions.',
},
currency: { id: 'label.currency', defaultMessage: 'Currency' }, currency: { id: 'label.currency', defaultMessage: 'Currency' },
model: { id: 'label.model', defaultMessage: 'Model' },
url: { id: 'label.url', defaultMessage: 'URL' }, url: { id: 'label.url', defaultMessage: 'URL' },
urls: { id: 'label.urls', defaultMessage: 'URLs' }, urls: { id: 'label.urls', defaultMessage: 'URLs' },
path: { id: 'label.path', defaultMessage: 'Path' }, path: { id: 'label.path', defaultMessage: 'Path' },
@@ -258,6 +264,7 @@ export const labels = defineMessages({
id: 'label.utm-description', id: 'label.utm-description',
defaultMessage: 'Track your campaigns through UTM parameters.', defaultMessage: 'Track your campaigns through UTM parameters.',
}, },
conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion Step' },
steps: { id: 'label.steps', defaultMessage: 'Steps' }, steps: { id: 'label.steps', defaultMessage: 'Steps' },
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' }, startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
endStep: { id: 'label.end-step', defaultMessage: 'End Step' }, endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
@@ -283,6 +290,11 @@ export const labels = defineMessages({
firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' }, firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' },
properties: { id: 'label.properties', defaultMessage: 'Properties' }, properties: { id: 'label.properties', defaultMessage: 'Properties' },
channels: { id: 'label.channels', defaultMessage: 'Channels' }, channels: { id: 'label.channels', defaultMessage: 'Channels' },
sources: { id: 'label.sources', defaultMessage: 'Sources' },
medium: { id: 'label.medium', defaultMessage: 'Medium' },
campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' },
content: { id: 'label.content', defaultMessage: 'Content' },
terms: { id: 'label.terms', defaultMessage: 'Terms' },
direct: { id: 'label.direct', defaultMessage: 'Direct' }, direct: { id: 'label.direct', defaultMessage: 'Direct' },
referral: { id: 'label.referral', defaultMessage: 'Referral' }, referral: { id: 'label.referral', defaultMessage: 'Referral' },
affiliate: { id: 'label.affiliate', defaultMessage: 'Affiliate' }, affiliate: { id: 'label.affiliate', defaultMessage: 'Affiliate' },

View File

@@ -1,21 +1,23 @@
import { useMemo, useState, useEffect } from 'react';
import { colord } from 'colord'; import { colord } from 'colord';
import { BarChart } from '@/components/charts/BarChart'; import { BarChart } from '@/components/charts/BarChart';
import { useDateRange, useLocale, useWebsiteEventsSeriesQuery } from '@/components/hooks'; import { useDateRange, useLocale, useWebsiteEventsSeriesQuery } from '@/components/hooks';
import { renderDateLabels } from '@/lib/charts'; import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants'; import { CHART_COLORS } from '@/lib/constants';
import { useMemo } from 'react';
export interface EventsChartProps { export interface EventsChartProps {
websiteId: string; websiteId: string;
className?: string; className?: string;
focusLabel?: string;
} }
export function EventsChart({ websiteId, className }: EventsChartProps) { export function EventsChart({ websiteId, className, focusLabel }: EventsChartProps) {
const { const {
dateRange: { startDate, endDate, unit, value }, dateRange: { startDate, endDate, unit, value },
} = useDateRange(websiteId); } = useDateRange(websiteId);
const { locale } = useLocale(); const { locale } = useLocale();
const { data, isLoading } = useWebsiteEventsSeriesQuery(websiteId); const { data, isLoading } = useWebsiteEventsSeriesQuery(websiteId);
const [label, setLabel] = useState<string>(focusLabel);
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!data) return []; if (!data) return [];
@@ -42,8 +44,15 @@ export function EventsChart({ websiteId, className }: EventsChartProps) {
borderWidth: 1, borderWidth: 1,
}; };
}), }),
focusLabel,
}; };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit, focusLabel]);
useEffect(() => {
if (label !== focusLabel) {
setLabel(focusLabel);
}
}, [focusLabel]);
return ( return (
<BarChart <BarChart

View File

@@ -1,12 +1,28 @@
import { MetricsTable, MetricsTableProps } from './MetricsTable'; import { MetricsTable, MetricsTableProps } from './MetricsTable';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
export function EventsTable(props: MetricsTableProps) { export interface EventsTableProps extends MetricsTableProps {
onLabelClick?: (value: string) => void;
}
export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
function handleDataLoad(data: any) { const handleDataLoad = (data: any) => {
props.onDataLoad?.(data); props.onDataLoad?.(data);
} };
const renderLabel = ({ x: label }) => {
if (onLabelClick) {
return (
<div onClick={() => onLabelClick(label)} style={{ cursor: 'pointer' }}>
{label}
</div>
);
}
return label;
};
return ( return (
<MetricsTable <MetricsTable
@@ -15,6 +31,7 @@ export function EventsTable(props: MetricsTableProps) {
type="event" type="event"
metric={formatMessage(labels.actions)} metric={formatMessage(labels.actions)}
onDataLoad={handleDataLoad} onDataLoad={handleDataLoad}
renderLabel={renderLabel}
/> />
); );
} }

View File

@@ -4,8 +4,8 @@ import { useSpring, config } from '@react-spring/web';
import classNames from 'classnames'; import classNames from 'classnames';
import { AnimatedDiv } from '@/components/common/AnimatedDiv'; import { AnimatedDiv } from '@/components/common/AnimatedDiv';
import { Empty } from '@/components/common/Empty'; import { Empty } from '@/components/common/Empty';
import { formatLongNumber } from '@/lib/format';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import styles from './ListTable.module.css'; import styles from './ListTable.module.css';
const ITEM_SIZE = 30; const ITEM_SIZE = 30;
@@ -21,6 +21,7 @@ export interface ListTableProps {
virtualize?: boolean; virtualize?: boolean;
showPercentage?: boolean; showPercentage?: boolean;
itemCount?: number; itemCount?: number;
currency?: string;
} }
export function ListTable({ export function ListTable({
@@ -34,6 +35,7 @@ export function ListTable({
virtualize = false, virtualize = false,
showPercentage = true, showPercentage = true,
itemCount = 10, itemCount = 10,
currency,
}: ListTableProps) { }: ListTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@@ -49,6 +51,7 @@ export function ListTable({
animate={animate && !virtualize} animate={animate && !virtualize}
showPercentage={showPercentage} showPercentage={showPercentage}
change={renderChange ? renderChange(row, index) : null} change={renderChange ? renderChange(row, index) : null}
currency={currency}
/> />
); );
}; };
@@ -82,7 +85,15 @@ export function ListTable({
); );
} }
const AnimatedRow = ({ label, value = 0, percent, change, animate, showPercentage = true }) => { const AnimatedRow = ({
label,
value = 0,
percent,
change,
animate,
showPercentage = true,
currency,
}) => {
const props = useSpring({ const props = useSpring({
width: percent, width: percent,
y: value, y: value,
@@ -96,7 +107,9 @@ const AnimatedRow = ({ label, value = 0, percent, change, animate, showPercentag
<div className={styles.value}> <div className={styles.value}>
{change} {change}
<AnimatedDiv className={styles.value} title={props?.y as any}> <AnimatedDiv className={styles.value} title={props?.y as any}>
{props.y?.to(formatLongNumber)} {currency
? props.y?.to(n => formatLongCurrency(n, currency))
: props.y?.to(formatLongNumber)}
</AnimatedDiv> </AnimatedDiv>
</div> </div>
{showPercentage && ( {showPercentage && (

View File

@@ -24,6 +24,7 @@ export { default as Logo } from './Logo';
export { default as Magnet } from './Magnet'; export { default as Magnet } from './Magnet';
export { default as Money } from './Money'; export { default as Money } from './Money';
export { default as Moon } from './Moon'; export { default as Moon } from './Moon';
export { default as Network } from './Network';
export { default as Nodes } from './Nodes'; export { default as Nodes } from './Nodes';
export { default as Overview } from './Overview'; export { default as Overview } from './Overview';
export { default as Path } from './Path'; export { default as Path } from './Path';

View File

@@ -2,5 +2,7 @@ declare module 'bcryptjs';
declare module 'chartjs-adapter-date-fns'; declare module 'chartjs-adapter-date-fns';
declare module 'cors'; declare module 'cors';
declare module 'debug'; declare module 'debug';
declare module 'fs-extra';
declare module 'jsonwebtoken'; declare module 'jsonwebtoken';
declare module 'md5'; declare module 'md5';
declare module 'prettier';

View File

@@ -8,10 +8,13 @@
"label.add-step": "Ajouter une étape", "label.add-step": "Ajouter une étape",
"label.add-website": "Ajouter un site", "label.add-website": "Ajouter un site",
"label.admin": "Administrateur", "label.admin": "Administrateur",
"label.affiliate": "Affiliation",
"label.after": "Après", "label.after": "Après",
"label.all": "Tout", "label.all": "Tout",
"label.all-time": "Toutes les données", "label.all-time": "Toutes les données",
"label.analytics": "Analytics", "label.analytics": "Analytics",
"label.attribution": "Attribution",
"label.attribution-description": "Découvrez comment les utilisateurs s'engagent avec votre marketing et ce qui génère des conversions.",
"label.average": "Moyenne", "label.average": "Moyenne",
"label.back": "Retour", "label.back": "Retour",
"label.before": "Avant", "label.before": "Avant",
@@ -19,17 +22,21 @@
"label.breakdown": "Répartition", "label.breakdown": "Répartition",
"label.browser": "Navigateur", "label.browser": "Navigateur",
"label.browsers": "Navigateurs", "label.browsers": "Navigateurs",
"label.campaigns": "Campagnes",
"label.cancel": "Annuler", "label.cancel": "Annuler",
"label.change-password": "Changer le mot de passe", "label.change-password": "Changer le mot de passe",
"label.channels": "Canaux",
"label.cities": "Villes", "label.cities": "Villes",
"label.city": "Ville", "label.city": "Ville",
"label.clear-all": "Réinitialiser", "label.clear-all": "Réinitialiser",
"label.compare": "Compare", "label.compare": "Comparer",
"label.confirm": "Confirmer", "label.confirm": "Confirmer",
"label.confirm-password": "Confirmation du mot de passe", "label.confirm-password": "Confirmation du mot de passe",
"label.contains": "Contient", "label.contains": "Contient",
"label.content": "Contenu",
"label.continue": "Continuer", "label.continue": "Continuer",
"label.count": "Count", "label.conversion-step": "Étape de conversion",
"label.count": "Compte",
"label.countries": "Pays", "label.countries": "Pays",
"label.country": "Pays", "label.country": "Pays",
"label.create": "Créer", "label.create": "Créer",
@@ -37,8 +44,9 @@
"label.create-team": "Créer une équipe", "label.create-team": "Créer une équipe",
"label.create-user": "Créer un utilisateur", "label.create-user": "Créer un utilisateur",
"label.created": "Créé", "label.created": "Créé",
"label.created-by": "Crée par", "label.created-by": "Créé par",
"label.current": "Current", "label.currency": "Devise",
"label.current": "Actuel",
"label.current-password": "Mot de passe actuel", "label.current-password": "Mot de passe actuel",
"label.custom-range": "Période personnalisée", "label.custom-range": "Période personnalisée",
"label.dashboard": "Tableau de bord", "label.dashboard": "Tableau de bord",
@@ -57,6 +65,7 @@
"label.details": "Détails", "label.details": "Détails",
"label.device": "Appareil", "label.device": "Appareil",
"label.devices": "Appareils", "label.devices": "Appareils",
"label.direct": "Direct",
"label.dismiss": "Ignorer", "label.dismiss": "Ignorer",
"label.does-not-contain": "Ne contient pas", "label.does-not-contain": "Ne contient pas",
"label.domain": "Domaine", "label.domain": "Domaine",
@@ -64,13 +73,14 @@
"label.edit": "Modifier", "label.edit": "Modifier",
"label.edit-dashboard": "Modifier le tableau de bord", "label.edit-dashboard": "Modifier le tableau de bord",
"label.edit-member": "Modifier le membre", "label.edit-member": "Modifier le membre",
"label.email": "E-mail",
"label.enable-share-url": "Activer l'URL de partage", "label.enable-share-url": "Activer l'URL de partage",
"label.end-step": "End Step", "label.end-step": "Étape de fin",
"label.entry": "URL d'entrée", "label.entry": "Chemin d'entrée",
"label.event": "Évènement", "label.event": "Évènement",
"label.event-data": "Données d'évènements", "label.event-data": "Données d'évènements",
"label.events": "Évènements", "label.events": "Évènements",
"label.exit": "Exit URL", "label.exit": "Chemin de sortie",
"label.false": "Faux", "label.false": "Faux",
"label.field": "Champ", "label.field": "Champ",
"label.fields": "Champs", "label.fields": "Champs",
@@ -80,31 +90,32 @@
"label.filters": "Filtres", "label.filters": "Filtres",
"label.first-seen": "Vu pour la première fois", "label.first-seen": "Vu pour la première fois",
"label.funnel": "Entonnoir", "label.funnel": "Entonnoir",
"label.funnel-description": "Suivi des conversions et des taux d'abandons.", "label.funnel-description": "Comprenez les taux de conversions et d'abandons des utilisateurs.",
"label.goal": "Goal", "label.goal": "Objectif",
"label.goals": "Goals", "label.goals": "Objectifs",
"label.goals-description": "Suivez vos objectifs en matière de pages vues et d'événements.", "label.goals-description": "Suivez vos objectifs en matière de pages vues et d'événements.",
"label.greater-than": "Supérieur à", "label.greater-than": "Supérieur à",
"label.greater-than-equals": "Supérieur ou égal à", "label.greater-than-equals": "Supérieur ou égal à",
"label.host": "Host", "label.grouped": "Groupé",
"label.hosts": "Hosts", "label.host": "Hôte",
"label.hosts": "Hôtes",
"label.insights": "Insights", "label.insights": "Insights",
"label.insights-description": "Analyse précise des données en utilisant des segments et des filtres.", "label.insights-description": "Analysez précisément vos données en utilisant des segments et des filtres.",
"label.is": "Est", "label.is": "Est",
"label.is-not": "N'est pas", "label.is-not": "N'est pas",
"label.is-not-set": "N'est pas défini", "label.is-not-set": "N'est pas défini",
"label.is-set": "Est défini", "label.is-set": "Est défini",
"label.join": "Rejoindre", "label.join": "Rejoindre",
"label.join-team": "Rejoindre une équipe", "label.join-team": "Rejoindre une équipe",
"label.journey": "Journey", "label.journey": "Parcours",
"label.journey-description": "Comprendre comment les utilisateurs naviguent sur votre site web.", "label.journey-description": "Comprennez comment les utilisateurs naviguent sur votre site.",
"label.language": "Langue", "label.language": "Langue",
"label.languages": "Langues", "label.languages": "Langues",
"label.laptop": "Portable", "label.laptop": "Portable",
"label.last-days": "{x} derniers jours", "label.last-days": "{x} derniers jours",
"label.last-hours": "{x} dernières heures", "label.last-hours": "{x} dernières heures",
"label.last-months": "{x} derniers mois", "label.last-months": "{x} derniers mois",
"label.last-seen": "Last seen", "label.last-seen": "Vu pour la dernière fois",
"label.leave": "Quitter", "label.leave": "Quitter",
"label.leave-team": "Quitter l'équipe", "label.leave-team": "Quitter l'équipe",
"label.less-than": "Inférieur à", "label.less-than": "Inférieur à",
@@ -114,10 +125,12 @@
"label.manage": "Gérer", "label.manage": "Gérer",
"label.manager": "Manager", "label.manager": "Manager",
"label.max": "Max", "label.max": "Max",
"label.medium": "Support",
"label.member": "Membre", "label.member": "Membre",
"label.members": "Membres", "label.members": "Membres",
"label.min": "Min", "label.min": "Min",
"label.mobile": "Téléphone", "label.mobile": "Téléphone",
"label.model": "Modèle",
"label.more": "Plus", "label.more": "Plus",
"label.my-account": "Mon compte", "label.my-account": "Mon compte",
"label.my-websites": "Mes sites", "label.my-websites": "Mes sites",
@@ -126,16 +139,26 @@
"label.none": "Aucun", "label.none": "Aucun",
"label.number-of-records": "{x} {x, plural, one {enregistrement} other {enregistrements}}", "label.number-of-records": "{x} {x, plural, one {enregistrement} other {enregistrements}}",
"label.ok": "OK", "label.ok": "OK",
"label.organic-search": "Recherche organique",
"label.organic-shopping": "E-commerce organique",
"label.organic-social": "Réseau social organique",
"label.organic-video": "Vidéo organique",
"label.os": "OS", "label.os": "OS",
"label.other": "Autre",
"label.overview": "Vue d'ensemble", "label.overview": "Vue d'ensemble",
"label.owner": "Propriétaire", "label.owner": "Propriétaire",
"label.page-of": "Page {current} sur {total}", "label.page-of": "Page {current} sur {total}",
"label.page-views": "Pages vues", "label.page-views": "Pages vues",
"label.pageTitle": "Titre de page", "label.pageTitle": "Titre de page",
"label.pages": "Pages", "label.pages": "Pages",
"label.paid-ads": "Publicités payantes",
"label.paid-search": "Recherche payante",
"label.paid-shopping": "E-commerce payant",
"label.paid-social": "Réseau social payant",
"label.paid-video": "Vidéo payante",
"label.password": "Mot de passe", "label.password": "Mot de passe",
"label.path": "Path", "label.path": "Chemin",
"label.paths": "Paths", "label.paths": "Chemins",
"label.powered-by": "Propulsé par {name}", "label.powered-by": "Propulsé par {name}",
"label.previous": "Précédent", "label.previous": "Précédent",
"label.previous-period": "Période précédente", "label.previous-period": "Période précédente",
@@ -147,6 +170,7 @@
"label.query": "Requête", "label.query": "Requête",
"label.query-parameters": "Paramètres de requête", "label.query-parameters": "Paramètres de requête",
"label.realtime": "Temps réel", "label.realtime": "Temps réel",
"label.referral": "Référent",
"label.referrer": "Site référent", "label.referrer": "Site référent",
"label.referrers": "Sites référents", "label.referrers": "Sites référents",
"label.refresh": "Rafraîchir", "label.refresh": "Rafraîchir",
@@ -160,28 +184,32 @@
"label.reset": "Réinitialiser", "label.reset": "Réinitialiser",
"label.reset-website": "Réinitialiser les statistiques", "label.reset-website": "Réinitialiser les statistiques",
"label.retention": "Rétention", "label.retention": "Rétention",
"label.retention-description": "Mesure de l'attractivité du site en visualisant les taux de visiteurs qui reviennent.", "label.retention-description": "Mesurez l'attractivité de votre site en suivant la fréquence de retour des utilisateurs.",
"label.revenue": "Revenue", "label.revenue": "Recettes",
"label.revenue-description": "Examinez vos revenus au fil du temps.", "label.revenue-description": "Examinez vos recettes et comment dépensent vos utilisateurs.",
"label.revenue-property": "Propriétés des revenues",
"label.role": "Rôle", "label.role": "Rôle",
"label.run-query": "Éxécuter la requête", "label.run-query": "Exécuter la requête",
"label.save": "Enregistrer", "label.save": "Enregistrer",
"label.screens": "Résolutions d'écran", "label.screens": "Résolutions d'écran",
"label.search": "Rechercher", "label.search": "Rechercher",
"label.select": "Selectionner", "label.select": "Sélectionner",
"label.select-date": "Choisir une période", "label.select-date": "Choisir une période",
"label.select-role": "Choisir un rôle", "label.select-role": "Choisir un rôle",
"label.select-website": "Choisir un site", "label.select-website": "Choisir un site",
"label.session": "Session", "label.session": "Session",
"label.session-data": "Session data",
"label.sessions": "Sessions", "label.sessions": "Sessions",
"label.settings": "Paramètres", "label.settings": "Paramètres",
"label.share-url": "URL de partage", "label.share-url": "URL de partage",
"label.single-day": "Journée", "label.single-day": "Journée",
"label.start-step": "Etape de démarrage", "label.sms": "SMS",
"label.sources": "Sources",
"label.start-step": "Étape de départ",
"label.steps": "Étapes", "label.steps": "Étapes",
"label.sum": "Somme", "label.sum": "Somme",
"label.tablet": "Tablette", "label.tablet": "Tablette",
"label.tag": "Tag",
"label.tags": "Tags",
"label.team": "Équipe", "label.team": "Équipe",
"label.team-id": "ID d'équipe", "label.team-id": "ID d'équipe",
"label.team-manager": "Manager de l'équipe", "label.team-manager": "Manager de l'équipe",
@@ -191,6 +219,7 @@
"label.team-view-only": "Vue d'équipe uniquement", "label.team-view-only": "Vue d'équipe uniquement",
"label.team-websites": "Sites d'équipes", "label.team-websites": "Sites d'équipes",
"label.teams": "Équipes", "label.teams": "Équipes",
"label.terms": "Mots clés",
"label.theme": "Thème", "label.theme": "Thème",
"label.this-month": "Ce mois", "label.this-month": "Ce mois",
"label.this-week": "Cette semaine", "label.this-week": "Cette semaine",
@@ -216,18 +245,17 @@
"label.url": "URL", "label.url": "URL",
"label.urls": "URLs", "label.urls": "URLs",
"label.user": "Utilisateur", "label.user": "Utilisateur",
"label.user-property": "Propriétés d'utilisateurs",
"label.username": "Nom d'utilisateur", "label.username": "Nom d'utilisateur",
"label.users": "Utilisateurs", "label.users": "Utilisateurs",
"label.utm": "UTM", "label.utm": "UTM",
"label.utm-description": "Suivi de campagnes via les paramètres UTM.", "label.utm-description": "Suivez vos campagnes via les paramètres UTM.",
"label.value": "Valeur", "label.value": "Valeur",
"label.view": "Voir", "label.view": "Voir",
"label.view-details": "Voir les détails", "label.view-details": "Voir les détails",
"label.view-only": "Consultation", "label.view-only": "Consultation",
"label.views": "Vues", "label.views": "Vues",
"label.views-per-visit": "Vues par visite", "label.views-per-visit": "Vues par visite",
"label.visit-duration": "Temps de visite moyen", "label.visit-duration": "Temps de visite",
"label.visitors": "Visiteurs", "label.visitors": "Visiteurs",
"label.visits": "Visites", "label.visits": "Visites",
"label.website": "Site", "label.website": "Site",
@@ -237,7 +265,7 @@
"label.yesterday": "Hier", "label.yesterday": "Hier",
"message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.", "message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.",
"message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement", "message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement",
"message.collected-data": "Collected data", "message.collected-data": "Donnée collectée",
"message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?", "message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?",
"message.confirm-leave": "Êtes-vous sûr de vouloir quitter {target} ?", "message.confirm-leave": "Êtes-vous sûr de vouloir quitter {target} ?",
"message.confirm-remove": "Êtes-vous sûr de vouloir retirer {target} ?", "message.confirm-remove": "Êtes-vous sûr de vouloir retirer {target} ?",

View File

@@ -20,7 +20,8 @@
"label.browser": "浏览器", "label.browser": "浏览器",
"label.browsers": "浏览器", "label.browsers": "浏览器",
"label.cancel": "取消", "label.cancel": "取消",
"label.change-password": "更新密码", "label.change-password": "修改密码",
"label.channels": "渠道",
"label.cities": "市/县", "label.cities": "市/县",
"label.city": "市/县", "label.city": "市/县",
"label.clear-all": "清除全部", "label.clear-all": "清除全部",
@@ -38,10 +39,10 @@
"label.create-user": "创建用户", "label.create-user": "创建用户",
"label.created": "已创建", "label.created": "已创建",
"label.created-by": "创建者", "label.created-by": "创建者",
"label.current": "前", "label.current": "前",
"label.current-password": "前密码", "label.current-password": "前密码",
"label.custom-range": "自定义时间段", "label.custom-range": "自定义时间段",
"label.dashboard": "仪表", "label.dashboard": "仪表",
"label.data": "统计数据", "label.data": "统计数据",
"label.date": "日期", "label.date": "日期",
"label.date-range": "时间段", "label.date-range": "时间段",
@@ -62,7 +63,7 @@
"label.domain": "域名", "label.domain": "域名",
"label.dropoff": "丢弃", "label.dropoff": "丢弃",
"label.edit": "编辑", "label.edit": "编辑",
"label.edit-dashboard": "编辑仪表", "label.edit-dashboard": "编辑仪表",
"label.edit-member": "编辑成员", "label.edit-member": "编辑成员",
"label.enable-share-url": "启用共享链接", "label.enable-share-url": "启用共享链接",
"label.end-step": "结束步骤", "label.end-step": "结束步骤",
@@ -80,7 +81,7 @@
"label.filters": "筛选", "label.filters": "筛选",
"label.first-seen": "首次出现", "label.first-seen": "首次出现",
"label.funnel": "分析", "label.funnel": "分析",
"label.funnel-description": "了解用户的转率和退出率。", "label.funnel-description": "了解用户的转率和出率。",
"label.goal": "目标", "label.goal": "目标",
"label.goals": "目标", "label.goals": "目标",
"label.goals-description": "跟踪页面浏览量和事件的目标。", "label.goals-description": "跟踪页面浏览量和事件的目标。",
@@ -141,7 +142,7 @@
"label.previous-period": "上一时期", "label.previous-period": "上一时期",
"label.previous-year": "上一年", "label.previous-year": "上一年",
"label.profile": "个人资料", "label.profile": "个人资料",
"label.properties": "Properties", "label.properties": "属性",
"label.property": "属性", "label.property": "属性",
"label.queries": "查询", "label.queries": "查询",
"label.query": "查询", "label.query": "查询",
@@ -160,9 +161,9 @@
"label.reset": "重置", "label.reset": "重置",
"label.reset-website": "重置统计数据", "label.reset-website": "重置统计数据",
"label.retention": "保留", "label.retention": "保留",
"label.retention-description": "通过踪用户返回的频率来衡量网站的用户粘性。", "label.retention-description": "通过踪用户回访频率来衡量网站的用户粘性。",
"label.revenue": "收入", "label.revenue": "收入",
"label.revenue-description": "查看您的收入随时间变化。", "label.revenue-description": "查看随时间变化的收入数据。",
"label.revenue-property": "收入值", "label.revenue-property": "收入值",
"label.role": "角色", "label.role": "角色",
"label.run-query": "查询", "label.run-query": "查询",
@@ -170,7 +171,7 @@
"label.screens": "屏幕尺寸", "label.screens": "屏幕尺寸",
"label.search": "搜索", "label.search": "搜索",
"label.select": "选择", "label.select": "选择",
"label.select-date": "选择数据", "label.select-date": "选择日期",
"label.select-role": "选择角色", "label.select-role": "选择角色",
"label.select-website": "选择网站", "label.select-website": "选择网站",
"label.session": "Session", "label.session": "Session",
@@ -184,7 +185,7 @@
"label.tablet": "平板", "label.tablet": "平板",
"label.team": "团队", "label.team": "团队",
"label.team-id": "团队 ID", "label.team-id": "团队 ID",
"label.team-manager": "团队管理", "label.team-manager": "团队管理",
"label.team-member": "团队成员", "label.team-member": "团队成员",
"label.team-name": "团队名称", "label.team-name": "团队名称",
"label.team-owner": "团队所有者", "label.team-owner": "团队所有者",
@@ -220,14 +221,14 @@
"label.username": "用户名", "label.username": "用户名",
"label.users": "用户", "label.users": "用户",
"label.utm": "UTM", "label.utm": "UTM",
"label.utm-description": "通过UTM参数追踪您的广告活动。", "label.utm-description": "通过 UTM 参数追踪您的广告活动。",
"label.value": "值", "label.value": "值",
"label.view": "查看", "label.view": "查看",
"label.view-details": "查看更多", "label.view-details": "查看更多",
"label.view-only": "仅浏览", "label.view-only": "仅浏览",
"label.views": "浏览量", "label.views": "浏览量",
"label.views-per-visit": "每次访问的浏览量", "label.views-per-visit": "每次访问的浏览量",
"label.visit-duration": "平均访问时", "label.visit-duration": "平均访问时",
"label.visitors": "访客", "label.visitors": "访客",
"label.visits": "访问次数", "label.visits": "访问次数",
"label.website": "网站", "label.website": "网站",
@@ -235,41 +236,41 @@
"label.websites": "网站", "label.websites": "网站",
"label.window": "窗口", "label.window": "窗口",
"label.yesterday": "昨天", "label.yesterday": "昨天",
"message.action-confirmation": "在下面的框中输入 {confirmation} 以确认。", "message.action-confirmation": "在下方输入框中输入 {confirmation} 以确认操作。",
"message.active-users": "当前在线 {x} ", "message.active-users": "当前在线 {x} 位访客",
"message.collected-data": "已收集的数据", "message.collected-data": "已收集的数据",
"message.confirm-delete": "你确定要删除 {target} 吗?", "message.confirm-delete": "你确定要删除 {target} 吗?",
"message.confirm-leave": "你确定要离开 {target} 吗?", "message.confirm-leave": "你确定要离开 {target} 吗?",
"message.confirm-remove": "您确定要移除 {target} ", "message.confirm-remove": "您确定要移除 {target} ",
"message.confirm-reset": "您确定要重置 {target} 的数据吗?", "message.confirm-reset": "您确定要重置 {target} 的数据吗?",
"message.delete-team-warning": "删除团队也会删除所有团队网站。", "message.delete-team-warning": "删除团队也会删除所有团队网站。",
"message.delete-website-warning": "所有相关数据将会被删除。", "message.delete-website-warning": "所有相关数据将会被删除。",
"message.error": "出现错误。", "message.error": "发生错误。",
"message.event-log": "{url} 上的 {event}", "message.event-log": "{url} 上的 {event}",
"message.go-to-settings": "去设置", "message.go-to-settings": "去设置",
"message.incorrect-username-password": "用户名或密码不正确。", "message.incorrect-username-password": "用户名或密码不正确。",
"message.invalid-domain": "无效域名", "message.invalid-domain": "无效域名",
"message.min-password-length": "密码最短长度为 {n} 个字符", "message.min-password-length": "密码最短长度为 {n} 个字符",
"message.new-version-available": "Umami 新版本 {version} 已推出", "message.new-version-available": "Umami 新版本 {version} 已发布",
"message.no-data-available": "无可用数据。", "message.no-data-available": "无数据。",
"message.no-event-data": "无可用事件。", "message.no-event-data": "无可用事件。",
"message.no-match-password": "密码不一致", "message.no-match-password": "密码不一致",
"message.no-results-found": "没有找到任何结果。", "message.no-results-found": "未找到结果。",
"message.no-team-websites": "这个团队没有任何网站。", "message.no-team-websites": "该团队暂无网站。",
"message.no-teams": "你还没有创建任何团队。", "message.no-teams": "您尚未创建任何团队。",
"message.no-users": "没有任何用户。", "message.no-users": "暂无用户。",
"message.no-websites-configured": "你还没有设置任何网站。", "message.no-websites-configured": "你还没有设置任何网站。",
"message.page-not-found": "页未找到。", "message.page-not-found": "页未找到。",
"message.reset-website": "如确定重置该网站,请在下面的输入框中输入 {confirmation} 进行二次确认。", "message.reset-website": "如确定重置该网站,请在下面输入 {confirmation} 确认。",
"message.reset-website-warning": "网站的所有统计数据将被删除,但您的跟踪代码将保持不变。", "message.reset-website-warning": "网站的所有统计数据将被删除,但您的跟踪代码将保持不变。",
"message.saved": "保存成功。", "message.saved": "保存成功。",
"message.share-url": "这是 {target} 的共享链接。", "message.share-url": "这是 {target} 的共享链接。",
"message.team-already-member": "你已是该团队的成员。", "message.team-already-member": "你已是该团队的成员。",
"message.team-not-found": "未找到团队。", "message.team-not-found": "未找到团队。",
"message.team-websites-info": "团队中的任何人都可查看网站。", "message.team-websites-info": "团队成员均可查看网站数据。",
"message.tracking-code": "跟踪代码", "message.tracking-code": "跟踪代码",
"message.transfer-team-website-to-user": "将网站转您的账户?", "message.transfer-team-website-to-user": "将网站转移到您的账户?",
"message.transfer-user-website-to-team": "选择要将该网站转移到哪个团队。", "message.transfer-user-website-to-team": "选择要转移此网站的团队。",
"message.transfer-website": "将网站所有权转移到您的账户或其他团队。", "message.transfer-website": "将网站所有权转移到您的账户或其他团队。",
"message.triggered-event": "触发事件", "message.triggered-event": "触发事件",
"message.user-deleted": "用户已删除。", "message.user-deleted": "用户已删除。",

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
export const CURRENT_VERSION = process.env.currentVersion; export const CURRENT_VERSION = process.env.currentVersion;
export const AUTH_TOKEN = 'umami.auth'; export const AUTH_TOKEN = 'umami.auth';
export const LOCALE_CONFIG = 'umami.locale'; export const LOCALE_CONFIG = 'umami.locale';
@@ -12,6 +11,7 @@ export const HOMEPAGE_URL = 'https://umami.is';
export const REPO_URL = 'https://github.com/umami-software/umami'; export const REPO_URL = 'https://github.com/umami-software/umami';
export const UPDATES_URL = 'https://api.umami.is/v1/updates'; export const UPDATES_URL = 'https://api.umami.is/v1/updates';
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png'; export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US'; export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US';
export const DEFAULT_THEME = 'light'; export const DEFAULT_THEME = 'light';
@@ -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',
'region',
];
export const SESSION_COLUMNS = [ export const SESSION_COLUMNS = [
'browser', 'browser',
@@ -42,7 +52,6 @@ export const SESSION_COLUMNS = [
'screen', 'screen',
'language', 'language',
'country', 'country',
'region',
'city', 'city',
'host', 'host',
]; ];
@@ -59,7 +68,7 @@ export const FILTER_COLUMNS = {
browser: 'browser', browser: 'browser',
device: 'device', device: 'device',
country: 'country', country: 'country',
region: 'subdivision1', region: 'region',
city: 'city', city: 'city',
language: 'language', language: 'language',
event: 'event_name', event: 'event_name',
@@ -117,6 +126,7 @@ export const REPORT_TYPES = {
utm: 'utm', utm: 'utm',
journey: 'journey', journey: 'journey',
revenue: 'revenue', revenue: 'revenue',
attribution: 'attribution',
} as const; } as const;
export const REPORT_PARAMETERS = { export const REPORT_PARAMETERS = {

View File

@@ -96,12 +96,12 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
// Cloudflare headers // Cloudflare headers
if (headers.get('cf-ipcountry')) { if (headers.get('cf-ipcountry')) {
const country = decodeHeader(headers.get('cf-ipcountry')); const country = decodeHeader(headers.get('cf-ipcountry'));
const subdivision1 = decodeHeader(headers.get('cf-region-code')); const region = decodeHeader(headers.get('cf-region-code'));
const city = decodeHeader(headers.get('cf-ipcity')); const city = decodeHeader(headers.get('cf-ipcity'));
return { return {
country, country,
subdivision1: getRegionCode(country, subdivision1), region: getRegionCode(country, region),
city, city,
}; };
} }
@@ -109,12 +109,12 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
// Vercel headers // Vercel headers
if (headers.get('x-vercel-ip-country')) { if (headers.get('x-vercel-ip-country')) {
const country = decodeHeader(headers.get('x-vercel-ip-country')); const country = decodeHeader(headers.get('x-vercel-ip-country'));
const subdivision1 = decodeHeader(headers.get('x-vercel-ip-country-region')); const region = decodeHeader(headers.get('x-vercel-ip-country-region'));
const city = decodeHeader(headers.get('x-vercel-ip-city')); const city = decodeHeader(headers.get('x-vercel-ip-city'));
return { return {
country, country,
subdivision1: getRegionCode(country, subdivision1), region: getRegionCode(country, region),
city, city,
}; };
} }
@@ -131,14 +131,12 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
if (result) { if (result) {
const country = result.country?.iso_code ?? result?.registered_country?.iso_code; const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
const subdivision1 = result.subdivisions?.[0]?.iso_code; const region = result.subdivisions?.[0]?.iso_code;
const subdivision2 = result.subdivisions?.[1]?.names?.en;
const city = result.city?.names?.en; const city = result.city?.names?.en;
return { return {
country, country,
subdivision1: getRegionCode(country, subdivision1), region: getRegionCode(country, region),
subdivision2,
city, city,
}; };
} }
@@ -149,14 +147,13 @@ export async function getClientInfo(request: Request, payload: Record<string, an
const ip = payload?.ip || getIpAddress(request.headers); const ip = payload?.ip || getIpAddress(request.headers);
const location = await getLocation(ip, request.headers, !!payload?.ip); const location = await getLocation(ip, request.headers, !!payload?.ip);
const country = location?.country; const country = location?.country;
const subdivision1 = location?.subdivision1; const region = location?.region;
const subdivision2 = location?.subdivision2;
const city = location?.city; const city = location?.city;
const browser = browserName(userAgent); const browser = browserName(userAgent);
const os = detectOS(userAgent) as string; const os = detectOS(userAgent) as string;
const device = getDevice(payload?.screen, os); const device = getDevice(payload?.screen, os);
return { userAgent, browser, os, ip, country, subdivision1, subdivision2, city, device }; return { userAgent, browser, os, ip, country, region, city, device };
} }
export function hasBlockedIp(clientIp: string) { export function hasBlockedIp(clientIp: string) {

View File

@@ -1,5 +1,6 @@
import debug from 'debug'; import debug from 'debug';
import prisma from '@umami/prisma-client'; import { PrismaClient } from '@prisma/client';
import { readReplicas } from '@prisma/extension-read-replicas';
import { formatInTimeZone } from 'date-fns-tz'; import { formatInTimeZone } from 'date-fns-tz';
import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db'; import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db';
import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants'; import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
@@ -10,6 +11,16 @@ import { filtersToArray } from './params';
const log = debug('umami:prisma'); const log = debug('umami:prisma');
const PRISMA = 'prisma';
const PRISMA_LOG_OPTIONS = {
log: [
{
emit: 'event',
level: 'query',
},
],
};
const MYSQL_DATE_FORMATS = { const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%dT%H:%i:00', minute: '%Y-%m-%dT%H:%i:00',
hour: '%Y-%m-%d %H:00:00', hour: '%Y-%m-%d %H:00:00',
@@ -151,7 +162,7 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}):
if (name === 'referrer') { if (name === 'referrer') {
arr.push( arr.push(
`and (website_event.referrer_domain != session.hostname or website_event.referrer_domain is null)`, `and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`,
); );
} }
} }
@@ -234,14 +245,16 @@ async function rawQuery(sql: string, data: object): Promise<any> {
return db === MYSQL ? '?' : `$${params.length}${type ?? ''}`; return db === MYSQL ? '?' : `$${params.length}${type ?? ''}`;
}); });
return prisma.rawQuery(query, params); return process.env.DATABASE_REPLICA_URL
? client.$replica().$queryRawUnsafe(query, ...params)
: client.$queryRawUnsafe(query, ...params);
} }
async function pagedQuery<T>(model: string, criteria: T, pageParams: PageParams) { async function pagedQuery<T>(model: string, criteria: T, pageParams: PageParams) {
const { page = 1, pageSize, orderBy, sortDescending = false } = pageParams || {}; const { page = 1, pageSize, orderBy, sortDescending = false } = pageParams || {};
const size = +pageSize || DEFAULT_PAGE_SIZE; const size = +pageSize || DEFAULT_PAGE_SIZE;
const data = await prisma.client[model].findMany({ const data = await client[model].findMany({
...criteria, ...criteria,
...{ ...{
...(size > 0 && { take: +size, skip: +size * (+page - 1) }), ...(size > 0 && { take: +size, skip: +size * (+page - 1) }),
@@ -255,7 +268,7 @@ async function pagedQuery<T>(model: string, criteria: T, pageParams: PageParams)
}, },
}); });
const count = await prisma.client[model].count({ where: (criteria as any).where }); const count = await client[model].count({ where: (criteria as any).where });
return { data, count, page: +page, pageSize: size, orderBy }; return { data, count, page: +page, pageSize: size, orderBy };
} }
@@ -323,8 +336,55 @@ function getSearchParameters(query: string, filters: { [key: string]: any }[]) {
}; };
} }
function transaction(input: any, options?: any) {
return client.$transaction(input, options);
}
function getClient(params?: {
logQuery?: boolean;
queryLogger?: () => void;
replicaUrl?: string;
options?: any;
}): PrismaClient {
const {
logQuery = !!process.env.LOG_QUERY,
queryLogger,
replicaUrl = process.env.DATABASE_REPLICA_URL,
options,
} = params || {};
const prisma = new PrismaClient({
errorFormat: 'pretty',
...(logQuery && PRISMA_LOG_OPTIONS),
...options,
});
if (replicaUrl) {
prisma.$extends(
readReplicas({
url: replicaUrl,
}),
);
}
if (logQuery) {
prisma.$on('query' as never, queryLogger || log);
}
if (process.env.NODE_ENV !== 'production') {
global[PRISMA] = prisma;
}
log('Prisma initialized');
return prisma;
}
const client = global[PRISMA] || getClient();
export default { export default {
...prisma, client,
transaction,
getAddIntervalQuery, getAddIntervalQuery,
getCastColumnQuery, getCastColumnQuery,
getDayDiffQuery, getDayDiffQuery,

View File

@@ -4,13 +4,13 @@ const REDIS = 'redis';
const enabled = !!process.env.REDIS_URL; const enabled = !!process.env.REDIS_URL;
function getClient() { function getClient() {
const client = new UmamiRedisClient(process.env.REDIS_URL); const redis = new UmamiRedisClient(process.env.REDIS_URL);
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
global[REDIS] = client; global[REDIS] = redis;
} }
return client; return redis;
} }
const client = global[REDIS] || getClient(); const client = global[REDIS] || getClient();

View File

@@ -62,6 +62,7 @@ export const reportTypeParam = z.enum([
'goals', 'goals',
'journey', 'journey',
'revenue', 'revenue',
'attribution',
]); ]);
export const reportParms = { export const reportParms = {

View File

@@ -196,8 +196,7 @@ export interface SessionData {
screen: string; screen: string;
language: string; language: string;
country: string; country: string;
subdivision1: string; region: string;
subdivision2: string;
city: string; city: string;
ip?: string; ip?: string;
userAgent?: string; userAgent?: string;

View File

@@ -52,7 +52,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
limit 1000) limit 1000)
select * from events select * from events
`, `,
{ ...params, query: `%${search}%` }, { ...params, search: `%${search}%` },
pageParams, pageParams,
); );
} }

View File

@@ -12,10 +12,21 @@ export async function saveEvent(args: {
visitId: string; visitId: string;
urlPath: string; urlPath: string;
urlQuery?: string; urlQuery?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmContent?: string;
utmTerm?: string;
referrerPath?: string; referrerPath?: string;
referrerQuery?: string; referrerQuery?: string;
referrerDomain?: string; referrerDomain?: string;
pageTitle?: string; pageTitle?: string;
gclid?: string;
fbclid?: string;
msclkid?: string;
ttclid?: string;
lifatid?: string;
twclid?: string;
eventName?: string; eventName?: string;
eventData?: any; eventData?: any;
hostname?: string; hostname?: string;
@@ -25,8 +36,7 @@ export async function saveEvent(args: {
screen?: string; screen?: string;
language?: string; language?: string;
country?: string; country?: string;
subdivision1?: string; region?: string;
subdivision2?: string;
city?: string; city?: string;
tag?: string; tag?: string;
createdAt?: Date; createdAt?: Date;
@@ -43,13 +53,25 @@ async function relationalQuery(data: {
visitId: string; visitId: string;
urlPath: string; urlPath: string;
urlQuery?: string; urlQuery?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmContent?: string;
utmTerm?: string;
referrerPath?: string; referrerPath?: string;
referrerQuery?: string; referrerQuery?: string;
referrerDomain?: string; referrerDomain?: string;
gclid?: string;
fbclid?: string;
msclkid?: string;
ttclid?: string;
lifatid?: string;
twclid?: string;
pageTitle?: string; pageTitle?: string;
eventName?: string; eventName?: string;
eventData?: any; eventData?: any;
tag?: string; tag?: string;
hostname?: string;
createdAt?: Date; createdAt?: Date;
}) { }) {
const { const {
@@ -58,13 +80,25 @@ async function relationalQuery(data: {
visitId, visitId,
urlPath, urlPath,
urlQuery, urlQuery,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
referrerPath, referrerPath,
referrerQuery, referrerQuery,
referrerDomain, referrerDomain,
eventName, eventName,
eventData, eventData,
pageTitle, pageTitle,
gclid,
fbclid,
msclkid,
ttclid,
lifatid,
twclid,
tag, tag,
hostname,
createdAt, createdAt,
} = data; } = data;
const websiteEventId = uuid(); const websiteEventId = uuid();
@@ -77,13 +111,25 @@ async function relationalQuery(data: {
visitId, visitId,
urlPath: urlPath?.substring(0, URL_LENGTH), urlPath: urlPath?.substring(0, URL_LENGTH),
urlQuery: urlQuery?.substring(0, URL_LENGTH), urlQuery: urlQuery?.substring(0, URL_LENGTH),
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
referrerPath: referrerPath?.substring(0, URL_LENGTH), referrerPath: referrerPath?.substring(0, URL_LENGTH),
referrerQuery: referrerQuery?.substring(0, URL_LENGTH), referrerQuery: referrerQuery?.substring(0, URL_LENGTH),
referrerDomain: referrerDomain?.substring(0, URL_LENGTH), referrerDomain: referrerDomain?.substring(0, URL_LENGTH),
pageTitle: pageTitle?.substring(0, PAGE_TITLE_LENGTH), pageTitle: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
gclid,
fbclid,
msclkid,
ttclid,
lifatid,
twclid,
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
tag, tag,
hostname,
createdAt, createdAt,
}, },
}); });
@@ -109,10 +155,21 @@ async function clickhouseQuery(data: {
visitId: string; visitId: string;
urlPath: string; urlPath: string;
urlQuery?: string; urlQuery?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmContent?: string;
utmTerm?: string;
referrerPath?: string; referrerPath?: string;
referrerQuery?: string; referrerQuery?: string;
referrerDomain?: string; referrerDomain?: string;
pageTitle?: string; pageTitle?: string;
gclid?: string;
fbclid?: string;
msclkid?: string;
ttclid?: string;
lifatid?: string;
twclid?: string;
eventName?: string; eventName?: string;
eventData?: any; eventData?: any;
hostname?: string; hostname?: string;
@@ -122,8 +179,7 @@ async function clickhouseQuery(data: {
screen?: string; screen?: string;
language?: string; language?: string;
country?: string; country?: string;
subdivision1?: string; region?: string;
subdivision2?: string;
city?: string; city?: string;
tag?: string; tag?: string;
createdAt?: Date; createdAt?: Date;
@@ -134,15 +190,25 @@ async function clickhouseQuery(data: {
visitId, visitId,
urlPath, urlPath,
urlQuery, urlQuery,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
referrerPath, referrerPath,
referrerQuery, referrerQuery,
referrerDomain, referrerDomain,
gclid,
fbclid,
msclkid,
ttclid,
lifatid,
twclid,
pageTitle, pageTitle,
eventName, eventName,
eventData, eventData,
country, country,
subdivision1, region,
subdivision2,
city, city,
tag, tag,
createdAt, createdAt,
@@ -159,20 +225,25 @@ async function clickhouseQuery(data: {
visit_id: visitId, visit_id: visitId,
event_id: eventId, event_id: eventId,
country: country, country: country,
subdivision1: region: country && region ? (region.includes('-') ? region : `${country}-${region}`) : null,
country && subdivision1
? subdivision1.includes('-')
? subdivision1
: `${country}-${subdivision1}`
: null,
subdivision2: subdivision2,
city: city, city: city,
url_path: urlPath?.substring(0, URL_LENGTH), url_path: urlPath?.substring(0, URL_LENGTH),
url_query: urlQuery?.substring(0, URL_LENGTH), url_query: urlQuery?.substring(0, URL_LENGTH),
utm_source: utmSource,
utm_medium: utmMedium,
utm_campaign: utmCampaign,
utm_content: utmContent,
utm_term: utmTerm,
referrer_path: referrerPath?.substring(0, URL_LENGTH), referrer_path: referrerPath?.substring(0, URL_LENGTH),
referrer_query: referrerQuery?.substring(0, URL_LENGTH), referrer_query: referrerQuery?.substring(0, URL_LENGTH),
referrer_domain: referrerDomain?.substring(0, URL_LENGTH), referrer_domain: referrerDomain?.substring(0, URL_LENGTH),
page_title: pageTitle?.substring(0, PAGE_TITLE_LENGTH), page_title: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
gclid: gclid,
fbclid: fbclid,
msclkid: msclkid,
ttclid: ttclid,
li_fat_id: lifatid,
twclid: twclid,
event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
tag: tag, tag: tag,

View File

@@ -32,7 +32,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
${filterQuery} ${filterQuery}
${dateQuery} ${dateQuery}
order by website_event.created_at desc order by website_event.created_at asc
limit 100 limit 100
`, `,
params, params,

View File

@@ -41,7 +41,7 @@ async function relationalQuery(
let excludeDomain = ''; let excludeDomain = '';
if (column === 'referrer_domain') { if (column === 'referrer_domain') {
excludeDomain = `and website_event.referrer_domain != session.hostname excludeDomain = `and website_event.referrer_domain != website_event.hostname
and website_event.referrer_domain != ''`; and website_event.referrer_domain != ''`;
} }

View File

@@ -0,0 +1,511 @@
import clickhouse from '@/lib/clickhouse';
import { EVENT_TYPE } from '@/lib/constants';
import { CLICKHOUSE, getDatabaseType, POSTGRESQL, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
export async function getAttribution(
...args: [
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
model: string;
steps: { type: string; value: string }[];
currency: string;
},
]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
model: string;
steps: { type: string; value: string }[];
currency: string;
},
): Promise<{
referrer: { name: string; value: number }[];
paidAds: { name: string; value: number }[];
utm_source: { name: string; value: number }[];
utm_medium: { name: string; value: number }[];
utm_campaign: { name: string; value: number }[];
utm_content: { name: string; value: number }[];
utm_term: { name: string; value: number }[];
total: { pageviews: number; visitors: number; visits: number };
}> {
const { startDate, endDate, model, steps, currency } = criteria;
const { rawQuery } = prisma;
const conversionStep = steps[0].value;
const eventType = steps[0].type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = steps[0].type === 'url' ? 'url_path' : 'event_name';
const db = getDatabaseType();
const like = db === POSTGRESQL ? 'ilike' : 'like';
function getUTMQuery(utmColumn: string) {
return `
select
coalesce(we.${utmColumn}, '') name,
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
from model m
join website_event we
on we.created_at = m.created_at
and we.session_id = m.session_id
${currency ? 'join events e on e.session_id = m.session_id' : ''}
where we.website_id = {{websiteId::uuid}}
and we.created_at between {{startDate}} and {{endDate}}
${currency ? '' : `and we.${utmColumn} != ''`}
group by 1
order by 2 desc
limit 20`;
}
const eventQuery = `WITH events AS (
select distinct
session_id,
max(created_at) max_dt
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and ${column} = {{conversionStep}}
and event_type = {{eventType}}
group by 1),`;
const revenueEventQuery = `WITH events AS (
select
we.session_id,
max(ed.created_at) max_dt,
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) value
from event_data ed
join website_event we
on we.event_id = ed.website_event_Id
and we.website_id = ed.website_id
join (select website_event_id
from event_data
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and data_key ${like} '%currency%'
and string_value = {{currency}}) currency
on currency.website_event_id = ed.website_event_id
where ed.website_id = {{websiteId::uuid}}
and ed.created_at between {{startDate}} and {{endDate}}
and ${column} = {{conversionStep}}
and ed.data_key ${like} '%revenue%'
group by 1),`;
function getModelQuery(model: string) {
return model === 'firstClick'
? `\n
model AS (select e.session_id,
min(we.created_at) created_at
from events e
join website_event we
on we.session_id = e.session_id
where we.website_id = {{websiteId::uuid}}
and we.created_at between {{startDate}} and {{endDate}}
group by e.session_id)`
: `\n
model AS (select e.session_id,
max(we.created_at) created_at
from events e
join website_event we
on we.session_id = e.session_id
where we.website_id = {{websiteId::uuid}}
and we.created_at between {{startDate}} and {{endDate}}
and we.created_at < e.max_dt
group by e.session_id)`;
}
const referrerRes = await rawQuery(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
select coalesce(we.referrer_domain, '') name,
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
from model m
join website_event we
on we.created_at = m.created_at
and we.session_id = m.session_id
join session s
on s.session_id = m.session_id
${currency ? 'join events e on e.session_id = m.session_id' : ''}
where we.website_id = {{websiteId::uuid}}
and we.created_at between {{startDate}} and {{endDate}}
${
currency
? ''
: `and we.referrer_domain != hostname
and we.referrer_domain != ''`
}
group by 1
order by 2 desc
limit 20
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const paidAdsres = await rawQuery(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)},
results AS (
select case
when coalesce(gclid, '') != '' then 'Google Ads'
when coalesce(fbclid, '') != '' then 'Facebook / Meta'
when coalesce(msclkid, '') != '' then 'Microsoft Ads'
when coalesce(ttclid, '') != '' then 'TikTok Ads'
when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads'
when coalesce(twclid, '') != '' then 'Twitter Ads (X)'
else ''
end name,
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
from model m
join website_event we
on we.created_at = m.created_at
and we.session_id = m.session_id
${currency ? 'join events e on e.session_id = m.session_id' : ''}
where we.website_id = {{websiteId::uuid}}
and we.created_at between {{startDate}} and {{endDate}}
group by 1
order by 2 desc
limit 20)
SELECT *
FROM results
${currency ? '' : `WHERE name != ''`}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const sourceRes = await rawQuery(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_source')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const mediumRes = await rawQuery(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_medium')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const campaignRes = await rawQuery(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_campaign')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const contentRes = await rawQuery(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_content')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const termRes = await rawQuery(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_term')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const totalRes = await rawQuery(
`
select
count(*) as "pageviews",
count(distinct session_id) as "visitors",
count(distinct visit_id) as "visits"
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and ${column} = {{conversionStep}}
and event_type = {{eventType}}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
).then(result => result?.[0]);
return {
referrer: referrerRes,
paidAds: paidAdsres,
utm_source: sourceRes,
utm_medium: mediumRes,
utm_campaign: campaignRes,
utm_content: contentRes,
utm_term: termRes,
total: totalRes,
};
}
async function clickhouseQuery(
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
model: string;
steps: { type: string; value: string }[];
currency: string;
},
): Promise<{
referrer: { name: string; value: number }[];
paidAds: { name: string; value: number }[];
utm_source: { name: string; value: number }[];
utm_medium: { name: string; value: number }[];
utm_campaign: { name: string; value: number }[];
utm_content: { name: string; value: number }[];
utm_term: { name: string; value: number }[];
total: { pageviews: number; visitors: number; visits: number };
}> {
const { startDate, endDate, model, steps, currency } = criteria;
const { rawQuery } = clickhouse;
const conversionStep = steps[0].value;
const eventType = steps[0].type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = steps[0].type === 'url' ? 'url_path' : 'event_name';
function getUTMQuery(utmColumn: string) {
return `
select
we.${utmColumn} name,
${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
from model m
join website_event we
on we.created_at = m.created_at
and we.session_id = m.session_id
${currency ? 'join events e on e.session_id = m.session_id' : ''}
where we.website_id = {websiteId:UUID}
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${currency ? '' : `and we.${utmColumn} != ''`}
group by 1
order by 2 desc
limit 20`;
}
const eventQuery = `WITH events AS (
select distinct
session_id,
max(created_at) max_dt
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and ${column} = {conversionStep:String}
and event_type = {eventType:UInt32}
group by 1),`;
const revenueEventQuery = `WITH events AS (
select
ed.session_id,
max(ed.created_at) max_dt,
sum(coalesce(toDecimal64(number_value, 2), toDecimal64(string_value, 2))) as value
from event_data ed
join (select event_id
from event_data
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'currency') > 0
and string_value = {currency:String}) c
on c.event_id = ed.event_id
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and ${column} = {conversionStep:String}
and positionCaseInsensitive(ed.data_key, 'revenue') > 0
group by 1),`;
function getModelQuery(model: string) {
return model === 'firstClick'
? `\n
model AS (select e.session_id,
min(we.created_at) created_at
from events e
join website_event we
on we.session_id = e.session_id
where we.website_id = {websiteId:UUID}
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
group by e.session_id)`
: `\n
model AS (select e.session_id,
max(we.created_at) created_at
from events e
join website_event we
on we.session_id = e.session_id
where we.website_id = {websiteId:UUID}
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and we.created_at < e.max_dt
group by e.session_id)`;
}
const referrerRes = await rawQuery<
{
name: string;
value: number;
}[]
>(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
select we.referrer_domain name,
${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
from model m
join website_event we
on we.created_at = m.created_at
and we.session_id = m.session_id
${currency ? 'join events e on e.session_id = m.session_id' : ''}
where we.website_id = {websiteId:UUID}
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${
currency
? ''
: `and we.referrer_domain != hostname
and we.referrer_domain != ''`
}
group by 1
order by 2 desc
limit 20
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const paidAdsres = await rawQuery<
{
name: string;
value: number;
}[]
>(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
select multiIf(gclid != '', 'Google Ads',
fbclid != '', 'Facebook / Meta',
msclkid != '', 'Microsoft Ads',
ttclid != '', 'TikTok Ads',
li_fat_id != '', ' LinkedIn Ads',
twclid != '', 'Twitter Ads (X)','') name,
${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
from model m
join website_event we
on we.created_at = m.created_at
and we.session_id = m.session_id
${currency ? 'join events e on e.session_id = m.session_id' : ''}
where we.website_id = {websiteId:UUID}
and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${currency ? '' : `and name != ''`}
group by 1
order by 2 desc
limit 20
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const sourceRes = await rawQuery<
{
name: string;
value: number;
}[]
>(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_source')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const mediumRes = await rawQuery<
{
name: string;
value: number;
}[]
>(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_medium')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const campaignRes = await rawQuery<
{
name: string;
value: number;
}[]
>(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_campaign')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const contentRes = await rawQuery<
{
name: string;
value: number;
}[]
>(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_content')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const termRes = await rawQuery<
{
name: string;
value: number;
}[]
>(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
${getUTMQuery('utm_term')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
);
const totalRes = await rawQuery<{ pageviews: number; visitors: number; visits: number }>(
`
select
count(*) as "pageviews",
uniqExact(session_id) as "visitors",
uniqExact(visit_id) as "visits"
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and ${column} = {conversionStep:String}
and event_type = {eventType:UInt32}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
).then(result => result?.[0]);
return {
referrer: referrerRes,
paidAds: paidAdsres,
utm_source: sourceRes,
utm_medium: mediumRes,
utm_campaign: campaignRes,
utm_content: contentRes,
utm_term: termRes,
total: totalRes,
};
}

View File

@@ -56,7 +56,9 @@ async function relationalQuery(
on we.event_id = ed.website_event_id on we.event_id = ed.website_event_id
join (select website_event_id join (select website_event_id
from event_data from event_data
where data_key ${like} '%currency%' where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and data_key ${like} '%currency%'
and string_value = {{currency}}) currency and string_value = {{currency}}) currency
on currency.website_event_id = ed.website_event_id on currency.website_event_id = ed.website_event_id
where ed.website_id = {{websiteId::uuid}} where ed.website_id = {{websiteId::uuid}}
@@ -80,7 +82,9 @@ async function relationalQuery(
on s.session_id = we.session_id on s.session_id = we.session_id
join (select website_event_id join (select website_event_id
from event_data from event_data
where data_key ${like} '%currency%' where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and data_key ${like} '%currency%'
and string_value = {{currency}}) currency and string_value = {{currency}}) currency
on currency.website_event_id = ed.website_event_id on currency.website_event_id = ed.website_event_id
where ed.website_id = {{websiteId::uuid}} where ed.website_id = {{websiteId::uuid}}
@@ -102,7 +106,9 @@ async function relationalQuery(
on we.event_id = ed.website_event_id on we.event_id = ed.website_event_id
join (select website_event_id join (select website_event_id
from event_data from event_data
where data_key ${like} '%currency%' where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and data_key ${like} '%currency%'
and string_value = {{currency}}) currency and string_value = {{currency}}) currency
on currency.website_event_id = ed.website_event_id on currency.website_event_id = ed.website_event_id
where ed.website_id = {{websiteId::uuid}} where ed.website_id = {{websiteId::uuid}}
@@ -124,7 +130,9 @@ async function relationalQuery(
on we.event_id = ed.website_event_id on we.event_id = ed.website_event_id
join (select website_event_id, string_value as currency join (select website_event_id, string_value as currency
from event_data from event_data
where data_key ${like} '%currency%') c where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and data_key ${like} '%currency%') c
on c.website_event_id = ed.website_event_id on c.website_event_id = ed.website_event_id
where ed.website_id = {{websiteId::uuid}} where ed.website_id = {{websiteId::uuid}}
and ed.created_at between {{startDate}} and {{endDate}} and ed.created_at between {{startDate}} and {{endDate}}
@@ -176,7 +184,9 @@ async function clickhouseQuery(
from event_data from event_data
join (select event_id join (select event_id
from event_data from event_data
where positionCaseInsensitive(data_key, 'currency') > 0 where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'currency') > 0
and string_value = {currency:String}) currency and string_value = {currency:String}) currency
on currency.event_id = event_data.event_id on currency.event_id = event_data.event_id
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
@@ -201,7 +211,9 @@ async function clickhouseQuery(
from event_data ed from event_data ed
join (select event_id join (select event_id
from event_data from event_data
where positionCaseInsensitive(data_key, 'currency') > 0 where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'currency') > 0
and string_value = {currency:String}) c and string_value = {currency:String}) c
on c.event_id = ed.event_id on c.event_id = ed.event_id
join (select distinct website_id, session_id, country join (select distinct website_id, session_id, country
@@ -231,7 +243,9 @@ async function clickhouseQuery(
from event_data from event_data
join (select event_id join (select event_id
from event_data from event_data
where positionCaseInsensitive(data_key, 'currency') > 0 where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'currency') > 0
and string_value = {currency:String}) currency and string_value = {currency:String}) currency
on currency.event_id = event_data.event_id on currency.event_id = event_data.event_id
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
@@ -259,7 +273,9 @@ async function clickhouseQuery(
from event_data ed from event_data ed
join (select event_id, string_value as currency join (select event_id, string_value as currency
from event_data from event_data
where positionCaseInsensitive(data_key, 'currency') > 0) c where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'currency') > 0) c
on c.event_id = ed.event_id on c.event_id = ed.event_id
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}

View File

@@ -2,34 +2,19 @@ import { Prisma } from '@prisma/client';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
export async function createSession(data: Prisma.SessionCreateInput) { export async function createSession(data: Prisma.SessionCreateInput) {
const { const { id, websiteId, browser, os, device, screen, language, country, region, city } = data;
id,
websiteId,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
subdivision2,
city,
} = data;
return prisma.client.session.create({ return prisma.client.session.create({
data: { data: {
id, id,
websiteId, websiteId,
hostname,
browser, browser,
os, os,
device, device,
screen, screen,
language, language,
country, country,
subdivision1, region,
subdivision2,
city, city,
}, },
}); });

View File

@@ -38,7 +38,7 @@ async function relationalQuery(
joinSession: SESSION_COLUMNS.includes(type), joinSession: SESSION_COLUMNS.includes(type),
}, },
); );
const includeCountry = column === 'city' || column === 'subdivision1'; const includeCountry = column === 'city' || column === 'region';
return rawQuery( return rawQuery(
` `
@@ -75,7 +75,7 @@ async function clickhouseQuery(
...filters, ...filters,
eventType: EVENT_TYPE.pageView, eventType: EVENT_TYPE.pageView,
}); });
const includeCountry = column === 'city' || column === 'subdivision1'; const includeCountry = column === 'city' || column === 'region';
let sql = ''; let sql = '';

View File

@@ -23,7 +23,7 @@ async function relationalQuery(websiteId: string, sessionId: string) {
screen, screen,
language, language,
country, country,
subdivision1, region,
city, city,
min(min_time) as "firstAt", min(min_time) as "firstAt",
max(max_time) as "lastAt", max(max_time) as "lastAt",
@@ -35,14 +35,14 @@ async function relationalQuery(websiteId: string, sessionId: string) {
session.session_id as id, session.session_id as id,
website_event.visit_id, website_event.visit_id,
session.website_id, session.website_id,
session.hostname, website_event.hostname,
session.browser, session.browser,
session.os, session.os,
session.device, session.device,
session.screen, session.screen,
session.language, session.language,
session.country, session.country,
session.subdivision1, session.region,
session.city, session.city,
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,
@@ -52,8 +52,8 @@ async function relationalQuery(websiteId: string, sessionId: string) {
join website_event on website_event.session_id = session.session_id join website_event on website_event.session_id = session.session_id
where session.website_id = {{websiteId::uuid}} where session.website_id = {{websiteId::uuid}}
and session.session_id = {{sessionId::uuid}} and session.session_id = {{sessionId::uuid}}
group by session.session_id, visit_id, session.website_id, session.hostname, session.browser, session.os, session.device, session.screen, session.language, session.country, session.subdivision1, session.city) t group by session.session_id, visit_id, session.website_id, website_event.hostname, session.browser, session.os, session.device, session.screen, session.language, session.country, session.region, session.city) t
group by id, website_id, hostname, browser, os, device, screen, language, country, subdivision1, city; group by id, website_id, hostname, browser, os, device, screen, language, country, region, city;
`, `,
{ websiteId, sessionId }, { websiteId, sessionId },
).then(result => result?.[0]); ).then(result => result?.[0]);
@@ -73,7 +73,7 @@ async function clickhouseQuery(websiteId: string, sessionId: string) {
screen, screen,
language, language,
country, country,
subdivision1, region,
city, city,
${getDateStringSQL('min(min_time)')} as firstAt, ${getDateStringSQL('min(min_time)')} as firstAt,
${getDateStringSQL('max(max_time)')} as lastAt, ${getDateStringSQL('max(max_time)')} as lastAt,
@@ -92,7 +92,7 @@ async function clickhouseQuery(websiteId: string, sessionId: string) {
screen, screen,
language, language,
country, country,
subdivision1, region,
city, city,
min(min_time) as min_time, min(min_time) as min_time,
max(max_time) as max_time, max(max_time) as max_time,
@@ -101,8 +101,8 @@ async function clickhouseQuery(websiteId: string, sessionId: string) {
from website_event_stats_hourly from website_event_stats_hourly
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and session_id = {sessionId:UUID} and session_id = {sessionId:UUID}
group by session_id, visit_id, website_id, hostname, browser, os, device, screen, language, country, subdivision1, city) t group by session_id, visit_id, website_id, hostname, browser, os, device, screen, language, country, region, city) t
group by id, websiteId, hostname, browser, os, device, screen, language, country, subdivision1, city; group by id, websiteId, hostname, browser, os, device, screen, language, country, region, city;
`, `,
{ websiteId, sessionId }, { websiteId, sessionId },
).then(result => result?.[0]); ).then(result => result?.[0]);

View File

@@ -24,14 +24,14 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
select select
session.session_id as "id", session.session_id as "id",
session.website_id as "websiteId", session.website_id as "websiteId",
session.hostname, website_event.hostname,
session.browser, session.browser,
session.os, session.os,
session.device, session.device,
session.screen, session.screen,
session.language, session.language,
session.country, session.country,
session.subdivision1, session.region,
session.city, session.city,
min(website_event.created_at) as "firstAt", min(website_event.created_at) as "firstAt",
max(website_event.created_at) as "lastAt", max(website_event.created_at) as "lastAt",
@@ -45,14 +45,14 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
${filterQuery} ${filterQuery}
group by session.session_id, group by session.session_id,
session.website_id, session.website_id,
session.hostname, website_event.hostname,
session.browser, session.browser,
session.os, session.os,
session.device, session.device,
session.screen, session.screen,
session.language, session.language,
session.country, session.country,
session.subdivision1, session.region,
session.city session.city
order by max(website_event.created_at) desc order by max(website_event.created_at) desc
limit 1000) limit 1000)
@@ -80,7 +80,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
screen, screen,
language, language,
country, country,
subdivision1, region,
city, city,
${getDateStringSQL('min(min_time)')} as firstAt, ${getDateStringSQL('min(min_time)')} as firstAt,
${getDateStringSQL('max(max_time)')} as lastAt, ${getDateStringSQL('max(max_time)')} as lastAt,
@@ -91,7 +91,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
${dateQuery} ${dateQuery}
${filterQuery} ${filterQuery}
group by session_id, website_id, hostname, browser, os, device, screen, language, country, subdivision1, city group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city
order by lastAt desc order by lastAt desc
limit 1000) limit 1000)
select * from sessions select * from sessions

View File

@@ -46,6 +46,7 @@
url: currentUrl, url: currentUrl,
referrer: currentRef, referrer: currentRef,
tag: tag ? tag : undefined, tag: tag ? tag : undefined,
id: identity ? identity : undefined,
}); });
const hasDoNotTrack = () => { const hasDoNotTrack = () => {
@@ -233,22 +234,35 @@
} }
}; };
const track = (obj, data) => { const track = (name, data) => {
if (typeof obj === 'string') { if (typeof name === 'string') {
return send({ return send({
...getPayload(), ...getPayload(),
name: obj, name,
data: typeof data === 'object' ? data : undefined, data,
}); });
} else if (typeof obj === 'object') { } else if (typeof name === 'object') {
return send(obj); return send({ ...name });
} else if (typeof obj === 'function') { } else if (typeof name === 'function') {
return send(obj(getPayload())); return send(name(getPayload()));
} }
return send(getPayload()); return send(getPayload());
}; };
const identify = data => send({ ...getPayload(), data }, 'identify'); const identify = (id, data) => {
if (typeof id === 'string') {
identity = id;
}
cache = '';
return send(
{
...getPayload(),
data: typeof id === 'object' ? id : data,
},
'identify',
);
};
/* Start */ /* Start */
@@ -265,6 +279,7 @@
let cache; let cache;
let initialized; let initialized;
let disabled = false; let disabled = false;
let identity;
if (autoTrack && !trackingDisabled()) { if (autoTrack && !trackingDisabled()) {
if (document.readyState === 'complete') { if (document.readyState === 'complete') {

17372
yarn.lock

File diff suppressed because it is too large Load Diff