Compare commits

...

87 Commits
master ... dev

Author SHA1 Message Date
Mike Cao 64c6d73b77 Update report parameters. 2023-06-05 03:08:43 -07:00
Mike Cao 22d477b98b Updates to reports. 2023-06-02 23:10:59 -07:00
Mike Cao 1869a809cf Fixed incorrect data field. 2023-06-01 19:30:07 -07:00
Brian Cao a3639d59a3 Add increment to usage. 2023-06-01 11:54:24 -07:00
Brian Cao dc6da1e41f Update yarn. 2023-06-01 10:34:39 -07:00
Brian Cao b484286523
Feat/um 305 unique session ch (#2065)
* Add session_data / session redis to CH.

* Add mysql migration.
2023-05-31 21:46:49 -07:00
Francis Cao 1038a54fe4 add mysql 02 migration 2023-05-31 12:01:16 -07:00
Mike Cao 77f758205b Fixed tracker config. 2023-05-30 19:58:28 -07:00
Mike Cao 450c11a1da Merge remote-tracking branch 'origin/dev' into dev 2023-05-30 16:53:12 -07:00
Mike Cao 43f5854f15 Update report parameters. 2023-05-30 16:49:22 -07:00
Francis Cao 0d567ebe60 Delete event_data on website reset 2023-05-29 21:19:53 -07:00
Mike Cao e9b0d3f796 Updated date range handling. 2023-05-28 22:28:11 -07:00
Mike Cao bfb52eb678 Added report context. Removed report store. 2023-05-28 21:37:34 -07:00
Mike Cao bc37f5124e Update report migration. 2023-05-27 22:36:41 -07:00
Mike Cao e8dda3952c Merge remote-tracking branch 'origin/dev' into dev 2023-05-27 13:29:24 -07:00
Mike Cao 0724201f69 Added indexes. Removed old migration. 2023-05-27 13:29:09 -07:00
Brian Cao 9d936fe8e5 add ownerId to non-cache. 2023-05-25 20:07:58 -07:00
Brian Cao 4c06487b11 Merge remote-tracking branch 'origin/dev' into dev 2023-05-25 16:46:39 -07:00
Brian Cao 06e6cbec9a Add share_id validation. 2023-05-25 16:46:37 -07:00
Mike Cao 3ccdd95f49 Moved render functions into lib/charts. 2023-05-25 10:26:00 -07:00
Mike Cao 5a5127c921 Converted popup into modal. 2023-05-24 21:54:49 -07:00
Mike Cao fb4dd75e18 Refactored funnel report. Made BarChart more generic. 2023-05-24 21:40:02 -07:00
Mike Cao 050cd2f5d9
Merge pull request #2047 from jclab-joseph/fix/tracking-code-base-path
Fix to add basePath to trackingCode generation
2023-05-22 21:17:56 -07:00
Brian Cao f01a745aad Merge remote-tracking branch 'origin/dev' into dev 2023-05-22 15:38:05 -07:00
Brian Cao 1f799f17e9 Fix permissions. 2023-05-22 15:38:03 -07:00
Mike Cao 0fb93ff00b Report updates. 2023-05-20 09:02:08 -07:00
Brian Cao 40f53e8856 Merge branch 'feat/um-285-report-schema' into dev 2023-05-18 13:21:35 -07:00
Brian Cao 4df7d6a2a1 Add userReport api 2023-05-18 13:13:18 -07:00
Brian Cao de509e7ccc checkpoint 2023-05-18 11:17:35 -07:00
Joseph Lee 55e91d5659 Fix to add basePath to trackingCode generation 2023-05-18 17:27:15 +09:00
Mike Cao a5700d4a25 Added reports section. 2023-05-17 23:20:06 -07:00
Mike Cao ad918c5bba Update CI workflow. 2023-05-16 21:19:39 -07:00
Mike Cao 9d4808f5ef Update check-env for CI build. 2023-05-16 21:13:17 -07:00
Mike Cao 2259d4fae5 Fixed script. 2023-05-16 20:46:15 -07:00
Mike Cao 19ac0fce36 Added check-env script. 2023-05-16 20:41:20 -07:00
Brian Cao cb038a51f3 Merge branch 'dev' into feat/um-285-report-schema 2023-05-16 20:40:58 -07:00
Brian Cao 53a5312c22 Merge branch 'dev' into feat/um-285-report-schema 2023-05-16 20:19:54 -07:00
Mike Cao c567eddff9 Fixed website undefined issue. 2023-05-16 19:40:02 -07:00
Mike Cao b663146ad5 Fixed data being undefined. 2023-05-16 19:31:40 -07:00
Mike Cao e98c0a62e3 Merge remote-tracking branch 'origin/dev' into dev 2023-05-16 19:17:28 -07:00
Mike Cao b48efef38b Allow multiple filters. 2023-05-16 19:17:12 -07:00
Mike Cao 6abffe3e4b
Merge pull request #2043 from tairosonloa/fix/gui-events-date-all-time
Fix: `BarChar` to correctly show year instead of epoch when unit is year
2023-05-16 17:24:52 -07:00
Brian Cao 748ad84a52 Merge branch 'dev' into feat/um-285-report-schema 2023-05-16 14:12:39 -07:00
Francis Cao f01668f4c8 add user role to login response 2023-05-16 12:17:51 -07:00
Aitor Alonso 2b5429aa1f
fix: show year instead of epoch 2023-05-16 21:02:56 +02:00
Francis Cao f528fac7be fix teams api route and messaging 2023-05-16 11:06:42 -07:00
Brian Cao 2ce62c1023 Add usage. 2023-05-15 20:41:12 -07:00
Brian Cao b5f84159d2 funnel checkpoint 2023-05-15 14:03:42 -07:00
Brian Cao ebc8cb488a Merge branch 'dev' into feat/um-285-report-schema 2023-05-14 21:38:14 -07:00
Brian Cao 07cb9f621d Funnel Table/Chart hooked up. 2023-05-14 21:38:03 -07:00
Mike Cao 345dee5720
Merge pull request #2038 from AkashRajpurohit/fix/filter-null-values-on-metric-table
fix: 🩹 null value check for metric data
2023-05-12 18:57:38 -07:00
Mike Cao c167ccbbfb
Merge pull request #2027 from theshamuel/patch-2
delete a redundant volume mapping
2023-05-12 18:57:08 -07:00
Mike Cao db7aff3ef0
Merge pull request #2026 from theshamuel/patch-1
add health-check for db container
2023-05-12 18:56:13 -07:00
Mike Cao f564d36dae
Merge pull request #2034 from ammar-madni/ammar-madni-fix-websiteId-undefined
Fix undefined websiteId in dynamic realtime page api request.
2023-05-12 18:54:06 -07:00
AkashRajpurohit c8ebb7695c fix: 🩹 null value check for metric data 2023-05-12 22:38:12 +05:30
Brian Cao 1130bca195 Funnel form. 2023-05-11 16:42:58 -07:00
Ammar 97f4a3bccc
Update index.js 2023-05-11 17:27:37 +01:00
Brian Cao dd2db308ee Merge branch 'dev' into feat/um-285-report-schema 2023-05-08 23:47:25 -07:00
Brian Cao d01aa5cd52 Add funnel queries 2023-05-08 23:46:58 -07:00
Francis Cao d827bf1417 update CH kafka engine tables for error handling 2023-05-07 23:13:40 -07:00
Mike Cao e92d958b24 Updated redis client. Soft deletes reset username. 2023-05-05 21:16:13 -07:00
theshamuel e773451679
delete a redundant volume mapping
I'd like to propose to delete from docker-compose a redundant volume mapping. As I see that `sql` directory had been deleted and now umami-app is responsible for migration.
2023-05-05 23:57:58 +01:00
theshamuel 98e58581e2
add health-check for db container
I'd like to propose a minor changes that can bring more stability in launching the umami server with docker-compose by adding a certain healthcheck for db container and a certain condition for depends_on directive for umami-app container
2023-05-05 23:53:16 +01:00
Mike Cao 37b94e5b96 Updated message bundles. 2023-05-04 14:54:05 -07:00
Mike Cao 2cd293726a
Merge pull request #2019 from cadesalaberry/chore/add-tracker-types
chore(tracker): 🏷️ add types for tracker
2023-05-04 14:25:44 -07:00
Mike Cao bfa662c24a
Merge pull request #1998 from valtlfelipe/patch-2
Update pt-BR.json translations
2023-05-04 14:24:26 -07:00
Mike Cao 7306e35591
Merge pull request #1997 from bilguun0203/master
Update mn-MN.json
2023-05-04 14:23:37 -07:00
Mike Cao 3f90c34f94
Merge pull request #2003 from Maxime-J/cleanup-check-db
Cleanup check-db
2023-05-04 14:23:09 -07:00
Mike Cao f02142eb8c
Merge pull request #2010 from RohrerF/swiss-german-translations
update de-* translations
2023-05-04 14:21:19 -07:00
Mike Cao 016407773f
Merge pull request #2020 from MarcHagen/update/lang/nl
Update NL language
2023-05-04 14:20:21 -07:00
Brian Cao 183c80539a Merge remote-tracking branch 'origin/dev' into dev 2023-05-03 17:18:00 -07:00
Brian Cao 9e2a478001 Add user usage. 2023-05-03 17:17:57 -07:00
Marc Hagen d5b413eb90
Update NL language 2023-05-04 01:44:17 +02:00
C-A de Salaberry d00d4f7343
chore(tracker): 🏷️ add types for tracker 2023-05-03 19:52:59 +02:00
Francis Cao 5692c0e07d add formatted message to reset website 2023-05-02 23:44:42 -07:00
Mike Cao c41fb5ee1b Updated packages. 2023-05-02 08:32:04 -07:00
Mike Cao f01e3ad502 Update Next.js 13.2.4. 2023-05-02 08:22:43 -07:00
Francis Cao 714a331cd2 remove navbar logo pointer 2023-05-01 11:33:08 -07:00
Brian Cao 65ebb26a70 Fix boolean event data. 2023-04-30 22:55:54 -07:00
Florian Roher 004e5a954a fix message.share-url 2023-04-28 23:15:40 +02:00
Florian Roher 0a1ed145af swiss german translations 2023-04-28 23:01:37 +02:00
Maxime-J ac0efb29aa Cleanup check-db 2023-04-27 13:38:07 +00:00
Felipe Valtl de Mello f5cb18b9b5
Update pt-BR.json 2023-04-26 10:55:12 -03:00
Felipe Valtl de Mello d38dc1b5ab
Update pt-BR.json translations 2023-04-26 10:53:25 -03:00
Bilguun Ochirbat c954bb2ce7
Update mn-MN.json 2023-04-26 17:07:19 +08:00
Francis Cao dcb9f69c43 Fix Title filters 2023-04-25 23:25:51 -07:00
Francis Cao 9b349b53c4 limit realtime record set to 15 2023-04-25 22:52:53 -07:00
255 changed files with 4295 additions and 1514 deletions

View File

@ -26,9 +26,9 @@ jobs:
db-type: mysql
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

View File

@ -1 +1 @@
<svg id="Layer_2" height="512" viewBox="0 0 30 30" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 2"><g fill="rgb(0,0,0)"><path d="m15 14a5.5 5.5 0 1 1 5.5-5.5 5.51 5.51 0 0 1 -5.5 5.5zm0-9a3.5 3.5 0 1 0 3.5 3.5 3.5 3.5 0 0 0 -3.5-3.5z"/><path d="m7.5 24.5a1 1 0 0 1 -1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1 -1.2 1.6 6.44 6.44 0 0 0 -3.9-1.3 6.51 6.51 0 0 0 -6.5 6.5 1 1 0 0 1 -1 1z"/><path d="m23 27a1 1 0 0 1 -1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1 -1 1z"/><path d="m26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" data-name="Layer 2" viewBox="0 0 30 30"><path d="M15 14a5.5 5.5 0 1 1 5.5-5.5A5.51 5.51 0 0 1 15 14zm0-9a3.5 3.5 0 1 0 3.5 3.5A3.5 3.5 0 0 0 15 5zM7.5 24.5a1 1 0 0 1-1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1-1.2 1.6A6.44 6.44 0 0 0 15 17a6.51 6.51 0 0 0-6.5 6.5 1 1 0 0 1-1 1zM23 27a1 1 0 0 1-1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1-1 1z"/><path d="M26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2z"/></svg>

Before

Width:  |  Height:  |  Size: 529 B

After

Width:  |  Height:  |  Size: 451 B

View File

@ -1 +1 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"/><path d="M11.168 11.445a1 1 0 0 1 1.387-.277l3 2a1 1 0 0 1-1.11 1.664l-3-2a1 1 0 0 1-.277-1.387z"/><path d="M12 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><g clip-rule="evenodd"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"/><path d="M11.168 11.445a1 1 0 0 1 1.387-.277l3 2a1 1 0 0 1-1.11 1.664l-3-2a1 1 0 0 1-.277-1.387z"/><path d="M12 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 400 B

View File

@ -1 +1 @@
<svg height="512pt" viewBox="0 0 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="M197.332 170.668h-160C16.746 170.668 0 153.922 0 133.332v-96C0 16.746 16.746 0 37.332 0h160c20.59 0 37.336 16.746 37.336 37.332v96c0 20.59-16.746 37.336-37.336 37.336zM37.332 32A5.336 5.336 0 0 0 32 37.332v96a5.337 5.337 0 0 0 5.332 5.336h160a5.338 5.338 0 0 0 5.336-5.336v-96A5.337 5.337 0 0 0 197.332 32zM197.332 512h-160C16.746 512 0 495.254 0 474.668v-224c0-20.59 16.746-37.336 37.332-37.336h160c20.59 0 37.336 16.746 37.336 37.336v224c0 20.586-16.746 37.332-37.336 37.332zm-160-266.668A5.337 5.337 0 0 0 32 250.668v224A5.336 5.336 0 0 0 37.332 480h160a5.337 5.337 0 0 0 5.336-5.332v-224a5.338 5.338 0 0 0-5.336-5.336zM474.668 512h-160c-20.59 0-37.336-16.746-37.336-37.332v-96c0-20.59 16.746-37.336 37.336-37.336h160c20.586 0 37.332 16.746 37.332 37.336v96C512 495.254 495.254 512 474.668 512zm-160-138.668a5.338 5.338 0 0 0-5.336 5.336v96a5.337 5.337 0 0 0 5.336 5.332h160a5.336 5.336 0 0 0 5.332-5.332v-96a5.337 5.337 0 0 0-5.332-5.336zM474.668 298.668h-160c-20.59 0-37.336-16.746-37.336-37.336v-224C277.332 16.746 294.078 0 314.668 0h160C495.254 0 512 16.746 512 37.332v224c0 20.59-16.746 37.336-37.332 37.336zM314.668 32a5.337 5.337 0 0 0-5.336 5.332v224a5.338 5.338 0 0 0 5.336 5.336h160a5.337 5.337 0 0 0 5.332-5.336v-224A5.336 5.336 0 0 0 474.668 32zm0 0"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512pt" height="512pt" viewBox="0 0 512 512"><path d="M197.332 170.668h-160C16.746 170.668 0 153.922 0 133.332v-96C0 16.746 16.746 0 37.332 0h160c20.59 0 37.336 16.746 37.336 37.332v96c0 20.59-16.746 37.336-37.336 37.336zM37.332 32A5.336 5.336 0 0 0 32 37.332v96a5.337 5.337 0 0 0 5.332 5.336h160a5.338 5.338 0 0 0 5.336-5.336v-96A5.337 5.337 0 0 0 197.332 32zm160 480h-160C16.746 512 0 495.254 0 474.668v-224c0-20.59 16.746-37.336 37.332-37.336h160c20.59 0 37.336 16.746 37.336 37.336v224c0 20.586-16.746 37.332-37.336 37.332zm-160-266.668A5.337 5.337 0 0 0 32 250.668v224A5.336 5.336 0 0 0 37.332 480h160a5.337 5.337 0 0 0 5.336-5.332v-224a5.338 5.338 0 0 0-5.336-5.336zM474.668 512h-160c-20.59 0-37.336-16.746-37.336-37.332v-96c0-20.59 16.746-37.336 37.336-37.336h160c20.586 0 37.332 16.746 37.332 37.336v96C512 495.254 495.254 512 474.668 512zm-160-138.668a5.338 5.338 0 0 0-5.336 5.336v96a5.337 5.337 0 0 0 5.336 5.332h160a5.336 5.336 0 0 0 5.332-5.332v-96a5.337 5.337 0 0 0-5.332-5.336zm160-74.664h-160c-20.59 0-37.336-16.746-37.336-37.336v-224C277.332 16.746 294.078 0 314.668 0h160C495.254 0 512 16.746 512 37.332v224c0 20.59-16.746 37.336-37.332 37.336zM314.668 32a5.337 5.337 0 0 0-5.336 5.332v224a5.338 5.338 0 0 0 5.336 5.336h160a5.337 5.337 0 0 0 5.332-5.336v-224A5.336 5.336 0 0 0 474.668 32zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

1
assets/funnel.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32"><path d="M29 11H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h26a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zM4 9h24V5H4z"/><path d="M25 17H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zM8 15h16v-4H8z"/><path d="M22 23H10a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zm-11-2h10v-4H11z"/><path d="M19 29h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zm-5-2h4v-4h-4z"/></svg>

After

Width:  |  Height:  |  Size: 487 B

1
assets/lightbulb.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 512 512"><path d="M223.718 124.76c-48.027 11.198-86.688 49.285-98.494 97.031-11.843 47.899 1.711 96.722 36.259 130.601C173.703 364.377 181 383.586 181 403.777V407c0 13.296 5.801 25.26 15 33.505V467c0 24.813 20.187 45 45 45h30c24.813 0 45-20.187 45-45v-26.495c9.199-8.245 15-20.208 15-33.505v-3.282c0-19.884 7.687-39.458 20.563-52.361C376.994 325.87 391 292.005 391 256c0-86.079-79.769-151.638-167.282-131.24zM286 467c0 8.271-6.729 15-15 15h-30c-8.271 0-15-6.729-15-15v-15h60v15zm44.326-136.834C311.689 348.843 301 375.651 301 403.718V407c0 8.271-6.729 15-15 15h-60c-8.271 0-15-6.729-15-15v-3.223c0-28.499-10.393-55.035-28.513-72.804-26.89-26.37-37.409-64.493-28.141-101.981 9.125-36.907 39.029-66.353 76.184-75.015C299.202 137.964 361 189.228 361 256c0 28.004-10.894 54.343-30.674 74.166zM139.327 118.114 96.9 75.688c-5.857-5.858-15.355-5.858-21.213 0-5.858 5.858-5.858 15.355 0 21.213l42.427 42.426c5.857 5.858 15.356 5.858 21.213 0 5.858-5.858 5.858-15.355 0-21.213zM76 241H15c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15zM497 241h-61c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15zM436.313 75.688c-5.856-5.858-15.354-5.858-21.213 0l-42.427 42.426c-5.858 5.857-5.858 15.355 0 21.213 5.857 5.858 15.355 5.858 21.213 0l42.427-42.426c5.858-5.857 5.858-15.355 0-21.213zM256 0c-8.284 0-15 6.716-15 15v61c0 8.284 6.716 15 15 15s15-6.716 15-15V15c0-8.284-6.716-15-15-15z"/><path d="M256 181c-6.166 0-12.447.739-18.658 2.194-25.865 6.037-47.518 27.328-53.879 52.979-1.994 8.041 2.907 16.175 10.947 18.17 8.042 1.994 16.176-2.909 18.17-10.948 3.661-14.758 16.647-27.5 31.593-30.989 3.982-.933 7.962-1.406 11.827-1.406 8.284 0 15-6.716 15-15s-6.716-15-15-15z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1 +1 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722z"/></svg>

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 433 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11" width="20" height="20"><circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 428 389.11"><circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z"/></svg>

Before

Width:  |  Height:  |  Size: 390 B

After

Width:  |  Height:  |  Size: 390 B

1
assets/nodes.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M19 9.874A4.002 4.002 0 0 0 18 2a4.002 4.002 0 0 0-3.874 3H9.874A4.002 4.002 0 0 0 2 6a4.002 4.002 0 0 0 3 3.874v4.252A4.002 4.002 0 0 0 6 22a4.002 4.002 0 0 0 3.874-3h4.252A4.002 4.002 0 0 0 22 18a4.002 4.002 0 0 0-3-3.874zM6 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm3.874 3A4.007 4.007 0 0 1 7 9.874v4.252A4.007 4.007 0 0 1 9.874 17h4.252A4.007 4.007 0 0 1 17 14.126V9.874A4.007 4.007 0 0 1 14.126 7zM18 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM8 18a2 2 0 1 0-4 0 2 2 0 0 0 4 0z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 636 B

View File

@ -1 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02zM111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703zM256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934zm0 0"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02zM111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703zM256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1 +1 @@
<svg height="512pt" viewBox="-56 0 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="M267 236.375c36.254-22.582 60.434-62.797 60.434-108.563C327.434 57.337 270.098 0 199.62 0 129.145 0 71.81 57.336 71.81 127.813c0 45.765 24.18 85.976 60.43 108.558C55.222 264.071 0 337.84 0 424.273v72.243C0 505.066 6.934 512 15.484 512H383.75c8.55 0 15.48-6.934 15.48-15.484v-72.243c0-86.43-55.218-160.195-132.23-187.898zm101.266 244.656H30.969v-56.758c0-92.992 75.652-168.644 168.648-168.644 92.992 0 168.649 75.652 168.649 168.644zm-71.801-353.219c0 53.403-43.442 96.848-96.844 96.848s-96.844-43.445-96.844-96.847c0-53.399 43.442-96.844 96.844-96.844s96.844 43.445 96.844 96.844zm0 0"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512pt" height="512pt" viewBox="-56 0 512 512"><path d="M267 236.375c36.254-22.582 60.434-62.797 60.434-108.563C327.434 57.337 270.098 0 199.62 0 129.145 0 71.81 57.336 71.81 127.813c0 45.765 24.18 85.976 60.43 108.558C55.222 264.071 0 337.84 0 424.273v72.243C0 505.066 6.934 512 15.484 512H383.75c8.55 0 15.48-6.934 15.48-15.484v-72.243c0-86.43-55.218-160.195-132.23-187.898zm101.266 244.656H30.969v-56.758c0-92.992 75.652-168.644 168.648-168.644 92.992 0 168.649 75.652 168.649 168.644zm-71.801-353.219c0 53.403-43.442 96.848-96.844 96.848s-96.844-43.445-96.844-96.847c0-53.399 43.442-96.844 96.844-96.844s96.844 43.445 96.844 96.844zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 695 B

After

Width:  |  Height:  |  Size: 695 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 477.869 477.869" style="enable-background:new 0 0 477.869 477.869" xml:space="preserve"><path d="M387.415 233.496c48.976-44.029 52.987-119.424 8.958-168.4C355.991 20.177 288.4 12.546 239.02 47.332c-53.83-37.99-128.264-25.149-166.254 28.68-34.859 49.393-27.259 117.054 17.689 157.483C34.606 262.935-.251 320.976.002 384.108v51.2c0 9.426 7.641 17.067 17.067 17.067h443.733c9.426 0 17.067-7.641 17.067-17.067v-51.2c.252-63.132-34.605-121.173-90.454-150.612zM307.201 59.842c47.062-.052 85.256 38.057 85.309 85.119.037 33.564-19.631 64.023-50.237 77.799-1.314.597-2.628 1.143-3.959 1.707a83.66 83.66 0 0 1-12.988 4.045c-.853.188-1.707.29-2.577.461a85.366 85.366 0 0 1-15.019 1.519c-2.27 0-4.557-.171-6.827-.375-.853 0-1.707 0-2.56-.171a86.219 86.219 0 0 1-27.904-8.226c-.324-.154-.7-.137-1.024-.273-1.707-.819-3.413-1.536-4.932-2.458.137-.171.222-.358.358-.529a119.721 119.721 0 0 0 18.278-33.297l.529-1.434a120.381 120.381 0 0 0 4.523-17.562c.154-.87.273-1.707.41-2.645.987-6.067 1.506-12.2 1.553-18.347a120.041 120.041 0 0 0-1.553-18.313c-.137-.887-.256-1.707-.41-2.645a120.414 120.414 0 0 0-4.523-17.562l-.529-1.434a119.747 119.747 0 0 0-18.278-33.297c-.137-.171-.222-.358-.358-.529a84.787 84.787 0 0 1 42.718-11.553zM85.335 145.176c-.121-47.006 37.886-85.21 84.892-85.331a85.112 85.112 0 0 1 59.134 23.686c.99.956 1.963 1.911 2.918 2.901a87.748 87.748 0 0 1 8.09 9.813c.751 1.058 1.434 2.185 2.133 3.277a83.951 83.951 0 0 1 6.263 11.52c.427.973.751 1.963 1.126 2.935a83.422 83.422 0 0 1 4.233 13.653c.12.512.154 1.024.256 1.553a80.338 80.338 0 0 1 0 32.119c-.102.529-.137 1.041-.256 1.553a83.228 83.228 0 0 1-4.233 13.653c-.375.973-.7 1.963-1.126 2.935a84.251 84.251 0 0 1-6.263 11.503c-.7 1.092-1.382 2.219-2.133 3.277a87.549 87.549 0 0 1-8.09 9.813c-.956.99-1.929 1.946-2.918 2.901a85.187 85.187 0 0 1-23.569 15.906 49.35 49.35 0 0 1-4.198 1.707 85.839 85.839 0 0 1-12.663 3.925c-1.075.239-2.185.375-3.277.563a84.67 84.67 0 0 1-14.046 1.417h-1.877a84.563 84.563 0 0 1-14.046-1.417c-1.092-.188-2.202-.324-3.277-.563a85.802 85.802 0 0 1-12.663-3.925c-1.417-.563-2.816-1.143-4.198-1.707-30.534-13.786-50.173-44.166-50.212-77.667zm221.866 273.066H34.135v-34.133c-.25-57.833 36.188-109.468 90.76-128.614a119.092 119.092 0 0 0 91.546 0 137.138 137.138 0 0 1 16.623 7.356c3.55 1.826 6.827 3.908 10.24 6.007 2.219 1.382 4.471 2.731 6.605 4.25 3.294 2.338 6.4 4.881 9.455 7.492 1.963 1.707 3.908 3.413 5.751 5.12 2.816 2.662 5.461 5.478 8.004 8.363a134.465 134.465 0 0 1 5.291 6.383 132.594 132.594 0 0 1 6.349 8.823c1.707 2.56 3.226 5.222 4.727 7.885 1.707 2.935 3.277 5.871 4.71 8.926 1.434 3.055 2.697 6.4 3.925 9.66 1.075 2.833 2.219 5.649 3.106 8.533 1.195 3.959 2.031 8.055 2.867 12.151.512 2.423 1.178 4.796 1.553 7.253a141.153 141.153 0 0 1 1.553 20.412v34.133zm136.534 0h-102.4v-34.133c0-5.342-.307-10.633-.785-15.872-.137-1.536-.375-3.055-.546-4.591-.461-3.772-.99-7.509-1.707-11.213a246.936 246.936 0 0 0-.973-4.762c-.819-3.8-1.769-7.566-2.85-11.298-.358-1.229-.683-2.475-1.058-3.686a169.105 169.105 0 0 0-20.565-43.127l-.666-.973a168.958 168.958 0 0 0-9.404-12.646l-.119-.154a154.895 154.895 0 0 0-11.008-12.237h.7a120.8 120.8 0 0 0 14.524 1.024h.939c4.496-.039 8.985-.33 13.449-.87 1.399-.171 2.782-.427 4.181-.649a117.43 117.43 0 0 0 10.752-2.167c1.007-.256 2.031-.495 3.055-.785a116.211 116.211 0 0 0 13.653-4.642c54.612 19.127 91.083 70.785 90.829 128.649v34.132z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 477.869 477.869" viewBox="0 0 477.869 477.869"><path d="M387.415 233.496c48.976-44.029 52.987-119.424 8.958-168.4C355.991 20.177 288.4 12.546 239.02 47.332c-53.83-37.99-128.264-25.149-166.254 28.68-34.859 49.393-27.259 117.054 17.689 157.483C34.606 262.935-.251 320.976.002 384.108v51.2c0 9.426 7.641 17.067 17.067 17.067h443.733c9.426 0 17.067-7.641 17.067-17.067v-51.2c.252-63.132-34.605-121.173-90.454-150.612zM307.201 59.842c47.062-.052 85.256 38.057 85.309 85.119.037 33.564-19.631 64.023-50.237 77.799-1.314.597-2.628 1.143-3.959 1.707a83.66 83.66 0 0 1-12.988 4.045c-.853.188-1.707.29-2.577.461a85.366 85.366 0 0 1-15.019 1.519c-2.27 0-4.557-.171-6.827-.375-.853 0-1.707 0-2.56-.171a86.219 86.219 0 0 1-27.904-8.226c-.324-.154-.7-.137-1.024-.273-1.707-.819-3.413-1.536-4.932-2.458.137-.171.222-.358.358-.529a119.721 119.721 0 0 0 18.278-33.297l.529-1.434a120.381 120.381 0 0 0 4.523-17.562c.154-.87.273-1.707.41-2.645.987-6.067 1.506-12.2 1.553-18.347a120.041 120.041 0 0 0-1.553-18.313c-.137-.887-.256-1.707-.41-2.645a120.414 120.414 0 0 0-4.523-17.562l-.529-1.434a119.747 119.747 0 0 0-18.278-33.297c-.137-.171-.222-.358-.358-.529a84.787 84.787 0 0 1 42.718-11.553zM85.335 145.176c-.121-47.006 37.886-85.21 84.892-85.331a85.112 85.112 0 0 1 59.134 23.686c.99.956 1.963 1.911 2.918 2.901a87.748 87.748 0 0 1 8.09 9.813c.751 1.058 1.434 2.185 2.133 3.277a83.951 83.951 0 0 1 6.263 11.52c.427.973.751 1.963 1.126 2.935a83.422 83.422 0 0 1 4.233 13.653c.12.512.154 1.024.256 1.553a80.338 80.338 0 0 1 0 32.119c-.102.529-.137 1.041-.256 1.553a83.228 83.228 0 0 1-4.233 13.653c-.375.973-.7 1.963-1.126 2.935a84.251 84.251 0 0 1-6.263 11.503c-.7 1.092-1.382 2.219-2.133 3.277a87.549 87.549 0 0 1-8.09 9.813c-.956.99-1.929 1.946-2.918 2.901a85.187 85.187 0 0 1-23.569 15.906 49.35 49.35 0 0 1-4.198 1.707 85.839 85.839 0 0 1-12.663 3.925c-1.075.239-2.185.375-3.277.563a84.67 84.67 0 0 1-14.046 1.417h-1.877a84.563 84.563 0 0 1-14.046-1.417c-1.092-.188-2.202-.324-3.277-.563a85.802 85.802 0 0 1-12.663-3.925c-1.417-.563-2.816-1.143-4.198-1.707-30.534-13.786-50.173-44.166-50.212-77.667zm221.866 273.066H34.135v-34.133c-.25-57.833 36.188-109.468 90.76-128.614a119.092 119.092 0 0 0 91.546 0 137.138 137.138 0 0 1 16.623 7.356c3.55 1.826 6.827 3.908 10.24 6.007 2.219 1.382 4.471 2.731 6.605 4.25 3.294 2.338 6.4 4.881 9.455 7.492 1.963 1.707 3.908 3.413 5.751 5.12 2.816 2.662 5.461 5.478 8.004 8.363a134.465 134.465 0 0 1 5.291 6.383 132.594 132.594 0 0 1 6.349 8.823c1.707 2.56 3.226 5.222 4.727 7.885 1.707 2.935 3.277 5.871 4.71 8.926 1.434 3.055 2.697 6.4 3.925 9.66 1.075 2.833 2.219 5.649 3.106 8.533 1.195 3.959 2.031 8.055 2.867 12.151.512 2.423 1.178 4.796 1.553 7.253a141.153 141.153 0 0 1 1.553 20.412v34.133zm136.534 0h-102.4v-34.133c0-5.342-.307-10.633-.785-15.872-.137-1.536-.375-3.055-.546-4.591-.461-3.772-.99-7.509-1.707-11.213a246.936 246.936 0 0 0-.973-4.762c-.819-3.8-1.769-7.566-2.85-11.298-.358-1.229-.683-2.475-1.058-3.686a169.105 169.105 0 0 0-20.565-43.127l-.666-.973a168.958 168.958 0 0 0-9.404-12.646l-.119-.154a154.895 154.895 0 0 0-11.008-12.237h.7a120.8 120.8 0 0 0 14.524 1.024h.939c4.496-.039 8.985-.33 13.449-.87 1.399-.171 2.782-.427 4.181-.649a117.43 117.43 0 0 0 10.752-2.167c1.007-.256 2.031-.495 3.055-.785a116.211 116.211 0 0 0 13.653-4.642c54.612 19.127 91.083 70.785 90.829 128.649v34.132z"/></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 511.999 511.999" style="enable-background:new 0 0 511.999 511.999" xml:space="preserve"><path d="M437.019 74.981C388.667 26.628 324.38 0 256 0 187.62 0 123.332 26.628 74.981 74.98 26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98 68.381 0 132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018zM96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230.423 230.423 0 0 1 15.806-17.528zm-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4zm-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437zm35.622 46.146a229.917 229.917 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679zm144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709v102.545zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06v76.141zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249v102.669zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157zm-34.934-44.964a230.122 230.122 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888zm-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006v-74.606zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505h-97.315zm-.002-208.845h.001c22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674V32.139zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249zm144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230.268 230.268 0 0 1-16.884 18.873zm34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 511.999 511.999" viewBox="0 0 511.999 511.999"><path d="M437.019 74.981C388.667 26.628 324.38 0 256 0 187.62 0 123.332 26.628 74.981 74.98 26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98 68.381 0 132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018zM96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230.423 230.423 0 0 1 15.806-17.528zm-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4zm-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437zm35.622 46.146a229.917 229.917 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679zm144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709v102.545zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06v76.141zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249v102.669zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157zm-34.934-44.964a230.122 230.122 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888zm-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006v-74.606zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505h-97.315zm-.002-208.845h.001c22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674V32.139zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249zm144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230.268 230.268 0 0 1-16.884 18.873zm34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993z"/></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Tooltip } from 'react-basics';
import styles from './HoverTooltip.module.css';
export function HoverTooltip({ tooltip }) {
export function HoverTooltip({ children }) {
const [position, setPosition] = useState({ x: -1000, y: -1000 });
useEffect(() => {
@ -18,8 +18,8 @@ export function HoverTooltip({ tooltip }) {
}, []);
return (
<div className={styles.tooltip} style={{ left: position.x, top: position.y }}>
<Tooltip position="top" action="none" label={tooltip} />
<div className={styles.tooltip} style={{ left: position.x, top: position.y - 16 }}>
<Tooltip position="top" action="none" label={children} />
</div>
);
}

View File

@ -4,7 +4,7 @@ import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simp
import classNames from 'classnames';
import { colord } from 'colord';
import HoverTooltip from 'components/common/HoverTooltip';
import { ISO_COUNTRIES, THEME_COLORS, MAP_FILE } from 'lib/constants';
import { ISO_COUNTRIES, MAP_FILE } from 'lib/constants';
import useTheme from 'hooks/useTheme';
import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale';
@ -15,16 +15,7 @@ import styles from './WorldMap.module.css';
export function WorldMap({ data, className }) {
const { basePath } = useRouter();
const [tooltip, setTooltip] = useState();
const [theme] = useTheme();
const colors = useMemo(
() => ({
baseColor: THEME_COLORS[theme].primary,
fillColor: THEME_COLORS[theme].gray100,
strokeColor: THEME_COLORS[theme].primary,
hoverColor: THEME_COLORS[theme].primary,
}),
[theme],
);
const { theme, colors } = useTheme();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const metrics = useMemo(() => (data ? percentFilter(data) : []), [data]);
@ -34,10 +25,10 @@ export function WorldMap({ data, className }) {
const country = metrics?.find(({ x }) => x === code);
if (!country) {
return colors.fillColor;
return colors.map.fillColor;
}
return colord(colors.baseColor)
return colord(colors.map.baseColor)
[theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
.toHex();
}
@ -70,11 +61,11 @@ export function WorldMap({ data, className }) {
key={geo.rsmKey}
geography={geo}
fill={getFillColor(code)}
stroke={colors.strokeColor}
stroke={colors.map.strokeColor}
opacity={getOpacity(code)}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: colors.hoverColor },
hover: { outline: 'none', fill: colors.map.hoverColor },
pressed: { outline: 'none' },
}}
onMouseOver={() => handleHover(code)}
@ -86,7 +77,7 @@ export function WorldMap({ data, className }) {
</Geographies>
</ZoomableGroup>
</ComposableMap>
{tooltip && <HoverTooltip tooltip={tooltip} />}
{tooltip && <HoverTooltip>{tooltip}</HoverTooltip>}
</div>
);
}

View File

@ -3,31 +3,22 @@ import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics';
import { endOfYear, isSameDay } from 'date-fns';
import DatePickerForm from 'components/metrics/DatePickerForm';
import useLocale from 'hooks/useLocale';
import { dateFormat, getDateRangeValues } from 'lib/date';
import { dateFormat } from 'lib/date';
import Icons from 'components/icons';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useMessages from 'hooks/useMessages';
export function DateFilter({ websiteId, value, className }) {
export function DateFilter({
value,
startDate,
endDate,
className,
onChange,
showAllTime = false,
alignment = 'end',
}) {
const { formatMessage, labels } = useMessages();
const { get } = useApi();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const [showPicker, setShowPicker] = useState(false);
async function handleDateChange(value) {
if (value === 'all' && websiteId) {
const data = await get(`/websites/${websiteId}`);
if (data) {
setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) });
}
} else if (value !== 'all') {
setDateRange(value);
}
}
const options = [
{ label: formatMessage(labels.today), value: '1day' },
{
@ -61,7 +52,7 @@ export function DateFilter({ websiteId, value, className }) {
value: '90day',
},
{ label: formatMessage(labels.thisYear), value: '1year' },
websiteId && {
showAllTime && {
label: formatMessage(labels.allTime),
value: 'all',
divider: true,
@ -74,7 +65,7 @@ export function DateFilter({ websiteId, value, className }) {
].filter(n => n);
const renderValue = value => {
return value === 'custom' ? (
return value.startsWith('range') ? (
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
) : (
options.find(e => e.value === value).label
@ -86,12 +77,12 @@ export function DateFilter({ websiteId, value, className }) {
setShowPicker(true);
return;
}
handleDateChange(value);
onChange(value);
};
const handlePickerChange = value => {
setShowPicker(false);
handleDateChange(value);
onChange(value);
};
const handleClose = () => setShowPicker(false);
@ -103,7 +94,8 @@ export function DateFilter({ websiteId, value, className }) {
items={options}
renderValue={renderValue}
value={value}
alignment="end"
alignment={alignment}
placeholder={formatMessage(labels.selectDate)}
onChange={handleChange}
>
{({ label, value, divider }) => (

View File

@ -9,8 +9,9 @@ export function LanguageButton() {
const { locale, saveLocale, dir } = useLocale();
const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
function handleSelect(value) {
function handleSelect(value, close) {
saveLocale(value);
close();
}
return (
@ -21,24 +22,28 @@ export function LanguageButton() {
</Icon>
</Button>
<Popup position="bottom" alignment={dir === 'rtl' ? 'start' : 'end'}>
<div className={styles.menu}>
{items.map(({ value, label }) => {
return (
<div
key={value}
className={classNames(styles.item, { [styles.selected]: value === locale })}
onClick={handleSelect.bind(null, value)}
>
<Text>{label}</Text>
{value === locale && (
<Icon className={styles.icon}>
<Icons.Check />
</Icon>
)}
</div>
);
})}
</div>
{close => {
return (
<div className={styles.menu}>
{items.map(({ value, label }) => {
return (
<div
key={value}
className={classNames(styles.item, { [styles.selected]: value === locale })}
onClick={handleSelect.bind(null, value, close)}
>
<Text>{label}</Text>
{value === locale && (
<Icon className={styles.icon}>
<Icons.Check />
</Icon>
)}
</div>
);
})}
</div>
);
}}
</Popup>
</PopupTrigger>
);

View File

@ -1,8 +1,7 @@
.menu {
display: flex;
flex-flow: row wrap;
min-width: 600px;
max-width: 100vw;
max-width: 640px;
padding: 10px;
background: var(--base50);
z-index: var(--z-index-popup);

View File

@ -5,7 +5,7 @@ import Icons from 'components/icons';
import styles from './ThemeButton.module.css';
export function ThemeButton() {
const [theme, setTheme] = useTheme();
const { theme, saveTheme } = useTheme();
const transitions = useTransition(theme, {
initial: { opacity: 1 },
@ -21,7 +21,7 @@ export function ThemeButton() {
});
function handleClick() {
setTheme(theme === 'light' ? 'dark' : 'light');
saveTheme(theme === 'light' ? 'dark' : 'light');
}
return (

View File

@ -0,0 +1,25 @@
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import DateFilter from './DateFilter';
export default function WebsiteDateFilter({ websiteId, value }) {
const { get } = useApi();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const handleChange = async value => {
if (value === 'all' && websiteId) {
const data = await get(`/websites/${websiteId}`);
if (data) {
setDateRange(`range:${new Date(data.createdAt)}:${Date.now()}`);
}
} else if (value !== 'all') {
setDateRange(value);
}
};
return (
<DateFilter value={value} startDate={startDate} endDate={endDate} onChange={handleChange} />
);
}

View File

@ -19,7 +19,6 @@ export function WebsiteSelect({ websiteId, onSelect }) {
onChange={onSelect}
alignment="end"
placeholder={formatMessage(labels.selectWebsite)}
style={{ width: 200 }}
>
{({ id, name }) => <Item key={id}>{name}</Item>}
</Dropdown>

View File

@ -19,6 +19,7 @@ export function NavBar() {
const links = [
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
{ label: formatMessage(labels.realtime), url: '/realtime' },
{ label: formatMessage(labels.reports), url: '/reports' },
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
].filter(n => n);

View File

@ -27,7 +27,6 @@
gap: 10px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
min-width: 0;
}

View File

@ -1,10 +1,11 @@
import classNames from 'classnames';
import React from 'react';
import styles from './PageHeader.module.css';
export function PageHeader({ title, children }) {
export function PageHeader({ title, children, className }) {
return (
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={classNames(styles.header, className)}>
{title && <div className={styles.title}>{title}</div>}
<div className={styles.actions}>{children}</div>
</div>
);

View File

@ -4,7 +4,6 @@
align-items: center;
align-content: center;
align-self: stretch;
margin-bottom: 40px;
flex-wrap: wrap;
}
@ -23,6 +22,7 @@
font-weight: 700;
gap: 20px;
height: 60px;
flex: 1;
}
.actions {

View File

@ -0,0 +1,23 @@
import { Column, Row } from 'react-basics';
import styles from './ReportsLayout.module.css';
export function SettingsLayout({ children, filter, header }) {
return (
<>
<Row>{header}</Row>
<Row>
{filter && (
<Column className={styles.filter} defaultSize={12} md={4} lg={3} xl={3}>
<h2>Filters</h2>
{filter}
</Column>
)}
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={9}>
{children}
</Column>
</Row>
</>
);
}
export default SettingsLayout;

View File

@ -0,0 +1,23 @@
.filter {
margin-top: 30px;
min-width: 200px;
max-width: 100vw;
padding: 10px;
background: var(--base50);
border-radius: 5px;
border: 1px solid var(--border-color);
}
.filter h2 {
padding-bottom: 20px;
}
.content {
min-height: 50vh;
}
@media only screen and (max-width: 768px) {
.menu {
display: none;
}
}

View File

@ -18,6 +18,7 @@ export const labels = defineMessages({
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
details: { id: 'label.details', defaultMessage: 'Details' },
website: { id: 'label.website', defaultMessage: 'Website' },
websites: { id: 'label.websites', defaultMessage: 'Websites' },
created: { id: 'label.created', defaultMessage: 'Created' },
edit: { id: 'label.edit', defaultMessage: 'Edit' },
@ -47,6 +48,8 @@ export const labels = defineMessages({
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
reset: { id: 'label.reset', defaultMessage: 'Reset' },
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
addField: { id: 'label.add-field', defaultMessage: 'Add field' },
addDescription: { id: 'label.add-description', defaultMessage: 'Add description' },
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
@ -79,7 +82,8 @@ export const labels = defineMessages({
countries: { id: 'label.countries', defaultMessage: 'Countries' },
languages: { id: 'label.languages', defaultMessage: 'Languages' },
events: { id: 'label.events', defaultMessage: 'Events' },
query: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
query: { id: 'label.query', defaultMessage: 'Query' },
queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
back: { id: 'label.back', defaultMessage: 'Back' },
visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
@ -97,6 +101,7 @@ export const labels = defineMessages({
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
all: { id: 'label.all', defaultMessage: 'All' },
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
@ -117,6 +122,17 @@ export const labels = defineMessages({
view: { id: 'label.view', defaultMessage: 'View' },
cities: { id: 'label.cities', defaultMessage: 'Cities' },
regions: { id: 'label.regions', defaultMessage: 'Regions' },
reports: { id: 'label.reports', defaultMessage: 'Reports' },
eventData: { id: 'label.event-data', defaultMessage: 'Event data' },
funnel: { id: 'label.funnel', defaultMessage: 'Funnel' },
url: { id: 'label.url', defaultMessage: 'URL' },
urls: { id: 'label.urls', defaultMessage: 'URLs' },
add: { id: 'label.add', defaultMessage: 'Add' },
window: { id: 'label.window', defaultMessage: 'Window' },
addUrl: { id: 'label.add-url', defaultMessage: 'Add URL' },
runQuery: { id: 'label.run-query', defaultMessage: 'Run query' },
field: { id: 'label.field', defaultMessage: 'Field' },
fields: { id: 'label.fields', defaultMessage: 'Fields' },
});
export const messages = defineMessages({
@ -158,6 +174,10 @@ export const messages = defineMessages({
id: 'message.team-already-member',
defaultMessage: 'You are already a member of the team.',
},
deleteAccount: {
id: 'message.delete-account',
defaultMessage: 'To delete this account, type {confirmation} in the box below to confirm.',
},
deleteWebsite: {
id: 'message.delete-website',
defaultMessage: 'To delete this website, type {confirmation} in the box below to confirm.',
@ -179,6 +199,10 @@ export const messages = defineMessages({
id: 'message.delete-website-warning',
defaultMessage: 'All website data will be deleted.',
},
noResultsFound: {
id: 'messages.no-results-found',
defaultMessage: 'No results were found.',
},
noWebsitesConfigured: {
id: 'messages.no-websites-configured',
defaultMessage: 'You do not have any websites configured.',

View File

@ -1,14 +1,13 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { StatusLight, Loading } from 'react-basics';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
import Chart from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import { formatLongNumber } from 'lib/format';
import { dateFormat } from 'lib/date';
import useLocale from 'hooks/useLocale';
import useTheme from 'hooks/useTheme';
import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { renderNumberLabels } from 'lib/charts';
import styles from './BarChart.module.css';
export function BarChart({
@ -17,84 +16,20 @@ export function BarChart({
animationDuration = DEFAULT_ANIMATION_DURATION,
stacked = false,
loading = false,
onCreate = () => {},
onUpdate = () => {},
renderXLabel,
renderYLabel,
XAxisType = 'time',
YAxisType = 'linear',
renderTooltip,
onCreate,
onUpdate,
className,
}) {
const canvas = useRef();
const chart = useRef(null);
const [tooltip, setTooltip] = useState(null);
const { locale } = useLocale();
const [theme] = useTheme();
const colors = useMemo(
() => ({
text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200,
}),
[theme],
);
const renderYLabel = label => {
return +label > 1000 ? formatLongNumber(label) : label;
};
const renderXLabel = useCallback(
(label, index, values) => {
const d = new Date(values[index].value);
switch (unit) {
case 'minute':
return dateFormat(d, 'h:mm', locale);
case 'hour':
return dateFormat(d, 'p', locale);
case 'day':
return dateFormat(d, 'MMM d', locale);
case 'month':
return dateFormat(d, 'MMM', locale);
default:
return label;
}
},
[locale, unit],
);
const renderTooltip = useCallback(
model => {
const { opacity, labelColors, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) {
setTooltip(null);
return;
}
const formats = {
millisecond: 'T',
second: 'pp',
minute: 'p',
hour: 'h:mm aaa - PP',
day: 'PPPP',
week: 'PPPP',
month: 'LLLL yyyy',
quarter: 'qqq',
year: 'yyyy',
};
setTooltip(
<div className={styles.tooltip}>
<div>{dateFormat(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
<div className={styles.value}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
</div>
</StatusLight>
</div>
</div>,
);
},
[unit],
);
const { theme, colors } = useTheme();
const getOptions = useCallback(() => {
return {
@ -115,12 +50,12 @@ export function BarChart({
},
tooltip: {
enabled: false,
external: renderTooltip,
external: renderTooltip ? renderTooltip.bind(null, setTooltip) : undefined,
},
},
scales: {
x: {
type: 'time',
type: XAxisType,
stacked: true,
time: {
unit,
@ -129,34 +64,44 @@ export function BarChart({
display: false,
},
border: {
color: colors.line,
color: colors.chart.line,
},
ticks: {
color: colors.text,
color: colors.chart.text,
autoSkip: false,
maxRotation: 0,
callback: renderXLabel,
},
},
y: {
type: 'linear',
type: YAxisType,
min: 0,
beginAtZero: true,
stacked,
grid: {
color: colors.line,
color: colors.chart.line,
},
border: {
color: colors.line,
color: colors.chart.line,
},
ticks: {
color: colors.text,
callback: renderYLabel,
callback: renderYLabel || renderNumberLabels,
},
},
},
};
}, [animationDuration, renderTooltip, renderXLabel, stacked, colors, unit, locale]);
}, [
animationDuration,
renderTooltip,
renderXLabel,
XAxisType,
YAxisType,
stacked,
colors,
unit,
locale,
]);
const createChart = () => {
Chart.defaults.font.family = 'Inter';
@ -171,7 +116,7 @@ export function BarChart({
options,
});
onCreate(chart.current);
onCreate?.(chart.current);
};
const updateChart = () => {
@ -184,7 +129,7 @@ export function BarChart({
chart.current.options = getOptions();
onUpdate(chart.current);
onUpdate?.(chart.current);
chart.current.update();
};
@ -206,7 +151,11 @@ export function BarChart({
<canvas ref={canvas} />
</div>
<Legend chart={chart.current} />
{tooltip && <HoverTooltip tooltip={tooltip} />}
{tooltip && (
<HoverTooltip>
<div className={styles.tooltip}>{tooltip}</div>
</HoverTooltip>
)}
</>
);
}

View File

@ -5,8 +5,8 @@ import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import NoData from 'components/common/NoData';
import { formatNumber, formatLongNumber } from 'lib/format';
import useMessages from 'hooks/useMessages';
import styles from './DataTable.module.css';
import useMessages from '../../hooks/useMessages';
export function DataTable({
data = [],

View File

@ -1,9 +1,9 @@
.table {
position: relative;
height: 100%;
display: grid;
grid-template-rows: fit-content(100%) auto;
overflow: hidden;
flex: 1;
}
.body {

View File

@ -2,7 +2,6 @@ import { useState } from 'react';
import { Button, ButtonGroup, Calendar } from 'react-basics';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import useLocale from 'hooks/useLocale';
import { getDateRangeValues } from 'lib/date';
import { getDateLocale } from 'lib/lang';
import { FILTER_DAY, FILTER_RANGE } from 'lib/constants';
import useMessages from 'hooks/useMessages';
@ -19,7 +18,7 @@ export function DatePickerForm({
const [selected, setSelected] = useState(
isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
);
const [date, setDate] = useState(defaultStartDate);
const [singleDate, setSingleDate] = useState(defaultStartDate);
const [startDate, setStartDate] = useState(defaultStartDate);
const [endDate, setEndDate] = useState(defaultEndDate);
const { locale } = useLocale();
@ -27,14 +26,14 @@ export function DatePickerForm({
const disabled =
selected === FILTER_DAY
? isAfter(minDate, date) && isBefore(maxDate, date)
? isAfter(minDate, singleDate) && isBefore(maxDate, singleDate)
: isAfter(startDate, endDate);
const handleSave = () => {
if (selected === FILTER_DAY) {
onChange({ ...getDateRangeValues(date, date), value: 'custom' });
onChange(`range:${singleDate.getTime()}:${singleDate.getTime()}`);
} else {
onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' });
onChange(`range:${startDate.getTime()}:${endDate.getTime()}`);
}
};
@ -48,7 +47,12 @@ export function DatePickerForm({
</div>
<div className={styles.calendars}>
{selected === FILTER_DAY && (
<Calendar date={date} minDate={minDate} maxDate={maxDate} onChange={setDate} />
<Calendar
date={singleDate}
minDate={minDate}
maxDate={maxDate}
onChange={setSingleDate}
/>
)}
{selected === FILTER_RANGE && (
<>

View File

@ -2,16 +2,15 @@ import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { colord } from 'colord';
import BarChart from './BarChart';
import { getDateArray, getDateLength } from 'lib/date';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import { getDateArray } from 'lib/date';
import { useApi, useLocale, useDateRange, useTimezone, usePageQuery } from 'hooks';
import { EVENT_COLORS } from 'lib/constants';
import { renderDateLabels, renderStatusTooltip } from 'lib/charts';
export function EventsChart({ websiteId, className, token }) {
const { get, useQuery } = useApi();
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
const { locale } = useLocale();
const [timezone] = useTimezone();
const {
query: { url, eventName },
@ -70,9 +69,10 @@ export function EventsChart({ websiteId, className, token }) {
datasets={datasets}
unit={unit}
height={300}
records={getDateLength(startDate, endDate, unit)}
loading={isLoading}
stacked
renderXLabel={renderDateLabels(unit, locale)}
renderTooltip={renderStatusTooltip(unit, locale)}
/>
);
}

View File

@ -16,13 +16,13 @@ export function MetricsBar({ websiteId }) {
const { startDate, endDate, modified } = dateRange;
const [format, setFormat] = useState(true);
const {
query: { url, referrer, os, browser, device, country, region, city },
query: { url, referrer, title, os, browser, device, country, region, city },
} = usePageQuery();
const { data, error, isLoading, isFetched } = useQuery(
[
'websites:stats',
{ websiteId, modified, url, referrer, os, browser, device, country, region, city },
{ websiteId, modified, url, referrer, title, os, browser, device, country, region, city },
],
() =>
get(`/websites/${websiteId}/stats`, {
@ -30,6 +30,7 @@ export function MetricsBar({ websiteId }) {
endAt: +endDate,
url,
referrer,
title,
os,
browser,
device,

View File

@ -1,7 +1,7 @@
.bar {
display: flex;
cursor: pointer;
min-height: 80px;
min-height: 110px;
gap: 20px;
flex-wrap: wrap;
}

View File

@ -30,7 +30,7 @@ export function MetricsTable({
const {
resolveUrl,
router,
query: { url, referrer, os, browser, device, country, region, city },
query: { url, referrer, title, os, browser, device, country, region, city },
} = usePageQuery();
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
@ -38,7 +38,20 @@ export function MetricsTable({
const { data, isLoading, isFetched, error } = useQuery(
[
'websites:metrics',
{ websiteId, type, modified, url, referrer, os, browser, device, country, region, city },
{
websiteId,
type,
modified,
url,
referrer,
os,
title,
browser,
device,
country,
region,
city,
},
],
() =>
get(`/websites/${websiteId}/metrics`, {
@ -46,6 +59,7 @@ export function MetricsTable({
startAt: +startDate,
endAt: +endDate,
url,
title,
referrer,
os,
browser,
@ -59,13 +73,27 @@ export function MetricsTable({
const filteredData = useMemo(() => {
if (data) {
let items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
let items = data;
if (dataFilter) {
if (Array.isArray(dataFilter)) {
items = dataFilter.reduce((arr, filter) => {
return filter(arr);
}, items);
} else {
items = dataFilter(data);
}
}
items = percentFilter(items);
if (limit) {
items = items.filter((e, i) => i < limit);
}
if (filterOptions?.sort === false) {
return items;
}
return items.sort(firstBy('y', -1).thenBy('x'));
}
return [];

View File

@ -1,34 +1,13 @@
import { useMemo } from 'react';
import { colord } from 'colord';
import BarChart from './BarChart';
import { THEME_COLORS } from 'lib/constants';
import useTheme from 'hooks/useTheme';
import useMessages from 'hooks/useMessages';
import useLocale from 'hooks/useLocale';
import { useLocale, useTheme, useMessages } from 'hooks';
import { renderDateLabels, renderStatusTooltip } from 'lib/charts';
export function PageviewsChart({ websiteId, data, unit, records, className, loading, ...props }) {
export function PageviewsChart({ websiteId, data, unit, className, loading, ...props }) {
const { formatMessage, labels } = useMessages();
const [theme] = useTheme();
const { colors } = useTheme();
const { locale } = useLocale();
const colors = useMemo(() => {
const primaryColor = colord(THEME_COLORS[theme].primary);
return {
views: {
hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(),
backgroundColor: primaryColor.alpha(0.4).toRgbString(),
borderColor: primaryColor.alpha(0.7).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
visitors: {
hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
backgroundColor: primaryColor.alpha(0.6).toRgbString(),
borderColor: primaryColor.alpha(0.9).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
};
}, [theme]);
const datasets = useMemo(() => {
if (!data) return [];
@ -37,13 +16,13 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load
label: formatMessage(labels.uniqueVisitors),
data: data.sessions,
borderWidth: 1,
...colors.visitors,
...colors.chart.visitors,
},
{
label: formatMessage(labels.pageViews),
data: data.pageviews,
borderWidth: 1,
...colors.views,
...colors.chart.views,
},
];
}, [data, locale, colors]);
@ -55,8 +34,9 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load
className={className}
datasets={datasets}
unit={unit}
records={records}
loading={loading}
renderXLabel={renderDateLabels(unit, locale)}
renderTooltip={renderStatusTooltip(unit, locale)}
/>
);
}

View File

@ -9,7 +9,7 @@ import styles from './QueryParametersTable.module.css';
const filters = {
[FILTER_RAW]: emptyFilter,
[FILTER_COMBINED]: paramFilter,
[FILTER_COMBINED]: [emptyFilter, paramFilter],
};
export function QueryParametersTable({ websiteId, showFilters, ...props }) {

View File

@ -5,7 +5,7 @@ import classNames from 'classnames';
import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
import WebsiteHeader from './WebsiteHeader';
import DateFilter from 'components/input/DateFilter';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import ErrorMessage from 'components/common/ErrorMessage';
import FilterTags from 'components/metrics/FilterTags';
import RefreshButton from 'components/input/RefreshButton';
@ -107,7 +107,7 @@ export function WebsiteChart({
<Column defaultSize={12} xl={4}>
<div className={styles.actions}>
<RefreshButton websiteId={websiteId} isLoading={isLoading} />
<DateFilter websiteId={websiteId} value={value} className={styles.dropdown} />
<WebsiteDateFilter websiteId={websiteId} value={value} className={styles.dropdown} />
</div>
</Column>
</Row>

View File

@ -1,3 +1,9 @@
.header {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
display: flex;
flex-direction: row;

View File

@ -28,20 +28,41 @@ export function TestConsole() {
window.umami.track({ url: '/page-view', referrer: 'https://www.google.com' });
window.umami.track('track-event-no-data');
window.umami.track('track-event-with-data', {
data: {
test: 'test-data',
boolean: true,
booleanError: 'true',
time: new Date(),
number: 1,
time2: new Date().toISOString(),
nested: {
test: 'test-data',
time: new Date(),
number: 1,
time2: new Date().toISOString(),
nested: {
object: {
test: 'test-data',
number: 1,
object: {
test: 'test-data',
},
},
array: [1, 2, 3],
},
array: [1, 2, 3],
});
}
function handleIdentifyClick() {
window.umami.identify({
userId: 123,
name: 'brian',
number: Math.random() * 100,
test: 'test-data',
boolean: true,
booleanError: 'true',
time: new Date(),
time2: new Date().toISOString(),
nested: {
test: 'test-data',
number: 1,
object: {
test: 'test-data',
},
},
array: [1, 2, 3],
});
}
@ -114,6 +135,10 @@ export function TestConsole() {
<Button id="manual-button" variant="action" onClick={handleClick}>
Run script
</Button>
<p />
<Button id="manual-button" variant="action" onClick={handleIdentifyClick}>
Run identify
</Button>
</Column>
</Row>
<Row>

View File

@ -10,6 +10,7 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
const { formatMessage, labels } = useMessages();
const { pageviews } = data;
const [filter, setFilter] = useState(FILTER_REFERRERS);
const limit = 15;
const buttons = [
{
@ -47,7 +48,8 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
}
return arr;
}, [])
.sort(firstBy('y', -1)),
.sort(firstBy('y', -1))
.slice(0, limit),
);
const pages = percentFilter(
@ -62,7 +64,8 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
}
return arr;
}, [])
.sort(firstBy('y', -1)),
.sort(firstBy('y', -1))
.slice(0, limit),
);
return [referrers, pages];

View File

@ -0,0 +1,43 @@
import { FormRow } from 'react-basics';
import DateFilter from 'components/input/DateFilter';
import WebsiteSelect from 'components/input/WebsiteSelect';
import { parseDateRange } from 'lib/date';
import { useContext } from 'react';
import { ReportContext } from './Report';
import { useMessages } from 'hooks';
export function BaseParameters() {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const { value, startDate, endDate } = dateRange || {};
const handleWebsiteSelect = websiteId => {
updateReport({ websiteId, parameters: { websiteId } });
};
const handleDateChange = value => {
updateReport({ parameters: { dateRange: { ...parseDateRange(value) } } });
};
return (
<>
<FormRow label={formatMessage(labels.website)}>
<WebsiteSelect websiteId={websiteId} onSelect={handleWebsiteSelect} />
</FormRow>
<FormRow label={formatMessage(labels.dateRange)}>
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={handleDateChange}
showAllTime
/>
</FormRow>
</>
);
}
export default BaseParameters;

View File

@ -0,0 +1,20 @@
import { createContext } from 'react';
import Page from 'components/layout/Page';
import styles from './reports.module.css';
import { useReport } from 'hooks';
export const ReportContext = createContext(null);
export function Report({ reportId, defaultParameters, children, ...props }) {
const report = useReport(reportId, defaultParameters);
return (
<ReportContext.Provider value={{ ...report }}>
<Page {...props} className={styles.container}>
{children}
</Page>
</ReportContext.Provider>
);
}
export default Report;

View File

@ -0,0 +1,7 @@
import styles from './reports.module.css';
export function ReportBody({ children }) {
return <div className={styles.body}>{children}</div>;
}
export default ReportBody;

View File

@ -0,0 +1,83 @@
import { useContext } from 'react';
import { useRouter } from 'next/router';
import { Icon, LoadingButton, InlineEditField, useToast } from 'react-basics';
import PageHeader from 'components/layout/PageHeader';
import { useMessages, useApi } from 'hooks';
import { ReportContext } from './Report';
import styles from './ReportHeader.module.css';
import reportStyles from './reports.module.css';
export function ReportHeader({ icon }) {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels, messages } = useMessages();
const { toast, showToast } = useToast();
const { post, useMutation } = useApi();
const router = useRouter();
const { mutate: create, isLoading: isCreating } = useMutation(data => post(`/reports`, data));
const { mutate: update, isLoading: isUpdating } = useMutation(data =>
post(`/reports/${data.id}`, data),
);
const { name, description, parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const handleSave = async () => {
if (!report.id) {
create(report, {
onSuccess: async ({ id }) => {
router.push(`/reports/${id}`, null, { shallow: true });
showToast({ message: formatMessage(messages.saved), variant: 'success' });
},
});
} else {
update(report, {
onSuccess: async () => {
showToast({ message: formatMessage(messages.saved), variant: 'success' });
},
});
}
};
const handleNameChange = name => {
updateReport({ name });
};
const handleDescriptionChange = description => {
updateReport({ description });
};
const Title = () => {
return (
<>
<Icon size="lg">{icon}</Icon>
<InlineEditField name="name" value={name} onCommit={handleNameChange} />
</>
);
};
return (
<div className={reportStyles.header}>
<PageHeader title={<Title />}>
<LoadingButton
variant="primary"
loading={isCreating || isUpdating}
disabled={!websiteId || !dateRange?.value || !name}
onClick={handleSave}
>
{formatMessage(labels.save)}
</LoadingButton>
</PageHeader>
<div className={styles.description}>
<InlineEditField
name="description"
value={description}
placeholder={`+ ${formatMessage(labels.addDescription)}`}
onCommit={handleDescriptionChange}
/>
</div>
{toast}
</div>
);
}
export default ReportHeader;

View File

@ -0,0 +1,3 @@
.description {
color: var(--font-color300);
}

View File

@ -0,0 +1,68 @@
import Link from 'next/link';
import { Button, Icons, Text, Icon } from 'react-basics';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import Funnel from 'assets/funnel.svg';
import Nodes from 'assets/nodes.svg';
import Lightbulb from 'assets/lightbulb.svg';
import styles from './ReportList.module.css';
const reports = [
{
title: 'Event data',
description: 'Query your custom event data.',
url: '/reports/event-data',
icon: <Nodes />,
},
{
title: 'Funnel',
description: 'Understand the conversion and drop-off rate of users.',
url: '/reports/funnel',
icon: <Funnel />,
},
{
title: 'Insights',
description: 'Explore your data by applying segments and filters.',
url: '/reports/insights',
icon: <Lightbulb />,
},
];
function ReportItem({ title, description, url, icon }) {
return (
<div className={styles.report}>
<div className={styles.title}>
<Icon size="lg">{icon}</Icon>
<Text>{title}</Text>
</div>
<div className={styles.description}>{description}</div>
<div className={styles.buttons}>
<Link href={url}>
<Button variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>Create</Text>
</Button>
</Link>
</div>
</div>
);
}
export function ReportList() {
return (
<Page>
<PageHeader title="Reports" />
<div className={styles.reports}>
{reports.map(({ title, description, url, icon }) => {
return (
<ReportItem key={title} icon={icon} title={title} description={description} url={url} />
);
})}
</div>
</Page>
);
}
export default ReportList;

View File

@ -0,0 +1,32 @@
.reports {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 20px;
}
.report {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
border: 1px solid var(--base500);
border-radius: var(--border-radius);
}
.title {
display: flex;
gap: 10px;
align-items: center;
font-size: var(--font-size-lg);
font-weight: 700;
}
.description {
flex: 1;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -0,0 +1,7 @@
import styles from './reports.module.css';
export function ReportMenu({ children }) {
return <div className={styles.menu}>{children}</div>;
}
export default ReportMenu;

View File

@ -0,0 +1,66 @@
import { useContext, useRef } from 'react';
import { useApi, useMessages } from 'hooks';
import { Form, FormRow, FormButtons, SubmitButton, Loading } from 'react-basics';
import { ReportContext } from 'components/pages/reports/Report';
import NoData from 'components/common/NoData';
import styles from './EventDataParameters.module.css';
import { DATA_TYPES } from 'lib/constants';
function useFields(websiteId, startDate, endDate) {
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery(
['fields', websiteId, startDate, endDate],
() => get('/reports/event-data', { websiteId, startAt: +startDate, endAt: +endDate }),
{ enabled: !!(websiteId && startDate && endDate) },
);
return { data, error, isLoading };
}
export function EventDataParameters() {
const { report, runReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const ref = useRef(null);
const { parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const { startDate, endDate } = dateRange || {};
const queryDisabled = !websiteId || !dateRange;
const { data, error, isLoading } = useFields(websiteId, startDate, endDate);
const handleSubmit = values => {
runReport(values);
};
if (!websiteId || !dateRange) {
return null;
}
if (isLoading) {
return <Loading icon="dots" />;
}
return (
<Form ref={ref} values={parameters} error={error} onSubmit={handleSubmit}>
<FormRow label={formatMessage(labels.fields)}>
<div className={styles.fields}>
{!data?.length && <NoData />}
{data?.map?.(({ eventKey, eventDataType }) => {
return (
<div className={styles.field} key={eventKey}>
<div className={styles.key}>{eventKey}</div>
<div className={styles.type}>{DATA_TYPES[eventDataType]}</div>
</div>
);
})}
</div>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default EventDataParameters;

View File

@ -0,0 +1,27 @@
.fields {
max-height: 300px;
overflow: auto;
}
.field {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 30px;
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius);
}
.field:hover {
background: var(--base75);
}
.key {
font-weight: 400;
}
.type {
color: var(--font-color300);
}

View File

@ -0,0 +1,23 @@
import Report from '../Report';
import ReportHeader from '../ReportHeader';
import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody';
import EventDataParameters from './EventDataParameters';
import Nodes from 'assets/nodes.svg';
const defaultParameters = {
type: 'event-data',
parameters: { fields: [], filters: [] },
};
export default function EventDataReport({ reportId }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Nodes />} />
<ReportMenu>
<EventDataParameters />
</ReportMenu>
<ReportBody>hi.</ReportBody>
</Report>
);
}

View File

@ -0,0 +1,28 @@
import { useMessages } from 'hooks';
import { Button, Form, FormButtons, FormRow } from 'react-basics';
export function FieldAddForm({ onClose }) {
const { formatMessage, labels } = useMessages();
const handleSave = () => {
onClose();
};
const handleClose = () => {
onClose();
};
return (
<Form>
<FormRow label={formatMessage(labels.url)}></FormRow>
<FormButtons align="center" flex>
<Button variant="primary" onClick={handleSave}>
{formatMessage(labels.save)}
</Button>
<Button onClick={handleClose}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}
export default FieldAddForm;

View File

@ -0,0 +1,63 @@
import { useCallback, useContext, useMemo } from 'react';
import { Loading } from 'react-basics';
import useMessages from 'hooks/useMessages';
import useTheme from 'hooks/useTheme';
import BarChart from 'components/metrics/BarChart';
import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css';
import { ReportContext } from '../Report';
export function FunnelChart({ className, loading }) {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { colors } = useTheme();
const { parameters, data } = report || {};
const renderXLabel = useCallback(
(label, index) => {
return parameters.urls[index];
},
[parameters],
);
const renderTooltip = useCallback((setTooltip, model) => {
const { opacity, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) {
setTooltip(null);
return;
}
setTooltip(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
}, []);
const datasets = useMemo(() => {
return [
{
label: formatMessage(labels.uniqueVisitors),
data: data,
borderWidth: 1,
...colors.chart.visitors,
},
];
}, [data]);
if (loading) {
return <Loading icon="dots" className={styles.loading} />;
}
return (
<BarChart
className={className}
datasets={datasets}
unit="day"
loading={loading}
renderXLabel={renderXLabel}
renderTooltip={renderTooltip}
XAxisType="category"
/>
);
}
export default FunnelChart;

View File

@ -0,0 +1,3 @@
.loading {
height: 300px;
}

View File

@ -0,0 +1,107 @@
import { useContext, useRef, useState } from 'react';
import { useMessages } from 'hooks';
import {
Icon,
Form,
FormButtons,
FormInput,
FormRow,
PopupTrigger,
Popup,
SubmitButton,
Text,
TextField,
Tooltip,
} from 'react-basics';
import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm';
import { ReportContext } from 'components/pages/reports/Report';
import styles from './FunnelParameters.module.css';
import BaseParameters from '../BaseParameters';
export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const ref = useRef(null);
const { parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2;
const handleSubmit = data => {
if (!queryDisabled) {
runReport(data);
}
};
const handleAddUrl = url => {
updateReport({ parameters: { urls: parameters.urls.concat(url) } });
};
const handleRemoveUrl = (index, e) => {
e.stopPropagation();
const urls = [...parameters.urls];
urls.splice(index, 1);
updateReport({ parameters: { urls } });
};
return (
<Form ref={ref} values={parameters} onSubmit={handleSubmit}>
<BaseParameters />
<FormRow label={formatMessage(labels.window)}>
<FormInput
name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
>
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton onAdd={handleAddUrl} />}>
<div className={styles.urls}>
{parameters?.urls?.map((url, index) => {
return (
<div key={index} className={styles.url}>
<Text>{url}</Text>
<Tooltip
className={styles.icon}
label={formatMessage(labels.remove)}
position="right"
>
<Icon onClick={handleRemoveUrl.bind(null, index)}>
<Icons.Close />
</Icon>
</Tooltip>
</div>
);
})}
</div>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
function AddUrlButton({ onAdd }) {
const { formatMessage, labels } = useMessages();
return (
<PopupTrigger>
<Tooltip label={formatMessage(labels.addUrl)}>
<Icon>
<Icons.Plus />
</Icon>
</Tooltip>
<Popup position="bottom" alignment="start">
{close => {
return <UrlAddForm onSave={onAdd} onClose={close} />;
}}
</Popup>
</PopupTrigger>
);
}
export default FunnelParameters;

View File

@ -0,0 +1,20 @@
.urls {
display: flex;
flex-direction: column;
gap: 16px;
}
.url {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 1px 1px 1px var(--base400);
}
.icon {
align-self: center;
}

View File

@ -0,0 +1,28 @@
import FunnelChart from './FunnelChart';
import FunnelTable from './FunnelTable';
import FunnelParameters from './FunnelParameters';
import Report from '../Report';
import ReportHeader from '../ReportHeader';
import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody';
import Funnel from 'assets/funnel.svg';
const defaultParameters = {
type: 'funnel',
parameters: { window: 60, urls: [] },
};
export default function FunnelReport({ reportId }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Funnel />} />
<ReportMenu>
<FunnelParameters />
</ReportMenu>
<ReportBody>
<FunnelChart />
<FunnelTable />
</ReportBody>
</Report>
);
}

View File

@ -0,0 +1,10 @@
.filters {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
line-height: 32px;
padding: 10px;
overflow: hidden;
}

View File

@ -0,0 +1,20 @@
import { useContext } from 'react';
import DataTable from 'components/metrics/DataTable';
import { useMessages } from 'hooks';
import { ReportContext } from '../Report';
export function FunnelTable() {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
return (
<DataTable
data={report?.data}
title={formatMessage(labels.url)}
metric={formatMessage(labels.visitors)}
showPercentage={false}
/>
);
}
export default FunnelTable;

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { useMessages } from 'hooks';
import { Button, Form, FormButtons, FormRow, TextField } from 'react-basics';
import styles from './UrlAddForm.module.css';
export function UrlAddForm({ defaultValue = '', onSave, onClose }) {
const [url, setUrl] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const handleSave = () => {
onSave?.(url);
setUrl('');
onClose();
};
const handleChange = e => {
setUrl(e.target.value);
};
return (
<Form className={styles.form} onSubmit={handleSave}>
<FormRow label={formatMessage(labels.url)}>
<TextField
name="url"
value={url}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
/>
</FormRow>
<FormButtons align="center" flex>
<Button variant="primary" onClick={handleSave}>
{formatMessage(labels.save)}
</Button>
</FormButtons>
</Form>
);
}
export default UrlAddForm;

View File

@ -0,0 +1,8 @@
.form {
background: var(--base50);
padding: 30px;
margin-top: 10px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
}

View File

@ -0,0 +1,25 @@
.container {
display: grid;
grid-template-rows: max-content 1fr;
grid-template-columns: max-content 1fr;
}
.header {
grid-row: 1 / 2;
grid-column: 1 / 3;
margin-bottom: 40px;
}
.menu {
width: 300px;
padding-right: 20px;
border-right: 1px solid var(--base300);
grid-row: 2/3;
grid-column: 1 / 2;
}
.body {
padding-left: 20px;
grid-row: 2/3;
grid-column: 2 / 3;
}

View File

@ -7,13 +7,19 @@ import useMessages from 'hooks/useMessages';
export function DateRangeSetting() {
const { formatMessage, labels } = useMessages();
const [dateRange, setDateRange] = useDateRange();
const { startDate, endDate, value } = dateRange;
const { value } = dateRange;
const handleChange = value => setDateRange(value);
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
return (
<Flexbox gap={10}>
<DateFilter value={value} startDate={startDate} endDate={endDate} />
<DateFilter
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
</Flexbox>
);

View File

@ -6,13 +6,13 @@ import Moon from 'assets/moon.svg';
import styles from './ThemeSetting.module.css';
export function ThemeSetting() {
const [theme, setTheme] = useTheme();
const { theme, saveTheme } = useTheme();
return (
<div className={styles.buttons}>
<Button
className={classNames({ [styles.active]: theme === 'light' })}
onClick={() => setTheme('light')}
onClick={() => saveTheme('light')}
>
<Icon>
<Sun />
@ -20,7 +20,7 @@ export function ThemeSetting() {
</Button>
<Button
className={classNames({ [styles.active]: theme === 'dark' })}
onClick={() => setTheme('dark')}
onClick={() => saveTheme('dark')}
>
<Icon>
<Moon />

View File

@ -5,7 +5,7 @@ import useMessages from 'hooks/useMessages';
export function TeamLeaveForm({ teamId, userId, teamName, onSave, onClose }) {
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { del, useMutation } = useApi();
const { mutate, error, isLoading } = useMutation(() => del(`/team/${teamId}/users/${userId}`));
const { mutate, error, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
const handleSubmit = async () => {
mutate(
@ -22,7 +22,7 @@ export function TeamLeaveForm({ teamId, userId, teamName, onSave, onClose }) {
return (
<Form onSubmit={handleSubmit} error={error}>
<p>
<FormattedMessage {...messages.confirmLeave} values={{ name: <b>{teamName}</b> }} />
<FormattedMessage {...messages.confirmDelete} values={{ target: <b>{teamName}</b> }} />
</p>
<FormButtons flex>
<SubmitButton variant="danger" disabled={isLoading}>

View File

@ -5,7 +5,7 @@ import { Icon, Icons, LoadingButton, Text } from 'react-basics';
export function TeamMemberRemoveButton({ teamId, userId, disabled, onSave }) {
const { formatMessage, labels } = useMessages();
const { del, useMutation } = useApi();
const { mutate, isLoading } = useMutation(() => del(`/team/${teamId}/users/${userId}`));
const { mutate, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
const handleRemoveTeamMember = () => {
mutate(

View File

@ -4,10 +4,12 @@ import useConfig from 'hooks/useConfig';
export function TrackingCode({ websiteId }) {
const { formatMessage, messages } = useMessages();
const { trackerScriptName } = useConfig();
const { basePath, trackerScriptName } = useConfig();
const url = trackerScriptName?.startsWith('http')
? trackerScriptName
: `${location.origin}/${trackerScriptName?.split(',')?.map(n => n.trim())?.[0] || 'script.js'}`;
: `${location.origin}${basePath}/${
trackerScriptName?.split(',')?.map(n => n.trim())?.[0] || 'script.js'
}`;
const code = `<script async src="${url}" data-website-id="${websiteId}"></script>`;

View File

@ -13,7 +13,7 @@ import useMessages from 'hooks/useMessages';
const CONFIRM_VALUE = 'RESET';
export function WebsiteResetForm({ websiteId, onSave, onClose }) {
const { formatMessage, labels, messages } = useMessages();
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { post, useMutation } = useApi();
const { mutate, error } = useMutation(data => post(`/websites/${websiteId}/reset`, data));
@ -28,7 +28,12 @@ export function WebsiteResetForm({ websiteId, onSave, onClose }) {
return (
<Form onSubmit={handleSubmit} error={error}>
<p>{formatMessage(messages.resetWebsite, { confirmation: CONFIRM_VALUE })}</p>
<p>
<FormattedMessage
{...messages.resetWebsite}
values={{ confirmation: <b>{CONFIRM_VALUE}</b> }}
/>
</p>
<FormRow label={formatMessage(labels.confirm)}>
<FormInput name="confirm" rules={{ validate: value => value === CONFIRM_VALUE }}>
<TextField autoComplete="off" />

View File

@ -0,0 +1,4 @@
ALTER TABLE "event_data" RENAME COLUMN "event_date_value" TO "date_value";
ALTER TABLE "event_data" RENAME COLUMN "event_numeric_value" TO "numeric_value";
ALTER TABLE "event_data" RENAME COLUMN "event_string_value" TO "string_value";
ALTER TABLE "event_data" RENAME COLUMN "event_data_type" TO "data_type";

View File

@ -58,7 +58,10 @@ CREATE TABLE umami.website_event_queue (
--event
event_type UInt32,
event_name String,
created_at DateTime('UTC')
created_at DateTime('UTC'),
--virtual columns
_error String,
_raw_message String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
@ -66,7 +69,7 @@ SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input bro
kafka_group_name = 'event_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 100;
kafka_handle_error_mode = 'stream'
CREATE MATERIALIZED VIEW umami.website_event_queue_mv TO umami.website_event AS
SELECT website_id,
@ -93,6 +96,19 @@ SELECT website_id,
created_at
FROM umami.website_event_queue;
CREATE MATERIALIZED VIEW umami.website_event_errors_mv
(
error String,
raw String
)
ENGINE = MergeTree
ORDER BY (error, raw)
SETTINGS index_granularity = 8192 AS
SELECT _error AS error,
_raw_message AS raw
FROM umami.website_event_queue
WHERE length(_error) > 0
CREATE TABLE umami.event_data
(
website_id UUID,
@ -101,10 +117,10 @@ CREATE TABLE umami.event_data
url_path String,
event_name String,
event_key String,
event_string_value Nullable(String),
event_numeric_value Nullable(Decimal64(4)), --922337203685477.5625
event_date_value Nullable(DateTime('UTC')),
event_data_type UInt32,
string_value Nullable(String),
numeric_value Nullable(Decimal64(4)), --922337203685477.5625
date_value Nullable(DateTime('UTC')),
data_type UInt32,
created_at DateTime('UTC')
)
engine = MergeTree
@ -118,11 +134,14 @@ CREATE TABLE umami.event_data_queue (
url_path String,
event_name String,
event_key String,
event_string_value Nullable(String),
event_numeric_value Nullable(Decimal64(4)), --922337203685477.5625
event_date_value Nullable(DateTime('UTC')),
event_data_type UInt32,
created_at DateTime('UTC')
string_value Nullable(String),
numeric_value Nullable(Decimal64(4)), --922337203685477.5625
date_value Nullable(DateTime('UTC')),
data_type UInt32,
created_at DateTime('UTC'),
--virtual columns
_error String,
_raw_message String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input broker list
@ -130,7 +149,7 @@ SETTINGS kafka_broker_list = 'domain:9092,domain:9093,domain:9094', -- input bro
kafka_group_name = 'event_data_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 100;
kafka_handle_error_mode = 'stream'
CREATE MATERIALIZED VIEW umami.event_data_queue_mv TO umami.event_data AS
SELECT website_id,
@ -139,9 +158,22 @@ SELECT website_id,
url_path,
event_name,
event_key,
event_string_value,
event_numeric_value,
event_date_value,
event_data_type,
string_value,
numeric_value,
date_value,
data_type,
created_at
FROM umami.event_data_queue;
FROM umami.event_data_queue;
CREATE MATERIALIZED VIEW umami.event_data_errors_mv
(
error String,
raw String
)
ENGINE = MergeTree
ORDER BY (error, raw)
SETTINGS index_granularity = 8192 AS
SELECT _error AS error,
_raw_message AS raw
FROM umami.event_data_queue
WHERE length(_error) > 0

View File

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE `report` (
`report_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
`website_id` VARCHAR(36) NOT NULL,
`type` VARCHAR(200) NOT NULL,
`name` VARCHAR(200) NOT NULL,
`description` VARCHAR(500) NOT NULL,
`parameters` VARCHAR(6000) NOT NULL,
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NULL,
UNIQUE INDEX `report_report_id_key`(`report_id`),
INDEX `report_user_id_idx`(`user_id`),
INDEX `report_website_id_idx`(`website_id`),
INDEX `report_type_idx`(`type`),
INDEX `report_name_idx`(`name`),
PRIMARY KEY (`report_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,37 @@
/*
Warnings:
- The primary key for the `event_data` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `event_data_type` on the `event_data` table. All the data in the column will be lost.
- You are about to drop the column `event_date_value` on the `event_data` table. All the data in the column will be lost.
- You are about to drop the column `event_id` on the `event_data` table. All the data in the column will be lost.
- You are about to drop the column `event_numeric_value` on the `event_data` table. All the data in the column will be lost.
- You are about to drop the column `event_string_value` on the `event_data` table. All the data in the column will be lost.
- Added the required column `data_type` to the `event_data` table without a default value. This is not possible if the table is not empty.
- Added the required column `event_data_id` to the `event_data` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `event_data` RENAME COLUMN `event_data_type` TO `data_type`;
ALTER TABLE `event_data` RENAME COLUMN `event_date_value` TO `date_value`;
ALTER TABLE `event_data` RENAME COLUMN `event_id` TO `event_data_id`;
ALTER TABLE `event_data` RENAME COLUMN `event_numeric_value` TO `numeric_value`;
ALTER TABLE `event_data` RENAME COLUMN `event_string_value` TO `string_value`;
-- CreateTable
CREATE TABLE `session_data` (
`session_data_id` VARCHAR(36) NOT NULL,
`website_id` VARCHAR(36) NOT NULL,
`session_id` VARCHAR(36) NOT NULL,
`event_key` VARCHAR(500) NOT NULL,
`event_string_value` VARCHAR(500) NULL,
`event_numeric_value` DECIMAL(19, 4) NULL,
`event_date_value` TIMESTAMP(0) NULL,
`event_data_type` INTEGER UNSIGNED NOT NULL,
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
INDEX `session_data_created_at_idx`(`created_at`),
INDEX `session_data_website_id_idx`(`website_id`),
INDEX `session_data_session_id_idx`(`session_id`),
PRIMARY KEY (`session_data_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -14,11 +14,12 @@ model User {
password String @db.VarChar(60)
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
website Website[]
teamUser TeamUser[]
report Report[]
@@map("user")
}
@ -39,6 +40,7 @@ model Session {
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
websiteEvent WebsiteEvent[]
sessionData SessionData[]
@@index([createdAt])
@@index([websiteId])
@ -53,12 +55,14 @@ model Website {
resetAt DateTime? @map("reset_at") @db.Timestamp(0)
userId String? @map("user_id") @db.VarChar(36)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
user User? @relation(fields: [userId], references: [id])
teamWebsite TeamWebsite[]
eventData EventData[]
report Report[]
sessionData SessionData[]
@@index([userId])
@@index([createdAt])
@ -92,15 +96,15 @@ model WebsiteEvent {
}
model EventData {
id String @id() @map("event_id") @db.VarChar(36)
websiteEventId String @map("website_event_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
eventKey String @map("event_key") @db.VarChar(500)
eventStringValue String? @map("event_string_value") @db.VarChar(500)
eventNumericValue Decimal? @map("event_numeric_value") @db.Decimal(19, 4)
eventDateValue DateTime? @map("event_date_value") @db.Timestamp(0)
eventDataType Int @map("event_data_type") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
id String @id() @map("event_data_id") @db.VarChar(36)
websiteEventId String @map("website_event_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
eventKey String @map("event_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numericValue Decimal? @map("numeric_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamp(0)
dataType Int @map("data_type") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
websiteEvent WebsiteEvent @relation(fields: [websiteEventId], references: [id])
@ -112,12 +116,32 @@ model EventData {
@@map("event_data")
}
model SessionData {
id String @id() @map("session_data_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36)
eventKey String @map("event_key") @db.VarChar(500)
eventStringValue String? @map("event_string_value") @db.VarChar(500)
eventNumericValue Decimal? @map("event_numeric_value") @db.Decimal(19, 4)
eventDateValue DateTime? @map("event_date_value") @db.Timestamp(0)
eventDataType Int @map("event_data_type") @db.UnsignedInt
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([createdAt])
@@index([websiteId])
@@index([sessionId])
@@map("session_data")
}
model Team {
id String @id() @unique() @map("team_id") @db.VarChar(36)
name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
teamUser TeamUser[]
teamWebsite TeamWebsite[]
@ -132,7 +156,7 @@ model TeamUser {
userId String @map("user_id") @db.VarChar(36)
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id])
@ -155,3 +179,24 @@ model TeamWebsite {
@@index([websiteId])
@@map("team_website")
}
model Report {
id String @id() @unique() @map("report_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
type String @map("type") @db.VarChar(200)
name String @map("name") @db.VarChar(200)
description String @map("description") @db.VarChar(500)
parameters String @map("parameters") @db.VarChar(6000)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([userId])
@@index([websiteId])
@@index([type])
@@index([name])
@@map("report")
}

View File

@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "report" (
"report_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"type" VARCHAR(200) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"description" VARCHAR(500) NOT NULL,
"parameters" VARCHAR(6000) NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "report_pkey" PRIMARY KEY ("report_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "report_report_id_key" ON "report"("report_id");
-- CreateIndex
CREATE INDEX "report_user_id_idx" ON "report"("user_id");
-- CreateIndex
CREATE INDEX "report_website_id_idx" ON "report"("website_id");
-- CreateIndex
CREATE INDEX "report_type_idx" ON "report"("type");
-- CreateIndex
CREATE INDEX "report_name_idx" ON "report"("name");

View File

@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "event_data" RENAME COLUMN "event_data_type" TO "data_type";
ALTER TABLE "event_data" RENAME COLUMN "event_date_value" TO "date_value";
ALTER TABLE "event_data" RENAME COLUMN "event_id" TO "event_data_id";
ALTER TABLE "event_data" RENAME COLUMN "event_numeric_value" TO "numeric_value";
ALTER TABLE "event_data" RENAME COLUMN "event_string_value" TO "string_value";
-- CreateTable
CREATE TABLE "session_data" (
"session_data_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"session_id" UUID NOT NULL,
"session_key" VARCHAR(500) NOT NULL,
"string_value" VARCHAR(500),
"numeric_value" DECIMAL(19,4),
"date_value" TIMESTAMPTZ(6),
"data_type" INTEGER NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "session_data_pkey" PRIMARY KEY ("session_data_id")
);
-- CreateIndex
CREATE INDEX "session_data_created_at_idx" ON "session_data"("created_at");
-- CreateIndex
CREATE INDEX "session_data_website_id_idx" ON "session_data"("website_id");
-- CreateIndex
CREATE INDEX "session_data_session_id_idx" ON "session_data"("session_id");

View File

@ -14,11 +14,12 @@ model User {
password String @db.VarChar(60)
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
website Website[]
teamUser TeamUser[]
report Report[]
@@map("user")
}
@ -39,6 +40,7 @@ model Session {
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
websiteEvent WebsiteEvent[]
sessionData SessionData[]
@@index([createdAt])
@@index([websiteId])
@ -53,12 +55,14 @@ model Website {
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
userId String? @map("user_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
user User? @relation(fields: [userId], references: [id])
teamWebsite TeamWebsite[]
eventData EventData[]
report Report[]
sessionData SessionData[]
@@index([userId])
@@index([createdAt])
@ -92,15 +96,15 @@ model WebsiteEvent {
}
model EventData {
id String @id() @map("event_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
websiteEventId String @map("website_event_id") @db.Uuid
eventKey String @map("event_key") @db.VarChar(500)
eventStringValue String? @map("event_string_value") @db.VarChar(500)
eventNumericValue Decimal? @map("event_numeric_value") @db.Decimal(19, 4)
eventDateValue DateTime? @map("event_date_value") @db.Timestamptz(6)
eventDataType Int @map("event_data_type") @db.Integer
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
id String @id() @map("event_data_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
websiteEventId String @map("website_event_id") @db.Uuid
eventKey String @map("event_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numericValue Decimal? @map("numeric_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
dataType Int @map("data_type") @db.Integer
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
websiteEvent WebsiteEvent @relation(fields: [websiteEventId], references: [id])
@ -111,12 +115,33 @@ model EventData {
@@map("event_data")
}
model SessionData {
id String @id() @map("session_data_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
sessionKey String @map("session_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numericValue Decimal? @map("numeric_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamptz(6)
dataType Int @map("data_type") @db.Integer
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
deletedAt DateTime? @default(now()) @map("deleted_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([createdAt])
@@index([websiteId])
@@index([sessionId])
@@map("session_data")
}
model Team {
id String @id() @unique() @map("team_id") @db.Uuid
name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
teamUser TeamUser[]
teamWebsite TeamWebsite[]
@ -131,7 +156,7 @@ model TeamUser {
userId String @map("user_id") @db.Uuid
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id])
@ -154,3 +179,24 @@ model TeamWebsite {
@@index([websiteId])
@@map("team_website")
}
model Report {
id String @id() @unique() @map("report_id") @db.Uuid
userId String @map("user_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
type String @map("type") @db.VarChar(200)
name String @map("name") @db.VarChar(200)
description String @map("description") @db.VarChar(500)
parameters String @map("parameters") @db.VarChar(6000)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([userId])
@@index([websiteId])
@@index([type])
@@index([name])
@@map("report")
}

View File

@ -10,7 +10,8 @@ services:
DATABASE_TYPE: postgresql
APP_SECRET: replace-me-with-a-random-string
depends_on:
- db
db:
condition: service_healthy
restart: always
db:
image: postgres:15-alpine
@ -19,8 +20,12 @@ services:
POSTGRES_USER: umami
POSTGRES_PASSWORD: umami
volumes:
- ./sql/schema.postgresql.sql:/docker-entrypoint-initdb.d/schema.postgresql.sql:ro
- umami-db-data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
umami-db-data:

18
hooks/index.js Normal file
View File

@ -0,0 +1,18 @@
export * from './useApi';
export * from './useConfig';
export * from './useCountryNames';
export * from './useDateRange';
export * from './useDocumentClick';
export * from './useEscapeKey';
export * from './useForceUpdate';
export * from './useLanguageNames';
export * from './useLocale';
export * from './useMessages';
export * from './usePageQuery';
export * from './useReport';
export * from './useRequireLogin';
export * from './useShareToken';
export * from './useSticky';
export * from './useTheme';
export * from './useTimezone';
export * from './useUser';

View File

@ -7,7 +7,7 @@ import useStore from 'store/app';
const selector = state => state.shareToken;
export default function useApi() {
export function useApi() {
const { basePath } = useRouter();
const shareToken = useStore(selector);
@ -18,3 +18,5 @@ export default function useApi() {
return { get, post, put, del, ...reactQuery };
}
export default useApi;

View File

@ -4,7 +4,7 @@ import useApi from 'hooks/useApi';
let loading = false;
export default function useConfig() {
export function useConfig() {
const { config } = useStore();
const { get } = useApi();
@ -23,3 +23,5 @@ export default function useConfig() {
return config || {};
}
export default useConfig;

View File

@ -7,7 +7,7 @@ const countryNames = {
'en-US': enUS,
};
export default function useCountryNames(locale) {
export function useCountryNames(locale) {
const [list, setList] = useState(countryNames[locale] || enUS);
const { basePath } = useRouter();
@ -32,3 +32,5 @@ export default function useCountryNames(locale) {
return list;
}
export default useCountryNames;

View File

@ -5,21 +5,23 @@ import useLocale from './useLocale';
import websiteStore, { setWebsiteDateRange } from 'store/websites';
import appStore, { setDateRange } from 'store/app';
export default function useDateRange(websiteId) {
export function useDateRange(websiteId) {
const { locale } = useLocale();
const websiteConfig = websiteStore(state => state[websiteId]?.dateRange);
const defaultConfig = DEFAULT_DATE_RANGE;
const globalConfig = appStore(state => state.dateRange);
const dateRange = parseDateRange(websiteConfig || globalConfig || defaultConfig, locale);
function saveDateRange(value) {
const saveDateRange = value => {
if (websiteId) {
setWebsiteDateRange(websiteId, value);
} else {
setItem(DATE_RANGE_CONFIG, value);
setDateRange(value);
}
}
};
return [dateRange, saveDateRange];
}
export default useDateRange;

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react';
export default function useDocumentClick(handler) {
export function useDocumentClick(handler) {
useEffect(() => {
document.addEventListener('click', handler);
@ -11,3 +11,5 @@ export default function useDocumentClick(handler) {
return null;
}
export default useDocumentClick;

View File

@ -1,6 +1,6 @@
import { useEffect, useCallback } from 'react';
export default function useEscapeKey(handler) {
export function useEscapeKey(handler) {
const escFunction = useCallback(event => {
if (event.keyCode === 27) {
handler(event);
@ -17,3 +17,5 @@ export default function useEscapeKey(handler) {
return null;
}
export default useEscapeKey;

View File

@ -1,9 +1,11 @@
import { useCallback, useState } from 'react';
export default function useForceUpdate() {
export function useForceUpdate() {
const [, update] = useState(Object.create(null));
return useCallback(() => {
update(Object.create(null));
}, [update]);
}
export default useForceUpdate;

View File

@ -7,7 +7,7 @@ const languageNames = {
'en-US': enUS,
};
export default function useLanguageNames(locale) {
export function useLanguageNames(locale) {
const [list, setList] = useState(languageNames[locale] || enUS);
const { basePath } = useRouter();
@ -32,3 +32,5 @@ export default function useLanguageNames(locale) {
return list;
}
export default useLanguageNames;

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { get, setItem } from 'next-basics';
import { httpGet, setItem } from 'next-basics';
import { LOCALE_CONFIG } from 'lib/constants';
import { getDateLocale, getTextDirection } from 'lib/lang';
import useStore, { setLocale } from 'store/app';
@ -13,7 +13,7 @@ const messages = {
const selector = state => state.locale;
export default function useLocale() {
export function useLocale() {
const locale = useStore(selector);
const { basePath } = useRouter();
const forceUpdate = useForceUpdate();
@ -21,7 +21,7 @@ export default function useLocale() {
const dateLocale = getDateLocale(locale);
async function loadMessages(locale) {
const { ok, data } = await get(`${basePath}/intl/messages/${locale}.json`);
const { ok, data } = await httpGet(`${basePath}/intl/messages/${locale}.json`);
if (ok) {
messages[locale] = data;
@ -61,3 +61,5 @@ export default function useLocale() {
return { locale, saveLocale, messages, dir, dateLocale };
}
export default useLocale;

View File

@ -1,7 +1,7 @@
import { useIntl, FormattedMessage } from 'react-intl';
import { messages, labels } from 'components/messages';
export default function useMessages() {
export function useMessages() {
const { formatMessage } = useIntl();
function getMessage(id) {
@ -12,3 +12,5 @@ export default function useMessages() {
return { formatMessage, FormattedMessage, messages, labels, getMessage };
}
export default useMessages;

View File

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { useRouter } from 'next/router';
import { buildUrl } from 'next-basics';
export default function usePageQuery() {
export function usePageQuery() {
const router = useRouter();
const { pathname, search } = location;
const { asPath } = router;
@ -29,3 +29,5 @@ export default function usePageQuery() {
return { pathname, query, resolveUrl, router };
}
export default usePageQuery;

76
hooks/useReport.js Normal file
View File

@ -0,0 +1,76 @@
import { produce } from 'immer';
import { useCallback, useEffect, useState } from 'react';
import useApi from './useApi';
const baseParameters = {
name: 'Untitled',
description: '',
parameters: {},
};
export function useReport(reportId, defaultParameters) {
const [report, setReport] = useState(null);
const [isRunning, setIsRunning] = useState(false);
const { get, post } = useApi();
const loadReport = async id => {
const data = await get(`/reports/${id}`);
setReport(data);
};
const runReport = useCallback(
async parameters => {
setIsRunning(true);
const { type } = report;
const data = await post(`/reports/${type}`, parameters);
setReport(
produce(state => {
state.parameters = parameters;
state.data = data;
return state;
}),
);
setIsRunning(false);
},
[report],
);
const updateReport = useCallback(
async data => {
setReport(
produce(state => {
const { parameters, ...rest } = data;
if (parameters) {
state.parameters = { ...state.parameters, ...parameters };
}
for (const key in rest) {
state[key] = rest[key];
}
return state;
}),
);
},
[report],
);
useEffect(() => {
if (!reportId) {
setReport({ ...baseParameters, ...defaultParameters });
} else {
loadReport(reportId);
}
}, []);
return { report, runReport, updateReport, isRunning };
}
export default useReport;

View File

@ -3,7 +3,7 @@ import { useRouter } from 'next/router';
import useApi from 'hooks/useApi';
import useUser from 'hooks/useUser';
export default function useRequireLogin() {
export function useRequireLogin() {
const router = useRouter();
const { get } = useApi();
const { user, setUser } = useUser();
@ -26,3 +26,5 @@ export default function useRequireLogin() {
return { user };
}
export default useRequireLogin;

View File

@ -4,7 +4,7 @@ import useApi from './useApi';
const selector = state => state.shareToken;
export default function useShareToken(shareId) {
export function useShareToken(shareId) {
const shareToken = useStore(selector);
const { get } = useApi();
@ -24,3 +24,5 @@ export default function useShareToken(shareId) {
return shareToken;
}
export default useShareToken;

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
export default function useSticky({ enabled = true, threshold = 1 }) {
export function useSticky({ enabled = true, threshold = 1 }) {
const [isSticky, setIsSticky] = useState(false);
const ref = useRef(null);
@ -21,3 +21,5 @@ export default function useSticky({ enabled = true, threshold = 1 }) {
return { ref, isSticky };
}
export default useSticky;

View File

@ -1,11 +1,12 @@
import { useEffect } from 'react';
import useStore, { setTheme } from 'store/app';
import { getItem, setItem } from 'next-basics';
import { THEME_CONFIG } from 'lib/constants';
import { THEME_COLORS, THEME_CONFIG } from 'lib/constants';
import { colord } from 'colord';
const selector = state => state.theme;
export default function useTheme() {
export function useTheme() {
const defaultTheme =
typeof window !== 'undefined'
? window?.matchMedia('(prefers-color-scheme: dark)')?.matches
@ -13,6 +14,35 @@ export default function useTheme() {
: 'light'
: 'light';
const theme = useStore(selector) || getItem(THEME_CONFIG) || defaultTheme;
const primaryColor = colord(THEME_COLORS[theme].primary);
const colors = {
theme: {
...THEME_COLORS[theme],
},
chart: {
text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200,
views: {
hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(),
backgroundColor: primaryColor.alpha(0.4).toRgbString(),
borderColor: primaryColor.alpha(0.7).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
visitors: {
hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
backgroundColor: primaryColor.alpha(0.6).toRgbString(),
borderColor: primaryColor.alpha(0.9).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
},
map: {
baseColor: THEME_COLORS[theme].primary,
fillColor: THEME_COLORS[theme].gray100,
strokeColor: THEME_COLORS[theme].primary,
hoverColor: THEME_COLORS[theme].primary,
},
};
function saveTheme(value) {
setItem(THEME_CONFIG, value);
@ -32,5 +62,7 @@ export default function useTheme() {
}
}, []);
return [theme, saveTheme];
return { theme, saveTheme, colors };
}
export default useTheme;

View File

@ -3,7 +3,7 @@ import { getTimezone } from 'lib/date';
import { getItem, setItem } from 'next-basics';
import { TIMEZONE_CONFIG } from 'lib/constants';
export default function useTimezone() {
export function useTimezone() {
const [timezone, setTimezone] = useState(getItem(TIMEZONE_CONFIG) || getTimezone());
const saveTimezone = useCallback(
@ -16,3 +16,5 @@ export default function useTimezone() {
return [timezone, saveTimezone];
}
export default useTimezone;

View File

@ -2,8 +2,10 @@ import useStore, { setUser } from 'store/app';
const selector = state => state.user;
export default function useUser() {
export function useUser() {
const user = useStore(selector);
return { user, setUser };
}
export default useUser;

View File

@ -1,16 +1,32 @@
{
"cs-CZ": ["label.reset", "metrics.device.tablet"],
"de-DE": [
"label.administrator",
"label.name",
"de-CH": [
"label.admin",
"label.analytics",
"label.desktop",
"label.details",
"label.domain",
"label.theme",
"metrics.device.desktop",
"metrics.device.laptop",
"metrics.device.tablet",
"metrics.referrers",
"metrics.utm",
"metrics.utm_medium"
"label.laptop",
"label.tablet",
"label.name",
"label.sessions",
"label.team",
"label.team-id",
"label.teams"
],
"de-DE": [
"label.admin",
"label.analytics",
"label.desktop",
"label.details",
"label.domain",
"label.laptop",
"label.tablet",
"label.name",
"label.sessions",
"label.team",
"label.team-id",
"label.teams"
],
"en-GB": "*",
"fr-FR": ["metrics.actions", "metrics.pages"],
@ -22,12 +38,15 @@
],
"nb-NO": ["label.administrator", "label.dashboard"],
"nl-NL": [
"label.administrator",
"label.websites",
"metrics.browsers",
"metrics.device.desktop",
"metrics.device.laptop",
"metrics.device.tablet"
"label.analytics",
"label.browsers",
"label.laptop",
"label.tablet",
"label.team",
"label.team-id",
"label.teams",
"label.website-id",
"label.websites"
],
"it-IT": [
"label.password",
@ -37,9 +56,5 @@
"metrics.device.tablet",
"metrics.filter.raw"
],
"pt-PT": [
"label.websites",
"metrics.device.desktop",
"metrics.device.tablet"
]
"pt-PT": ["label.websites", "metrics.device.desktop", "metrics.device.tablet"]
}

View File

@ -1,120 +1,155 @@
{
"label.accounts": "Accounts",
"label.add-account": "Add account",
"label.add-column": "Add column",
"label.add-filter": "Add filter",
"label.access-code": "Access code",
"label.actions": "Actions",
"label.activity-log": "Activity log",
"label.add-website": "Add website",
"label.administrator": "Administrator",
"label.admin": "Administrator",
"label.all": "All",
"label.all-time": "All time",
"label.all-websites": "All websites",
"label.analytics": "Analytics",
"label.average-visit-time": "Average visit time",
"label.back": "Back",
"label.bounce-rate": "Bounce rate",
"label.browsers": "Browsers",
"label.cancel": "Cancel",
"label.change-password": "Change password",
"label.cities": "Cities",
"label.clear-all": "Clear all",
"label.confirm": "Confirm",
"label.confirm-password": "Confirm password",
"label.copy-to-clipboard": "Copy to clipboard",
"label.continue": "Continue",
"label.countries": "Countries",
"label.create-team": "Create team",
"label.create-user": "Create user",
"label.created": "Created",
"label.current-password": "Current password",
"label.custom-range": "Custom range",
"label.dashboard": "Dashboard",
"label.data": "Data",
"label.date-range": "Date range",
"label.default-date-range": "Default date range",
"label.delete": "Delete",
"label.delete-account": "Delete account",
"label.delete-team": "Delete team",
"label.delete-user": "Delete user",
"label.delete-website": "Delete website",
"label.desktop": "Desktop",
"label.details": "Details",
"label.devices": "Devices",
"label.dismiss": "Dismiss",
"label.domain": "Domain",
"label.edit": "Edit",
"label.edit-account": "Edit account",
"label.edit-website": "Edit website",
"label.edit-dashboard": "Edit dashboard",
"label.enable-share-url": "Enable share URL",
"label.event-data": "Event Data",
"label.field-name": "Field Name",
"label.invalid": "Invalid",
"label.invalid-domain": "Invalid domain",
"label.events": "Events",
"label.filter-combined": "Combined",
"label.filter-raw": "Raw",
"label.funnel": "Funnel",
"label.join": "Join",
"label.join-team": "Join team",
"label.language": "Language",
"label.languages": "Languages",
"label.laptop": "Laptop",
"label.last-days": "Last {x} days",
"label.last-hours": "Last {x} hours",
"label.logged-in-as": "Logged in as {username}",
"label.leave": "Leave",
"label.leave-team": "Leave team",
"label.login": "Login",
"label.logout": "Logout",
"label.members": "Members",
"label.mobile": "Mobile",
"label.more": "More",
"label.name": "Name",
"label.new-password": "New password",
"label.none": "None",
"label.operating-systems": "Operating systems",
"label.owner": "Owner",
"label.page-views": "Page views",
"label.pages": "Pages",
"label.password": "Password",
"label.passwords-dont-match": "Passwords don't match",
"label.powered-by": "Powered by {name}",
"label.profile": "Profile",
"label.queries": "Queries",
"label.query": "Query",
"label.query-parameters": "Query parameters",
"label.realtime": "Realtime",
"label.realtime-logs": "Realtime logs",
"label.referrers": "Referrers",
"label.refresh": "Refresh",
"label.regenerate": "Regenerate",
"label.regions": "Regions",
"label.remove": "Remove",
"label.reports": "Reports",
"label.required": "Required",
"label.reset": "Reset",
"label.reset-website": "Reset statistics",
"label.role": "Role",
"label.save": "Save",
"label.search": "Search",
"label.screens": "Screens",
"label.select-date": "Select date",
"label.select-website": "Select website",
"label.sessions": "Sessions",
"label.settings": "Settings",
"label.share-url": "Share URL",
"label.single-day": "Single day",
"label.tablet": "Tablet",
"label.team": "Team",
"label.team-guest": "Team guest",
"label.team-id": "Team ID",
"label.team-member": "Team member",
"label.team-owner": "Team owner",
"label.teams": "Teams",
"label.theme": "Theme",
"label.this-month": "This month",
"label.this-week": "This week",
"label.this-year": "This year",
"label.timezone": "Timezone",
"label.title": "Title",
"label.today": "Today",
"label.toggle-charts": "Toggle charts",
"label.tracking-code": "Tracking code",
"label.type": "Type",
"label.unique-visitors": "Unique visitors",
"label.unknown": "Unknown",
"label.urls": "URLs",
"label.user": "User",
"label.username": "Username",
"label.value": "Value",
"label.users": "Users",
"label.view": "View",
"label.view-details": "View details",
"label.views": "Views",
"label.visitors": "Visitors",
"label.website": "Website",
"label.website-id": "Website ID",
"label.websites": "Websites",
"label.yesterday": "Yesterday",
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
"message.confirm-delete": "Are you sure you want to delete {target}?",
"message.confirm-leave": "Are you sure you want to leave {target}?",
"message.confirm-reset": "Are you sure you want to reset {target}'s statistics?",
"message.copied": "Copied!",
"message.delete-warning": "All associated data will be deleted as well.",
"message.edit-dashboard": "Edit dashboard",
"message.failure": "Something went wrong.",
"message.get-share-url": "Get share URL",
"message.get-tracking-code": "Get tracking code",
"message.delete-account": "To delete this account, type {confirmation} in the box below to confirm.",
"message.delete-website": "To delete this website, type {confirmation} in the box below to confirm.",
"message.delete-website-warning": "All website data will be deleted.",
"message.error": "Something went wrong.",
"message.event-log": "{event} on {url}",
"message.go-to-settings": "Go to settings",
"message.incorrect-username-password": "Incorrect username/password.",
"message.log.visitor": "Visitor from {country} using {browser} on {os} {device}",
"message.new-version-available": "A new version of umami {version} is available!",
"message.invalid-domain": "Invalid domain. Do not include http/https.",
"message.min-password-length": "Minimum length of {n} characters",
"message.no-data-available": "No data available.",
"message.no-websites-configured": "You don't have any websites configured.",
"message.no-match-password": "Passwords do not match.",
"message.no-teams": "You have not created any teams.",
"message.no-users": "There are no users.",
"message.page-not-found": "Page not found.",
"message.powered-by": "Powered by {name}",
"message.reset-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
"message.save-success": "Saved successfully.",
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
"message.reset-website-warning": "All statistics for this website will be deleted, but your settings will remain intact.",
"message.saved": "Saved.",
"message.share-url": "This is the publicly shared URL for {target}.",
"message.toggle-charts": "Toggle charts",
"message.track-stats": "To track stats for {target}, place the following code in the {head} section of your website.",
"message.type-delete": "Type {delete} in the box below to confirm.",
"message.type-reset": "Type {reset} in the box below to confirm.",
"metrics.actions": "Actions",
"metrics.average-visit-time": "Average visit time",
"metrics.bounce-rate": "Bounce rate",
"metrics.browsers": "Browsers",
"metrics.countries": "Countries",
"metrics.device.desktop": "Desktop",
"metrics.device.laptop": "Laptop",
"metrics.device.mobile": "Mobile",
"metrics.device.tablet": "Tablet",
"metrics.devices": "Devices",
"metrics.events": "Events",
"metrics.filter.combined": "Combined",
"metrics.filter.raw": "Raw",
"metrics.languages": "Languages",
"metrics.operating-systems": "Operating systems",
"metrics.page-views": "Page views",
"metrics.pages": "Pages",
"metrics.query-parameters": "Query parameters",
"metrics.referrers": "Referrers",
"metrics.screens": "Screens",
"metrics.unique-visitors": "Unique visitors",
"metrics.views": "Views",
"metrics.visitors": "Visitors"
"message.team-already-member": "You are already a member of the team.",
"message.team-not-found": "Team not found.",
"message.tracking-code": "To track stats for this website, place the following code in the <head>...</head> section of your HTML.",
"message.user-deleted": "User deleted.",
"message.visitor-log": "Visitor from {country} using {browser} on {os} {device}",
"messages.no-results-found": "No results were found.",
"messages.no-team-websites": "This team does not have any websites.",
"messages.no-websites-configured": "You do not have any websites configured.",
"messages.team-websites-info": "Websites can be viewed by anyone on the team."
}

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