Merge pull request #3528 from umami-software/analytics

v2.19.0
This commit is contained in:
Mike Cao 2025-07-26 18:34:00 -07:00 committed by GitHub
commit 60eaaaff60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 4526 additions and 2988 deletions

View file

@ -1,3 +1,5 @@
import { uuid } from '../../src/lib/crypto';
describe('Website API tests', () => { describe('Website API tests', () => {
Cypress.session.clearAllSavedSessions(); Cypress.session.clearAllSavedSessions();
@ -65,6 +67,37 @@ describe('Website API tests', () => {
}); });
}); });
it('Creates a website with a fixed ID.', () => {
cy.fixture('websites').then(data => {
const websiteCreate = data.websiteCreate;
const fixedId = uuid();
cy.request({
method: 'POST',
url: '/api/websites',
headers: {
'Content-Type': 'application/json',
Authorization: Cypress.env('authorization'),
},
body: { ...websiteCreate, id: fixedId },
}).then(response => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('id', fixedId);
expect(response.body).to.have.property('name', 'Cypress Website');
expect(response.body).to.have.property('domain', 'cypress.com');
// cleanup
cy.request({
method: 'DELETE',
url: `/api/websites/${fixedId}`,
headers: {
'Content-Type': 'application/json',
Authorization: Cypress.env('authorization'),
},
});
});
});
});
it('Returns all tracked websites.', () => { it('Returns all tracked websites.', () => {
cy.request({ cy.request({
method: 'GET', method: 'GET',

View file

@ -0,0 +1,253 @@
-- create new hourly table
CREATE TABLE umami.website_event_stats_hourly_new
(
website_id UUID,
session_id UUID,
visit_id UUID,
hostname SimpleAggregateFunction(groupArrayArray, Array(String)),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String),
region LowCardinality(String),
city String,
entry_url AggregateFunction(argMin, String, DateTime('UTC')),
exit_url AggregateFunction(argMax, String, DateTime('UTC')),
url_path SimpleAggregateFunction(groupArrayArray, Array(String)),
url_query SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_source SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_medium SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_campaign SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_content SimpleAggregateFunction(groupArrayArray, Array(String)),
utm_term SimpleAggregateFunction(groupArrayArray, Array(String)),
referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)),
page_title SimpleAggregateFunction(groupArrayArray, Array(String)),
gclid SimpleAggregateFunction(groupArrayArray, Array(String)),
fbclid SimpleAggregateFunction(groupArrayArray, Array(String)),
msclkid SimpleAggregateFunction(groupArrayArray, Array(String)),
ttclid SimpleAggregateFunction(groupArrayArray, Array(String)),
li_fat_id SimpleAggregateFunction(groupArrayArray, Array(String)),
twclid SimpleAggregateFunction(groupArrayArray, Array(String)),
event_type UInt32,
event_name SimpleAggregateFunction(groupArrayArray, Array(String)),
views SimpleAggregateFunction(sum, UInt64),
min_time SimpleAggregateFunction(min, DateTime('UTC')),
max_time SimpleAggregateFunction(max, DateTime('UTC')),
tag SimpleAggregateFunction(groupArrayArray, Array(String)),
distinct_id String,
created_at Datetime('UTC')
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (
website_id,
event_type,
toStartOfHour(created_at),
cityHash64(visit_id),
visit_id
)
SAMPLE BY cityHash64(visit_id);
-- create view
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv_new
TO umami.website_event_stats_hourly_new
AS
SELECT
website_id,
session_id,
visit_id,
hostnames as hostname,
browser,
os,
device,
screen,
language,
country,
region,
city,
entry_url,
exit_url,
url_paths as url_path,
url_query,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
referrer_domain,
page_title,
gclid,
fbclid,
msclkid,
ttclid,
li_fat_id,
twclid,
event_type,
event_name,
views,
min_time,
max_time,
tag,
distinct_id,
timestamp as created_at
FROM (SELECT
website_id,
session_id,
visit_id,
arrayFilter(x -> x != '', groupArray(hostname)) hostnames,
browser,
os,
device,
screen,
language,
country,
region,
city,
argMinState(url_path, created_at) entry_url,
argMaxState(url_path, created_at) exit_url,
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
arrayFilter(x -> x != '' and x != hostname, groupArray(referrer_domain)) referrer_domain,
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
event_type,
if(event_type = 2, groupArray(event_name), []) event_name,
sumIf(1, event_type = 1) views,
min(created_at) min_time,
max(created_at) max_time,
arrayFilter(x -> x != '', groupArray(tag)) tag,
distinct_id,
toStartOfHour(created_at) timestamp
FROM umami.website_event
GROUP BY website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
region,
city,
event_type,
distinct_id,
timestamp);
-- rename tables
RENAME TABLE umami.website_event_stats_hourly TO umami.website_event_stats_hourly_old;
RENAME TABLE umami.website_event_stats_hourly_new TO umami.website_event_stats_hourly;
-- drop views
DROP TABLE umami.website_event_stats_hourly_mv;
DROP TABLE umami.website_event_stats_hourly_mv_new;
-- recreate view
CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv
TO umami.website_event_stats_hourly
AS
SELECT
website_id,
session_id,
visit_id,
hostnames as hostname,
browser,
os,
device,
screen,
language,
country,
region,
city,
entry_url,
exit_url,
url_paths as url_path,
url_query,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
referrer_domain,
page_title,
gclid,
fbclid,
msclkid,
ttclid,
li_fat_id,
twclid,
event_type,
event_name,
views,
min_time,
max_time,
tag,
distinct_id,
timestamp as created_at
FROM (SELECT
website_id,
session_id,
visit_id,
arrayFilter(x -> x != '', groupArray(hostname)) hostnames,
browser,
os,
device,
screen,
language,
country,
region,
city,
argMinState(url_path, created_at) entry_url,
argMaxState(url_path, created_at) exit_url,
arrayFilter(x -> x != '', groupArray(url_path)) as url_paths,
arrayFilter(x -> x != '', groupArray(url_query)) url_query,
arrayFilter(x -> x != '', groupArray(utm_source)) utm_source,
arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium,
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
arrayFilter(x -> x != '' and x != hostname, groupArray(referrer_domain)) referrer_domain,
arrayFilter(x -> x != '', groupArray(page_title)) page_title,
arrayFilter(x -> x != '', groupArray(gclid)) gclid,
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
arrayFilter(x -> x != '', groupArray(msclkid)) msclkid,
arrayFilter(x -> x != '', groupArray(ttclid)) ttclid,
arrayFilter(x -> x != '', groupArray(li_fat_id)) li_fat_id,
arrayFilter(x -> x != '', groupArray(twclid)) twclid,
event_type,
if(event_type = 2, groupArray(event_name), []) event_name,
sumIf(1, event_type = 1) views,
min(created_at) min_time,
max(created_at) max_time,
arrayFilter(x -> x != '', groupArray(tag)) tag,
distinct_id,
toStartOfHour(created_at) timestamp
FROM umami.website_event
GROUP BY website_id,
session_id,
visit_id,
hostname,
browser,
os,
device,
screen,
language,
country,
region,
city,
event_type,
distinct_id,
timestamp);

View file

@ -90,7 +90,7 @@ CREATE TABLE umami.website_event_stats_hourly
website_id UUID, website_id UUID,
session_id UUID, session_id UUID,
visit_id UUID, visit_id UUID,
hostname LowCardinality(String), hostname SimpleAggregateFunction(groupArrayArray, Array(String)),
browser LowCardinality(String), browser LowCardinality(String),
os LowCardinality(String), os LowCardinality(String),
device LowCardinality(String), device LowCardinality(String),
@ -143,7 +143,7 @@ SELECT
website_id, website_id,
session_id, session_id,
visit_id, visit_id,
hostname, hostnames as hostname,
browser, browser,
os, os,
device, device,
@ -181,7 +181,7 @@ FROM (SELECT
website_id, website_id,
session_id, session_id,
visit_id, visit_id,
hostname, arrayFilter(x -> x != '', groupArray(hostname)) hostnames,
browser, browser,
os, os,
device, device,
@ -199,7 +199,7 @@ FROM (SELECT
arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign, arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign,
arrayFilter(x -> x != '', groupArray(utm_content)) utm_content, arrayFilter(x -> x != '', groupArray(utm_content)) utm_content,
arrayFilter(x -> x != '', groupArray(utm_term)) utm_term, arrayFilter(x -> x != '', groupArray(utm_term)) utm_term,
arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain, arrayFilter(x -> x != '' and x != hostname, groupArray(referrer_domain)) referrer_domain,
arrayFilter(x -> x != '', groupArray(page_title)) page_title, arrayFilter(x -> x != '', groupArray(page_title)) page_title,
arrayFilter(x -> x != '', groupArray(gclid)) gclid, arrayFilter(x -> x != '', groupArray(gclid)) gclid,
arrayFilter(x -> x != '', groupArray(fbclid)) fbclid, arrayFilter(x -> x != '', groupArray(fbclid)) fbclid,
@ -246,3 +246,38 @@ SELECT * ORDER BY toStartOfDay(created_at), website_id, referrer_domain, created
); );
ALTER TABLE umami.website_event MATERIALIZE PROJECTION website_event_referrer_domain_projection; ALTER TABLE umami.website_event MATERIALIZE PROJECTION website_event_referrer_domain_projection;
-- revenue
CREATE TABLE umami.website_revenue
(
website_id UUID,
session_id UUID,
event_id UUID,
event_name String,
currency String,
revenue DECIMAL(18,4),
created_at DateTime('UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (website_id, session_id, created_at)
SETTINGS index_granularity = 8192;
CREATE MATERIALIZED VIEW umami.website_revenue_mv
TO umami.website_revenue
AS
SELECT DISTINCT
ed.website_id,
ed.session_id,
ed.event_id,
ed.event_name,
c.currency,
coalesce(toDecimal64(ed.number_value, 2), toDecimal64(ed.string_value, 2)) revenue,
ed.created_at
FROM umami.event_data ed
JOIN (SELECT event_id, string_value as currency
FROM umami.event_data
WHERE positionCaseInsensitive(data_key, 'currency') > 0) c
ON c.event_id = ed.event_id
WHERE positionCaseInsensitive(data_key, 'revenue') > 0;

View file

@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE `segment` (
`segment_id` VARCHAR(36) NOT NULL,
`website_id` VARCHAR(36) NOT NULL,
`type` VARCHAR(200) NOT NULL,
`name` VARCHAR(200) NOT NULL,
`parameters` JSON NOT NULL,
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NULL,
UNIQUE INDEX `segment_segment_id_key`(`segment_id`),
INDEX `segment_website_id_idx`(`website_id`),
PRIMARY KEY (`segment_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `report` MODIFY `parameters` JSON NOT NULL;

View file

@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE `revenue` (
`revenue_id` VARCHAR(36) NOT NULL,
`website_id` VARCHAR(36) NOT NULL,
`session_id` VARCHAR(36) NOT NULL,
`event_id` VARCHAR(36) NOT NULL,
`event_name` VARCHAR(50) NOT NULL,
`currency` VARCHAR(100) NOT NULL,
`revenue` DECIMAL(19, 4) NULL,
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
UNIQUE INDEX `revenue_revenue_id_key`(`revenue_id`),
INDEX `revenue_website_id_idx`(`website_id`),
INDEX `revenue_session_id_idx`(`session_id`),
INDEX `revenue_website_id_created_at_idx`(`website_id`, `created_at`),
INDEX `revenue_website_id_session_id_created_at_idx`(`website_id`, `session_id`, `created_at`),
PRIMARY KEY (`revenue_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View file

@ -43,6 +43,7 @@ model Session {
websiteEvent WebsiteEvent[] websiteEvent WebsiteEvent[]
sessionData SessionData[] sessionData SessionData[]
revenue Revenue[]
@@index([createdAt]) @@index([createdAt])
@@index([websiteId]) @@index([websiteId])
@ -76,7 +77,9 @@ model Website {
team Team? @relation(fields: [teamId], references: [id]) team Team? @relation(fields: [teamId], references: [id])
eventData EventData[] eventData EventData[]
report Report[] report Report[]
revenue Revenue[]
sessionData SessionData[] sessionData SessionData[]
segment Segment[]
@@index([userId]) @@index([userId])
@@index([teamId]) @@index([teamId])
@ -215,10 +218,10 @@ model Report {
id String @id() @unique() @map("report_id") @db.VarChar(36) id String @id() @unique() @map("report_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36) userId String @map("user_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36) websiteId String @map("website_id") @db.VarChar(36)
type String @map("type") @db.VarChar(200) type String @db.VarChar(200)
name String @map("name") @db.VarChar(200) name String @db.VarChar(200)
description String @map("description") @db.VarChar(500) description String @db.VarChar(500)
parameters String @map("parameters") @db.VarChar(6000) parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
@ -231,3 +234,38 @@ model Report {
@@index([name]) @@index([name])
@@map("report") @@map("report")
} }
model Segment {
id String @id() @unique() @map("segment_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
type String @db.VarChar(200)
name String @db.VarChar(200)
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
@@index([websiteId])
@@map("segment")
}
model Revenue {
id String @id() @unique() @map("revenue_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36)
eventId String @map("event_id") @db.VarChar(36)
eventName String @map("event_name") @db.VarChar(50)
currency String @db.VarChar(100)
revenue Decimal? @db.Decimal(19, 4)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([websiteId])
@@index([sessionId])
@@index([websiteId, createdAt])
@@index([websiteId, sessionId, createdAt])
@@map("revenue")
}

View file

@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "segment" (
"segment_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"type" VARCHAR(200) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"parameters" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "segment_pkey" PRIMARY KEY ("segment_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "segment_segment_id_key" ON "segment"("segment_id");
-- CreateIndex
CREATE INDEX "segment_website_id_idx" ON "segment"("website_id");

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "report"
ALTER COLUMN "parameters" SET DATA TYPE JSONB USING parameters::JSONB;

View file

@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "revenue" (
"revenue_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"session_id" UUID NOT NULL,
"event_id" UUID NOT NULL,
"event_name" VARCHAR(50) NOT NULL,
"currency" VARCHAR(100) NOT NULL,
"revenue" DECIMAL(19,4),
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "revenue_pkey" PRIMARY KEY ("revenue_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "revenue_revenue_id_key" ON "revenue"("revenue_id");
-- CreateIndex
CREATE INDEX "revenue_website_id_idx" ON "revenue"("website_id");
-- CreateIndex
CREATE INDEX "revenue_session_id_idx" ON "revenue"("session_id");
-- CreateIndex
CREATE INDEX "revenue_website_id_created_at_idx" ON "revenue"("website_id", "created_at");
-- CreateIndex
CREATE INDEX "revenue_website_id_session_id_created_at_idx" ON "revenue"("website_id", "session_id", "created_at");

View file

@ -43,6 +43,7 @@ model Session {
websiteEvent WebsiteEvent[] websiteEvent WebsiteEvent[]
sessionData SessionData[] sessionData SessionData[]
revenue Revenue[]
@@index([createdAt]) @@index([createdAt])
@@index([websiteId]) @@index([websiteId])
@ -76,7 +77,9 @@ model Website {
team Team? @relation(fields: [teamId], references: [id]) team Team? @relation(fields: [teamId], references: [id])
eventData EventData[] eventData EventData[]
report Report[] report Report[]
revenue Revenue[]
sessionData SessionData[] sessionData SessionData[]
segment Segment[]
@@index([userId]) @@index([userId])
@@index([teamId]) @@index([teamId])
@ -103,12 +106,12 @@ model WebsiteEvent {
referrerQuery String? @map("referrer_query") @db.VarChar(500) referrerQuery String? @map("referrer_query") @db.VarChar(500)
referrerDomain String? @map("referrer_domain") @db.VarChar(500) referrerDomain String? @map("referrer_domain") @db.VarChar(500)
pageTitle String? @map("page_title") @db.VarChar(500) pageTitle String? @map("page_title") @db.VarChar(500)
gclid String? @map("gclid") @db.VarChar(255) gclid String? @db.VarChar(255)
fbclid String? @map("fbclid") @db.VarChar(255) fbclid String? @db.VarChar(255)
msclkid String? @map("msclkid") @db.VarChar(255) msclkid String? @db.VarChar(255)
ttclid String? @map("ttclid") @db.VarChar(255) ttclid String? @db.VarChar(255)
lifatid String? @map("li_fat_id") @db.VarChar(255) lifatid String? @map("li_fat_id") @db.VarChar(255)
twclid String? @map("twclid") @db.VarChar(255) twclid String? @db.VarChar(255)
eventType Int @default(1) @map("event_type") @db.Integer eventType Int @default(1) @map("event_type") @db.Integer
eventName String? @map("event_name") @db.VarChar(50) eventName String? @map("event_name") @db.VarChar(50)
tag String? @db.VarChar(50) tag String? @db.VarChar(50)
@ -199,7 +202,7 @@ model TeamUser {
id String @id() @unique() @map("team_user_id") @db.Uuid id String @id() @unique() @map("team_user_id") @db.Uuid
teamId String @map("team_id") @db.Uuid teamId String @map("team_id") @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
role String @map("role") @db.VarChar(50) role String @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
@ -215,10 +218,10 @@ model Report {
id String @id() @unique() @map("report_id") @db.Uuid id String @id() @unique() @map("report_id") @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
type String @map("type") @db.VarChar(200) type String @db.VarChar(200)
name String @map("name") @db.VarChar(200) name String @db.VarChar(200)
description String @map("description") @db.VarChar(500) description String @db.VarChar(500)
parameters String @map("parameters") @db.VarChar(6000) parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
@ -231,3 +234,38 @@ model Report {
@@index([name]) @@index([name])
@@map("report") @@map("report")
} }
model Segment {
id String @id() @unique() @map("segment_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
type String @db.VarChar(200)
name String @db.VarChar(200)
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
@@index([websiteId])
@@map("segment")
}
model Revenue {
id String @id() @unique() @map("revenue_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
eventId String @map("event_id") @db.Uuid
eventName String @map("event_name") @db.VarChar(50)
currency String @db.VarChar(100)
revenue Decimal? @db.Decimal(19, 4)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([websiteId])
@@index([sessionId])
@@index([websiteId, createdAt])
@@index([websiteId, sessionId, createdAt])
@@map("revenue")
}

View file

@ -12,12 +12,8 @@ const cloudMode = process.env.CLOUD_MODE;
const cloudUrl = process.env.CLOUD_URL; const cloudUrl = process.env.CLOUD_URL;
const corsMaxAge = process.env.CORS_MAX_AGE; const corsMaxAge = process.env.CORS_MAX_AGE;
const defaultLocale = process.env.DEFAULT_LOCALE; const defaultLocale = process.env.DEFAULT_LOCALE;
const disableLogin = process.env.DISABLE_LOGIN;
const disableUI = process.env.DISABLE_UI;
const faviconURL = process.env.FAVICON_URL;
const forceSSL = process.env.FORCE_SSL; const forceSSL = process.env.FORCE_SSL;
const frameAncestors = process.env.ALLOWED_FRAME_URLS ?? ''; const frameAncestors = process.env.ALLOWED_FRAME_URLS ?? '';
const privateMode = process.env.PRIVATE_MODE;
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME; const trackerScriptName = process.env.TRACKER_SCRIPT_NAME;
const trackerScriptURL = process.env.TRACKER_SCRIPT_URL; const trackerScriptURL = process.env.TRACKER_SCRIPT_URL;
@ -173,13 +169,11 @@ if (cloudMode && cloudUrl) {
permanent: false, permanent: false,
}); });
if (disableLogin) {
redirects.push({ redirects.push({
source: '/login', source: '/login',
destination: cloudUrl, destination: cloudUrl,
permanent: false, permanent: false,
}); });
}
} }
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
@ -191,10 +185,6 @@ export default {
cloudUrl, cloudUrl,
currentVersion: pkg.version, currentVersion: pkg.version,
defaultLocale, defaultLocale,
disableLogin,
disableUI,
faviconURL,
privateMode,
}, },
basePath, basePath,
output: 'standalone', output: 'standalone',

View file

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "2.18.1", "version": "2.19.0",
"description": "A modern, privacy-focused alternative to Google Analytics.", "description": "A modern, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>", "author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT", "license": "MIT",
@ -11,7 +11,7 @@
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"dev-turbo": "next dev -p 3001 --turbopack", "dev-turbo": "next dev -p 3000 --turbopack",
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app", "build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
"start": "next start", "start": "next start",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app", "build-docker": "npm-run-all build-db build-tracker build-geo build-app",
@ -100,12 +100,14 @@
"is-localhost-ip": "^1.4.0", "is-localhost-ip": "^1.4.0",
"isbot": "^5.1.16", "isbot": "^5.1.16",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"kafkajs": "^2.1.0", "kafkajs": "^2.1.0",
"maxmind": "^4.3.24", "maxmind": "^4.3.24",
"md5": "^2.3.0", "md5": "^2.3.0",
"next": "15.3.3", "next": "15.3.3",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"papaparse": "^5.5.3",
"prisma": "6.7.0", "prisma": "6.7.0",
"pure-rand": "^6.1.0", "pure-rand": "^6.1.0",
"react": "^19.0.0", "react": "^19.0.0",

4431
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

View file

@ -38,7 +38,7 @@
"label.add-step": [ "label.add-step": [
{ {
"type": 0, "type": 0,
"value": "Add step" "value": "إضافة خطوة"
} }
], ],
"label.add-website": [ "label.add-website": [
@ -77,6 +77,18 @@
"value": "تحليلات" "value": "تحليلات"
} }
], ],
"label.attribution": [
{
"type": 0,
"value": "الإسناد"
}
],
"label.attribution-description": [
{
"type": 0,
"value": "شاهد كيف يتفاعل المستخدمون مع حملاتك التسويقية وما الذي يحفز التحويلات."
}
],
"label.average": [ "label.average": [
{ {
"type": 0, "type": 0,
@ -122,7 +134,7 @@
"label.cancel": [ "label.cancel": [
{ {
"type": 0, "type": 0,
"value": "ألغِ" "value": "إلغاء"
} }
], ],
"label.change-password": [ "label.change-password": [
@ -152,7 +164,7 @@
"label.compare": [ "label.compare": [
{ {
"type": 0, "type": 0,
"value": "Compare" "value": "المقارنة"
} }
], ],
"label.confirm": [ "label.confirm": [
@ -170,7 +182,7 @@
"label.contains": [ "label.contains": [
{ {
"type": 0, "type": 0,
"value": "يحتوي" "value": "يحتوي على"
} }
], ],
"label.continue": [ "label.continue": [
@ -182,7 +194,7 @@
"label.count": [ "label.count": [
{ {
"type": 0, "type": 0,
"value": "Count" "value": "العدد"
} }
], ],
"label.countries": [ "label.countries": [
@ -236,7 +248,7 @@
"label.current": [ "label.current": [
{ {
"type": 0, "type": 0,
"value": "Current" "value": "الحالي"
} }
], ],
"label.current-password": [ "label.current-password": [
@ -254,7 +266,7 @@
"label.dashboard": [ "label.dashboard": [
{ {
"type": 0, "type": 0,
"value": "الشاشة الرئيسية" "value": "لوحة التحكم"
} }
], ],
"label.data": [ "label.data": [
@ -356,7 +368,7 @@
"label.does-not-contain": [ "label.does-not-contain": [
{ {
"type": 0, "type": 0,
"value": "لا يحتوي" "value": "لا يحتوي على"
} }
], ],
"label.domain": [ "label.domain": [
@ -374,7 +386,7 @@
"label.edit": [ "label.edit": [
{ {
"type": 0, "type": 0,
"value": "عدّل" "value": "تعديل"
} }
], ],
"label.edit-dashboard": [ "label.edit-dashboard": [
@ -398,13 +410,13 @@
"label.end-step": [ "label.end-step": [
{ {
"type": 0, "type": 0,
"value": "End Step" "value": "الخطوة الأخيرة"
} }
], ],
"label.entry": [ "label.entry": [
{ {
"type": 0, "type": 0,
"value": "Entry URL" "value": "رابط الدخول"
} }
], ],
"label.event": [ "label.event": [
@ -428,7 +440,7 @@
"label.exit": [ "label.exit": [
{ {
"type": 0, "type": 0,
"value": "Exit URL" "value": "رابط المغادرة"
} }
], ],
"label.false": [ "label.false": [
@ -476,7 +488,7 @@
"label.first-seen": [ "label.first-seen": [
{ {
"type": 0, "type": 0,
"value": "First seen" "value": "أول ظهور"
} }
], ],
"label.funnel": [ "label.funnel": [
@ -494,19 +506,19 @@
"label.goal": [ "label.goal": [
{ {
"type": 0, "type": 0,
"value": "Goal" "value": "الهدف"
} }
], ],
"label.goals": [ "label.goals": [
{ {
"type": 0, "type": 0,
"value": "Goals" "value": "الأهداف"
} }
], ],
"label.goals-description": [ "label.goals-description": [
{ {
"type": 0, "type": 0,
"value": "Track your goals for pageviews and events." "value": "تابع تحقق أهدافك المرتبطة بمشاهدات الصفحات والأحداث."
} }
], ],
"label.greater-than": [ "label.greater-than": [
@ -548,13 +560,13 @@
"label.is": [ "label.is": [
{ {
"type": 0, "type": 0,
"value": "هو" "value": "يساوي"
} }
], ],
"label.is-not": [ "label.is-not": [
{ {
"type": 0, "type": 0,
"value": م" "value": ا يساوي"
} }
], ],
"label.is-not-set": [ "label.is-not-set": [
@ -584,13 +596,13 @@
"label.journey": [ "label.journey": [
{ {
"type": 0, "type": 0,
"value": "Journey" "value": "رحلة المستخدم"
} }
], ],
"label.journey-description": [ "label.journey-description": [
{ {
"type": 0, "type": 0,
"value": "Understand how users navigate through your website." "value": "تعرّف على كيفية تنقّل المستخدمين داخل موقعك."
} }
], ],
"label.language": [ "label.language": [
@ -642,7 +654,7 @@
"label.last-months": [ "label.last-months": [
{ {
"type": 0, "type": 0,
"value": "Last " "value": "آخر "
}, },
{ {
"type": 1, "type": 1,
@ -650,13 +662,13 @@
}, },
{ {
"type": 0, "type": 0,
"value": " months" "value": " شهر/أشهر"
} }
], ],
"label.last-seen": [ "label.last-seen": [
{ {
"type": 0, "type": 0,
"value": "Last seen" "value": "آخر ظهور"
} }
], ],
"label.leave": [ "label.leave": [
@ -704,7 +716,7 @@
"label.manager": [ "label.manager": [
{ {
"type": 0, "type": 0,
"value": "Manager" "value": "مدير"
} }
], ],
"label.max": [ "label.max": [
@ -876,13 +888,13 @@
"label.path": [ "label.path": [
{ {
"type": 0, "type": 0,
"value": "Path" "value": "المسار"
} }
], ],
"label.paths": [ "label.paths": [
{ {
"type": 0, "type": 0,
"value": "Paths" "value": "المسارات"
} }
], ],
"label.powered-by": [ "label.powered-by": [
@ -898,19 +910,19 @@
"label.previous": [ "label.previous": [
{ {
"type": 0, "type": 0,
"value": "Previous" "value": "السابق"
} }
], ],
"label.previous-period": [ "label.previous-period": [
{ {
"type": 0, "type": 0,
"value": "Previous period" "value": "الفترة السابقة"
} }
], ],
"label.previous-year": [ "label.previous-year": [
{ {
"type": 0, "type": 0,
"value": "Previous year" "value": "العام السابق"
} }
], ],
"label.profile": [ "label.profile": [
@ -922,13 +934,13 @@
"label.properties": [ "label.properties": [
{ {
"type": 0, "type": 0,
"value": "Properties" "value": "الخصائص"
} }
], ],
"label.property": [ "label.property": [
{ {
"type": 0, "type": 0,
"value": "Property" "value": "الخاصية"
} }
], ],
"label.queries": [ "label.queries": [
@ -1042,19 +1054,19 @@
"label.revenue": [ "label.revenue": [
{ {
"type": 0, "type": 0,
"value": "Revenue" "value": "الإيرادات"
} }
], ],
"label.revenue-description": [ "label.revenue-description": [
{ {
"type": 0, "type": 0,
"value": "Look into your revenue across time." "value": "قم بإلقاء نظرة على بيانات إيراداتك وكيفية إنفاق المستخدمين."
} }
], ],
"label.revenue-property": [ "label.revenue-property": [
{ {
"type": 0, "type": 0,
"value": "Revenue Property" "value": "خاصية الإيرادات"
} }
], ],
"label.role": [ "label.role": [
@ -1114,7 +1126,7 @@
"label.session": [ "label.session": [
{ {
"type": 0, "type": 0,
"value": "Session" "value": "الزيارة"
} }
], ],
"label.sessions": [ "label.sessions": [
@ -1144,13 +1156,13 @@
"label.start-step": [ "label.start-step": [
{ {
"type": 0, "type": 0,
"value": "Start Step" "value": "الخطوة الأولى"
} }
], ],
"label.steps": [ "label.steps": [
{ {
"type": 0, "type": 0,
"value": "Steps" "value": "الخطوات"
} }
], ],
"label.sum": [ "label.sum": [
@ -1165,6 +1177,18 @@
"value": "تابلت" "value": "تابلت"
} }
], ],
"label.tag": [
{
"type": 0,
"value": "الوسم"
}
],
"label.tags": [
{
"type": 0,
"value": "الوسوم"
}
],
"label.team": [ "label.team": [
{ {
"type": 0, "type": 0,
@ -1180,7 +1204,7 @@
"label.team-manager": [ "label.team-manager": [
{ {
"type": 0, "type": 0,
"value": "Team manager" "value": "مدير الفريق"
} }
], ],
"label.team-member": [ "label.team-member": [
@ -1204,7 +1228,7 @@
"label.team-view-only": [ "label.team-view-only": [
{ {
"type": 0, "type": 0,
"value": "Team view only" "value": "عرض الفريق فقط"
} }
], ],
"label.team-websites": [ "label.team-websites": [
@ -1288,13 +1312,13 @@
"label.transactions": [ "label.transactions": [
{ {
"type": 0, "type": 0,
"value": "Transactions" "value": "المعاملات"
} }
], ],
"label.transfer": [ "label.transfer": [
{ {
"type": 0, "type": 0,
"value": "Transfer" "value": "نقل"
} }
], ],
"label.transfer-website": [ "label.transfer-website": [
@ -1330,7 +1354,7 @@
"label.uniqueCustomers": [ "label.uniqueCustomers": [
{ {
"type": 0, "type": 0,
"value": "Unique Customers" "value": "العملاء الفريدون"
} }
], ],
"label.unknown": [ "label.unknown": [
@ -1348,19 +1372,19 @@
"label.update": [ "label.update": [
{ {
"type": 0, "type": 0,
"value": "Update" "value": "تحديث"
} }
], ],
"label.url": [ "label.url": [
{ {
"type": 0, "type": 0,
"value": "URL" "value": "الرابط"
} }
], ],
"label.urls": [ "label.urls": [
{ {
"type": 0, "type": 0,
"value": "URLs" "value": "الروابط"
} }
], ],
"label.user": [ "label.user": [
@ -1372,7 +1396,7 @@
"label.user-property": [ "label.user-property": [
{ {
"type": 0, "type": 0,
"value": "User Property" "value": "سمات المستخدم"
} }
], ],
"label.username": [ "label.username": [
@ -1396,7 +1420,7 @@
"label.utm-description": [ "label.utm-description": [
{ {
"type": 0, "type": 0,
"value": "Track your campaigns through UTM parameters." "value": "تابع حملاتك التسويقية باستخدام معلمات UTM."
} }
], ],
"label.value": [ "label.value": [
@ -1432,7 +1456,7 @@
"label.views-per-visit": [ "label.views-per-visit": [
{ {
"type": 0, "type": 0,
"value": "Views per visit" "value": "مشاهدات لكل زيارة"
} }
], ],
"label.visit-duration": [ "label.visit-duration": [
@ -1450,7 +1474,7 @@
"label.visits": [ "label.visits": [
{ {
"type": 0, "type": 0,
"value": "Visits" "value": "الزيارات"
} }
], ],
"label.website": [ "label.website": [
@ -1534,7 +1558,7 @@
"message.collected-data": [ "message.collected-data": [
{ {
"type": 0, "type": 0,
"value": "Collected data" "value": "البيانات المجمعة"
} }
], ],
"message.confirm-delete": [ "message.confirm-delete": [
@ -1754,15 +1778,7 @@
"message.share-url": [ "message.share-url": [
{ {
"type": 0, "type": 0,
"value": "هذا الرابط الذي تم مشاركته بشكل عام لـ " "value": "إحصائيات موقعك متاحة للجميع على الرابط التالي:"
},
{
"type": 1,
"value": "target"
},
{
"type": 0,
"value": "."
} }
], ],
"message.team-already-member": [ "message.team-already-member": [

View file

@ -0,0 +1,41 @@
-----------------------------------------------------
-- PostgreSQL
-----------------------------------------------------
INSERT INTO "revenue"
SELECT gen_random_uuid() revenue_id,
ed.website_id,
we.session_id,
we.event_id,
we.event_name,
currency.string_value currency,
coalesce(ed.number_value, cast(ed.string_value as numeric(19,4))) revenue,
ed.created_at
FROM event_data ed
JOIN website_event we
ON we.event_id = ed.website_event_id
JOIN (SELECT website_event_id, string_value
FROM event_data
WHERE data_key ilike '%currency%') currency
ON currency.website_event_id = ed.website_event_id
WHERE ed.data_key ilike '%revenue%';
-----------------------------------------------------
-- MySQL
-----------------------------------------------------
INSERT INTO `revenue`
SELECT UUID() revenue_id,
ed.website_id,
we.session_id,
we.event_id,
we.event_name,
currency.string_value currency,
coalesce(ed.number_value, cast(ed.string_value as decimal(19,4))) revenue,
ed.created_at
FROM event_data ed
JOIN website_event we
ON we.event_id = ed.website_event_id
JOIN (SELECT website_event_id, string_value
FROM event_data
WHERE data_key like '%currency%') currency
ON currency.website_event_id = ed.website_event_id
WHERE ed.data_key like '%revenue%';

View file

@ -22,10 +22,6 @@ export function App({ children }) {
return null; return null;
} }
if (config.uiDisabled) {
return null;
}
return ( return (
<> <>
{children} {children}

View file

@ -13,13 +13,14 @@ export function UpdateNotice({ user, config }) {
const { latest, checked, hasUpdate, releaseUrl } = useStore(); const { latest, checked, hasUpdate, releaseUrl } = useStore();
const pathname = usePathname(); const pathname = usePathname();
const [dismissed, setDismissed] = useState(checked); const [dismissed, setDismissed] = useState(checked);
const allowUpdate = const allowUpdate =
process.env.NODE_ENV === 'production' && process.env.NODE_ENV === 'production' &&
user?.isAdmin && user?.isAdmin &&
!config?.updatesDisabled && !config?.updatesDisabled &&
!config?.privateMode &&
!pathname.includes('/share/') && !pathname.includes('/share/') &&
!process.env.cloudMode && !process.env.cloudMode &&
!process.env.privateMode &&
!dismissed; !dismissed;
const updateCheck = useCallback(() => { const updateCheck = useCallback(() => {

View file

@ -9,6 +9,7 @@ export function LanguageSetting() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { locale, saveLocale } = useLocale(); const { locale, saveLocale } = useLocale();
const options = search const options = search
? Object.keys(languages).filter(n => { ? Object.keys(languages).filter(n => {
return ( return (

View file

@ -1,6 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { ReportContext } from './Report'; import { ReportContext } from './Report';
import styles from './ReportBody.module.css'; import styles from './ReportBody.module.css';
import { DownloadButton } from '@/components/input/DownloadButton';
export function ReportBody({ children }) { export function ReportBody({ children }) {
const { report } = useContext(ReportContext); const { report } = useContext(ReportContext);
@ -9,7 +10,14 @@ export function ReportBody({ children }) {
return null; return null;
} }
return <div className={styles.body}>{children}</div>; return (
<div className={styles.body}>
{report.type !== 'revenue' && report.type !== 'attribution' && (
<DownloadButton filename={report.name} data={report.data} />
)}
{children}
</div>
);
} }
export default ReportBody; export default ReportBody;

View file

@ -31,5 +31,9 @@ export default function ReportPage({ reportId }: { reportId: string }) {
const ReportComponent = reports[report.type]; const ReportComponent = reports[report.type];
if (!ReportComponent) {
return null;
}
return <ReportComponent reportId={reportId} />; return <ReportComponent reportId={reportId} />;
} }

View file

@ -15,6 +15,31 @@ function toArray(data: { [key: string]: number } = {}) {
.sort(firstBy('value', -1)); .sort(firstBy('value', -1));
} }
function parseParameters(data: any[]) {
return data.reduce((obj, { url_query, num }) => {
try {
const searchParams = new URLSearchParams(url_query);
for (const [key, value] of searchParams) {
if (key.match(/^utm_(\w+)$/)) {
const name = value;
if (!obj[key]) {
obj[key] = { [name]: Number(num) };
} else if (!obj[key][name]) {
obj[key][name] = Number(num);
} else {
obj[key][name] += Number(num);
}
}
}
} catch {
// Ignore
}
return obj;
}, {});
}
export default function UTMView() { export default function UTMView() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { report } = useContext(ReportContext); const { report } = useContext(ReportContext);
@ -27,7 +52,7 @@ export default function UTMView() {
return ( return (
<div> <div>
{UTM_PARAMS.map(param => { {UTM_PARAMS.map(param => {
const items = toArray(data[param]); const items = toArray(parseParameters(data)[param]);
const chartData = { const chartData = {
labels: items.map(({ name }) => name), labels: items.map(({ name }) => name),
datasets: [ datasets: [

View file

@ -7,7 +7,7 @@ import WebsiteExpandedView from './WebsiteExpandedView';
import WebsiteHeader from './WebsiteHeader'; import WebsiteHeader from './WebsiteHeader';
import WebsiteMetricsBar from './WebsiteMetricsBar'; import WebsiteMetricsBar from './WebsiteMetricsBar';
import WebsiteTableView from './WebsiteTableView'; import WebsiteTableView from './WebsiteTableView';
import { FILTER_COLUMNS } from '@/lib/constants'; import { FILTER_COLUMNS, FILTER_GROUPS } from '@/lib/constants';
export default function WebsiteDetailsPage({ websiteId }: { websiteId: string }) { export default function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
const pathname = usePathname(); const pathname = usePathname();
@ -17,7 +17,7 @@ export default function WebsiteDetailsPage({ websiteId }: { websiteId: string })
const { view } = query; const { view } = query;
const params = Object.keys(query).reduce((obj, key) => { const params = Object.keys(query).reduce((obj, key) => {
if (FILTER_COLUMNS[key]) { if (FILTER_COLUMNS[key] || FILTER_GROUPS[key]) {
obj[key] = query[key]; obj[key] = query[key];
} }
return obj; return obj;

View file

@ -1,6 +1,6 @@
import { Dropdown, Item } from 'react-basics'; import { Dropdown, Item } from 'react-basics';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDateRange, useMessages, useSticky } from '@/components/hooks'; import { useDateRange, useMessages, useNavigation, useSticky } from '@/components/hooks';
import WebsiteDateFilter from '@/components/input/WebsiteDateFilter'; import WebsiteDateFilter from '@/components/input/WebsiteDateFilter';
import MetricCard from '@/components/metrics/MetricCard'; import MetricCard from '@/components/metrics/MetricCard';
import MetricsBar from '@/components/metrics/MetricsBar'; import MetricsBar from '@/components/metrics/MetricsBar';
@ -8,6 +8,7 @@ import { formatShortTime, formatLongNumber } from '@/lib/format';
import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats'; import useWebsiteStats from '@/components/hooks/queries/useWebsiteStats';
import useStore, { setWebsiteDateCompare } from '@/store/websites'; import useStore, { setWebsiteDateCompare } from '@/store/websites';
import WebsiteFilterButton from './WebsiteFilterButton'; import WebsiteFilterButton from './WebsiteFilterButton';
import { ExportButton } from '@/components/input/ExportButton';
import styles from './WebsiteMetricsBar.module.css'; import styles from './WebsiteMetricsBar.module.css';
export function WebsiteMetricsBar({ export function WebsiteMetricsBar({
@ -31,6 +32,9 @@ export function WebsiteMetricsBar({
websiteId, websiteId,
compareMode && dateCompare, compareMode && dateCompare,
); );
const {
query: { view },
} = useNavigation();
const isAllTime = dateRange.value === 'all'; const isAllTime = dateRange.value === 'all';
const { pageviews, visitors, visits, bounces, totaltime } = data || {}; const { pageviews, visitors, visits, bounces, totaltime } = data || {};
@ -109,7 +113,10 @@ export function WebsiteMetricsBar({
</MetricsBar> </MetricsBar>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
<div>
{showFilter && <WebsiteFilterButton websiteId={websiteId} />} {showFilter && <WebsiteFilterButton websiteId={websiteId} />}
{!view && <ExportButton websiteId={websiteId} />}
</div>
<WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} /> <WebsiteDateFilter websiteId={websiteId} showAllTime={!compareMode} />
{compareMode && ( {compareMode && (
<div className={styles.vs}> <div className={styles.vs}>

View file

@ -14,6 +14,7 @@ export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
const pathname = usePathname(); const pathname = usePathname();
const tableProps = { const tableProps = {
websiteId, websiteId,
allowDownload: false,
limit: 10, limit: 10,
}; };
const isSharePage = pathname.includes('/share/'); const isSharePage = pathname.includes('/share/');

View file

@ -3,7 +3,7 @@ import WebsiteHeader from '../WebsiteHeader';
import WebsiteMetricsBar from '../WebsiteMetricsBar'; import WebsiteMetricsBar from '../WebsiteMetricsBar';
import FilterTags from '@/components/metrics/FilterTags'; import FilterTags from '@/components/metrics/FilterTags';
import { useNavigation } from '@/components/hooks'; import { useNavigation } from '@/components/hooks';
import { FILTER_COLUMNS } from '@/lib/constants'; import { FILTER_COLUMNS, FILTER_GROUPS } from '@/lib/constants';
import WebsiteChart from '../WebsiteChart'; import WebsiteChart from '../WebsiteChart';
import WebsiteCompareTables from './WebsiteCompareTables'; import WebsiteCompareTables from './WebsiteCompareTables';
@ -11,7 +11,7 @@ export function WebsiteComparePage({ websiteId }) {
const { query } = useNavigation(); const { query } = useNavigation();
const params = Object.keys(query).reduce((obj, key) => { const params = Object.keys(query).reduce((obj, key) => {
if (FILTER_COLUMNS[key]) { if (FILTER_COLUMNS[key] || FILTER_GROUPS[key]) {
obj[key] = query[key]; obj[key] = query[key];
} }
return obj; return obj;

View file

@ -14,12 +14,14 @@
color: var(--primary400); color: var(--primary400);
} }
.title { .header {
text-align: center; margin-bottom: 40px;
font-weight: bold;
margin: 20px 0;
} }
.chart { .title {
font-weight: bold;
}
.data {
min-height: 620px; min-height: 620px;
} }

View file

@ -1,7 +1,9 @@
import { GridColumn, GridTable } from 'react-basics'; import { useMemo } from 'react';
import { GridColumn, GridTable, Flexbox, Button, ButtonGroup, Loading } from 'react-basics';
import { useEventDataProperties, useEventDataValues, useMessages } from '@/components/hooks'; import { useEventDataProperties, useEventDataValues, useMessages } from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import PieChart from '@/components/charts/PieChart'; import PieChart from '@/components/charts/PieChart';
import ListTable from '@/components/metrics/ListTable';
import { useState } from 'react'; import { useState } from 'react';
import { CHART_COLORS } from '@/lib/constants'; import { CHART_COLORS } from '@/lib/constants';
import styles from './EventProperties.module.css'; import styles from './EventProperties.module.css';
@ -9,12 +11,19 @@ import styles from './EventProperties.module.css';
export function EventProperties({ websiteId }: { websiteId: string }) { export function EventProperties({ websiteId }: { websiteId: string }) {
const [propertyName, setPropertyName] = useState(''); const [propertyName, setPropertyName] = useState('');
const [eventName, setEventName] = useState(''); const [eventName, setEventName] = useState('');
const [propertyView, setPropertyView] = useState('table');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { data, isLoading, isFetched, error } = useEventDataProperties(websiteId); const { data, isLoading, isFetched, error } = useEventDataProperties(websiteId);
const { data: values } = useEventDataValues(websiteId, eventName, propertyName); const { data: values } = useEventDataValues(websiteId, eventName, propertyName);
const chartData =
propertyName && values const propertySum = useMemo(() => {
? { return values?.reduce((sum, { total }) => sum + total, 0) ?? 0;
}, [values]);
const chartData = useMemo(() => {
if (!propertyName || !values) return null;
return {
labels: values.map(({ value }) => value), labels: values.map(({ value }) => value),
datasets: [ datasets: [
{ {
@ -23,8 +32,17 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
borderWidth: 0, borderWidth: 0,
}, },
], ],
} };
: null; }, [propertyName, values]);
const tableData = useMemo(() => {
if (!propertyName || !values || propertySum === 0) return [];
return values.map(({ value, total }) => ({
x: value,
y: total,
z: 100 * (total / propertySum),
}));
}, [propertyName, values, propertySum]);
const handleRowClick = row => { const handleRowClick = row => {
setEventName(row.eventName); setEventName(row.eventName);
@ -52,9 +70,25 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
<GridColumn name="total" label={formatMessage(labels.count)} alignment="end" /> <GridColumn name="total" label={formatMessage(labels.count)} alignment="end" />
</GridTable> </GridTable>
{propertyName && ( {propertyName && (
<div className={styles.chart}> <div className={styles.data}>
<div className={styles.title}>{propertyName}</div> <Flexbox className={styles.header} gap={12} justifyContent="space-between">
<div className={styles.title}>{`${eventName}: ${propertyName}`}</div>
<ButtonGroup
selectedKey={propertyView}
onSelect={key => setPropertyView(key as string)}
>
<Button key="table">{formatMessage(labels.table)}</Button>
<Button key="chart">{formatMessage(labels.chart)}</Button>
</ButtonGroup>
</Flexbox>
{!values ? (
<Loading icon="dots" />
) : propertyView === 'table' ? (
<ListTable data={tableData} />
) : (
<PieChart key={propertyName + eventName} type="doughnut" data={chartData} /> <PieChart key={propertyName + eventName} type="doughnut" data={chartData} />
)}
</div> </div>
)} )}
</div> </div>

View file

@ -9,16 +9,22 @@ import { useMessages } from '@/components/hooks';
import { Item, Tabs } from 'react-basics'; import { Item, Tabs } from 'react-basics';
import { useState } from 'react'; import { useState } from 'react';
import EventProperties from './EventProperties'; import EventProperties from './EventProperties';
import { getItem, setItem } from '@/lib/storage';
export default function EventsPage({ websiteId }) { export default function EventsPage({ websiteId }) {
const [label, setLabel] = useState(null); const [label, setLabel] = useState(null);
const [tab, setTab] = useState('activity'); const [tab, setTab] = useState(getItem('eventTab') || 'activity');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const handleLabelClick = (value: string) => { const handleLabelClick = (value: string) => {
setLabel(value !== label ? value : ''); setLabel(value !== label ? value : '');
}; };
const onSelect = (value: 'activity' | 'properties') => {
setItem('eventTab', value);
setTab(value);
};
return ( return (
<> <>
<WebsiteHeader websiteId={websiteId} /> <WebsiteHeader websiteId={websiteId} />
@ -34,11 +40,7 @@ export default function EventsPage({ websiteId }) {
/> />
</GridRow> </GridRow>
<div> <div>
<Tabs <Tabs selectedKey={tab} onSelect={onSelect} style={{ marginBottom: 30 }}>
selectedKey={tab}
onSelect={(value: any) => setTab(value)}
style={{ marginBottom: 30 }}
>
<Item key="activity">{formatMessage(labels.activity)}</Item> <Item key="activity">{formatMessage(labels.activity)}</Item>
<Item key="properties">{formatMessage(labels.properties)}</Item> <Item key="properties">{formatMessage(labels.properties)}</Item>
</Tabs> </Tabs>

View file

@ -1,10 +1,19 @@
'use server'; 'use server';
export async function getConfig() { export type Config = {
faviconUrl: string | undefined;
privateMode: boolean;
telemetryDisabled: boolean;
trackerScriptName: string | undefined;
updatesDisabled: boolean;
};
export async function getConfig(): Promise<Config> {
return { return {
faviconUrl: process.env.FAVICON_URL,
privateMode: !!process.env.PRIVATE_MODE,
telemetryDisabled: !!process.env.DISABLE_TELEMETRY, telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
trackerScriptName: process.env.TRACKER_SCRIPT_NAME, trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
uiDisabled: !!process.env.DISABLE_UI,
updatesDisabled: !!process.env.DISABLE_UPDATES, updatesDisabled: !!process.env.DISABLE_UPDATES,
}; };
} }

View file

@ -20,8 +20,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo
return unauthorized(); return unauthorized();
} }
report.parameters = JSON.parse(report.parameters);
return json(report); return json(report);
} }
@ -62,7 +60,7 @@ export async function POST(
type, type,
name, name,
description, description,
parameters: JSON.stringify(parameters), parameters: parameters,
} as any); } as any);
return json(result); return json(result);

View file

@ -103,7 +103,7 @@ export async function POST(request: Request) {
type, type,
name, name,
description, description,
parameters: JSON.stringify(parameters), parameters: parameters,
} as any); } as any);
return json(result); return json(result);

View file

@ -2,10 +2,17 @@ import { CURRENT_VERSION, TELEMETRY_PIXEL } from '@/lib/constants';
export async function GET() { export async function GET() {
if ( if (
process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'production' ||
process.env.DISABLE_TELEMETRY && process.env.DISABLE_TELEMETRY ||
process.env.PRIVATE_MODE process.env.PRIVATE_MODE
) { ) {
return new Response('/* telemetry disabled */', {
headers: {
'content-type': 'text/javascript',
},
});
}
const script = ` const script = `
(()=>{const i=document.createElement('img'); (()=>{const i=document.createElement('img');
i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}'); i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}');
@ -18,11 +25,4 @@ export async function GET() {
'content-type': 'text/javascript', 'content-type': 'text/javascript',
}, },
}); });
}
return new Response('/* telemetry disabled */', {
headers: {
'content-type': 'text/javascript',
},
});
} }

View file

@ -4,7 +4,7 @@ import { startOfHour, startOfMonth } from 'date-fns';
import clickhouse from '@/lib/clickhouse'; import clickhouse from '@/lib/clickhouse';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, forbidden, serverError } from '@/lib/response'; import { badRequest, json, forbidden, serverError } from '@/lib/response';
import { fetchSession, fetchWebsite } from '@/lib/load'; import { fetchWebsite } from '@/lib/load';
import { getClientInfo, hasBlockedIp } from '@/lib/detect'; import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { createToken, parseToken } from '@/lib/jwt'; import { createToken, parseToken } from '@/lib/jwt';
import { secret, uuid, hash } from '@/lib/crypto'; import { secret, uuid, hash } from '@/lib/crypto';
@ -103,14 +103,10 @@ export async function POST(request: Request) {
const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt); const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
// Find session
if (!clickhouse.enabled && !cache?.sessionId) {
const session = await fetchSession(websiteId, sessionId);
// Create a session if not found // Create a session if not found
if (!session) { if (!clickhouse.enabled && !cache?.sessionId) {
try { await createSession(
await createSession({ {
id: sessionId, id: sessionId,
websiteId, websiteId,
browser, browser,
@ -122,13 +118,9 @@ export async function POST(request: Request) {
region, region,
city, city,
distinctId: id, distinctId: id,
}); },
} catch (e: any) { { skipDuplicates: true },
if (!e.message.toLowerCase().includes('unique constraint')) { );
return serverError(e);
}
}
}
} }
// Visit info // Visit info
@ -145,7 +137,8 @@ export async function POST(request: Request) {
const base = hostname ? `https://${hostname}` : 'https://localhost'; const base = hostname ? `https://${hostname}` : 'https://localhost';
const currentUrl = new URL(url, base); const currentUrl = new URL(url, base);
let urlPath = currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash; let urlPath =
currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash;
const urlQuery = currentUrl.search.substring(1); const urlQuery = currentUrl.search.substring(1);
const urlDomain = currentUrl.hostname.replace(/^www./, ''); const urlDomain = currentUrl.hostname.replace(/^www./, '');

View file

@ -3,7 +3,7 @@ import { parseRequest, getRequestDateRange, getRequestFilters } from '@/lib/requ
import { unauthorized, json } from '@/lib/response'; import { unauthorized, json } from '@/lib/response';
import { canViewWebsite } from '@/lib/auth'; import { canViewWebsite } from '@/lib/auth';
import { filterParams, timezoneParam, unitParam } from '@/lib/schema'; import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
import { getEventMetrics } from '@/queries'; import { getEventStats } from '@/queries';
export async function GET( export async function GET(
request: Request, request: Request,
@ -32,14 +32,14 @@ export async function GET(
} }
const filters = { const filters = {
...getRequestFilters(query), ...(await getRequestFilters(query)),
startDate, startDate,
endDate, endDate,
timezone, timezone,
unit, unit,
}; };
const data = await getEventMetrics(websiteId, filters); const data = await getEventStats(websiteId, filters);
return json(data); return json(data);
} }

View file

@ -0,0 +1,73 @@
import { z } from 'zod';
import JSZip from 'jszip';
import Papa from 'papaparse';
import { getRequestFilters, parseRequest } from '@/lib/request';
import { unauthorized, json } from '@/lib/response';
import { canViewWebsite } from '@/lib/auth';
import { pagingParams } from '@/lib/schema';
import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
startAt: z.coerce.number().int(),
endAt: z.coerce.number().int(),
...pagingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { startAt, endAt } = query;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const startDate = new Date(+startAt);
const endDate = new Date(+endAt);
const filters = {
...(await getRequestFilters(query)),
startDate,
endDate,
};
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
getEventMetrics(websiteId, 'event', filters),
getPageviewMetrics(websiteId, 'url', filters),
getPageviewMetrics(websiteId, 'referrer', filters),
getSessionMetrics(websiteId, 'browser', filters),
getSessionMetrics(websiteId, 'os', filters),
getSessionMetrics(websiteId, 'device', filters),
getSessionMetrics(websiteId, 'country', filters),
]);
const zip = new JSZip();
const parse = (data: any) => {
return Papa.unparse(data, {
header: true,
skipEmptyLines: true,
});
};
zip.file('events.csv', parse(events));
zip.file('pages.csv', parse(pages));
zip.file('referrers.csv', parse(referrers));
zip.file('browsers.csv', parse(browsers));
zip.file('os.csv', parse(os));
zip.file('devices.csv', parse(devices));
zip.file('countries.csv', parse(countries));
const content = await zip.generateAsync({ type: 'nodebuffer' });
const base64 = content.toString('base64');
return json({ zip: base64 });
}

View file

@ -15,7 +15,12 @@ import {
} from '@/lib/constants'; } from '@/lib/constants';
import { getRequestFilters, getRequestDateRange, parseRequest } from '@/lib/request'; import { getRequestFilters, getRequestDateRange, parseRequest } from '@/lib/request';
import { json, unauthorized, badRequest } from '@/lib/response'; import { json, unauthorized, badRequest } from '@/lib/response';
import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries'; import {
getPageviewMetrics,
getSessionMetrics,
getEventMetrics,
getChannelMetrics,
} from '@/queries';
import { filterParams } from '@/lib/schema'; import { filterParams } from '@/lib/schema';
export async function GET( export async function GET(
@ -48,7 +53,7 @@ export async function GET(
const { startDate, endDate } = await getRequestDateRange(query); const { startDate, endDate } = await getRequestDateRange(query);
const column = FILTER_COLUMNS[type] || type; const column = FILTER_COLUMNS[type] || type;
const filters = { const filters = {
...getRequestFilters(query), ...(await getRequestFilters(query)),
startDate, startDate,
endDate, endDate,
}; };
@ -85,7 +90,13 @@ export async function GET(
} }
if (EVENT_COLUMNS.includes(type)) { if (EVENT_COLUMNS.includes(type)) {
const data = await getPageviewMetrics(websiteId, type, filters, limit, offset); let data;
if (type === 'event') {
data = await getEventMetrics(websiteId, type, filters, limit, offset);
} else {
data = await getPageviewMetrics(websiteId, type, filters, limit, offset);
}
return json(data); return json(data);
} }

View file

@ -35,7 +35,7 @@ export async function GET(
const { startDate, endDate, unit } = await getRequestDateRange(query); const { startDate, endDate, unit } = await getRequestDateRange(query);
const filters = { const filters = {
...getRequestFilters(query), ...(await getRequestFilters(query)),
startDate, startDate,
endDate, endDate,
timezone, timezone,

View file

@ -0,0 +1,92 @@
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/lib/auth';
import { parseRequest } from '@/lib/request';
import { json, notFound, ok, unauthorized } from '@/lib/response';
import { segmentTypeParam } from '@/lib/schema';
import { deleteSegment, getSegment, updateSegment } from '@/queries';
import { z } from 'zod';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { websiteId, segmentId } = await params;
const segment = await getSegment(segmentId);
if (websiteId && !(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
return json(segment);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
) {
const schema = z.object({
type: segmentTypeParam,
name: z.string().max(200),
parameters: z.object({}).passthrough(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId, segmentId } = await params;
const { type, name, parameters } = body;
const segment = await getSegment(segmentId);
if (!segment) {
return notFound();
}
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
const result = await updateSegment(segmentId, {
type,
name,
parameters,
} as any);
return json(result);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ websiteId: string; segmentId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { websiteId, segmentId } = await params;
const segment = await getSegment(segmentId);
if (!segment) {
return notFound();
}
if (!(await canDeleteWebsite(auth, websiteId))) {
return unauthorized();
}
await deleteSegment(segmentId);
return ok();
}

View file

@ -0,0 +1,67 @@
import { canUpdateWebsite, canViewWebsite } from '@/lib/auth';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { segmentTypeParam } from '@/lib/schema';
import { createSegment, getWebsiteSegments } from '@/queries';
import { z } from 'zod';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
type: segmentTypeParam,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { type } = query;
if (websiteId && !(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const segments = await getWebsiteSegments(websiteId, type);
return json(segments);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
type: segmentTypeParam,
name: z.string().max(200),
parameters: z.object({}).passthrough(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { type, name, parameters } = body;
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
const result = await createSegment({
id: uuid(),
websiteId,
type,
name,
parameters,
} as any);
return json(result);
}

View file

@ -29,7 +29,7 @@ export async function GET(
const { startDate, endDate } = await getRequestDateRange(query); const { startDate, endDate } = await getRequestDateRange(query);
const filters = getRequestFilters(query); const filters = await getRequestFilters(query);
const metrics = await getWebsiteSessionStats(websiteId, { const metrics = await getWebsiteSessionStats(websiteId, {
...filters, ...filters,

View file

@ -37,7 +37,7 @@ export async function GET(
endDate, endDate,
); );
const filters = getRequestFilters(query); const filters = await getRequestFilters(query);
const metrics = await getWebsiteStats(websiteId, { const metrics = await getWebsiteStats(websiteId, {
...filters, ...filters,

View file

@ -1,9 +1,9 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth'; import { canViewWebsite } from '@/lib/auth';
import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; import { EVENT_COLUMNS, FILTER_COLUMNS, FILTER_GROUPS, SESSION_COLUMNS } from '@/lib/constants';
import { getValues } from '@/queries'; import { getRequestDateRange, parseRequest } from '@/lib/request';
import { parseRequest, getRequestDateRange } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response'; import { badRequest, json, unauthorized } from '@/lib/response';
import { getWebsiteSegments, getValues } from '@/queries';
import { z } from 'zod';
export async function GET( export async function GET(
request: Request, request: Request,
@ -30,11 +30,17 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type)) { if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !FILTER_GROUPS[type]) {
return badRequest('Invalid type.'); return badRequest('Invalid type.');
} }
const values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search); let values;
if (FILTER_GROUPS[type]) {
values = (await getWebsiteSegments(websiteId, type)).map(segment => ({ value: segment.name }));
} else {
values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search);
}
return json(values.filter(n => n).sort()); return json(values.filter(n => n).sort());
} }

View file

@ -26,6 +26,7 @@ export async function POST(request: Request) {
domain: z.string().max(500), domain: z.string().max(500),
shareId: z.string().max(50).nullable().optional(), shareId: z.string().max(50).nullable().optional(),
teamId: z.string().nullable().optional(), teamId: z.string().nullable().optional(),
id: z.string().uuid().nullable().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -34,14 +35,14 @@ export async function POST(request: Request) {
return error(); return error();
} }
const { name, domain, shareId, teamId } = body; const { id, name, domain, shareId, teamId } = body;
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
return unauthorized(); return unauthorized();
} }
const data: any = { const data: any = {
id: uuid(), id: id ?? uuid(),
createdBy: auth.user.id, createdBy: auth.user.id,
name, name,
domain, domain,

View file

@ -9,6 +9,14 @@ import '@/styles/index.css';
import '@/styles/variables.css'; import '@/styles/variables.css';
export default function ({ children }) { export default function ({ children }) {
if (process.env.DISABLE_UI) {
return (
<html>
<body></body>
</html>
);
}
return ( return (
<html lang="en" data-scroll="0"> <html lang="en" data-scroll="0">
<head> <head>

View file

@ -3,10 +3,6 @@ import LoginForm from './LoginForm';
import styles from './LoginPage.module.css'; import styles from './LoginPage.module.css';
export function LoginPage() { export function LoginPage() {
if (process.env.disableLogin) {
return null;
}
return ( return (
<div className={styles.page}> <div className={styles.page}>
<LoginForm /> <LoginForm />

View file

@ -2,6 +2,10 @@ import { Metadata } from 'next';
import LoginPage from './LoginPage'; import LoginPage from './LoginPage';
export default async function () { export default async function () {
if (process.env.DISABLE_LOGIN) {
return null;
}
return <LoginPage />; return <LoginPage />;
} }

View file

@ -6,9 +6,9 @@ import { setUser } from '@/store/app';
import { removeClientAuthToken } from '@/lib/client'; import { removeClientAuthToken } from '@/lib/client';
export function LogoutPage() { export function LogoutPage() {
const disabled = !!(process.env.disableLogin || process.env.cloudMode);
const router = useRouter(); const router = useRouter();
const { post } = useApi(); const { post } = useApi();
const disabled = process.env.cloudMode;
useEffect(() => { useEffect(() => {
async function logout() { async function logout() {

View file

@ -2,6 +2,10 @@ import LogoutPage from './LogoutPage';
import { Metadata } from 'next'; import { Metadata } from 'next';
export default function () { export default function () {
if (process.env.DISABLE_LOGIN) {
return null;
}
return <LogoutPage />; return <LogoutPage />;
} }

1
src/assets/download.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m97.4999924 82.6562576.0000076-11.298912c0-1.957756-1.5870743-3.544838-3.544838-3.544838h-4.785324c-1.9577637 0-3.544838 1.5870743-3.544838 3.5448303l-.0000076 11.2989121c0 1.639595-1.329155 2.96875-2.96875 2.96875l-65.3124924-.0000229c-1.639596 0-2.96875-1.329155-2.968749-2.96875l.0000038-11.298912c0-1.957756-1.5870762-3.544838-3.544836-3.544838h-4.7853256c-1.9577594 0-3.5448372 1.5870743-3.544838 3.544838l-.0000036 11.298912c-.0000026 8.1979752 6.6457672 14.84375 14.8437443 14.84375l65.3124965.0000229c8.1979751 0 14.84375-6.6457672 14.84375-14.8437424z"/><path d="m29.6809349 44.1050034-3.3884087 3.3884048c-1.3843441 1.384346-1.384346 3.6288109-.0000019 5.0131569l19.5066929 19.5067101c2.3174515 2.3200302 6.0768623 2.3221207 8.3968925.0046768.0015564-.0015564.0031128-.0031204.0046692-.0046768l19.5067177-19.5066948c1.384346-1.3843422 1.384346-3.6288109 0-5.0131569l-3.3884125-3.3884048c-1.3843384-1.384346-3.6288071-1.384346-5.0131531-.0000038l-9.3684235 9.3684196.0000153-47.4285965c0-1.9577589-1.5870781-3.544837-3.5448341-3.5448377l-4.7853279-.0000014c-1.9577599-.0000007-3.544838 1.5870759-3.544838 3.5448353l-.0000153 47.4285965-9.3684158-9.3684235c-1.3843459-1.384346-3.6288127-1.384346-5.0131568-.0000038z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
src/assets/export.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><switch><g><path d="m8.7 7.7 2.3-2.3v9.6c0 .6.4 1 1 1s1-.4 1-1v-9.6l2.3 2.3c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0zm12.3 6.3c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1h-14c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1z"/></g></switch></svg>

After

Width:  |  Height:  |  Size: 493 B

View file

@ -1,3 +1,4 @@
import { useConfig } from '@/components/hooks';
import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants'; import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants';
function getHostName(url: string) { function getHostName(url: string) {
@ -6,11 +7,13 @@ function getHostName(url: string) {
} }
export function Favicon({ domain, ...props }) { export function Favicon({ domain, ...props }) {
if (process.env.privateMode) { const config = useConfig();
if (config?.privateMode) {
return null; return null;
} }
const url = process.env.faviconURL || FAVICON_URL; const url = config?.faviconUrl || FAVICON_URL;
const hostName = domain ? getHostName(domain) : null; const hostName = domain ? getHostName(domain) : null;
const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName; const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName;
const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null; const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null;

View file

@ -1,8 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import useStore, { setConfig } from '@/store/app'; import useStore, { setConfig } from '@/store/app';
import { getConfig } from '@/app/actions/getConfig'; import { getConfig, Config } from '@/app/actions/getConfig';
export function useConfig() { export function useConfig(): Config {
const { config } = useStore(); const { config } = useStore();
async function loadConfig() { async function loadConfig() {

View file

@ -29,7 +29,7 @@ export function useReport(
data.parameters = { data.parameters = {
...defaultParameters?.parameters, ...defaultParameters?.parameters,
...data.parameters, ...data.parameters,
dateRange: parseDateRange(dateRange.value), dateRange: dateRange ? parseDateRange(dateRange?.value) : {},
}; };
setReport(data); setReport(data);

View file

@ -4,6 +4,8 @@ export function useFields() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const fields = [ const fields = [
// { name: 'cohort', type: 'string', label: formatMessage(labels.cohort) },
// { name: 'segment', type: 'string', label: formatMessage(labels.segment) },
{ name: 'url', type: 'string', label: formatMessage(labels.url) }, { name: 'url', type: 'string', label: formatMessage(labels.url) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },

View file

@ -21,6 +21,8 @@ export function useFilterParams(websiteId: string) {
city, city,
event, event,
tag, tag,
segment,
cohort,
}, },
} = useNavigation(); } = useNavigation();
@ -42,5 +44,7 @@ export function useFilterParams(websiteId: string) {
city, city,
event, event,
tag, tag,
segment,
cohort,
}; };
} }

View file

@ -8,6 +8,8 @@ import Change from '@/assets/change.svg';
import Clock from '@/assets/clock.svg'; import Clock from '@/assets/clock.svg';
import Compare from '@/assets/compare.svg'; import Compare from '@/assets/compare.svg';
import Dashboard from '@/assets/dashboard.svg'; import Dashboard from '@/assets/dashboard.svg';
import Download from '@/assets/download.svg';
import Export from '@/assets/export.svg';
import Eye from '@/assets/eye.svg'; import Eye from '@/assets/eye.svg';
import Gear from '@/assets/gear.svg'; import Gear from '@/assets/gear.svg';
import Globe from '@/assets/globe.svg'; import Globe from '@/assets/globe.svg';
@ -37,6 +39,8 @@ const icons = {
Clock, Clock,
Compare, Compare,
Dashboard, Dashboard,
Download,
Export,
Eye, Eye,
Gear, Gear,
Globe, Globe,

View file

@ -0,0 +1,41 @@
import Papa from 'papaparse';
import { Button, Icon, TooltipPopup } from 'react-basics';
import Icons from '@/components/icons';
import { useMessages } from '@/components/hooks';
export function DownloadButton({
filename = 'data',
data,
}: {
filename?: string;
data?: any;
onClick?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const handleClick = async () => {
downloadCsv(`${filename}.csv`, Papa.unparse(data));
};
return (
<TooltipPopup label={formatMessage(labels.download)} position="top">
<Button variant="quiet" onClick={handleClick} disabled={!data}>
<Icon>
<Icons.Download />
</Icon>
</Button>
</TooltipPopup>
);
}
function downloadCsv(filename: string, data: any) {
const blob = new Blob([data], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,47 @@
import { useState } from 'react';
import { Icon, TooltipPopup, LoadingButton } from 'react-basics';
import Icons from '@/components/icons';
import { useMessages, useApi } from '@/components/hooks';
import { useFilterParams } from '@/components/hooks/useFilterParams';
import { useSearchParams } from 'next/navigation';
export function ExportButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const [isLoading, setIsLoading] = useState(false);
const params = useFilterParams(websiteId);
const searchParams = useSearchParams();
const { get } = useApi();
const handleClick = async () => {
setIsLoading(true);
const { zip } = await get(`/websites/${websiteId}/export`, { ...params, ...searchParams });
const binary = atob(zip);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'download.zip';
a.click();
URL.revokeObjectURL(url);
setIsLoading(false);
};
return (
<TooltipPopup label={formatMessage(labels.download)} position="top">
<LoadingButton variant="quiet" isLoading={isLoading} onClick={handleClick}>
<Icon>
<Icons.Download />
</Icon>
</LoadingButton>
</TooltipPopup>
);
}

View file

@ -99,6 +99,8 @@ export const labels = defineMessages({
countries: { id: 'label.countries', defaultMessage: 'Countries' }, countries: { id: 'label.countries', defaultMessage: 'Countries' },
languages: { id: 'label.languages', defaultMessage: 'Languages' }, languages: { id: 'label.languages', defaultMessage: 'Languages' },
tags: { id: 'label.tags', defaultMessage: 'Tags' }, tags: { id: 'label.tags', defaultMessage: 'Tags' },
segments: { id: 'label.segments', defaultMessage: 'Segments' },
cohorts: { id: 'label.cohorts', defaultMessage: 'Cohorts' },
count: { id: 'label.count', defaultMessage: 'Count' }, count: { id: 'label.count', defaultMessage: 'Count' },
average: { id: 'label.average', defaultMessage: 'Average' }, average: { id: 'label.average', defaultMessage: 'Average' },
sum: { id: 'label.sum', defaultMessage: 'Sum' }, sum: { id: 'label.sum', defaultMessage: 'Sum' },
@ -229,6 +231,8 @@ export const labels = defineMessages({
device: { id: 'label.device', defaultMessage: 'Device' }, device: { id: 'label.device', defaultMessage: 'Device' },
pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' }, pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
tag: { id: 'label.tag', defaultMessage: 'Tag' }, tag: { id: 'label.tag', defaultMessage: 'Tag' },
segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
day: { id: 'label.day', defaultMessage: 'Day' }, day: { id: 'label.day', defaultMessage: 'Day' },
date: { id: 'label.date', defaultMessage: 'Date' }, date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
@ -310,6 +314,9 @@ export const labels = defineMessages({
paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' }, paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' },
grouped: { id: 'label.grouped', defaultMessage: 'Grouped' }, grouped: { id: 'label.grouped', defaultMessage: 'Grouped' },
other: { id: 'label.other', defaultMessage: 'Other' }, other: { id: 'label.other', defaultMessage: 'Other' },
chart: { id: 'label.chart', defaultMessage: 'Chart' },
table: { id: 'label.table', defaultMessage: 'Table' },
download: { id: 'label.download', defaultMessage: 'Download' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View file

@ -32,6 +32,7 @@ export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
metric={formatMessage(labels.actions)} metric={formatMessage(labels.actions)}
onDataLoad={handleDataLoad} onDataLoad={handleDataLoad}
renderLabel={renderLabel} renderLabel={renderLabel}
allowDownload={false}
/> />
); );
} }

View file

@ -14,6 +14,13 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.footer { .footer {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -15,6 +15,7 @@ import {
import Icons from '@/components/icons'; import Icons from '@/components/icons';
import ListTable, { ListTableProps } from './ListTable'; import ListTable, { ListTableProps } from './ListTable';
import styles from './MetricsTable.module.css'; import styles from './MetricsTable.module.css';
import { DownloadButton } from '@/components/input/DownloadButton';
export interface MetricsTableProps extends ListTableProps { export interface MetricsTableProps extends ListTableProps {
websiteId: string; websiteId: string;
@ -29,6 +30,7 @@ export interface MetricsTableProps extends ListTableProps {
searchFormattedValues?: boolean; searchFormattedValues?: boolean;
showMore?: boolean; showMore?: boolean;
params?: { [key: string]: any }; params?: { [key: string]: any };
allowDownload?: boolean;
children?: ReactNode; children?: ReactNode;
} }
@ -44,6 +46,7 @@ export function MetricsTable({
searchFormattedValues = false, searchFormattedValues = false,
showMore = true, showMore = true,
params, params,
allowDownload = true,
children, children,
...props ...props
}: MetricsTableProps) { }: MetricsTableProps) {
@ -104,7 +107,10 @@ export function MetricsTable({
autoFocus={true} autoFocus={true}
/> />
)} )}
<div className={styles.buttons}>
{children} {children}
{allowDownload && <DownloadButton filename={type} data={filteredData} />}
</div>
</div> </div>
{data && !error && ( {data && !error && (
<ListTable {...(props as ListTableProps)} data={filteredData} className={className} /> <ListTable {...(props as ListTableProps)} data={filteredData} className={className} />

View file

@ -62,7 +62,7 @@ export function PagesTable({ allowFilter, ...props }: PagesTableProps) {
{...props} {...props}
title={formatMessage(labels.pages)} title={formatMessage(labels.pages)}
type={view} type={view}
metric={formatMessage(labels.views)} metric={formatMessage(labels.visitors)}
dataFilter={emptyFilter} dataFilter={emptyFilter}
renderLabel={renderLink} renderLabel={renderLink}
> >

View file

@ -5,13 +5,15 @@
"label.add": "أضِف", "label.add": "أضِف",
"label.add-description": "أضِف وصف", "label.add-description": "أضِف وصف",
"label.add-member": "أضِف عضو", "label.add-member": "أضِف عضو",
"label.add-step": "Add step", "label.add-step": "إضافة خطوة",
"label.add-website": "إضافة موقع", "label.add-website": "إضافة موقع",
"label.admin": "مدير", "label.admin": "مدير",
"label.after": "يعد", "label.after": "يعد",
"label.all": "الكل", "label.all": "الكل",
"label.all-time": "كل الوقت", "label.all-time": "كل الوقت",
"label.analytics": "تحليلات", "label.analytics": "تحليلات",
"label.attribution": "الإسناد",
"label.attribution-description": "شاهد كيف يتفاعل المستخدمون مع حملاتك التسويقية وما الذي يحفز التحويلات.",
"label.average": "المتوسط", "label.average": "المتوسط",
"label.back": "للخلف", "label.back": "للخلف",
"label.before": "قبل", "label.before": "قبل",
@ -19,17 +21,17 @@
"label.breakdown": "التصنيف", "label.breakdown": "التصنيف",
"label.browser": "المتصفح", "label.browser": "المتصفح",
"label.browsers": "المتصفحات", "label.browsers": "المتصفحات",
"label.cancel": "ألغِ", "label.cancel": "إلغاء",
"label.change-password": "تغيير كلمة المرور", "label.change-password": "تغيير كلمة المرور",
"label.cities": "المدن", "label.cities": "المدن",
"label.city": "المدينة", "label.city": "المدينة",
"label.clear-all": "مسح الكل", "label.clear-all": "مسح الكل",
"label.compare": "Compare", "label.compare": "المقارنة",
"label.confirm": "تأكيد", "label.confirm": "تأكيد",
"label.confirm-password": "تأكيد كلمة المرور", "label.confirm-password": "تأكيد كلمة المرور",
"label.contains": "يحتوي", "label.contains": "يحتوي على",
"label.continue": "تابع", "label.continue": "تابع",
"label.count": "Count", "label.count": "العدد",
"label.countries": "الدول", "label.countries": "الدول",
"label.country": "الدولة", "label.country": "الدولة",
"label.create": "أنشِئ", "label.create": "أنشِئ",
@ -38,10 +40,10 @@
"label.create-user": "أنشِئ مستخدم", "label.create-user": "أنشِئ مستخدم",
"label.created": "أُنشئت", "label.created": "أُنشئت",
"label.created-by": "أُنشئ من قبل", "label.created-by": "أُنشئ من قبل",
"label.current": "Current", "label.current": "الحالي",
"label.current-password": "كلمة المرور الحالية", "label.current-password": "كلمة المرور الحالية",
"label.custom-range": "فترة مخصّصة", "label.custom-range": "فترة مخصّصة",
"label.dashboard": "الشاشة الرئيسية", "label.dashboard": "لوحة التحكم",
"label.data": "البيانات", "label.data": "البيانات",
"label.date": "التاريخ", "label.date": "التاريخ",
"label.date-range": "فترة مخصّصة", "label.date-range": "فترة مخصّصة",
@ -58,19 +60,19 @@
"label.device": "الجهاز", "label.device": "الجهاز",
"label.devices": "الأجهزة", "label.devices": "الأجهزة",
"label.dismiss": "تجاهل", "label.dismiss": "تجاهل",
"label.does-not-contain": "لا يحتوي", "label.does-not-contain": "لا يحتوي على",
"label.domain": "النطاق", "label.domain": "النطاق",
"label.dropoff": "إنزال", "label.dropoff": "إنزال",
"label.edit": "عدّل", "label.edit": "تعديل",
"label.edit-dashboard": "عدّل لوحة التحكم", "label.edit-dashboard": "عدّل لوحة التحكم",
"label.edit-member": "عدّل العضو", "label.edit-member": "عدّل العضو",
"label.enable-share-url": "فعّل مشاركة الرابط", "label.enable-share-url": "فعّل مشاركة الرابط",
"label.end-step": "End Step", "label.end-step": "الخطوة الأخيرة",
"label.entry": "Entry URL", "label.entry": "رابط الدخول",
"label.event": "الحدث", "label.event": "الحدث",
"label.event-data": "تاريخ الحدث", "label.event-data": "تاريخ الحدث",
"label.events": "الأحداث", "label.events": "الأحداث",
"label.exit": "Exit URL", "label.exit": "رابط المغادرة",
"label.false": "خطأ", "label.false": "خطأ",
"label.field": "الحقل", "label.field": "الحقل",
"label.fields": "الحقول", "label.fields": "الحقول",
@ -78,33 +80,33 @@
"label.filter-combined": "مُجمّعة", "label.filter-combined": "مُجمّعة",
"label.filter-raw": "خام", "label.filter-raw": "خام",
"label.filters": "التصفيات", "label.filters": "التصفيات",
"label.first-seen": "First seen", "label.first-seen": "أول ظهور",
"label.funnel": "قمع", "label.funnel": "قمع",
"label.funnel-description": "فهم معدل التحويل والانقطاع عن المستخدمين.", "label.funnel-description": "فهم معدل التحويل والانقطاع عن المستخدمين.",
"label.goal": "Goal", "label.goal": "الهدف",
"label.goals": "Goals", "label.goals": "الأهداف",
"label.goals-description": "Track your goals for pageviews and events.", "label.goals-description": "تابع تحقق أهدافك المرتبطة بمشاهدات الصفحات والأحداث.",
"label.greater-than": "أكبَر مِن", "label.greater-than": "أكبَر مِن",
"label.greater-than-equals": "أكبَر مِن أو يساوي", "label.greater-than-equals": "أكبَر مِن أو يساوي",
"label.host": "Host", "label.host": "Host",
"label.hosts": "Hosts", "label.hosts": "Hosts",
"label.insights": "نتائج التحليلات", "label.insights": "نتائج التحليلات",
"label.insights-description": "تعمق في بياناتك باستخدام الشرائح والتصفيات.", "label.insights-description": "تعمق في بياناتك باستخدام الشرائح والتصفيات.",
"label.is": "هو", "label.is": "يساوي",
"label.is-not": م", "label.is-not": ا يساوي",
"label.is-not-set": "لم ضُبط", "label.is-not-set": "لم ضُبط",
"label.is-set": "ضُبط", "label.is-set": "ضُبط",
"label.join": "انضم", "label.join": "انضم",
"label.join-team": "انضم للفريق", "label.join-team": "انضم للفريق",
"label.journey": "Journey", "label.journey": "رحلة المستخدم",
"label.journey-description": "Understand how users navigate through your website.", "label.journey-description": "تعرّف على كيفية تنقّل المستخدمين داخل موقعك.",
"label.language": "اللغة", "label.language": "اللغة",
"label.languages": "اللغات", "label.languages": "اللغات",
"label.laptop": "لابتوب", "label.laptop": "لابتوب",
"label.last-days": "آخر {x} يوم/ايام", "label.last-days": "آخر {x} يوم/ايام",
"label.last-hours": "آخر {x} ساعة", "label.last-hours": "آخر {x} ساعة",
"label.last-months": "Last {x} months", "label.last-months": "آخر {x} شهر/أشهر",
"label.last-seen": "Last seen", "label.last-seen": "آخر ظهور",
"label.leave": "غادر", "label.leave": "غادر",
"label.leave-team": "مغادرة المجموعة", "label.leave-team": "مغادرة المجموعة",
"label.less-than": "أقل مِن", "label.less-than": "أقل مِن",
@ -112,7 +114,7 @@
"label.login": "تسجيل الدخول", "label.login": "تسجيل الدخول",
"label.logout": "تسجيل الخروج", "label.logout": "تسجيل الخروج",
"label.manage": "التحكم", "label.manage": "التحكم",
"label.manager": "Manager", "label.manager": "مدير",
"label.max": "الحد الأقصى", "label.max": "الحد الأقصى",
"label.member": "عضو", "label.member": "عضو",
"label.members": "الأعضاء", "label.members": "الأعضاء",
@ -134,15 +136,15 @@
"label.pageTitle": "عنوان الصفحة", "label.pageTitle": "عنوان الصفحة",
"label.pages": "الصفحات", "label.pages": "الصفحات",
"label.password": "كلمة المرور", "label.password": "كلمة المرور",
"label.path": "Path", "label.path": "المسار",
"label.paths": "Paths", "label.paths": "المسارات",
"label.powered-by": "مشغل بواسطة {name}", "label.powered-by": "مشغل بواسطة {name}",
"label.previous": "Previous", "label.previous": "السابق",
"label.previous-period": "Previous period", "label.previous-period": "الفترة السابقة",
"label.previous-year": "Previous year", "label.previous-year": "العام السابق",
"label.profile": "الملف الشخصي", "label.profile": "الملف الشخصي",
"label.properties": "Properties", "label.properties": "الخصائص",
"label.property": "Property", "label.property": "الخاصية",
"label.queries": "استعلامات", "label.queries": "استعلامات",
"label.query": "استعلام", "label.query": "استعلام",
"label.query-parameters": "متغيرات الرابط", "label.query-parameters": "متغيرات الرابط",
@ -161,9 +163,9 @@
"label.reset-website": "اعادة تعيين الإحصائيات", "label.reset-website": "اعادة تعيين الإحصائيات",
"label.retention": "الاحتفاظ", "label.retention": "الاحتفاظ",
"label.retention-description": "قس مدى ثبات موقعك على الويب من خلال تتبع عدد مرات عودة المستخدمين.", "label.retention-description": "قس مدى ثبات موقعك على الويب من خلال تتبع عدد مرات عودة المستخدمين.",
"label.revenue": "Revenue", "label.revenue": "الإيرادات",
"label.revenue-description": "Look into your revenue across time.", "label.revenue-description": "قم بإلقاء نظرة على بيانات إيراداتك وكيفية إنفاق المستخدمين.",
"label.revenue-property": "Revenue Property", "label.revenue-property": "خاصية الإيرادات",
"label.role": "الصلاحية", "label.role": "الصلاحية",
"label.run-query": "شغّل الاستعلام", "label.run-query": "شغّل الاستعلام",
"label.save": "حفظ", "label.save": "حفظ",
@ -173,22 +175,24 @@
"label.select-date": "حدد التاريخ", "label.select-date": "حدد التاريخ",
"label.select-role": "حدد الدور", "label.select-role": "حدد الدور",
"label.select-website": "حدد موقع", "label.select-website": "حدد موقع",
"label.session": "Session", "label.session": "الزيارة",
"label.sessions": "الزيارات", "label.sessions": "الزيارات",
"label.settings": "الإعدادات", "label.settings": "الإعدادات",
"label.share-url": "مشاركة الرابط", "label.share-url": "مشاركة الرابط",
"label.single-day": "يوم واحد", "label.single-day": "يوم واحد",
"label.start-step": "Start Step", "label.start-step": "الخطوة الأولى",
"label.steps": "Steps", "label.steps": "الخطوات",
"label.sum": "المجموع", "label.sum": "المجموع",
"label.tablet": "تابلت", "label.tablet": "تابلت",
"label.tag": "الوسم",
"label.tags": "الوسوم",
"label.team": "الفريق", "label.team": "الفريق",
"label.team-id": "معرّف الفريق", "label.team-id": "معرّف الفريق",
"label.team-manager": "Team manager", "label.team-manager": "مدير الفريق",
"label.team-member": "عضو الفريق", "label.team-member": "عضو الفريق",
"label.team-name": "اسم الفريق", "label.team-name": "اسم الفريق",
"label.team-owner": "مدير الفريق", "label.team-owner": "مدير الفريق",
"label.team-view-only": "Team view only", "label.team-view-only": "عرض الفريق فقط",
"label.team-websites": "مواقع الفريق", "label.team-websites": "مواقع الفريق",
"label.teams": "الفرق", "label.teams": "الفرق",
"label.theme": "السمة", "label.theme": "السمة",
@ -202,34 +206,34 @@
"label.total": "الإجمالي", "label.total": "الإجمالي",
"label.total-records": "إجمالي السجلات", "label.total-records": "إجمالي السجلات",
"label.tracking-code": "كود التتبع", "label.tracking-code": "كود التتبع",
"label.transactions": "Transactions", "label.transactions": "المعاملات",
"label.transfer": "Transfer", "label.transfer": "نقل",
"label.transfer-website": "انقل الموقع", "label.transfer-website": "انقل الموقع",
"label.true": "حقيقي", "label.true": "حقيقي",
"label.type": "النوع", "label.type": "النوع",
"label.unique": "فريد", "label.unique": "فريد",
"label.unique-visitors": "زائرون فريدون", "label.unique-visitors": "زائرون فريدون",
"label.uniqueCustomers": "Unique Customers", "label.uniqueCustomers": "العملاء الفريدون",
"label.unknown": "غير معروف", "label.unknown": "غير معروف",
"label.untitled": "بدون عنوان", "label.untitled": "بدون عنوان",
"label.update": "Update", "label.update": "تحديث",
"label.url": "URL", "label.url": "الرابط",
"label.urls": "URLs", "label.urls": "الروابط",
"label.user": "المستخدم", "label.user": "المستخدم",
"label.user-property": "User Property", "label.user-property": "سمات المستخدم",
"label.username": "اسم المستخدم", "label.username": "اسم المستخدم",
"label.users": "المستخدمين", "label.users": "المستخدمين",
"label.utm": "UTM", "label.utm": "UTM",
"label.utm-description": "Track your campaigns through UTM parameters.", "label.utm-description": "تابع حملاتك التسويقية باستخدام معلمات UTM.",
"label.value": "القيمة", "label.value": "القيمة",
"label.view": "عرض", "label.view": "عرض",
"label.view-details": "عرض التفاصيل", "label.view-details": "عرض التفاصيل",
"label.view-only": "عرض فقط", "label.view-only": "عرض فقط",
"label.views": "المشاهدات", "label.views": "المشاهدات",
"label.views-per-visit": "Views per visit", "label.views-per-visit": "مشاهدات لكل زيارة",
"label.visit-duration": "متوسط وقت الزيارة", "label.visit-duration": "متوسط وقت الزيارة",
"label.visitors": "الزوار", "label.visitors": "الزوار",
"label.visits": "Visits", "label.visits": "الزيارات",
"label.website": "الموقع", "label.website": "الموقع",
"label.website-id": "معرّف الموقع", "label.website-id": "معرّف الموقع",
"label.websites": "المواقع", "label.websites": "المواقع",
@ -237,7 +241,7 @@
"label.yesterday": "الأمس", "label.yesterday": "الأمس",
"message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.", "message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.",
"message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}", "message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}",
"message.collected-data": "Collected data", "message.collected-data": "البيانات المجمعة",
"message.confirm-delete": "هل أنت متأكد من حذف {target}?", "message.confirm-delete": "هل أنت متأكد من حذف {target}?",
"message.confirm-leave": "هل أنت متأكد من مغادرة {target}?", "message.confirm-leave": "هل أنت متأكد من مغادرة {target}?",
"message.confirm-remove": "هل انت متأكد من حذف {target}?", "message.confirm-remove": "هل انت متأكد من حذف {target}?",
@ -261,9 +265,9 @@
"message.no-websites-configured": "لم تقم بإعداد اي موقع.", "message.no-websites-configured": "لم تقم بإعداد اي موقع.",
"message.page-not-found": "الصفحة غير موجودة.", "message.page-not-found": "الصفحة غير موجودة.",
"message.reset-website": "لإعادة ضبط موقع الويب هذا، اكتب {confirmation} في المربع أدناه للتأكيد.", "message.reset-website": "لإعادة ضبط موقع الويب هذا، اكتب {confirmation} في المربع أدناه للتأكيد.",
"message.reset-website-warning": "سيتم اعادة تعيين كافة الإحصائيات لهذا الموقع، لكن لن يتم تعيير كود التتبع", "message.reset-website-warning": "سيتم اعادة تعيين كافة الإحصائيات لهذا الموقع، لكن لن يتم تغيير كود التتبع",
"message.saved": "تم الحفظ بنجاح.", "message.saved": "تم الحفظ بنجاح.",
"message.share-url": "هذا الرابط الذي تم مشاركته بشكل عام لـ {target}.", "message.share-url": "إحصائيات موقعك متاحة للجميع على الرابط التالي:",
"message.team-already-member": "أنت عضو في الفريق", "message.team-already-member": "أنت عضو في الفريق",
"message.team-not-found": "لم يتم العثور على الفريق", "message.team-not-found": "لم يتم العثور على الفريق",
"message.team-websites-info": "يمكن مشاهدة الموقع من اي عضو في الفريق.", "message.team-websites-info": "يمكن مشاهدة الموقع من اي عضو في الفريق.",

View file

@ -1,279 +1,279 @@
{ {
"label.access-code": "Access code", "label.access-code": "Mã truy cập",
"label.actions": "Hành động", "label.actions": "Hành động",
"label.activity": "Activity log", "label.activity": "Nhật ký hoạt động",
"label.add": "Add", "label.add": "Thêm",
"label.add-description": "Add description", "label.add-description": "Thêm mô tả",
"label.add-member": "Add member", "label.add-member": "Thêm thành viên",
"label.add-step": "Add step", "label.add-step": "Thêm bước",
"label.add-website": "Thêm website", "label.add-website": "Thêm website",
"label.admin": "Quản trị", "label.admin": "Quản trị",
"label.after": "After", "label.after": "Sau đó",
"label.all": "Tất cả", "label.all": "Tất cả",
"label.all-time": "Toàn thời gian", "label.all-time": "Toàn thời gian",
"label.analytics": "Analytics", "label.analytics": "Phân tích",
"label.average": "Average", "label.average": "Trung bình",
"label.back": "Quay về", "label.back": "Quay lại",
"label.before": "Before", "label.before": "Trước đó",
"label.bounce-rate": "Tỷ lệ thoát trang", "label.bounce-rate": "Tỷ lệ thoát trang",
"label.breakdown": "Breakdown", "label.breakdown": "Phân tích chi tiết",
"label.browser": "Browser", "label.browser": "Trình duyệt",
"label.browsers": "Trình duyệt", "label.browsers": "Các trình duyệt",
"label.cancel": "Huỷ bỏ", "label.cancel": "Hủy bỏ",
"label.change-password": "Đổi mật khẩu", "label.change-password": "Đổi mật khẩu",
"label.cities": "Cities", "label.cities": "Các thành phố",
"label.city": "City", "label.city": "Thành phố",
"label.clear-all": "Clear all", "label.clear-all": "Xóa tất cả",
"label.compare": "Compare", "label.compare": "So sánh",
"label.confirm": "Confirm", "label.confirm": "Xác nhận",
"label.confirm-password": "Xác nhận mật khẩu", "label.confirm-password": "Xác nhận mật khẩu",
"label.contains": "Contains", "label.contains": "Chứa",
"label.continue": "Continue", "label.continue": "Tiếp tục",
"label.count": "Count", "label.count": "Số lượng",
"label.countries": "Quốc gia", "label.countries": "Các quốc gia",
"label.country": "Country", "label.country": "Quốc gia",
"label.create": "Create", "label.create": "Tạo",
"label.create-report": "Create report", "label.create-report": "Tạo báo cáo",
"label.create-team": "Create team", "label.create-team": "Tạo nhóm",
"label.create-user": "Create user", "label.create-user": "Tạo người dùng",
"label.created": "Created", "label.created": "Đã tạo",
"label.created-by": "Created By", "label.created-by": "Được tạo bởi",
"label.current": "Current", "label.current": "Hiện tại",
"label.current-password": "Mật khẩu hiện tại", "label.current-password": "Mật khẩu hiện tại",
"label.custom-range": "Phạm vi ngày tuỳ chọn", "label.custom-range": "Phạm vi tùy chỉnh",
"label.dashboard": "Bảng điều khiển", "label.dashboard": "Bảng điều khiển",
"label.data": "Data", "label.data": "Dữ liệu",
"label.date": "Date", "label.date": "Ngày",
"label.date-range": "Phạm vi ngày", "label.date-range": "Phạm vi ngày",
"label.day": "Day", "label.day": "Ngày",
"label.default-date-range": "Khoảng thời gian mặc định", "label.default-date-range": "Khoảng thời gian mặc định",
"label.delete": "X", "label.delete": "Xóa",
"label.delete-report": "Delete report", "label.delete-report": "Xóa báo cáo",
"label.delete-team": "Delete team", "label.delete-team": "Xóa nhóm",
"label.delete-user": "Delete user", "label.delete-user": "Xóa người dùng",
"label.delete-website": "Xóa website", "label.delete-website": "Xóa website",
"label.description": "Description", "label.description": "Mô tả",
"label.desktop": "Máy bàn", "label.desktop": "Máy tính để bàn",
"label.details": "Details", "label.details": "Chi tiết",
"label.device": "Device", "label.device": "Thiết bị",
"label.devices": "Thiết bị", "label.devices": "Các thiết bị",
"label.dismiss": "Loại trừ", "label.dismiss": "Bỏ qua",
"label.does-not-contain": "Does not contain", "label.does-not-contain": "Không chứa",
"label.domain": "Tên miền", "label.domain": "Tên miền",
"label.dropoff": "Dropoff", "label.dropoff": "Tỷ lệ bỏ qua",
"label.edit": "Chỉnh sửa", "label.edit": "Chỉnh sửa",
"label.edit-dashboard": "Edit dashboard", "label.edit-dashboard": "Chỉnh sửa bảng điều khiển",
"label.edit-member": "Edit member", "label.edit-member": "Chỉnh sửa thành viên",
"label.enable-share-url": "Bật khả năng chia sẻ URL", "label.enable-share-url": "Bật chia sẻ URL",
"label.end-step": "End Step", "label.end-step": "Bước kết thúc",
"label.entry": "Entry URL", "label.entry": "URL truy cập",
"label.event": "Event", "label.event": "Sự kiện",
"label.event-data": "Event data", "label.event-data": "Dữ liệu sự kiện",
"label.events": "Sự kiện", "label.events": "Các sự kiện",
"label.exit": "Exit URL", "label.exit": "URL thoát",
"label.false": "False", "label.false": "Sai",
"label.field": "Field", "label.field": "Trường",
"label.fields": "Fields", "label.fields": "Các trường",
"label.filter": "Filter", "label.filter": "Lọc",
"label.filter-combined": "Kết hợp", "label.filter-combined": "Kết hợp lọc",
"label.filter-raw": "Gốc", "label.filter-raw": "Lọc thô",
"label.filters": "Filters", "label.filters": "Bộ lọc",
"label.first-seen": "First seen", "label.first-seen": "Lần đầu tiên nhìn thấy",
"label.funnel": "Funnel", "label.funnel": "Phễu",
"label.funnel-description": "Understand the conversion and drop-off rate of users.", "label.funnel-description": "Tìm hiểu tỷ lệ chuyển đổi và bỏ qua của người dùng.",
"label.goal": "Goal", "label.goal": "Mục tiêu",
"label.goals": "Goals", "label.goals": "Các mục tiêu",
"label.goals-description": "Track your goals for pageviews and events.", "label.goals-description": "Theo dõi các mục tiêu của bạn cho lượt xem trang và sự kiện.",
"label.greater-than": "Greater than", "label.greater-than": "Lớn hơn",
"label.greater-than-equals": "Greater than or equals", "label.greater-than-equals": "Lớn hơn hoặc bằng",
"label.host": "Host", "label.host": "Máy chủ",
"label.hosts": "Hosts", "label.hosts": "Các máy chủ",
"label.insights": "Insights", "label.insights": "Thông tin chi tiết",
"label.insights-description": "Dive deeper into your data by using segments and filters.", "label.insights-description": "Tìm hiểu sâu hơn về dữ liệu của bạn bằng cách sử dụng phân đoạn và bộ lọc.",
"label.is": "Is", "label.is": "",
"label.is-not": "Is not", "label.is-not": "Không phải là",
"label.is-not-set": "Is not set", "label.is-not-set": "Chưa được đặt",
"label.is-set": "Is set", "label.is-set": "Đã đặt",
"label.join": "Join", "label.join": "Tham gia",
"label.join-team": "Join team", "label.join-team": "Tham gia nhóm",
"label.journey": "Journey", "label.journey": "Hành trình",
"label.journey-description": "Understand how users navigate through your website.", "label.journey-description": "Hiểu cách người dùng điều hướng qua website của bạn.",
"label.language": "Language", "label.language": "Ngôn ngữ",
"label.languages": "Ngôn ngữ", "label.languages": "Các ngôn ngữ",
"label.laptop": "Laptop", "label.laptop": "Máy tính xách tay",
"label.last-days": "{x} ngày gần nhất", "label.last-days": "{x} ngày gần nhất",
"label.last-hours": "{x} giờ gần nhất", "label.last-hours": "{x} giờ gần nhất",
"label.last-months": "Last {x} months", "label.last-months": "{x} tháng gần nhất",
"label.last-seen": "Last seen", "label.last-seen": "Lần cuối cùng nhìn thấy",
"label.leave": "Leave", "label.leave": "Rời khỏi",
"label.leave-team": "Leave team", "label.leave-team": "Rời nhóm",
"label.less-than": "Less than", "label.less-than": "Nhỏ hơn",
"label.less-than-equals": "Less than or equals", "label.less-than-equals": "Nhỏ hơn hoặc bằng",
"label.login": "Đăng nhập", "label.login": "Đăng nhập",
"label.logout": "Đăng xuất", "label.logout": "Đăng xuất",
"label.manage": "Manage", "label.manage": "Quản lý",
"label.manager": "Manager", "label.manager": "Quản lý",
"label.max": "Max", "label.max": "Tối đa",
"label.member": "Member", "label.member": "Thành viên",
"label.members": "Members", "label.members": "Các thành viên",
"label.min": "Min", "label.min": "Tối thiểu",
"label.mobile": "Di động", "label.mobile": "Di động",
"label.more": "Thêm", "label.more": "Thêm",
"label.my-account": "My account", "label.my-account": "Tài khoản của tôi",
"label.my-websites": "My websites", "label.my-websites": "Các website của tôi",
"label.name": "Tên", "label.name": "Tên",
"label.new-password": "Mật khẩu mới", "label.new-password": "Mật khẩu mới",
"label.none": "None", "label.none": "Không",
"label.number-of-records": "{x} {x, plural, one {record} other {records}}", "label.number-of-records": "{x} {x, plural, one {bản ghi} other {bản ghi}}",
"label.ok": "OK", "label.ok": "OK",
"label.os": "OS", "label.os": "Hệ điều hành",
"label.overview": "Overview", "label.overview": "Tổng quan",
"label.owner": "Chủ sở hữu", "label.owner": "Chủ sở hữu",
"label.page-of": "Page {current} of {total}", "label.page-of": "Trang {current} trên {total}",
"label.page-views": "Lượt xem", "label.page-views": "Lượt xem trang",
"label.pageTitle": "Page title", "label.pageTitle": "Tiêu đề trang",
"label.pages": "Trang", "label.pages": "Các trang",
"label.password": "Mật khẩu", "label.password": "Mật khẩu",
"label.path": "Path", "label.path": "Đường dẫn",
"label.paths": "Paths", "label.paths": "Các đường dẫn",
"label.powered-by": "Bản quyền thuộc về {name}", "label.powered-by": "Được cung cấp bởi {name}",
"label.previous": "Previous", "label.previous": "Trước",
"label.previous-period": "Previous period", "label.previous-period": "Kỳ trước",
"label.previous-year": "Previous year", "label.previous-year": "Năm trước",
"label.profile": "Hồ sơ", "label.profile": "Hồ sơ",
"label.properties": "Properties", "label.properties": "Thuộc tính",
"label.property": "Property", "label.property": "Thuộc tính",
"label.queries": "Queries", "label.queries": "Truy vấn",
"label.query": "Query", "label.query": "Truy vấn",
"label.query-parameters": "Query parameters", "label.query-parameters": "Tham số truy vấn",
"label.realtime": "Thời gian thực", "label.realtime": "Thời gian thực",
"label.referrer": "Referrer", "label.referrer": "Nguồn giới thiệu",
"label.referrers": "Liên kết giới thiệu", "label.referrers": "Các nguồn giới thiệu",
"label.refresh": "Làm mới", "label.refresh": "Làm mới",
"label.regenerate": "Regenerate", "label.regenerate": "Tạo lại",
"label.region": "Region", "label.region": "Vùng",
"label.regions": "Regions", "label.regions": "Các vùng",
"label.remove": "Remove", "label.remove": "Xóa",
"label.remove-member": "Remove member", "label.remove-member": "Xóa thành viên",
"label.reports": "Reports", "label.reports": "Báo cáo",
"label.required": "Yêu cầu", "label.required": "Yêu cầu",
"label.reset": "Tái thiết lập", "label.reset": "Đặt lại",
"label.reset-website": "Tái thiết lập thống kê", "label.reset-website": "Đặt lại thống kê website",
"label.retention": "Retention", "label.retention": "Tỷ lệ giữ chân",
"label.retention-description": "Measure your website stickiness by tracking how often users return.", "label.retention-description": "Đo lường mức độ gắn bó của website bằng cách theo dõi tần suất người dùng quay lại.",
"label.revenue": "Revenue", "label.revenue": "Doanh thu",
"label.revenue-description": "Look into your revenue across time.", "label.revenue-description": "Xem xét doanh thu của bạn theo thời gian.",
"label.revenue-property": "Revenue Property", "label.revenue-property": "Thuộc tính doanh thu",
"label.role": "Role", "label.role": "Vai trò",
"label.run-query": "Run query", "label.run-query": "Chạy truy vấn",
"label.save": "Lưu", "label.save": "Lưu",
"label.screens": "Screens", "label.screens": "Màn hình",
"label.search": "Search", "label.search": "Tìm kiếm",
"label.select": "Select", "label.select": "Chọn",
"label.select-date": "Select date", "label.select-date": "Chọn ngày",
"label.select-role": "Select role", "label.select-role": "Chọn vai trò",
"label.select-website": "Select website", "label.select-website": "Chọn website",
"label.session": "Session", "label.session": "Phiên",
"label.sessions": "Sessions", "label.sessions": "Các phiên",
"label.settings": "Cài đặt", "label.settings": "Cài đặt",
"label.share-url": "Chia sẻ URL", "label.share-url": "Chia sẻ URL",
"label.single-day": "Trong ngày", "label.single-day": "Một ngày",
"label.start-step": "Start Step", "label.start-step": "Bước bắt đầu",
"label.steps": "Steps", "label.steps": "Các bước",
"label.sum": "Sum", "label.sum": "Tổng",
"label.tablet": "Máy tính bảng", "label.tablet": "Máy tính bảng",
"label.team": "Team", "label.team": "Nhóm",
"label.team-id": "Team ID", "label.team-id": "ID nhóm",
"label.team-manager": "Team manager", "label.team-manager": "Quản lý nhóm",
"label.team-member": "Team member", "label.team-member": "Thành viên nhóm",
"label.team-name": "Team name", "label.team-name": "Tên nhóm",
"label.team-owner": "Team owner", "label.team-owner": "Chủ sở hữu nhóm",
"label.team-view-only": "Team view only", "label.team-view-only": "Chỉ xem nhóm",
"label.team-websites": "Team websites", "label.team-websites": "Các website của nhóm",
"label.teams": "Teams", "label.teams": "Các nhóm",
"label.theme": "Giao diện", "label.theme": "Chủ đề",
"label.this-month": "Tháng này", "label.this-month": "Tháng này",
"label.this-week": "Tuần này", "label.this-week": "Tuần này",
"label.this-year": "Năm nay", "label.this-year": "Năm nay",
"label.timezone": "Múi giờ", "label.timezone": "Múi giờ",
"label.title": "Title", "label.title": "Tiêu đề",
"label.today": "Hôm nay", "label.today": "Hôm nay",
"label.toggle-charts": "Bật/tắt biểu đồ", "label.toggle-charts": "Bật/tắt biểu đồ",
"label.total": "Total", "label.total": "Tổng",
"label.total-records": "Total records", "label.total-records": "Tổng số bản ghi",
"label.tracking-code": "Mã theo dõi", "label.tracking-code": "Mã theo dõi",
"label.transactions": "Transactions", "label.transactions": "Giao dịch",
"label.transfer": "Transfer", "label.transfer": "Chuyển giao",
"label.transfer-website": "Transfer website", "label.transfer-website": "Chuyển giao website",
"label.true": "True", "label.true": "Đúng",
"label.type": "Type", "label.type": "Loại",
"label.unique": "Unique", "label.unique": "Duy nhất",
"label.unique-visitors": "Khách truy cập một lần", "label.unique-visitors": "Khách truy cập duy nhất",
"label.uniqueCustomers": "Unique Customers", "label.uniqueCustomers": "Khách hàng duy nhất",
"label.unknown": "Không rõ", "label.unknown": "Không rõ",
"label.untitled": "Untitled", "label.untitled": "Không có tiêu đề",
"label.update": "Update", "label.update": "Cập nhật",
"label.url": "URL", "label.url": "URL",
"label.urls": "URLs", "label.urls": "Các URL",
"label.user": "User", "label.user": "Người dùng",
"label.user-property": "User Property", "label.user-property": "Thuộc tính người dùng",
"label.username": "Tên đăng nhập", "label.username": "Tên đăng nhập",
"label.users": "Users", "label.users": "Người dùng",
"label.utm": "UTM", "label.utm": "UTM",
"label.utm-description": "Track your campaigns through UTM parameters.", "label.utm-description": "Theo dõi các chiến dịch của bạn thông qua các tham số UTM.",
"label.value": "Value", "label.value": "Giá trị",
"label.view": "View", "label.view": "Xem",
"label.view-details": "Xem chi tiết", "label.view-details": "Xem chi tiết",
"label.view-only": "View only", "label.view-only": "Chỉ xem",
"label.views": "Xem", "label.views": "Lượt xem",
"label.views-per-visit": "Views per visit", "label.views-per-visit": "Lượt xem trên mỗi lượt truy cập",
"label.visit-duration": "Thời gian truy cập trung bình", "label.visit-duration": "Thời lượng truy cập",
"label.visitors": "Khách", "label.visitors": "Khách truy cập",
"label.visits": "Visits", "label.visits": "Lượt truy cập",
"label.website": "Website", "label.website": "Website",
"label.website-id": "Website ID", "label.website-id": "ID website",
"label.websites": "Websites", "label.websites": "Các website",
"label.window": "Window", "label.window": "Cửa sổ",
"label.yesterday": "Yesterday", "label.yesterday": "Hôm qua",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.", "message.action-confirmation": "Nhập {confirmation} vào ô bên dưới để xác nhận.",
"message.active-users": "{x} hiện tại {x, plural, one {một} other {trên}}", "message.active-users": "{x} {x, plural, one {người dùng} other {người dùng}} đang hoạt động",
"message.collected-data": "Collected data", "message.collected-data": "Dữ liệu đã thu thập",
"message.confirm-delete": "Bạn có chắc chắn muốn x {target}?", "message.confirm-delete": "Bạn có chắc chắn muốn xóa {target}?",
"message.confirm-leave": "Are you sure you want to leave {target}?", "message.confirm-leave": "Bạn có chắc chắn muốn rời {target}?",
"message.confirm-remove": "Are you sure you want to remove {target}?", "message.confirm-remove": "Bạn có chắc chắn muốn xóa {target}?",
"message.confirm-reset": "Bạn có chắc chắn muốn tái thiết lập thống kê {target}?", "message.confirm-reset": "Bạn có chắc chắn muốn đặt lại thống kê {target}?",
"message.delete-team-warning": "Deleting a team will also delete all team websites.", "message.delete-team-warning": "Việc xóa một nhóm cũng sẽ xóa tất cả các website của nhóm.",
"message.delete-website-warning": "Tất cả các dữ liệu liên quan cũng sẽ bị xoá.", "message.delete-website-warning": "Tất cả dữ liệu liên quan cũng sẽ bị xóa.",
"message.error": "Đã xảy ra lỗi.", "message.error": "Đã xảy ra lỗi.",
"message.event-log": "{event} on {url}", "message.event-log": "{event} trên {url}",
"message.go-to-settings": "Chuyển tới cài đặt", "message.go-to-settings": "Chuyển đến cài đặt",
"message.incorrect-username-password": "Sai tên đăng nhập/mật khẩu.", "message.incorrect-username-password": "Sai tên đăng nhập/mật khẩu.",
"message.invalid-domain": "Tên miền không hợp lệ", "message.invalid-domain": "Tên miền không hợp lệ",
"message.min-password-length": "Minimum length of {n} characters", "message.min-password-length": "Độ dài tối thiểu {n} ký tự",
"message.new-version-available": "A new version of Umami {version} is available!", "message.new-version-available": "Có phiên bản mới của Umami {version}!",
"message.no-data-available": "Không có dữ liệu.", "message.no-data-available": "Không có dữ liệu.",
"message.no-event-data": "No event data is available.", "message.no-event-data": "Không có dữ liệu sự kiện.",
"message.no-match-password": "Mật khẩu không đồng nhất", "message.no-match-password": "Mật khẩu không khớp",
"message.no-results-found": "No results were found.", "message.no-results-found": "Không tìm thấy kết quả nào.",
"message.no-team-websites": "This team does not have any websites.", "message.no-team-websites": "Nhóm này không có bất kỳ website nào.",
"message.no-teams": "You have not created any teams.", "message.no-teams": "Bạn chưa tạo nhóm nào.",
"message.no-users": "There are no users.", "message.no-users": "Không có người dùng nào.",
"message.no-websites-configured": "Bạn chưa có bất cứ website nào.", "message.no-websites-configured": "Bạn chưa cấu hình bất kỳ website nào.",
"message.page-not-found": "Trang không tìm thấy.", "message.page-not-found": "Không tìm thấy trang.",
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", "message.reset-website": "Để đặt lại website này, nhập {confirmation} vào ô bên dưới để xác nhận.",
"message.reset-website-warning": "Tất cả số liệu thống kê của website này sẽ bị x, nhưng mã theo dõi sẽ vẫn giữ nguyên.", "message.reset-website-warning": "Tất cả số liệu thống kê của website này sẽ bị xóa, nhưng mã theo dõi sẽ vẫn giữ nguyên.",
"message.saved": "Đã lưu thành công.", "message.saved": "Đã lưu thành công.",
"message.share-url": "Đây là đường dẫn URL cho {target}.", "message.share-url": "Đây là đường dẫn URL cho {target}.",
"message.team-already-member": "You are already a member of the team.", "message.team-already-member": "Bạn đã là thành viên của nhóm.",
"message.team-not-found": "Team not found.", "message.team-not-found": "Không tìm thấy nhóm.",
"message.team-websites-info": "Websites can be viewed by anyone on the team.", "message.team-websites-info": "Bất kỳ ai trong nhóm đều có thể xem các website.",
"message.tracking-code": "Mã theo dõi", "message.tracking-code": "Mã theo dõi",
"message.transfer-team-website-to-user": "Transfer this website to your account?", "message.transfer-team-website-to-user": "Chuyển website này sang tài khoản của bạn?",
"message.transfer-user-website-to-team": "Select the team to transfer this website to.", "message.transfer-user-website-to-team": "Chọn nhóm để chuyển website này đến.",
"message.transfer-website": "Transfer website ownership to your account or another team.", "message.transfer-website": "Chuyển quyền sở hữu website sang tài khoản của bạn hoặc một nhóm khác.",
"message.triggered-event": "Triggered event", "message.triggered-event": "Sự kiện được kích hoạt",
"message.user-deleted": "User deleted.", "message.user-deleted": "Người dùng đã bị xóa.",
"message.viewed-page": "Viewed page", "message.viewed-page": "Đã xem trang",
"message.visitor-log": "Khách từ {country} đang ng {browser} trên {os} {device}", "message.visitor-log": "Khách từ {country} đang sử dụng {browser} trên {os} {device}",
"message.visitors-dropped-off": "Visitors dropped off" "message.visitors-dropped-off": "Khách truy cập đã rời đi"
} }

View file

@ -89,6 +89,21 @@ function mapFilter(column: string, operator: string, name: string, type: string
} }
} }
function mapCohortFilter(column: string, operator: string, value: string) {
switch (operator) {
case OPERATORS.equals:
return `${column} = '${value}'`;
case OPERATORS.notEquals:
return `${column} != '${value}'`;
case OPERATORS.contains:
return `positionCaseInsensitive(${column}, '${value}') > 0`;
case OPERATORS.doesNotContain:
return `positionCaseInsensitive(${column}, '${value}') = 0`;
default:
return '';
}
}
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) { function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) {
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => { const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
if (column) { if (column) {
@ -105,6 +120,42 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {})
return query.join('\n'); return query.join('\n');
} }
function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) {
const query = filtersToArray(filters, options).reduce(
(arr, { name, column, operator, value }) => {
if (column) {
arr.push(
`${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`,
);
if (name === 'referrer') {
arr.push(`and referrer_domain != hostname`);
}
}
return arr;
},
[],
);
if (query.length > 0) {
// add website and date range filters
query.push(`and website_id = '${websiteId}'`);
query.push(
`and created_at between parseDateTimeBestEffort('${filters.startDate}') and parseDateTimeBestEffort('${filters.endDate}')`,
);
return `join
(select distinct session_id
from website_event
${query.join('\n')}) cohort
on cohort.session_id = website_event.session_id
`;
}
return '';
}
function getDateQuery(filters: QueryFilters = {}) { function getDateQuery(filters: QueryFilters = {}) {
const { startDate, endDate, timezone } = filters; const { startDate, endDate, timezone } = filters;
@ -146,6 +197,7 @@ async function parseFilters(websiteId: string, filters: QueryFilters = {}, optio
websiteId, websiteId,
startDate: maxDate(filters.startDate, new Date(website?.resetAt)), startDate: maxDate(filters.startDate, new Date(website?.resetAt)),
}, },
cohortQuery: getCohortQuery(websiteId, filters?.cohort),
}; };
} }

View file

@ -13,7 +13,7 @@ export const UPDATES_URL = 'https://api.umami.is/v1/updates';
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png'; export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico'; export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US'; export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_THEME = 'light'; export const DEFAULT_THEME = 'light';
export const DEFAULT_ANIMATION_DURATION = 300; export const DEFAULT_ANIMATION_DURATION = 300;
export const DEFAULT_DATE_RANGE = '24hour'; export const DEFAULT_DATE_RANGE = '24hour';
@ -33,7 +33,17 @@ export const FILTER_REFERRERS = 'filter-referrers';
export const FILTER_PAGES = 'filter-pages'; export const FILTER_PAGES = 'filter-pages';
export const UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute']; export const UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute'];
export const EVENT_COLUMNS = ['url', 'entry', 'exit', 'referrer', 'title', 'query', 'event', 'tag']; export const EVENT_COLUMNS = [
'url',
'entry',
'exit',
'referrer',
'title',
'query',
'event',
'tag',
'host',
];
export const SESSION_COLUMNS = [ export const SESSION_COLUMNS = [
'browser', 'browser',
@ -44,9 +54,13 @@ export const SESSION_COLUMNS = [
'country', 'country',
'city', 'city',
'region', 'region',
'host',
]; ];
export const FILTER_GROUPS = {
segment: 'segment',
cohort: 'cohort',
};
export const FILTER_COLUMNS = { export const FILTER_COLUMNS = {
url: 'url_path', url: 'url_path',
entry: 'url_path', entry: 'url_path',

View file

@ -5,12 +5,13 @@ import ipaddr from 'ipaddr.js';
import maxmind from 'maxmind'; import maxmind from 'maxmind';
import { import {
DESKTOP_OS, DESKTOP_OS,
MOBILE_OS,
DESKTOP_SCREEN_WIDTH, DESKTOP_SCREEN_WIDTH,
LAPTOP_SCREEN_WIDTH,
MOBILE_SCREEN_WIDTH,
IP_ADDRESS_HEADERS, IP_ADDRESS_HEADERS,
LAPTOP_SCREEN_WIDTH,
MOBILE_OS,
MOBILE_SCREEN_WIDTH,
} from './constants'; } from './constants';
import { safeDecodeURIComponent } from '@/lib/url';
const MAXMIND = 'maxmind'; const MAXMIND = 'maxmind';
@ -124,7 +125,9 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
if (!global[MAXMIND]) { if (!global[MAXMIND]) {
const dir = path.join(process.cwd(), 'geo'); const dir = path.join(process.cwd(), 'geo');
global[MAXMIND] = await maxmind.open(path.resolve(dir, 'GeoLite2-City.mmdb')); global[MAXMIND] = await maxmind.open(
process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'),
);
} }
// When the client IP is extracted from headers, sometimes the value includes a port // When the client IP is extracted from headers, sometimes the value includes a port
@ -148,9 +151,9 @@ export async function getClientInfo(request: Request, payload: Record<string, an
const userAgent = payload?.userAgent || request.headers.get('user-agent'); const userAgent = payload?.userAgent || request.headers.get('user-agent');
const ip = payload?.ip || getIpAddress(request.headers); const ip = payload?.ip || getIpAddress(request.headers);
const location = await getLocation(ip, request.headers, !!payload?.ip); const location = await getLocation(ip, request.headers, !!payload?.ip);
const country = location?.country; const country = safeDecodeURIComponent(location?.country);
const region = location?.region; const region = safeDecodeURIComponent(location?.region);
const city = location?.city; const city = safeDecodeURIComponent(location?.city);
const browser = browserName(userAgent); const browser = browserName(userAgent);
const os = detectOS(userAgent) as string; const os = detectOS(userAgent) as string;
const device = getDevice(payload?.screen, os); const device = getDevice(payload?.screen, os);

View file

@ -155,6 +155,24 @@ function mapFilter(column: string, operator: string, name: string, type: string
} }
} }
function mapCohortFilter(column: string, operator: string, value: string) {
const db = getDatabaseType();
const like = db === POSTGRESQL ? 'ilike' : 'like';
switch (operator) {
case OPERATORS.equals:
return `${column} = '${value}'`;
case OPERATORS.notEquals:
return `${column} != '${value}'`;
case OPERATORS.contains:
return `${column} ${like} '${value}'`;
case OPERATORS.doesNotContain:
return `${column} not ${like} '${value}'`;
default:
return '';
}
}
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string { function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string {
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => { const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
if (column) { if (column) {
@ -173,6 +191,43 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}):
return query.join('\n'); return query.join('\n');
} }
function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) {
const query = filtersToArray(filters, options).reduce(
(arr, { name, column, operator, value }) => {
if (column) {
arr.push(
`${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`,
);
if (name === 'referrer') {
arr.push(`and referrer_domain != hostname`);
}
}
return arr;
},
[],
);
if (query.length > 0) {
// add website and date range filters
query.push(`and website_event.website_id = '${websiteId}'`);
query.push(
`and website_event.created_at between '${filters.startDate}'::timestamptz and '${filters.endDate}'::timestamptz`,
);
return `join
(select distinct website_event.session_id
from website_event
join session on session.session_id = website_event.session_id
${query.join('\n')}) cohort
on cohort.session_id = website_event.session_id
`;
}
return '';
}
function getDateQuery(filters: QueryFilters = {}) { function getDateQuery(filters: QueryFilters = {}) {
const { startDate, endDate } = filters; const { startDate, endDate } = filters;
@ -219,6 +274,7 @@ async function parseFilters(
websiteId, websiteId,
startDate: maxDate(filters.startDate, website?.resetAt), startDate: maxDate(filters.startDate, website?.resetAt),
}, },
cohortQuery: getCohortQuery(websiteId, filters?.cohort),
}; };
} }

View file

@ -1,9 +1,9 @@
import { z, ZodSchema } from 'zod'; import { z, ZodSchema } from 'zod';
import { FILTER_COLUMNS } from '@/lib/constants'; import { FILTER_COLUMNS, FILTER_GROUPS } from '@/lib/constants';
import { badRequest, unauthorized } from '@/lib/response'; import { badRequest, unauthorized } from '@/lib/response';
import { getAllowedUnits, getMinimumUnit } from '@/lib/date'; import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
import { checkAuth } from '@/lib/auth'; import { checkAuth } from '@/lib/auth';
import { getWebsiteDateRange } from '@/queries'; import { getWebsiteSegment, getWebsiteDateRange } from '@/queries';
export async function getJsonBody(request: Request) { export async function getJsonBody(request: Request) {
try { try {
@ -85,14 +85,28 @@ export async function getRequestDateRange(query: Record<string, any>) {
}; };
} }
export function getRequestFilters(query: Record<string, any>) { export async function getRequestFilters(query: Record<string, any>, websiteId?: string) {
return Object.keys(FILTER_COLUMNS).reduce((obj, key) => { const result: Record<string, any> = {};
const value = query[key];
for (const key of Object.keys(FILTER_COLUMNS)) {
const value = query[key];
if (value !== undefined) { if (value !== undefined) {
obj[key] = value; result[key] = value;
}
} }
return obj; for (const key of Object.keys(FILTER_GROUPS)) {
}, {}); const value = query[key];
if (value !== undefined) {
const segment = await getWebsiteSegment(websiteId, key, value);
if (key === 'segment') {
// merge filters into result
Object.assign(result, segment.parameters);
} else {
result[key] = segment.parameters;
}
}
}
return result;
} }

View file

@ -17,6 +17,8 @@ export const filterParams = {
host: z.string().optional(), host: z.string().optional(),
language: z.string().optional(), language: z.string().optional(),
event: z.string().optional(), event: z.string().optional(),
segment: z.string().optional(),
cohort: z.string().optional(),
}; };
export const pagingParams = { export const pagingParams = {
@ -74,3 +76,5 @@ export const reportParms = {
value: z.string().optional(), value: z.string().optional(),
}), }),
}; };
export const segmentTypeParam = z.enum(['segment', 'cohort']);

View file

@ -158,6 +158,7 @@ export interface QueryFilters {
event?: string; event?: string;
search?: string; search?: string;
tag?: string; tag?: string;
cohort?: { [key: string]: string };
} }
export interface QueryOptions { export interface QueryOptions {

View file

@ -1,4 +1,5 @@
export * from '@/queries/prisma/report'; export * from '@/queries/prisma/report';
export * from '@/queries/prisma/segment';
export * from '@/queries/prisma/team'; export * from '@/queries/prisma/team';
export * from '@/queries/prisma/teamUser'; export * from '@/queries/prisma/teamUser';
export * from '@/queries/prisma/user'; export * from '@/queries/prisma/user';
@ -10,6 +11,7 @@ export * from '@/queries/sql/events/getEventDataValues';
export * from '@/queries/sql/events/getEventDataStats'; export * from '@/queries/sql/events/getEventDataStats';
export * from '@/queries/sql/events/getEventDataUsage'; export * from '@/queries/sql/events/getEventDataUsage';
export * from '@/queries/sql/events/getEventMetrics'; export * from '@/queries/sql/events/getEventMetrics';
export * from '@/queries/sql/events/getEventStats';
export * from '@/queries/sql/events/getWebsiteEvents'; export * from '@/queries/sql/events/getWebsiteEvents';
export * from '@/queries/sql/events/getEventUsage'; export * from '@/queries/sql/events/getEventUsage';
export * from '@/queries/sql/events/saveEvent'; export * from '@/queries/sql/events/saveEvent';

View file

@ -0,0 +1,45 @@
import prisma from '@/lib/prisma';
import { Prisma, Segment } from '@prisma/client';
async function findSegment(criteria: Prisma.SegmentFindUniqueArgs): Promise<Segment> {
return prisma.client.Segment.findUnique(criteria);
}
export async function getSegment(segmentId: string): Promise<Segment> {
return findSegment({
where: {
id: segmentId,
},
});
}
export async function getWebsiteSegment(
websiteId: string,
type: string,
name: string,
): Promise<Segment> {
return prisma.client.segment.findFirst({
where: { websiteId, type, name },
});
}
export async function getWebsiteSegments(websiteId: string, type: string): Promise<Segment[]> {
return prisma.client.Segment.findMany({
where: { websiteId, type },
});
}
export async function createSegment(data: Prisma.SegmentUncheckedCreateInput): Promise<Segment> {
return prisma.client.Segment.create({ data });
}
export async function updateSegment(
SegmentId: string,
data: Prisma.SegmentUpdateInput,
): Promise<Segment> {
return prisma.client.Segment.update({ where: { id: SegmentId }, data });
}
export async function deleteSegment(SegmentId: string): Promise<Segment> {
return prisma.client.Segment.delete({ where: { id: SegmentId } });
}

View file

@ -14,7 +14,7 @@ export async function getEventDataFields(
async function relationalQuery(websiteId: string, filters: QueryFilters) { async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { rawQuery, parseFilters, getDateSQL } = prisma; const { rawQuery, parseFilters, getDateSQL } = prisma;
const { filterQuery, params } = await parseFilters(websiteId, filters); const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -29,6 +29,9 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
count(*) as "total" count(*) as "total"
from event_data from event_data
join website_event on website_event.event_id = event_data.website_event_id join website_event on website_event.event_id = event_data.website_event_id
and website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${cohortQuery}
where event_data.website_id = {{websiteId::uuid}} where event_data.website_id = {{websiteId::uuid}}
and event_data.created_at between {{startDate}} and {{endDate}} and event_data.created_at between {{startDate}} and {{endDate}}
${filterQuery} ${filterQuery}
@ -45,7 +48,7 @@ async function clickhouseQuery(
filters: QueryFilters, filters: QueryFilters,
): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { ): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, filters); const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -56,7 +59,8 @@ async function clickhouseQuery(
data_type = 4, toString(date_trunc('hour', date_value)), data_type = 4, toString(date_trunc('hour', date_value)),
string_value) as "value", string_value) as "value",
count(*) as "total" count(*) as "total"
from event_data from event_data website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterQuery} ${filterQuery}

View file

@ -17,7 +17,7 @@ async function relationalQuery(
filters: QueryFilters & { propertyName?: string }, filters: QueryFilters & { propertyName?: string },
) { ) {
const { rawQuery, parseFilters } = prisma; const { rawQuery, parseFilters } = prisma;
const { filterQuery, params } = await parseFilters(websiteId, filters, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters, {
columns: { propertyName: 'data_key' }, columns: { propertyName: 'data_key' },
}); });
@ -29,6 +29,9 @@ async function relationalQuery(
count(*) as "total" count(*) as "total"
from event_data from event_data
join website_event on website_event.event_id = event_data.website_event_id join website_event on website_event.event_id = event_data.website_event_id
and website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${cohortQuery}
where event_data.website_id = {{websiteId::uuid}} where event_data.website_id = {{websiteId::uuid}}
and event_data.created_at between {{startDate}} and {{endDate}} and event_data.created_at between {{startDate}} and {{endDate}}
${filterQuery} ${filterQuery}
@ -45,7 +48,7 @@ async function clickhouseQuery(
filters: QueryFilters & { propertyName?: string }, filters: QueryFilters & { propertyName?: string },
): Promise<{ eventName: string; propertyName: string; total: number }[]> { ): Promise<{ eventName: string; propertyName: string; total: number }[]> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, filters, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters, {
columns: { propertyName: 'data_key' }, columns: { propertyName: 'data_key' },
}); });
@ -55,7 +58,8 @@ async function clickhouseQuery(
event_name as eventName, event_name as eventName,
data_key as propertyName, data_key as propertyName,
count(*) as total count(*) as total
from event_data from event_data website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterQuery} ${filterQuery}

View file

@ -18,7 +18,7 @@ export async function getEventDataStats(
async function relationalQuery(websiteId: string, filters: QueryFilters) { async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { rawQuery, parseFilters } = prisma; const { rawQuery, parseFilters } = prisma;
const { filterQuery, params } = await parseFilters(websiteId, filters); const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -32,8 +32,12 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
data_key, data_key,
count(*) as "total" count(*) as "total"
from event_data from event_data
where website_id = {{websiteId::uuid}} join website_event on website_event.event_id = event_data.website_event_id
and created_at between {{startDate}} and {{endDate}} and website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${cohortQuery}
where event_data.website_id = {{websiteId::uuid}}
and event_data.created_at between {{startDate}} and {{endDate}}
${filterQuery} ${filterQuery}
group by website_event_id, data_key group by website_event_id, data_key
) as t ) as t
@ -47,7 +51,7 @@ async function clickhouseQuery(
filters: QueryFilters, filters: QueryFilters,
): Promise<{ events: number; properties: number; records: number }[]> { ): Promise<{ events: number; properties: number; records: number }[]> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, filters); const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -60,7 +64,8 @@ async function clickhouseQuery(
event_id, event_id,
data_key, data_key,
count(*) as "total" count(*) as "total"
from event_data from event_data website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterQuery} ${filterQuery}

View file

@ -20,7 +20,7 @@ async function relationalQuery(
filters: QueryFilters & { eventName?: string; propertyName?: string }, filters: QueryFilters & { eventName?: string; propertyName?: string },
) { ) {
const { rawQuery, parseFilters, getDateSQL } = prisma; const { rawQuery, parseFilters, getDateSQL } = prisma;
const { filterQuery, params } = await parseFilters(websiteId, filters); const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -33,6 +33,9 @@ async function relationalQuery(
count(*) as "total" count(*) as "total"
from event_data from event_data
join website_event on website_event.event_id = event_data.website_event_id join website_event on website_event.event_id = event_data.website_event_id
and website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${cohortQuery}
where event_data.website_id = {{websiteId::uuid}} where event_data.website_id = {{websiteId::uuid}}
and event_data.created_at between {{startDate}} and {{endDate}} and event_data.created_at between {{startDate}} and {{endDate}}
and event_data.data_key = {{propertyName}} and event_data.data_key = {{propertyName}}
@ -51,7 +54,7 @@ async function clickhouseQuery(
filters: QueryFilters & { eventName?: string; propertyName?: string }, filters: QueryFilters & { eventName?: string; propertyName?: string },
): Promise<{ value: string; total: number }[]> { ): Promise<{ value: string; total: number }[]> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, filters); const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -60,7 +63,8 @@ async function clickhouseQuery(
data_type = 4, toString(date_trunc('hour', date_value)), data_type = 4, toString(date_trunc('hour', date_value)),
string_value) as "value", string_value) as "value",
count(*) as "total" count(*) as "total"
from event_data from event_data website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and data_key = {propertyName:String} and data_key = {propertyName:String}

View file

@ -1,40 +1,57 @@
import clickhouse from '@/lib/clickhouse'; import clickhouse from '@/lib/clickhouse';
import { EVENT_TYPE } from '@/lib/constants'; import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import { QueryFilters, WebsiteEventMetric } from '@/lib/types'; import { QueryFilters } from '@/lib/types';
export async function getEventMetrics( export async function getEventMetrics(
...args: [websiteId: string, filters: QueryFilters] ...args: [
): Promise<WebsiteEventMetric[]> { websiteId: string,
type: string,
filters: QueryFilters,
limit?: number | string,
offset?: number | string,
]
) {
return runQuery({ return runQuery({
[PRISMA]: () => relationalQuery(...args), [PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args),
}); });
} }
async function relationalQuery(websiteId: string, filters: QueryFilters) { async function relationalQuery(
const { timezone = 'utc', unit = 'day' } = filters; websiteId: string,
const { rawQuery, getDateSQL, parseFilters } = prisma; type: string,
const { filterQuery, joinSession, params } = await parseFilters(websiteId, { filters: QueryFilters,
limit: number | string = 500,
offset: number | string = 0,
) {
const column = FILTER_COLUMNS[type] || type;
const { rawQuery, parseFilters } = prisma;
const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(
websiteId,
{
...filters, ...filters,
eventType: EVENT_TYPE.customEvent, eventType: EVENT_TYPE.customEvent,
}); },
{ joinSession: SESSION_COLUMNS.includes(type) },
);
return rawQuery( return rawQuery(
` `
select select ${column} x,
event_name x, count(*) as y
${getDateSQL('website_event.created_at', unit, timezone)} t,
count(*) y
from website_event from website_event
${cohortQuery}
${joinSession} ${joinSession}
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
and event_type = {{eventType}} and event_type = {{eventType}}
${filterQuery} ${filterQuery}
group by 1, 2 group by 1
order by 2 order by 2 desc
limit ${limit}
offset ${offset}
`, `,
params, params,
); );
@ -42,49 +59,32 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
async function clickhouseQuery( async function clickhouseQuery(
websiteId: string, websiteId: string,
type: string,
filters: QueryFilters, filters: QueryFilters,
): Promise<{ x: string; t: string; y: number }[]> { limit: number | string = 500,
const { timezone = 'UTC', unit = 'day' } = filters; offset: number | string = 0,
const { rawQuery, getDateSQL, parseFilters } = clickhouse; ): Promise<{ x: string; y: number }[]> {
const { filterQuery, params } = await parseFilters(websiteId, { const column = FILTER_COLUMNS[type] || type;
const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
...filters, ...filters,
eventType: EVENT_TYPE.customEvent, eventType: EVENT_TYPE.customEvent,
}); });
let sql = ''; return rawQuery(
`select ${column} x,
if (filterQuery) { count(*) as y
sql = `
select
event_name x,
${getDateSQL('created_at', unit, timezone)} t,
count(*) y
from website_event from website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}
${filterQuery} ${filterQuery}
group by x, t group by x
order by t order by y desc
`; limit ${limit}
} else { offset ${offset}
sql = ` `,
select params,
event_name x, );
${getDateSQL('created_at', unit, timezone)} t,
count(*) y
from (
select arrayJoin(event_name) as event_name,
created_at
from website_event_stats_hourly website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
) as g
group by x, t
order by t
`;
}
return rawQuery(sql, params);
} }

View file

@ -0,0 +1,92 @@
import clickhouse from '@/lib/clickhouse';
import { EVENT_TYPE } from '@/lib/constants';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import { QueryFilters, WebsiteEventMetric } from '@/lib/types';
export async function getEventStats(
...args: [websiteId: string, filters: QueryFilters]
): Promise<WebsiteEventMetric[]> {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { timezone = 'utc', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = prisma;
const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(websiteId, {
...filters,
eventType: EVENT_TYPE.customEvent,
});
return rawQuery(
`
select
event_name x,
${getDateSQL('website_event.created_at', unit, timezone)} t,
count(*) y
from website_event
${cohortQuery}
${joinSession}
where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
and event_type = {{eventType}}
${filterQuery}
group by 1, 2
order by 2
`,
params,
);
}
async function clickhouseQuery(
websiteId: string,
filters: QueryFilters,
): Promise<{ x: string; t: string; y: number }[]> {
const { timezone = 'UTC', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
...filters,
eventType: EVENT_TYPE.customEvent,
});
let sql = '';
if (filterQuery || cohortQuery) {
sql = `
select
event_name x,
${getDateSQL('created_at', unit, timezone)} t,
count(*) y
from website_event
${cohortQuery}
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
${filterQuery}
group by x, t
order by t
`;
} else {
sql = `
select
event_name x,
${getDateSQL('created_at', unit, timezone)} t,
count(*) y
from (
select arrayJoin(event_name) as event_name,
created_at
from website_event_stats_hourly website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32}
) as g
group by x, t
order by t
`;
}
return rawQuery(sql, params);
}

View file

@ -15,7 +15,7 @@ export function getWebsiteEvents(
async function relationalQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) { async function relationalQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) {
const { pagedRawQuery, parseFilters } = prisma; const { pagedRawQuery, parseFilters } = prisma;
const { search } = pageParams; const { search } = pageParams;
const { filterQuery, params } = await parseFilters(websiteId, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
...filters, ...filters,
}); });
@ -24,7 +24,6 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
return pagedRawQuery( return pagedRawQuery(
` `
with events as (
select select
event_id as "id", event_id as "id",
website_id as "websiteId", website_id as "websiteId",
@ -39,6 +38,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
event_type as "eventType", event_type as "eventType",
event_name as "eventName" event_name as "eventName"
from website_event from website_event
${cohortQuery}
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}} and created_at between {{startDate}} and {{endDate}}
${filterQuery} ${filterQuery}
@ -49,8 +49,6 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
: '' : ''
} }
order by created_at desc order by created_at desc
limit 1000)
select * from events
`, `,
{ ...params, search: `%${search}%` }, { ...params, search: `%${search}%` },
pageParams, pageParams,
@ -59,12 +57,11 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
async function clickhouseQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) { async function clickhouseQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) {
const { pagedQuery, parseFilters } = clickhouse; const { pagedQuery, parseFilters } = clickhouse;
const { params, dateQuery, filterQuery } = await parseFilters(websiteId, filters); const { params, dateQuery, filterQuery, cohortQuery } = await parseFilters(websiteId, filters);
const { search } = pageParams; const { search } = pageParams;
return pagedQuery( return pagedQuery(
` `
with events as (
select select
event_id as id, event_id as id,
website_id as websiteId, website_id as websiteId,
@ -79,6 +76,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
event_type as eventType, event_type as eventType,
event_name as eventName event_name as eventName
from website_event from website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
${dateQuery} ${dateQuery}
${filterQuery} ${filterQuery}
@ -89,8 +87,6 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
: '' : ''
} }
order by created_at desc order by created_at desc
limit 1000)
select * from events
`, `,
{ ...params, search }, { ...params, search },
pageParams, pageParams,

View file

@ -5,6 +5,7 @@ import kafka from '@/lib/kafka';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import { saveEventData } from './saveEventData'; import { saveEventData } from './saveEventData';
import { saveRevenue } from './saveRevenue';
export interface SaveEventArgs { export interface SaveEventArgs {
websiteId: string; websiteId: string;
@ -130,6 +131,20 @@ async function relationalQuery({
eventData, eventData,
createdAt, createdAt,
}); });
const { revenue, currency } = eventData;
if (revenue > 0 && currency) {
await saveRevenue({
websiteId,
sessionId,
eventId: websiteEventId,
eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
currency,
revenue,
createdAt,
});
}
} }
} }

View file

@ -0,0 +1,36 @@
import { uuid } from '@/lib/crypto';
import { PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
export interface SaveRevenueArgs {
websiteId: string;
sessionId: string;
eventId: string;
eventName: string;
currency: string;
revenue: number;
createdAt: Date;
}
export async function saveRevenue(data: SaveRevenueArgs) {
return runQuery({
[PRISMA]: () => relationalQuery(data),
});
}
async function relationalQuery(data: SaveRevenueArgs) {
const { websiteId, sessionId, eventId, eventName, currency, revenue, createdAt } = data;
await prisma.client.revenue.create({
data: {
id: uuid(),
websiteId,
sessionId,
eventId,
eventName,
currency,
revenue,
createdAt,
},
});
}

View file

@ -12,7 +12,7 @@ export async function getChannelMetrics(...args: [websiteId: string, filters?: Q
async function relationalQuery(websiteId: string, filters: QueryFilters) { async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { rawQuery, parseFilters } = prisma; const { rawQuery, parseFilters } = prisma;
const { params, filterQuery, dateQuery } = await parseFilters(websiteId, filters); const { params, filterQuery, cohortQuery, dateQuery } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -21,6 +21,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
url_query as query, url_query as query,
count(distinct session_id) as visitors count(distinct session_id) as visitors
from website_event from website_event
${cohortQuery}
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
${filterQuery} ${filterQuery}
${dateQuery} ${dateQuery}
@ -36,7 +37,7 @@ async function clickhouseQuery(
filters: QueryFilters, filters: QueryFilters,
): Promise<{ x: string; y: number }[]> { ): Promise<{ x: string; y: number }[]> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { params, filterQuery, dateQuery } = await parseFilters(websiteId, filters); const { params, filterQuery, cohortQuery, dateQuery } = await parseFilters(websiteId, filters);
const sql = ` const sql = `
select select
@ -44,6 +45,7 @@ async function clickhouseQuery(
url_query as query, url_query as query,
uniq(session_id) as visitors uniq(session_id) as visitors
from website_event from website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
${filterQuery} ${filterQuery}
${dateQuery} ${dateQuery}

View file

@ -12,7 +12,7 @@ export async function getRealtimeActivity(...args: [websiteId: string, filters:
async function relationalQuery(websiteId: string, filters: QueryFilters) { async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { rawQuery, parseFilters } = prisma; const { rawQuery, parseFilters } = prisma;
const { params, filterQuery, dateQuery } = await parseFilters(websiteId, filters); const { params, filterQuery, cohortQuery, dateQuery } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -27,6 +27,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
website_event.url_path as "urlPath", website_event.url_path as "urlPath",
website_event.referrer_domain as "referrerDomain" website_event.referrer_domain as "referrerDomain"
from website_event from website_event
${cohortQuery}
inner join session inner join session
on session.session_id = website_event.session_id on session.session_id = website_event.session_id
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
@ -41,7 +42,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promise<{ x: number }> { async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promise<{ x: number }> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { params, filterQuery, dateQuery } = await parseFilters(websiteId, filters); const { params, filterQuery, cohortQuery, dateQuery } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -56,6 +57,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promis
url_path as urlPath, url_path as urlPath,
referrer_domain as referrerDomain referrer_domain as referrerDomain
from website_event from website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
${filterQuery} ${filterQuery}
${dateQuery} ${dateQuery}

View file

@ -21,6 +21,12 @@ async function relationalQuery(
const { rawQuery, getSearchSQL } = prisma; const { rawQuery, getSearchSQL } = prisma;
const params = {}; const params = {};
let searchQuery = ''; let searchQuery = '';
let excludeDomain = '';
if (column === 'referrer_domain') {
excludeDomain = `and website_event.referrer_domain != website_event.hostname
and website_event.referrer_domain != ''`;
}
if (search) { if (search) {
if (decodeURIComponent(search).includes(',')) { if (decodeURIComponent(search).includes(',')) {
@ -49,6 +55,7 @@ async function relationalQuery(
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
${searchQuery} ${searchQuery}
${excludeDomain}
group by 1 group by 1
order by 2 desc order by 2 desc
limit 10 limit 10
@ -73,6 +80,11 @@ async function clickhouseQuery(
const { rawQuery, getSearchSQL } = clickhouse; const { rawQuery, getSearchSQL } = clickhouse;
const params = {}; const params = {};
let searchQuery = ''; let searchQuery = '';
let excludeDomain = '';
if (column === 'referrer_domain') {
excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`;
}
if (search) { if (search) {
searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`; searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`;
@ -103,6 +115,7 @@ async function clickhouseQuery(
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
${searchQuery} ${searchQuery}
${excludeDomain}
group by 1 group by 1
order by 2 desc order by 2 desc
limit 10 limit 10

View file

@ -23,7 +23,7 @@ async function relationalQuery(
{ pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }[] { pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }[]
> { > {
const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma; const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma;
const { filterQuery, joinSession, params } = await parseFilters(websiteId, { const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(websiteId, {
...filters, ...filters,
eventType: EVENT_TYPE.pageView, eventType: EVENT_TYPE.pageView,
}); });
@ -44,6 +44,7 @@ async function relationalQuery(
min(website_event.created_at) as "min_time", min(website_event.created_at) as "min_time",
max(website_event.created_at) as "max_time" max(website_event.created_at) as "max_time"
from website_event from website_event
${cohortQuery}
${joinSession} ${joinSession}
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
@ -63,7 +64,7 @@ async function clickhouseQuery(
{ pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }[] { pageviews: number; visitors: number; visits: number; bounces: number; totaltime: number }[]
> { > {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
...filters, ...filters,
eventType: EVENT_TYPE.pageView, eventType: EVENT_TYPE.pageView,
}); });
@ -86,6 +87,7 @@ async function clickhouseQuery(
min(created_at) min_time, min(created_at) min_time,
max(created_at) max_time max(created_at) max_time
from website_event from website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}
@ -107,7 +109,8 @@ async function clickhouseQuery(
sum(views) c, sum(views) c,
min(min_time) min_time, min(min_time) min_time,
max(max_time) max_time max(max_time) max_time
from umami.website_event_stats_hourly "website_event" from website_event_stats_hourly "website_event"
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}

View file

@ -28,13 +28,13 @@ async function relationalQuery(
) { ) {
const column = FILTER_COLUMNS[type] || type; const column = FILTER_COLUMNS[type] || type;
const { rawQuery, parseFilters } = prisma; const { rawQuery, parseFilters } = prisma;
const { filterQuery, joinSession, params } = await parseFilters( const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(
websiteId, websiteId,
{ {
...filters, ...filters,
eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
}, },
{ joinSession: SESSION_COLUMNS.includes(type) || column === 'referrer_domain' }, { joinSession: SESSION_COLUMNS.includes(type) },
); );
let entryExitQuery = ''; let entryExitQuery = '';
@ -66,8 +66,9 @@ async function relationalQuery(
return rawQuery( return rawQuery(
` `
select ${column} x, select ${column} x,
${column === 'referrer_domain' ? 'count(distinct website_event.session_id)' : 'count(*)'} as y count(distinct website_event.session_id) as y
from website_event from website_event
${cohortQuery}
${joinSession} ${joinSession}
${entryExitQuery} ${entryExitQuery}
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
@ -93,7 +94,7 @@ async function clickhouseQuery(
): Promise<{ x: string; y: number }[]> { ): Promise<{ x: string; y: number }[]> {
const column = FILTER_COLUMNS[type] || type; const column = FILTER_COLUMNS[type] || type;
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
...filters, ...filters,
eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
}); });
@ -125,8 +126,9 @@ async function clickhouseQuery(
sql = ` sql = `
select ${column} x, select ${column} x,
${column === 'referrer_domain' ? 'uniq(session_id)' : 'count(*)'} as y uniq(website_event.session_id) as y
from website_event from website_event
${cohortQuery}
${entryExitQuery} ${entryExitQuery}
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}
@ -143,28 +145,29 @@ async function clickhouseQuery(
let columnQuery = `arrayJoin(${column})`; let columnQuery = `arrayJoin(${column})`;
if (column === 'referrer_domain') { if (column === 'referrer_domain') {
excludeDomain = `and t != hostname and t != ''`; excludeDomain = `and t != ''`;
columnQuery = `session_id s, arrayJoin(${column})`;
} }
if (type === 'entry') { if (type === 'entry') {
columnQuery = `visit_id x, argMinMerge(entry_url)`; columnQuery = `argMinMerge(entry_url)`;
} }
if (type === 'exit') { if (type === 'exit') {
columnQuery = `visit_id x, argMaxMerge(exit_url)`; columnQuery = `argMaxMerge(exit_url)`;
} }
if (type === 'entry' || type === 'exit') { if (type === 'entry' || type === 'exit') {
groupByQuery = 'group by x'; groupByQuery = 'group by s';
} }
sql = ` sql = `
select g.t as x, select g.t as x,
${column === 'referrer_domain' ? 'uniq(s)' : 'count(*)'} as y uniq(s) as y
from ( from (
select ${columnQuery} as t select session_id s,
${columnQuery} as t
from website_event_stats_hourly website_event from website_event_stats_hourly website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}

View file

@ -14,7 +14,7 @@ export async function getPageviewStats(...args: [websiteId: string, filters: Que
async function relationalQuery(websiteId: string, filters: QueryFilters) { async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { timezone = 'utc', unit = 'day' } = filters; const { timezone = 'utc', unit = 'day' } = filters;
const { getDateSQL, parseFilters, rawQuery } = prisma; const { getDateSQL, parseFilters, rawQuery } = prisma;
const { filterQuery, joinSession, params } = await parseFilters(websiteId, { const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(websiteId, {
...filters, ...filters,
eventType: EVENT_TYPE.pageView, eventType: EVENT_TYPE.pageView,
}); });
@ -25,6 +25,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
${getDateSQL('website_event.created_at', unit, timezone)} x, ${getDateSQL('website_event.created_at', unit, timezone)} x,
count(*) y count(*) y
from website_event from website_event
${cohortQuery}
${joinSession} ${joinSession}
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
@ -43,7 +44,7 @@ async function clickhouseQuery(
): Promise<{ x: string; y: number }[]> { ): Promise<{ x: string; y: number }[]> {
const { timezone = 'utc', unit = 'day' } = filters; const { timezone = 'utc', unit = 'day' } = filters;
const { parseFilters, rawQuery, getDateSQL } = clickhouse; const { parseFilters, rawQuery, getDateSQL } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
...filters, ...filters,
eventType: EVENT_TYPE.pageView, eventType: EVENT_TYPE.pageView,
}); });
@ -60,6 +61,7 @@ async function clickhouseQuery(
${getDateSQL('website_event.created_at', unit, timezone)} as t, ${getDateSQL('website_event.created_at', unit, timezone)} as t,
count(*) as y count(*) as y
from website_event from website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}
@ -78,6 +80,7 @@ async function clickhouseQuery(
${getDateSQL('website_event.created_at', unit, timezone)} as t, ${getDateSQL('website_event.created_at', unit, timezone)} as t,
sum(views)as y sum(views)as y
from website_event_stats_hourly website_event from website_event_stats_hourly website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}

View file

@ -79,24 +79,14 @@ async function relationalQuery(
const revenueEventQuery = `WITH events AS ( const revenueEventQuery = `WITH events AS (
select select
we.session_id, session_id,
max(ed.created_at) max_dt, max(created_at) max_dt,
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) value sum(revenue) value
from event_data ed from revenue
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}} where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}} 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 ${column} = {{conversionStep}}
and ed.data_key ${like} '%revenue%' and currency ${like} {{currency}}
group by 1),`; group by 1),`;
function getModelQuery(model: string) { function getModelQuery(model: string) {
@ -311,21 +301,14 @@ async function clickhouseQuery(
const revenueEventQuery = `WITH events AS ( const revenueEventQuery = `WITH events AS (
select select
ed.session_id, session_id,
max(ed.created_at) max_dt, max(created_at) max_dt,
sum(coalesce(toDecimal64(number_value, 2), toDecimal64(string_value, 2))) as value sum(revenue) as value
from event_data ed from website_revenue
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} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and ${column} = {conversionStep:String} and ${column} = {conversionStep:String}
and positionCaseInsensitive(ed.data_key, 'revenue') > 0 and currency = {currency:String}
group by 1),`; group by 1),`;
function getModelQuery(model: string) { function getModelQuery(model: string) {

View file

@ -228,7 +228,7 @@ async function clickhouseQuery(
` `
WITH level0 AS ( WITH level0 AS (
select distinct session_id, url_path, referrer_path, event_name, created_at select distinct session_id, url_path, referrer_path, event_name, created_at
from umami.website_event from website_event
where (${stepFilterQuery}) where (${stepFilterQuery})
and website_id = {websiteId:UUID} and website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}

View file

@ -24,7 +24,7 @@ async function relationalQuery(
}[] }[]
> { > {
const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma; const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma;
const { filterQuery, joinSession, params } = await parseFilters( const { filterQuery, cohortQuery, joinSession, params } = await parseFilters(
websiteId, websiteId,
{ {
...filters, ...filters,
@ -53,6 +53,7 @@ async function relationalQuery(
min(website_event.created_at) as "min_time", min(website_event.created_at) as "min_time",
max(website_event.created_at) as "max_time" max(website_event.created_at) as "max_time"
from website_event from website_event
${cohortQuery}
${joinSession} ${joinSession}
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
@ -80,7 +81,7 @@ async function clickhouseQuery(
}[] }[]
> { > {
const { parseFilters, rawQuery } = clickhouse; const { parseFilters, rawQuery } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, {
...filters, ...filters,
eventType: EVENT_TYPE.pageView, eventType: EVENT_TYPE.pageView,
}); });
@ -103,6 +104,7 @@ async function clickhouseQuery(
min(created_at) min_time, min(created_at) min_time,
max(created_at) max_time max(created_at) max_time
from website_event from website_event
${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}

View file

@ -229,7 +229,7 @@ async function clickhouseQuery(
visit_id, visit_id,
coalesce(nullIf(event_name, ''), url_path) event, coalesce(nullIf(event_name, ''), url_path) event,
row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number
from umami.website_event from website_event
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}),
${sequenceQuery} ${sequenceQuery}

View file

@ -48,22 +48,13 @@ async function relationalQuery(
const chartRes = await rawQuery( const chartRes = await rawQuery(
` `
select select
we.event_name x, event_name x,
${getDateSQL('ed.created_at', unit, timezone)} t, ${getDateSQL('created_at', unit, timezone)} t,
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) y sum(revenue) y
from event_data ed from revenue
join website_event we
on we.event_id = ed.website_event_id
join (select website_event_id
from event_data
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}} and created_at between {{startDate}} and {{endDate}}
and data_key ${like} '%currency%' and currency ${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 ed.data_key ${like} '%revenue%'
group by x, t group by x, t
order by t order by t
`, `,
@ -74,22 +65,13 @@ async function relationalQuery(
` `
select select
s.country as name, s.country as name,
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) value sum(r.revenue) value
from event_data ed from revenue r
join website_event we
on we.event_id = ed.website_event_id
join session s join session s
on s.session_id = we.session_id on s.session_id = r.session_id
join (select website_event_id where r.website_id = {{websiteId::uuid}}
from event_data and r.created_at between {{startDate}} and {{endDate}}
where website_id = {{websiteId::uuid}} and r.currency ${like} {{currency}}
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 ed.data_key ${like} '%revenue%'
group by s.country group by s.country
`, `,
{ websiteId, startDate, endDate, currency }, { websiteId, startDate, endDate, currency },
@ -98,22 +80,13 @@ async function relationalQuery(
const totalRes = await rawQuery( const totalRes = await rawQuery(
` `
select select
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) as sum, sum(revenue) as sum,
count(distinct event_id) as count, count(distinct event_id) as count,
count(distinct session_id) as unique_count count(distinct session_id) as unique_count
from event_data ed from revenue r
join website_event we
on we.event_id = ed.website_event_id
join (select website_event_id
from event_data
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}} and created_at between {{startDate}} and {{endDate}}
and data_key ${like} '%currency%' and currency ${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 ed.data_key ${like} '%revenue%'
`, `,
{ websiteId, startDate, endDate, currency }, { websiteId, startDate, endDate, currency },
).then(result => result?.[0]); ).then(result => result?.[0]);
@ -121,24 +94,15 @@ async function relationalQuery(
const tableRes = await rawQuery( const tableRes = await rawQuery(
` `
select select
c.currency, currency,
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) as sum, sum(revenue) as sum,
count(distinct ed.website_event_id) as count, count(distinct event_id) as count,
count(distinct we.session_id) as unique_count count(distinct session_id) as unique_count
from event_data ed from revenue r
join website_event we
on we.event_id = ed.website_event_id
join (select website_event_id, string_value as currency
from event_data
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}} and created_at between {{startDate}} and {{endDate}}
and data_key ${like} '%currency%') c group by currency
on c.website_event_id = ed.website_event_id order by sum desc
where ed.website_id = {{websiteId::uuid}}
and ed.created_at between {{startDate}} and {{endDate}}
and ed.data_key ${like} '%revenue%'
group by c.currency
order by sum desc;
`, `,
{ websiteId, startDate, endDate, unit, timezone, currency }, { websiteId, startDate, endDate, unit, timezone, currency },
); );
@ -180,18 +144,11 @@ async function clickhouseQuery(
select select
event_name x, event_name x,
${getDateSQL('created_at', unit, timezone)} t, ${getDateSQL('created_at', unit, timezone)} t,
sum(coalesce(toDecimal64(number_value, 2), toDecimal64(string_value, 2))) y sum(revenue) y
from event_data from website_revenue
join (select event_id
from event_data
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'currency') > 0 and currency = {currency:String}
and string_value = {currency:String}) currency
on currency.event_id = event_data.event_id
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'revenue') > 0
group by x, t group by x, t
order by t order by t
`, `,
@ -207,24 +164,18 @@ async function clickhouseQuery(
` `
select select
s.country as name, s.country as name,
sum(coalesce(toDecimal64(number_value, 2), toDecimal64(string_value, 2))) as value sum(w.revenue) as value
from event_data ed from website_revenue w
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
join (select distinct website_id, session_id, country join (select distinct website_id, session_id, country
from website_event_stats_hourly from website_event_stats_hourly
where website_id = {websiteId:UUID}) s where website_id = {websiteId:UUID}) s
on ed.website_id = s.website_id on w.website_id = s.website_id
and ed.session_id = s.session_id and w.session_id = s.session_id
where ed.website_id = {websiteId:UUID} where w.website_id = {websiteId:UUID}
and ed.created_at between {startDate:DateTime64} and {endDate:DateTime64} and w.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(ed.data_key, 'revenue') > 0 and w.currency = {currency:String}
group by s.country group by s.country
order by value desc
`, `,
{ websiteId, startDate, endDate, currency }, { websiteId, startDate, endDate, currency },
); );
@ -237,20 +188,13 @@ async function clickhouseQuery(
}>( }>(
` `
select select
sum(coalesce(toDecimal64(number_value, 2), toDecimal64(string_value, 2))) as sum, sum(revenue) as sum,
uniqExact(event_id) as count, uniqExact(event_id) as count,
uniqExact(session_id) as unique_count uniqExact(session_id) as unique_count
from event_data from website_revenue
join (select event_id
from event_data
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'currency') > 0 and currency = {currency:String}
and string_value = {currency:String}) currency
on currency.event_id = event_data.event_id
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'revenue') > 0
`, `,
{ websiteId, startDate, endDate, currency }, { websiteId, startDate, endDate, currency },
).then(result => result?.[0]); ).then(result => result?.[0]);
@ -266,22 +210,15 @@ async function clickhouseQuery(
>( >(
` `
select select
c.currency, currency,
sum(coalesce(toDecimal64(ed.number_value, 2), toDecimal64(ed.string_value, 2))) as sum, sum(revenue) as sum,
uniqExact(ed.event_id) as count, uniqExact(event_id) as count,
uniqExact(ed.session_id) as unique_count uniqExact(session_id) as unique_count
from event_data ed from website_revenue
join (select event_id, string_value as currency
from event_data
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'currency') > 0) c group by currency
on c.event_id = ed.event_id order by sum desc
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and positionCaseInsensitive(data_key, 'revenue') > 0
group by c.currency
order by sum desc;
`, `,
{ websiteId, startDate, endDate, unit, timezone, currency }, { websiteId, startDate, endDate, unit, timezone, currency },
); );

View file

@ -44,7 +44,7 @@ async function relationalQuery(
startDate, startDate,
endDate, endDate,
}, },
).then(result => parseParameters(result as any[])); );
} }
async function clickhouseQuery( async function clickhouseQuery(
@ -73,30 +73,5 @@ async function clickhouseQuery(
startDate, startDate,
endDate, endDate,
}, },
).then(result => parseParameters(result as any[])); );
}
function parseParameters(data: any[]) {
return data.reduce((obj, { url_query, num }) => {
try {
const searchParams = new URLSearchParams(url_query);
for (const [key, value] of searchParams) {
if (key.match(/^utm_(\w+)$/)) {
const name = value;
if (!obj[key]) {
obj[key] = { [name]: Number(num) };
} else if (!obj[key][name]) {
obj[key][name] = Number(num);
} else {
obj[key][name] += Number(num);
}
}
}
} catch {
// Ignore
}
return obj;
}, {});
} }

View file

@ -1,7 +1,10 @@
import { Prisma } from '@prisma/client'; 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,
options = { skipDuplicates: false },
) {
const { const {
id, id,
websiteId, websiteId,
@ -16,7 +19,8 @@ export async function createSession(data: Prisma.SessionCreateInput) {
distinctId, distinctId,
} = data; } = data;
return prisma.client.session.create({ try {
return await prisma.client.session.create({
data: { data: {
id, id,
websiteId, websiteId,
@ -31,4 +35,15 @@ export async function createSession(data: Prisma.SessionCreateInput) {
distinctId, distinctId,
}, },
}); });
} catch (e: any) {
// With skipDuplicates flag: ignore unique constraint error and return null
if (
options.skipDuplicates &&
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
return null;
}
throw e;
}
} }

View file

@ -17,7 +17,7 @@ async function relationalQuery(
filters: QueryFilters & { propertyName?: string }, filters: QueryFilters & { propertyName?: string },
) { ) {
const { rawQuery, parseFilters } = prisma; const { rawQuery, parseFilters } = prisma;
const { filterQuery, params } = await parseFilters(websiteId, filters, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters, {
columns: { propertyName: 'data_key' }, columns: { propertyName: 'data_key' },
}); });
@ -25,12 +25,13 @@ async function relationalQuery(
` `
select select
data_key as "propertyName", data_key as "propertyName",
count(distinct d.session_id) as "total" count(distinct session_data.session_id) as "total"
from website_event e from website_event
join session_data d ${cohortQuery}
on d.session_id = e.session_id join session_data
where e.website_id = {{websiteId::uuid}} on session_data.session_id = website_event.session_id
and e.created_at between {{startDate}} and {{endDate}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${filterQuery} ${filterQuery}
group by 1 group by 1
order by 2 desc order by 2 desc
@ -45,7 +46,7 @@ async function clickhouseQuery(
filters: QueryFilters & { propertyName?: string }, filters: QueryFilters & { propertyName?: string },
): Promise<{ propertyName: string; total: number }[]> { ): Promise<{ propertyName: string; total: number }[]> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, filters, { const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters, {
columns: { propertyName: 'data_key' }, columns: { propertyName: 'data_key' },
}); });
@ -53,13 +54,14 @@ async function clickhouseQuery(
` `
select select
data_key as propertyName, data_key as propertyName,
count(distinct d.session_id) as total count(distinct session_data.session_id) as total
from website_event e from website_event
join session_data d final ${cohortQuery}
on d.session_id = e.session_id join session_data final
where e.website_id = {websiteId:UUID} on session_data.session_id = website_event.session_id
and e.created_at between {startDate:DateTime64} and {endDate:DateTime64} where website_event.website_id = {websiteId:UUID}
and d.data_key != '' and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and session_data.data_key != ''
${filterQuery} ${filterQuery}
group by 1 group by 1
order by 2 desc order by 2 desc

View file

@ -17,7 +17,7 @@ async function relationalQuery(
filters: QueryFilters & { propertyName?: string }, filters: QueryFilters & { propertyName?: string },
) { ) {
const { rawQuery, parseFilters, getDateSQL } = prisma; const { rawQuery, parseFilters, getDateSQL } = prisma;
const { filterQuery, params } = await parseFilters(websiteId, filters); const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -27,13 +27,14 @@ async function relationalQuery(
when data_type = 4 then ${getDateSQL('date_value', 'hour')} when data_type = 4 then ${getDateSQL('date_value', 'hour')}
else string_value else string_value
end as "value", end as "value",
count(distinct d.session_id) as "total" count(distinct session_data.session_id) as "total"
from website_event e from website_event e
${cohortQuery}
join session_data d join session_data d
on d.session_id = e.session_id on session_data.session_id = website_event.session_id
where e.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and e.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
and d.data_key = {{propertyName}} and session_data.data_key = {{propertyName}}
${filterQuery} ${filterQuery}
group by value group by value
order by 2 desc order by 2 desc
@ -48,7 +49,7 @@ async function clickhouseQuery(
filters: QueryFilters & { propertyName?: string }, filters: QueryFilters & { propertyName?: string },
): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { ): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, filters); const { filterQuery, cohortQuery, params } = await parseFilters(websiteId, filters);
return rawQuery( return rawQuery(
` `
@ -56,13 +57,14 @@ async function clickhouseQuery(
multiIf(data_type = 2, replaceAll(string_value, '.0000', ''), multiIf(data_type = 2, replaceAll(string_value, '.0000', ''),
data_type = 4, toString(date_trunc('hour', date_value)), data_type = 4, toString(date_trunc('hour', date_value)),
string_value) as "value", string_value) as "value",
uniq(d.session_id) as "total" uniq(session_data.session_id) as "total"
from website_event e from website_event e
${cohortQuery}
join session_data d final join session_data d final
on d.session_id = e.session_id on session_data.session_id = website_event.session_id
where e.website_id = {websiteId:UUID} where website_event.website_id = {websiteId:UUID}
and e.created_at between {startDate:DateTime64} and {endDate:DateTime64} and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
and d.data_key = {propertyName:String} and session_data.data_key = {propertyName:String}
${filterQuery} ${filterQuery}
group by value group by value
order by 2 desc order by 2 desc

Some files were not shown because too many files have changed in this diff Show more