mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
Merge branch 'dev' into jajaja
# Conflicts: # db/mysql/schema.prisma # package.json # pnpm-lock.yaml # src/app/(main)/reports/[reportId]/ReportBody.tsx # src/app/(main)/reports/[reportId]/ReportPage.tsx # src/app/(main)/reports/utm/UTMView.tsx # src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx # src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx # src/app/(main)/websites/[websiteId]/events/EventsPage.tsx # src/app/api/reports/[reportId]/route.ts # src/app/api/websites/[websiteId]/metrics/route.ts # src/components/hooks/queries/useReport.ts # src/components/icons.ts # src/components/messages.ts # src/components/metrics/MetricsTable.module.css # src/components/metrics/MetricsTable.tsx # src/queries/sql/events/getEventMetrics.ts # src/queries/sql/reports/getUTM.ts
This commit is contained in:
commit
45c9ea9c22
28 changed files with 571 additions and 139 deletions
14
db/mysql/migrations/11_add_segment/migration.sql
Normal file
14
db/mysql/migrations/11_add_segment/migration.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `segment` (
|
||||
`segment_id` VARCHAR(36) NOT NULL,
|
||||
`website_id` VARCHAR(36) NOT NULL,
|
||||
`type` VARCHAR(200) NOT NULL,
|
||||
`name` VARCHAR(200) NOT NULL,
|
||||
`parameters` JSON NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` TIMESTAMP(0) NULL,
|
||||
|
||||
UNIQUE INDEX `segment_segment_id_key`(`segment_id`),
|
||||
INDEX `segment_website_id_idx`(`website_id`),
|
||||
PRIMARY KEY (`segment_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE `report` MODIFY `parameters` JSON NOT NULL;
|
||||
18
db/mysql/migrations/13_add_revenue/migration.sql
Normal file
18
db/mysql/migrations/13_add_revenue/migration.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `revenue` (
|
||||
`revenue_id` VARCHAR(36) NOT NULL,
|
||||
`website_id` VARCHAR(36) NOT NULL,
|
||||
`session_id` VARCHAR(36) NOT NULL,
|
||||
`event_id` VARCHAR(36) NOT NULL,
|
||||
`event_name` VARCHAR(50) NOT NULL,
|
||||
`currency` VARCHAR(100) NOT NULL,
|
||||
`revenue` DECIMAL(19, 4) NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
|
||||
UNIQUE INDEX `revenue_revenue_id_key`(`revenue_id`),
|
||||
INDEX `revenue_website_id_idx`(`website_id`),
|
||||
INDEX `revenue_session_id_idx`(`session_id`),
|
||||
INDEX `revenue_website_id_created_at_idx`(`website_id`, `created_at`),
|
||||
INDEX `revenue_website_id_session_id_created_at_idx`(`website_id`, `session_id`, `created_at`),
|
||||
PRIMARY KEY (`revenue_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
|
@ -107,6 +107,7 @@
|
|||
"is-localhost-ip": "^1.4.0",
|
||||
"isbot": "^5.1.16",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"kafkajs": "^2.1.0",
|
||||
"lucide-react": "^0.517.0",
|
||||
"maxmind": "^4.3.27",
|
||||
|
|
@ -114,6 +115,8 @@
|
|||
"next": "15.4.3",
|
||||
"node-fetch": "^3.2.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"papaparse": "^5.5.3",
|
||||
"prisma": "6.7.0",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "6.12.0",
|
||||
"pure-rand": "^6.1.0",
|
||||
|
|
|
|||
78
pnpm-lock.yaml
generated
78
pnpm-lock.yaml
generated
|
|
@ -119,6 +119,9 @@ importers:
|
|||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
kafkajs:
|
||||
specifier: ^2.1.0
|
||||
version: 2.2.4
|
||||
|
|
@ -140,6 +143,9 @@ importers:
|
|||
npm-run-all:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
papaparse:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
pg:
|
||||
specifier: ^8.16.3
|
||||
version: 8.16.3
|
||||
|
|
@ -4219,6 +4225,9 @@ packages:
|
|||
resolution: {integrity: sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@9.0.21:
|
||||
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
|
||||
|
||||
|
|
@ -4476,6 +4485,9 @@ packages:
|
|||
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
|
|
@ -4746,6 +4758,9 @@ packages:
|
|||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
jwa@1.4.2:
|
||||
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||
|
||||
|
|
@ -4792,6 +4807,9 @@ packages:
|
|||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
lilconfig@2.1.0:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -5270,6 +5288,12 @@ packages:
|
|||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
papaparse@5.5.3:
|
||||
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -5867,6 +5891,9 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
|
@ -6048,6 +6075,9 @@ packages:
|
|||
resolution: {integrity: sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -6186,6 +6216,9 @@ packages:
|
|||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
|
|
@ -6242,6 +6275,9 @@ packages:
|
|||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
sharp@0.34.3:
|
||||
resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
|
@ -6416,6 +6452,9 @@ packages:
|
|||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -11714,6 +11753,8 @@ snapshots:
|
|||
|
||||
ignore@7.0.4: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@9.0.21: {}
|
||||
|
||||
import-cwd@3.0.0:
|
||||
|
|
@ -11947,6 +11988,8 @@ snapshots:
|
|||
call-bound: 1.0.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isbot@5.1.28: {}
|
||||
|
|
@ -12423,6 +12466,13 @@ snapshots:
|
|||
object.assign: 4.1.7
|
||||
object.values: 1.2.1
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
jwa@1.4.2:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
|
|
@ -12464,6 +12514,10 @@ snapshots:
|
|||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
lilconfig@2.1.0: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
|
@ -12963,6 +13017,10 @@ snapshots:
|
|||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
papaparse@5.5.3: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
|
@ -13521,6 +13579,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
promise.series@0.2.0: {}
|
||||
|
|
@ -13800,6 +13860,16 @@ snapshots:
|
|||
parse-json: 5.2.0
|
||||
type-fest: 1.4.0
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.2
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
redent@3.0.0:
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
|
|
@ -13973,6 +14043,8 @@ snapshots:
|
|||
has-symbols: 1.1.0
|
||||
isarray: 2.0.5
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-identifier@0.4.2: {}
|
||||
|
|
@ -14034,6 +14106,8 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
sharp@0.34.3:
|
||||
dependencies:
|
||||
color: 4.2.3
|
||||
|
|
@ -14277,6 +14351,10 @@ snapshots:
|
|||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
|
|
|||
41
scripts/data-migrations/populate-revenue-table.sql
Normal file
41
scripts/data-migrations/populate-revenue-table.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-----------------------------------------------------
|
||||
-- PostgreSQL
|
||||
-----------------------------------------------------
|
||||
INSERT INTO "revenue"
|
||||
SELECT gen_random_uuid() revenue_id,
|
||||
ed.website_id,
|
||||
we.session_id,
|
||||
we.event_id,
|
||||
we.event_name,
|
||||
currency.string_value currency,
|
||||
coalesce(ed.number_value, cast(ed.string_value as numeric(19,4))) revenue,
|
||||
ed.created_at
|
||||
FROM event_data ed
|
||||
JOIN website_event we
|
||||
ON we.event_id = ed.website_event_id
|
||||
JOIN (SELECT website_event_id, string_value
|
||||
FROM event_data
|
||||
WHERE data_key ilike '%currency%') currency
|
||||
ON currency.website_event_id = ed.website_event_id
|
||||
WHERE ed.data_key ilike '%revenue%';
|
||||
|
||||
-----------------------------------------------------
|
||||
-- MySQL
|
||||
-----------------------------------------------------
|
||||
INSERT INTO `revenue`
|
||||
SELECT UUID() revenue_id,
|
||||
ed.website_id,
|
||||
we.session_id,
|
||||
we.event_id,
|
||||
we.event_name,
|
||||
currency.string_value currency,
|
||||
coalesce(ed.number_value, cast(ed.string_value as decimal(19,4))) revenue,
|
||||
ed.created_at
|
||||
FROM event_data ed
|
||||
JOIN website_event we
|
||||
ON we.event_id = ed.website_event_id
|
||||
JOIN (SELECT website_event_id, string_value
|
||||
FROM event_data
|
||||
WHERE data_key like '%currency%') currency
|
||||
ON currency.website_event_id = ed.website_event_id
|
||||
WHERE ed.data_key like '%revenue%';
|
||||
|
|
@ -1,28 +1,36 @@
|
|||
'use client';
|
||||
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
|
||||
import { EventsTable } from '@/components/metrics/EventsTable';
|
||||
import { useState } from 'react';
|
||||
import { useState, Key } from 'react';
|
||||
import { EventsDataTable } from './EventsDataTable';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { EventsChart } from '@/components/metrics/EventsChart';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { EventProperties } from './EventProperties';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
|
||||
const KEY_NAME = 'umami.events.tab';
|
||||
|
||||
export function EventsPage({ websiteId }) {
|
||||
const [label, setLabel] = useState(null);
|
||||
const [tab, setTab] = useState('activity');
|
||||
const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleLabelClick = (value: string) => {
|
||||
setLabel(value !== label ? value : '');
|
||||
};
|
||||
|
||||
const handleSelect = (value: Key) => {
|
||||
setItem(KEY_NAME, value);
|
||||
setTab(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Column gap="3">
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<Panel>
|
||||
<Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}>
|
||||
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
|
||||
<TabList>
|
||||
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
||||
<Tab id="chart">{formatMessage(labels.chart)}</Tab>
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ export async function POST(
|
|||
type,
|
||||
name,
|
||||
description,
|
||||
parameters,
|
||||
});
|
||||
parameters: parameters,
|
||||
} as any);
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { parseRequest, getQueryFilters } from '@/lib/request';
|
|||
import { unauthorized, json } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
|
||||
import { getEventMetrics } from '@/queries';
|
||||
import { getEventStats } from '@/queries';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
|
|
@ -31,7 +31,7 @@ export async function GET(
|
|||
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getEventMetrics(websiteId, filters);
|
||||
const data = await getEventStats(websiteId, filters);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
|
|
|||
64
src/app/api/websites/[websiteId]/export/route.ts
Normal file
64
src/app/api/websites/[websiteId]/export/route.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { z } from 'zod';
|
||||
import JSZip from 'jszip';
|
||||
import Papa from 'papaparse';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { unauthorized, json } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { pagingParams, dateRangeParams } from '@/lib/schema';
|
||||
import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
...dateRangeParams,
|
||||
...pagingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
|
||||
getEventMetrics(websiteId, { type: 'event' }, filters),
|
||||
getPageviewMetrics(websiteId, { type: 'path' }, filters),
|
||||
getPageviewMetrics(websiteId, { type: 'referrer' }, filters),
|
||||
getSessionMetrics(websiteId, { type: 'browser' }, filters),
|
||||
getSessionMetrics(websiteId, { type: 'os' }, filters),
|
||||
getSessionMetrics(websiteId, { type: 'device' }, filters),
|
||||
getSessionMetrics(websiteId, { type: '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 });
|
||||
}
|
||||
|
|
@ -13,7 +13,12 @@ import {
|
|||
} from '@/lib/constants';
|
||||
import { parseRequest, getQueryFilters } from '@/lib/request';
|
||||
import { json, unauthorized, badRequest } from '@/lib/response';
|
||||
import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries';
|
||||
import {
|
||||
getEventMetrics,
|
||||
getPageviewMetrics,
|
||||
getSessionMetrics,
|
||||
getChannelMetrics,
|
||||
} from '@/queries';
|
||||
import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
|
||||
|
||||
export async function GET(
|
||||
|
|
@ -71,7 +76,13 @@ export async function GET(
|
|||
}
|
||||
|
||||
if (EVENT_COLUMNS.includes(type)) {
|
||||
const data = await getPageviewMetrics(websiteId, { type, limit, offset }, filters);
|
||||
let data;
|
||||
|
||||
if (type === 'event') {
|
||||
data = await getEventMetrics(websiteId, { type, limit, offset }, filters);
|
||||
} else {
|
||||
data = await getPageviewMetrics(websiteId, { type, limit, offset }, filters);
|
||||
}
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
|
|
|||
1
src/assets/download.svg
Normal file
1
src/assets/download.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m97.4999924 82.6562576.0000076-11.298912c0-1.957756-1.5870743-3.544838-3.544838-3.544838h-4.785324c-1.9577637 0-3.544838 1.5870743-3.544838 3.5448303l-.0000076 11.2989121c0 1.639595-1.329155 2.96875-2.96875 2.96875l-65.3124924-.0000229c-1.639596 0-2.96875-1.329155-2.968749-2.96875l.0000038-11.298912c0-1.957756-1.5870762-3.544838-3.544836-3.544838h-4.7853256c-1.9577594 0-3.5448372 1.5870743-3.544838 3.544838l-.0000036 11.298912c-.0000026 8.1979752 6.6457672 14.84375 14.8437443 14.84375l65.3124965.0000229c8.1979751 0 14.84375-6.6457672 14.84375-14.8437424z"/><path d="m29.6809349 44.1050034-3.3884087 3.3884048c-1.3843441 1.384346-1.384346 3.6288109-.0000019 5.0131569l19.5066929 19.5067101c2.3174515 2.3200302 6.0768623 2.3221207 8.3968925.0046768.0015564-.0015564.0031128-.0031204.0046692-.0046768l19.5067177-19.5066948c1.384346-1.3843422 1.384346-3.6288109 0-5.0131569l-3.3884125-3.3884048c-1.3843384-1.384346-3.6288071-1.384346-5.0131531-.0000038l-9.3684235 9.3684196.0000153-47.4285965c0-1.9577589-1.5870781-3.544837-3.5448341-3.5448377l-4.7853279-.0000014c-1.9577599-.0000007-3.544838 1.5870759-3.544838 3.5448353l-.0000153 47.4285965-9.3684158-9.3684235c-1.3843459-1.384346-3.6288127-1.384346-5.0131568-.0000038z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/assets/export.svg
Normal file
1
src/assets/export.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><switch><g><path d="m8.7 7.7 2.3-2.3v9.6c0 .6.4 1 1 1s1-.4 1-1v-9.6l2.3 2.3c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0zm12.3 6.3c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1h-14c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1z"/></g></switch></svg>
|
||||
|
After Width: | Height: | Size: 493 B |
5
src/components/declarations.d.ts
vendored
5
src/components/declarations.d.ts
vendored
|
|
@ -1,5 +0,0 @@
|
|||
declare module '*.css';
|
||||
declare module '*.svg';
|
||||
declare module '*.json';
|
||||
declare module 'react-simple-maps';
|
||||
declare module 'uuid';
|
||||
42
src/components/input/DownloadButton.tsx
Normal file
42
src/components/input/DownloadButton.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import Papa from 'papaparse';
|
||||
import { Button, Icon, TooltipTrigger, Tooltip } from '@umami/react-zen';
|
||||
import { Download } 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 (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="quiet" onClick={handleClick} isDisabled={!data}>
|
||||
<Icon>
|
||||
<Download />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip>{formatMessage(labels.download)}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
64
src/components/input/ExportButton.tsx
Normal file
64
src/components/input/ExportButton.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useState } from 'react';
|
||||
import { Icon, Tooltip, TooltipTrigger, LoadingButton } from '@umami/react-zen';
|
||||
import { Download } from '@/components/icons';
|
||||
import { useMessages, useApi } from '@/components/hooks';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useDateParameters } from '@/components/hooks/useDateParameters';
|
||||
import { useFilterParameters } from '@/components/hooks/useFilterParameters';
|
||||
|
||||
export function ExportButton({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const date = useDateParameters(websiteId);
|
||||
const filters = useFilterParameters();
|
||||
const searchParams = useSearchParams();
|
||||
const { get } = useApi();
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { zip } = await get(`/websites/${websiteId}/export`, {
|
||||
...date,
|
||||
...filters,
|
||||
...searchParams,
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
await loadZip(zip);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipTrigger delay={0}>
|
||||
<LoadingButton
|
||||
variant="quiet"
|
||||
showText={!isLoading}
|
||||
isLoading={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon>
|
||||
<Download />
|
||||
</Icon>
|
||||
</LoadingButton>
|
||||
<Tooltip>{formatMessage(labels.download)}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadZip(zip: string) {
|
||||
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);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { isAfter } from 'date-fns';
|
|||
import { Chevron, Close, Compare } from '@/components/icons';
|
||||
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { DateFilter } from './DateFilter';
|
||||
import { ExportButton } from '@/components/input/ExportButton';
|
||||
|
||||
export function WebsiteDateFilter({
|
||||
websiteId,
|
||||
|
|
@ -99,6 +100,7 @@ export function WebsiteDateFilter({
|
|||
<Icon fillColor>{compare ? <Close /> : <Compare />}</Icon>
|
||||
</Button>
|
||||
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
|
||||
<ExportButton websiteId={websiteId} />
|
||||
</TooltipTrigger>
|
||||
)}
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { Select, SelectProps, ListItem } from '@umami/react-zen';
|
||||
import { Select, SelectProps, ListItem, Text } from '@umami/react-zen';
|
||||
import { useUserWebsitesQuery, useWebsiteQuery, useNavigation } from '@/components/hooks';
|
||||
import { ButtonProps } from 'react-basics';
|
||||
|
||||
|
|
@ -38,7 +38,11 @@ export function WebsiteSelect({
|
|||
searchValue={search}
|
||||
onSearch={handleSearch}
|
||||
onChange={handleSelect}
|
||||
renderValue={() => website?.name}
|
||||
renderValue={() => (
|
||||
<Text truncate style={{ maxWidth: 160 }}>
|
||||
{website?.name}
|
||||
</Text>
|
||||
)}
|
||||
>
|
||||
{({ id, name }: any) => <ListItem key={id}>{name}</ListItem>}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -338,6 +338,7 @@ export const labels = defineMessages({
|
|||
location: { id: 'label.location', defaultMessage: 'Location' },
|
||||
chart: { id: 'label.chart', defaultMessage: 'Chart' },
|
||||
table: { id: 'label.table', defaultMessage: 'Table' },
|
||||
download: { id: 'label.download', defaultMessage: 'Download' },
|
||||
traffic: { id: 'label.traffic', defaultMessage: 'Traffic' },
|
||||
behavior: { id: 'label.behavior', defaultMessage: 'Behavior' },
|
||||
growth: { id: 'label.growth', defaultMessage: 'Growth' },
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
|
|||
metric={formatMessage(labels.actions)}
|
||||
onDataLoad={handleDataLoad}
|
||||
renderLabel={renderLabel}
|
||||
allowDownload={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { Icon, Text, SearchField, Row, Column } from '@umami/react-zen';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
|
||||
|
|
@ -7,6 +7,7 @@ import { useNavigation, useWebsiteMetricsQuery, useMessages, useFormat } from '@
|
|||
import { Arrow } from '@/components/icons';
|
||||
import { ListTable, ListTableProps } from './ListTable';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||
|
||||
export interface MetricsTableProps extends ListTableProps {
|
||||
websiteId: string;
|
||||
|
|
@ -18,9 +19,8 @@ export interface MetricsTableProps extends ListTableProps {
|
|||
allowSearch?: boolean;
|
||||
searchFormattedValues?: boolean;
|
||||
showMore?: boolean;
|
||||
params?: Record<string, any>;
|
||||
onDataLoad?: (data: any) => any;
|
||||
className?: string;
|
||||
params?: { [key: string]: any };
|
||||
allowDownload?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -34,8 +34,7 @@ export function MetricsTable({
|
|||
searchFormattedValues = false,
|
||||
showMore = true,
|
||||
params,
|
||||
onDataLoad,
|
||||
className,
|
||||
allowDownload = true,
|
||||
children,
|
||||
...props
|
||||
}: MetricsTableProps) {
|
||||
|
|
@ -86,22 +85,17 @@ export function MetricsTable({
|
|||
return [];
|
||||
}, [data, dataFilter, search, limit, formatValue, type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
onDataLoad?.(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Column gap="3" justifyContent="space-between">
|
||||
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error} gap>
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
{allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
|
||||
{children}
|
||||
<Row>
|
||||
{children}
|
||||
{allowDownload && <DownloadButton filename={type} data={filteredData} />}
|
||||
</Row>
|
||||
</Row>
|
||||
{data && (
|
||||
<ListTable {...(props as ListTableProps)} data={filteredData} className={className} />
|
||||
)}
|
||||
{data && <ListTable {...(props as ListTableProps)} data={filteredData} />}
|
||||
<Row justifyContent="center">
|
||||
{showMore && data && !error && limit && (
|
||||
<LinkButton href={updateParams({ view: type })} variant="quiet">
|
||||
|
|
|
|||
6
src/declaration.d.ts
vendored
6
src/declaration.d.ts
vendored
|
|
@ -1,3 +1,6 @@
|
|||
declare module '*.css';
|
||||
declare module '*.svg';
|
||||
declare module '*.json';
|
||||
declare module 'bcryptjs';
|
||||
declare module 'chartjs-adapter-date-fns';
|
||||
declare module 'cors';
|
||||
|
|
@ -6,5 +9,8 @@ declare module 'debug';
|
|||
declare module 'fs-extra';
|
||||
declare module 'jsonwebtoken';
|
||||
declare module 'md5';
|
||||
declare module 'papaparse';
|
||||
declare module 'prettier';
|
||||
declare module 'react-simple-maps';
|
||||
declare module 'semver';
|
||||
declare module 'uuid';
|
||||
|
|
|
|||
|
|
@ -227,8 +227,7 @@ async function rawQuery<T = unknown>(
|
|||
params: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
if (process.env.LOG_QUERY) {
|
||||
log('QUERY:\n', query);
|
||||
log('PARAMETERS:\n', params);
|
||||
log({ query, params });
|
||||
}
|
||||
|
||||
await connect();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export * from '@/queries/sql/events/getEventDataValues';
|
|||
export * from '@/queries/sql/events/getEventDataStats';
|
||||
export * from '@/queries/sql/events/getEventDataUsage';
|
||||
export * from '@/queries/sql/events/getEventMetrics';
|
||||
export * from '@/queries/sql/events/getEventStats';
|
||||
export * from '@/queries/sql/events/getWebsiteEvents';
|
||||
export * from '@/queries/sql/events/getEventUsage';
|
||||
export * from '@/queries/sql/events/saveEvent';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
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 prisma from '@/lib/prisma';
|
||||
import { QueryFilters } from '@/lib/types';
|
||||
|
||||
export interface WebsiteEventMetricParameters {
|
||||
type: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
export interface WebsiteEventMetricData {
|
||||
x: string;
|
||||
t: string;
|
||||
|
|
@ -11,7 +17,7 @@ export interface WebsiteEventMetricData {
|
|||
}
|
||||
|
||||
export async function getEventMetrics(
|
||||
...args: [websiteId: string, filters: QueryFilters]
|
||||
...args: [websiteId: string, parameters: WebsiteEventMetricParameters, filters: QueryFilters]
|
||||
): Promise<WebsiteEventMetricData[]> {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(...args),
|
||||
|
|
@ -19,29 +25,38 @@ export async function getEventMetrics(
|
|||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
||||
const { timezone = 'utc', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = prisma;
|
||||
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
eventType: EVENT_TYPE.customEvent,
|
||||
});
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
parameters: WebsiteEventMetricParameters,
|
||||
filters: QueryFilters,
|
||||
) {
|
||||
const { type, limit = 500, offset = 0 } = parameters;
|
||||
const column = FILTER_COLUMNS[type] || type;
|
||||
const { rawQuery, parseFilters } = prisma;
|
||||
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters(
|
||||
{
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.customEvent,
|
||||
},
|
||||
{ joinSession: SESSION_COLUMNS.includes(type) },
|
||||
);
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
select
|
||||
event_name x,
|
||||
${getDateSQL('website_event.created_at', unit, timezone)} t,
|
||||
count(*) y
|
||||
select ${column} x,
|
||||
count(*) as y
|
||||
from website_event
|
||||
${joinSessionQuery}
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
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
|
||||
group by 1
|
||||
order by 2 desc
|
||||
limit ${limit}
|
||||
offset ${offset}
|
||||
`,
|
||||
queryParams,
|
||||
);
|
||||
|
|
@ -49,51 +64,32 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
|||
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
parameters: WebsiteEventMetricParameters,
|
||||
filters: QueryFilters,
|
||||
): Promise<WebsiteEventMetricData[]> {
|
||||
const { timezone = 'UTC', unit = 'day' } = filters;
|
||||
const { rawQuery, getDateSQL, parseFilters } = clickhouse;
|
||||
const { type, limit = 500, offset = 0 } = parameters;
|
||||
const column = FILTER_COLUMNS[type] || type;
|
||||
const { rawQuery, parseFilters } = clickhouse;
|
||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
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 as 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, queryParams);
|
||||
return rawQuery(
|
||||
`select ${column} x,
|
||||
count(*) as 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
|
||||
order by y desc
|
||||
limit ${limit}
|
||||
offset ${offset}
|
||||
`,
|
||||
queryParams,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
100
src/queries/sql/events/getEventStats.ts
Normal file
100
src/queries/sql/events/getEventStats.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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 } from '@/lib/types';
|
||||
|
||||
interface WebsiteEventMetric {
|
||||
x: string;
|
||||
t: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
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, joinSessionQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.customEvent,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
select
|
||||
event_name x,
|
||||
${getDateSQL('website_event.created_at', unit, timezone)} t,
|
||||
count(*) y
|
||||
from website_event
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
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
|
||||
`,
|
||||
queryParams,
|
||||
);
|
||||
}
|
||||
|
||||
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, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
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, queryParams);
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
import clickhouse from '@/lib/clickhouse';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { QueryFilters } from '@/lib/types';
|
||||
|
||||
export interface UTMParameters {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export async function getUTM(
|
||||
...args: [websiteId: string, parameters: UTMParameters, filters: QueryFilters]
|
||||
...args: [
|
||||
websiteId: string,
|
||||
filters: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
timezone?: string;
|
||||
},
|
||||
]
|
||||
) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(...args),
|
||||
|
|
@ -19,12 +20,14 @@ export async function getUTM(
|
|||
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
parameters: UTMParameters,
|
||||
filters: QueryFilters,
|
||||
filters: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
timezone?: string;
|
||||
},
|
||||
) {
|
||||
const { startDate, endDate } = parameters;
|
||||
const { rawQuery, parseFilters } = prisma;
|
||||
const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId, startDate, endDate });
|
||||
const { startDate, endDate } = filters;
|
||||
const { rawQuery } = prisma;
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
|
|
@ -34,21 +37,26 @@ async function relationalQuery(
|
|||
and created_at between {{startDate}} and {{endDate}}
|
||||
and coalesce(url_query, '') != ''
|
||||
and event_type = 1
|
||||
${filterQuery}
|
||||
group by 1
|
||||
`,
|
||||
queryParams,
|
||||
).then(result => parseParameters(result as any[]));
|
||||
{
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
parameters: UTMParameters,
|
||||
filters: QueryFilters,
|
||||
filters: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
timezone?: string;
|
||||
},
|
||||
) {
|
||||
const { startDate, endDate } = parameters;
|
||||
const { rawQuery, parseFilters } = clickhouse;
|
||||
const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId, startDate, endDate });
|
||||
const { startDate, endDate } = filters;
|
||||
const { rawQuery } = clickhouse;
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
|
|
@ -58,34 +66,12 @@ async function clickhouseQuery(
|
|||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and url_query != ''
|
||||
and event_type = 1
|
||||
${filterQuery}
|
||||
group by 1
|
||||
`,
|
||||
queryParams,
|
||||
).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;
|
||||
}, {});
|
||||
{
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { QueryFilters } from '@/lib/types';
|
|||
|
||||
export interface SessionMetricsParameters {
|
||||
type: string;
|
||||
limit: number | string;
|
||||
offset: number | string;
|
||||
limit?: number | string;
|
||||
offset?: number | string;
|
||||
}
|
||||
|
||||
export async function getSessionMetrics(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue