├── .cargo └── config.toml ├── .github ├── .gitattributes ├── CONTRIBUTING.md └── workflows │ ├── audit.yaml.disable │ ├── container.yaml │ ├── release.yaml │ ├── test-web.yaml │ └── test.yaml ├── .gitignore ├── .rustfmt.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── NOTICE.md ├── README.md ├── about.toml ├── audit.toml ├── biome.json ├── data ├── config.example.toml ├── countries.txt ├── geo.json ├── images │ ├── liwan-desktop-dark.png │ ├── liwan-desktop-full-dark.png │ ├── liwan-desktop-full.png │ └── liwan-desktop.png ├── licenses-cargo.json ├── licenses-npm.json ├── referrer_icons.txt ├── referrers.txt ├── spammers.txt └── ua_regexes.yaml ├── scripts ├── Dockerfile ├── build-licenses.sh ├── build-tracker.sh ├── deploy-demo.sh └── screenshot │ ├── .gitignore │ ├── bun.lockb │ ├── index.ts │ └── package.json ├── src ├── app │ ├── core.rs │ ├── core │ │ ├── entities.rs │ │ ├── events.rs │ │ ├── geoip.rs │ │ ├── onboarding.rs │ │ ├── projects.rs │ │ ├── reports.rs │ │ ├── reports_cached.rs │ │ ├── sessions.rs │ │ └── users.rs │ ├── db.rs │ ├── mod.rs │ └── models.rs ├── cli.rs ├── config.rs ├── lib.rs ├── main.rs ├── migrations │ ├── app │ │ └── V1__initial.sql │ └── events │ │ ├── V1__initial.sql │ │ ├── V2__remove_indexes.sql │ │ ├── V3__utm_params.sql │ │ └── V4__session_intervals.sql ├── utils │ ├── duckdb.rs │ ├── geo.rs │ ├── hash.rs │ ├── mod.rs │ ├── referrer.rs │ ├── refinery_duckdb.rs │ ├── refinery_sqlite.rs │ ├── seed.rs │ ├── useragent.rs │ └── validate.rs └── web │ ├── mod.rs │ ├── routes │ ├── admin.rs │ ├── auth.rs │ ├── dashboard.rs │ ├── event.rs │ └── mod.rs │ ├── session.rs │ └── webext.rs ├── tests ├── auth.rs ├── common │ └── mod.rs ├── dashboard.rs └── event.rs ├── tracker ├── LICENSE.md ├── README.md ├── package.json ├── script.d.ts ├── script.min.js └── script.ts └── web ├── .gitignore ├── astro.config.ts ├── bun.lock ├── package.json ├── public ├── favicon.ico └── favicon.svg ├── src ├── api │ ├── client.ts │ ├── constants.ts │ ├── dashboard.ts │ ├── hooks.ts │ ├── index.ts │ ├── query.ts │ ├── ranges.test.ts │ ├── ranges.ts │ └── types.ts ├── components │ ├── ThemeSwitcher.astro │ ├── card.module.css │ ├── card.tsx │ ├── daterange │ │ ├── daterange.module.css │ │ └── index.tsx │ ├── dialog.module.css │ ├── dialog.tsx │ ├── dimensions │ │ ├── dimensions.module.css │ │ ├── index.tsx │ │ └── modal.tsx │ ├── graph │ │ ├── axis.ts │ │ ├── graph.module.css │ │ ├── graph.tsx │ │ └── index.tsx │ ├── icons.module.css │ ├── icons.tsx │ ├── project.module.css │ ├── project.tsx │ ├── project │ │ ├── filter.module.css │ │ ├── filter.tsx │ │ ├── metric.module.css │ │ ├── metric.tsx │ │ ├── project.module.css │ │ ├── project.tsx │ │ ├── range.module.css │ │ └── range.tsx │ ├── projects.module.css │ ├── projects.tsx │ ├── settings │ │ ├── dialogs.module.css │ │ ├── dialogs.tsx │ │ ├── me.module.css │ │ ├── me.tsx │ │ ├── tables.module.css │ │ └── tables.tsx │ ├── table.module.css │ ├── table.tsx │ ├── tags.module.css │ ├── tags.tsx │ ├── toast.module.css │ ├── toast.ts │ ├── userInfo.module.css │ ├── userInfo.tsx │ └── worldmap │ │ ├── index.tsx │ │ └── map.module.css ├── env.d.ts ├── global.css ├── hooks │ └── persist.ts ├── layouts │ ├── Base.astro │ ├── Layout.astro │ └── Settings.astro ├── pages │ ├── attributions.astro │ ├── index.astro │ ├── login.astro │ ├── p │ │ └── [...project].astro │ ├── settings │ │ ├── entities.astro │ │ ├── me.astro │ │ ├── projects.astro │ │ └── users.astro │ └── setup.astro ├── utils.test.ts └── utils.ts └── tsconfig.json /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | 3 | [target.x86_64-unknown-linux-musl] 4 | rustflags=["-C", "target_cpu=x86-64-v2"] 5 | -------------------------------------------------------------------------------- /.github/.gitattributes: -------------------------------------------------------------------------------- 1 | **/bun.lock linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Licensing 2 | 3 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in Liwan by you, as defined in the Apache-2.0 license, shall be licensed under the terms of the [Apache License, Version 2.0](https://opensource.org/license/apache-2-0) without any additional terms or conditions. 4 | -------------------------------------------------------------------------------- /.github/workflows/audit.yaml.disable: -------------------------------------------------------------------------------- 1 | name: "Audit Dependencies" 2 | on: 3 | push: 4 | paths: 5 | # Run if workflow changes 6 | - ".github/workflows/audit.yml" 7 | # Run on changed dependencies 8 | - "**/Cargo.toml" 9 | - "**/Cargo.lock" 10 | # Run if the configuration file changes 11 | - "**/audit.toml" 12 | # Rerun periodicly to pick up new advisories 13 | schedule: 14 | - cron: "0 0 * * *" 15 | # Run manually 16 | workflow_dispatch: 17 | 18 | jobs: 19 | audit: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | issues: write 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions-rust-lang/audit@v1 27 | name: Audit Rust Dependencies 28 | -------------------------------------------------------------------------------- /.github/workflows/container.yaml: -------------------------------------------------------------------------------- 1 | name: "Build & Publish Container Image" 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | tag: 7 | type: string 8 | required: true 9 | workflow_dispatch: 10 | inputs: 11 | tag: 12 | description: "Tag (e.g. liwan-v0.1.0)" 13 | required: true 14 | type: string 15 | 16 | jobs: 17 | docker-build: 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Setup Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | - name: Extract Semver 28 | id: semver 29 | env: 30 | INPUT_TAG: "${{ inputs.tag }}" 31 | run: | 32 | SEMVER_VERSION=$(echo "$INPUT_TAG" | sed -E 's/liwan-v//') 33 | echo "SEMVER_VERSION=${SEMVER_VERSION}" >> "$GITHUB_OUTPUT" 34 | - name: Setup Docker Metadata 35 | uses: docker/metadata-action@v5 36 | id: meta 37 | with: 38 | images: ghcr.io/${{ github.actor }}/liwan 39 | tags: | 40 | type=semver,pattern={{version}},value=${{ steps.semver.outputs.SEMVER_VERSION }} 41 | type=semver,pattern={{major}}.{{minor}},value=${{ steps.semver.outputs.SEMVER_VERSION }} 42 | type=semver,pattern={{major}},value=${{ steps.semver.outputs.SEMVER_VERSION }} 43 | type=raw,edge 44 | - name: Login to GitHub Container Registry 45 | uses: docker/login-action@v3 46 | with: 47 | registry: ghcr.io 48 | username: ${{ github.actor }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | - name: Build and push Docker images 51 | uses: docker/build-push-action@v6 52 | with: 53 | context: . 54 | file: ./scripts/Dockerfile 55 | push: true 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | platforms: | 59 | linux/amd64 60 | linux/arm64 61 | build-args: | 62 | TAR_URL_AMD64=https://github.com/explodingcamera/liwan/releases/download/${{ inputs.tag }}/liwan-x86_64-unknown-linux-musl.tar.gz 63 | TAR_URL_ARM64=https://github.com/explodingcamera/liwan/releases/download/${{ inputs.tag }}/liwan-aarch64-unknown-linux-musl.tar.gz 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - liwan-v[0-9]+.* 7 | 8 | jobs: 9 | create-release: 10 | permissions: 11 | contents: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | persist-credentials: false 17 | - uses: taiki-e/create-gh-release-action@v1.9.1 18 | with: 19 | changelog: CHANGELOG.md 20 | allow-missing-changelog: true 21 | draft: false # has to be public to have the right url's for the docker image 22 | prefix: liwan 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | build-web: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | with: 30 | persist-credentials: false 31 | - uses: oven-sh/setup-bun@v2 32 | with: 33 | bun-version: latest 34 | - name: Build web project 35 | run: | 36 | bun install 37 | bun run build 38 | working-directory: ./web 39 | - name: Upload web assets 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: web-dist 43 | path: ./web/dist 44 | 45 | upload-assets: 46 | permissions: 47 | contents: write 48 | needs: [create-release, build-web] 49 | strategy: 50 | matrix: 51 | include: 52 | - target: x86_64-unknown-linux-musl 53 | os: ubuntu-latest 54 | build-tool: cargo-zigbuild 55 | - target: aarch64-unknown-linux-musl 56 | os: ubuntu-latest 57 | build-tool: cargo-zigbuild 58 | - target: aarch64-apple-darwin 59 | os: macos-latest 60 | build-tool: cargo 61 | 62 | runs-on: ${{ matrix.os }} 63 | steps: 64 | - uses: actions/checkout@v4 65 | with: 66 | persist-credentials: false 67 | - uses: actions/download-artifact@v4 68 | with: 69 | name: web-dist 70 | path: ./web/dist 71 | - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 72 | - uses: taiki-e/upload-rust-binary-action@v1.26.0 73 | with: 74 | bin: liwan 75 | target: ${{ matrix.target }} 76 | build-tool: ${{ matrix.build-tool }} 77 | token: ${{ secrets.GITHUB_TOKEN }} 78 | 79 | publish-container: 80 | permissions: 81 | packages: write 82 | contents: read 83 | needs: [create-release, upload-assets] 84 | uses: explodingcamera/liwan/.github/workflows/container.yaml@main 85 | with: 86 | tag: ${{ github.ref_name }} 87 | -------------------------------------------------------------------------------- /.github/workflows/test-web.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests (web) 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "**/*.ts" 7 | - "**/*.tsx" 8 | - "web/bun.lock" 9 | push: 10 | paths: 11 | - "**/*.ts" 12 | - "**/*.tsx" 13 | - "web/bun.lock" 14 | workflow_dispatch: 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | persist-credentials: false 23 | - uses: oven-sh/setup-bun@v2 24 | with: 25 | bun-version: latest 26 | - name: Build web project 27 | run: | 28 | bun install 29 | bun run build 30 | working-directory: ./web 31 | - name: Run tests 32 | run: bun test 33 | working-directory: ./web 34 | - name: Run typecheck 35 | run: bun typecheck 36 | working-directory: ./web 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "**/*.rs" 7 | - "Cargo.lock" 8 | push: 9 | paths: 10 | - "**/*.rs" 11 | - "Cargo.lock" 12 | workflow_dispatch: 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu-latest 20 | - macos-latest 21 | name: Run tests on ${{ matrix.os }} 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | persist-credentials: false 27 | - uses: actions-rust-lang/setup-rust-toolchain@v1 28 | - uses: Swatinem/rust-cache@v2 29 | - run: mkdir ./web/dist 30 | - name: Run tests 31 | run: cargo test --all --all-features 32 | - name: Run clippy 33 | run: cargo clippy --all-features -- -W warnings 34 | - name: Run fmt 35 | run: cargo fmt --all -- --check 36 | continue-on-error: true 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | liwan.config.toml 3 | /liwan-data 4 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width=120 2 | use_small_heuristics="Max" 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | 18 | 19 | 20 | 21 | ## [v1.2.0] - 2025-05-19 22 | 23 | - Liwan has been relicensed under the terms of the **Apache-2.0** license (this also applies to all previous versions) 24 | - Updated to the latest version of DuckDB (1.2) 25 | - Updated list of referrer spammers, user-agents and site icons 26 | - Ellipsis for long URLs in the UI 27 | - Overall performance improvements and memory usage optimizations 28 | 29 | ## [v1.1.1] - 2025-05-19 30 | 31 | - Force duckdb to always checkpoint the database after shutting down. This is required for upgrading to the latest version of duckdb in liwan 1.2, which has issues loading the old checkpoint files when using musl libc. 32 | 33 | ## [v1.1.0] - 2024-12-28 34 | 35 | - Improved query caching to prevent unnecessary database queries 36 | - Added Country Code to Google Referrer URLs 37 | - Improved Multi-User Support (Non-admin users can now be granted access to specific projects) 38 | 39 | ## [v1.0.0] - 2024-12-06 40 | 41 | ### 🚀 Features 42 | 43 | - **UTM parameters**: Added support for UTM parameters. You can filter and search by UTM source, medium, campaign, content, and term. ([#13](https://github.com/explodingcamera/liwan/pull/13)) 44 | - **New Date Ranges**: Fully reworked date ranges. Data is more accurate and consistent now, and you can move to the next or previous time range. Also includes some new time ranges like `Week to Date` and `All Time`. You can now also select a custom date range to view your data. ([97cdfce](https://github.com/explodingcamera/liwan/commit/97cdfce509ed2fd2fd74b23c73726a5e01b7b288), [391c580](https://github.com/explodingcamera/liwan/commit/391c580c926e2b4ca250e08bbe725210774d99b2)) 45 | - **UI Improvements**: A lot of small improvements to the UI for better polish and usability. 46 | - **New Metrics**: Added new metrics: `Bounce Rate`, `Average Time on Page` ([97cdfce](https://github.com/explodingcamera/liwan/commit/97cdfce509ed2fd2fd74b23c73726a5e01b7b288)) 47 | - **Favicons can be disabled**: You can now disable fetching favicons from DuckDuckGo (`config.toml` setting: `disable_favicons`) ([2100bfe](https://github.com/explodingcamera/liwan/commit/2100bfe6ba868b59d2b383220f22b0dbf23a6712)) 48 | - **New Graphs**: Graphs are now custom-built using d3 directly to improve performance and flexibility. ([eb1415d](https://github.com/explodingcamera/liwan/commit/eb1415d6bdf6d3be9509b0b4fa743b6f112b2c0a)) 49 | 50 | ### 🐛 Bug Fixes 51 | 52 | - Fixed a potential panic when entities are not found in the database ([`31405a7`](https://github.com/explodingcamera/liwan/commit/31405a721dc5c5493098e211927281cca7816fec)) 53 | - Fixed issues with the `Yesterday` Date Range ([`76278b57`](https://github.com/explodingcamera/liwan/commit/76278b579c5fe1557bf1c184542ed6ed2aba57cd)) 54 | - Fixed issue with NaN values in the bounce rate metric ([eb1415d](https://github.com/explodingcamera/liwan/commit/eb1415d6bdf6d3be9509b0b4fa743b6f112b2c0a)) 55 | 56 | ### Other 57 | 58 | - Removed Sessions and Average Views per Session metrics. They were not accurate and were removed to avoid confusion. 59 | - Added more tests & improved API performance ([`95d95d0`](https://github.com/explodingcamera/liwan/commit/95d95d0f4670d20a6fa4fc6a7f4b17e4b1854391)) 60 | 61 | ## [v0.1.1] - 2024-09-24 62 | 63 | ### ⚡ Performance 64 | 65 | - **Database indexes**: Removed unnecessary indexes to improve performance and reduce disk usage ([`6191a72`](https://github.com/explodingcamera/liwan/commit/6191a72f08e8659237bc6c22139bde94432f66bb)) 66 | 67 | ## [v0.1.0] - 2024-09-18 68 | 69 | This is the first full release of the Liwan! 🎉 70 | All essential features for web analytics are now available, including: 71 | 72 | - Live tracking of page views 73 | - Geolocation of visitors 74 | - Automatic GeoIP database updates 75 | - Basic user management 76 | - Filtering and searching 77 | - Multiple tracking dimensions: URL, referrer, browser, OS, device type, country, and city 78 | - Multiple metrics: page views, unique visitors, sessions, and average views per session 79 | - Multiple date ranges (custom date ranges are coming soon!) 80 | - Documentation and a simple setup guide at [liwan.dev](https://liwan.dev) 81 | - A simple and clean UI 82 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name="liwan" 3 | version="1.2.0" 4 | edition="2024" 5 | rust-version="1.87" 6 | repository="https://github.com/explodingcamera/liwan" 7 | license="Apache-2.0" 8 | publish=false 9 | 10 | [lib] 11 | path="src/lib.rs" 12 | 13 | [[bin]] 14 | name="liwan" 15 | path="src/main.rs" 16 | 17 | [dependencies] 18 | # async/concurrency 19 | tokio={version="1.44", default-features=false, features=["macros", "rt-multi-thread", "signal"]} 20 | tokio-util={version="0.7", features=["io"]} 21 | futures-lite="2.6" 22 | crossbeam-utils="0.8" 23 | crossbeam-channel="0.5" 24 | quick_cache="0.6" 25 | 26 | # encoding 27 | hex={version="0.4"} 28 | bs58="0.5" 29 | serde={version="1.0", features=["derive"]} 30 | serde_json={version="1.0"} 31 | md-5="0.10" 32 | async-compression={version="0.4", default-features=false, features=["gzip", "tokio"]} 33 | tokio-tar={package="astral-tokio-tar", version="0.5"} 34 | sha3={version="0.10"} 35 | argon2={version="0.5"} 36 | 37 | # general 38 | argh={version="0.1"} 39 | eyre={version="0.6"} 40 | rand={version="0.9"} 41 | time={version="0.3"} 42 | colored={version="3.0"} 43 | figment={version="0.10", features=["toml", "env"]} 44 | tracing={version="0.1", default-features=false, features=["std"]} 45 | tracing-subscriber={version="0.3", features=["env-filter"]} 46 | 47 | # web 48 | poem={version="3.1", default-features=false, features=[ 49 | "embed", 50 | "cookie", 51 | "compression", 52 | "tower-compat", 53 | ]} 54 | poem-openapi={version="5.1", default-features=false, features=["time"]} 55 | tower={version="0.5", default-features=false, features=["limit"]} 56 | uaparser="0.6" 57 | mime_guess={version="2.0"} 58 | rust-embed={version="8.7"} 59 | reqwest={version="0.12", default-features=false, features=[ 60 | "json", 61 | "stream", 62 | "charset", 63 | "rustls-tls-webpki-roots-no-provider", 64 | ]} 65 | rustls={version="0.23", features=["aws_lc_rs"]} 66 | 67 | # database 68 | duckdb={git="https://github.com/explodingcamera-contrib/duckdb-rs", features=[ 69 | "buildtime_bindgen", 70 | "bundled", 71 | "time", 72 | "r2d2", 73 | ]} 74 | rusqlite={version="0.35", features=["bundled", "modern_sqlite", "time"]} 75 | r2d2={version="0.8"} 76 | r2d2_sqlite="0.28" 77 | refinery={version="0.8", default-features=false} 78 | refinery-core={version="0.8", default-features=false} 79 | maxminddb={version="0.26", optional=true} 80 | ahash="0.8" 81 | 82 | [target.'cfg(not(target_env = "msvc"))'.dependencies] 83 | tikv-jemallocator="0.6" 84 | 85 | [dev-dependencies] 86 | figment={version="*", features=["test"]} 87 | poem={version="*", features=["test"]} 88 | cookie={version="*", default-features=false} 89 | 90 | [features] 91 | default=["geoip"] 92 | geoip=["dep:maxminddb"] 93 | _enable_seeding=[] 94 | 95 | [profile.dev] 96 | opt-level=1 97 | 98 | [profile.release] 99 | lto="thin" 100 | panic="abort" 101 | strip=true 102 | opt-level=2 103 | -------------------------------------------------------------------------------- /NOTICE.md: -------------------------------------------------------------------------------- 1 | - `data/ua_regexes.yaml` is based on data from [ua-parser/uap-core](https://github.com/ua-parser/uap-core/blob/master/regexes.yaml) (Copyright 2009 Google Inc. and available under the Apache License, Version 2.0) 2 | - `data/spammers.txt` is in the public domain (see [matomo-org/referrer-spam-list](https://github.com/matomo-org/referrer-spam-list)) 3 | - `data/socials.txt` is based on [matomo-org/searchengine-and-social-list](https://github.com/matomo-org/searchengine-and-social-list) (available under the CC0 1.0 Universal Public Domain Dedication) 4 | - `data/geo.json` is based on data from [Natural Earth](https://naturalearthdata.com/) (which itself is in the public domain) 5 | - See [CONTRIBUTING](.github/CONTRIBUTING.md) for more information on licensing of contributions from external contributors. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

5 | 6 | liwan.dev - Easy & Privacy-First Web Analytics 7 |

8 |
9 | 10 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/explodingcamera/liwan/test.yaml?style=flat-square) 11 | ![GitHub Release](https://img.shields.io/github/v/release/explodingcamera/liwan?style=flat-square) 12 | [![Container](https://img.shields.io/badge/Container-ghcr.io%2Fexplodingcamera%2Fliwan%3Aedge-blue?style=flat-square)](https://github.com/explodingcamera/liwan/pkgs/container/liwan) 13 | 14 |
15 | 16 |
17 | 18 |
19 |     20 | 21 |
22 | 23 | ## Features 24 | 25 | - **Quick setup**\ 26 | Quickly get started with Liwan with a single, self-contained binary . No database or complex setup required. The tracking script is a single line of code that works with any website and less than 1KB in size. 27 | - **Privacy first**\ 28 | Liwan respects your users’ privacy by default. No cookies, no cross-site tracking, no persistent identifiers. All data is stored on your server. 29 | - **Lightweight**\ 30 | You can run Liwan on a cheap VPS, your old mac mini, or even a Raspberry Pi. Written in Rust and using tokio for async I/O, Liwan is fast and efficient. 31 | - **Open source**\ 32 | Fully open source. You can change, extend, and contribute to the codebase. 33 | - **Accurate data**\ 34 | Get accurate data about your website’s visitors, page views, referrers, and more. Liwan detects bots and crawlers and filters them out by default. 35 | - **Real-time analytics**\ 36 | See your website’s traffic in real-time. Liwan updates the dashboard automatically as new visitors come in. 37 | 38 | ## License 39 | 40 | Unless otherwise noted, the code in this repository is available under the terms of the Apache-2.0 license. See [LICENSE](LICENSE.md) for more information. 41 | -------------------------------------------------------------------------------- /about.toml: -------------------------------------------------------------------------------- 1 | workarounds=["ring", "rustls"] 2 | accepted=[ 3 | "Apache-2.0", 4 | "MIT", 5 | "ISC", 6 | "MPL-2.0", 7 | "BSD-3-Clause", 8 | "Unicode-DFS-2016", 9 | "Unicode-3.0", 10 | "OpenSSL", 11 | "Zlib", 12 | "CDLA-Permissive-2.0", 13 | ] 14 | targets=["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl", "aarch64-apple-darwin"] 15 | ignore-build-dependencies=true 16 | ignore-dev-dependencies=true 17 | no-clearly-defined=true 18 | -------------------------------------------------------------------------------- /audit.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | ignore=[] 3 | informational_warnings=["unsound"] 4 | severity_threshold="low" 5 | 6 | [target] 7 | arch=["x86_64", "aarch64"] 8 | os=["linux", "windows", "apple"] 9 | 10 | [yanked] 11 | enabled=true 12 | update_index=true 13 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "formatter": { 4 | "indentStyle": "tab", 5 | "indentWidth": 2, 6 | "lineWidth": 120 7 | }, 8 | "vcs": { 9 | "enabled": true, 10 | "useIgnoreFile": true, 11 | "clientKind": "git", 12 | "defaultBranch": "main" 13 | }, 14 | "css": { 15 | "parser": { "cssModules": true }, 16 | "formatter": { 17 | "indentStyle": "space" 18 | } 19 | }, 20 | "linter": { 21 | "rules": { 22 | "suspicious": { 23 | "noArrayIndexKey": "off" 24 | }, 25 | "a11y": { 26 | "useSemanticElements": "off" 27 | } 28 | } 29 | }, 30 | "files": { 31 | "ignore": [ 32 | "**/node_modules/*", 33 | "**/dist/*", 34 | "**/target/*", 35 | "**/.astro/*", 36 | "**/api/dashboard.ts", 37 | "tracker/script.min.js", 38 | "tracker/script.d.ts" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /data/config.example.toml: -------------------------------------------------------------------------------- 1 | # The base URL of the Liwan instance 2 | base_url="http://localhost:9042" 3 | 4 | # The port to listen on (http) 5 | port=9042 6 | 7 | # # Folder to store the database in (Will be created if it doesn't exist) 8 | # # defaults to $HOME/.local/share/liwan/data on linux/macos 9 | # # defaults to ./liwan-data on other platforms 10 | # data_dir="./liwan-data" 11 | 12 | # GeoIp settings (Optional) 13 | [geoip] 14 | # # MaxMind account ID and license key 15 | # # Required to automatically download the database and keep it up to date 16 | # maxmind_account_id="MY_ACCOUNT_ID" 17 | # maxmind_license_key="MY_LICENSE_KEY" 18 | # maxmind_edition="GeoLite2-City" 19 | 20 | # # If you don't want to automatically download the database, you can specify a path to a local file 21 | # # Otherwise, the database will be downloaded automatically and stored in the data_dir 22 | # maxmind_db_path="./GeoLite2-City.mmdb" 23 | 24 | [duckdb] 25 | # See https://liwan.dev/guides/duckdb 26 | # threads=2 27 | # memory_limit=2G 28 | -------------------------------------------------------------------------------- /data/countries.txt: -------------------------------------------------------------------------------- 1 | Afghanistan=AF 2 | Åland Islands=AX 3 | Albania=AL 4 | Algeria=DZ 5 | American Samoa=AS 6 | Andorra=AD 7 | Angola=AO 8 | Anguilla=AI 9 | Antarctica=AQ 10 | Antigua and Barbuda=AG 11 | Argentina=AR 12 | Armenia=AM 13 | Aruba=AW 14 | Australia=AU 15 | Austria=AT 16 | Azerbaijan=AZ 17 | Bahamas=BS 18 | Bahrain=BH 19 | Bangladesh=BD 20 | Barbados=BB 21 | Belarus=BY 22 | Belgium=BE 23 | Belize=BZ 24 | Benin=BJ 25 | Bermuda=BM 26 | Bhutan=BT 27 | Bolivia=BO 28 | Bonaire=BQ 29 | Bosnia and Herzegovina=BA 30 | Botswana=BW 31 | Bouvet Island=BV 32 | Brazil=BR 33 | British Indian Ocean Territory=IO 34 | Brunei Darussalam=BN 35 | Bulgaria=BG 36 | Burkina Faso=BF 37 | Burundi=BI 38 | Cabo Verde=CV 39 | Cambodia=KH 40 | Cameroon=CM 41 | Canada=CA 42 | Cayman Islands=KY 43 | Central African Republic=CF 44 | Chad=TD 45 | Chile=CL 46 | China=CN 47 | Christmas Island=CX 48 | Cocos Islands=CC 49 | Colombia=CO 50 | Comoros=KM 51 | Congo=CG 52 | Congo=CD 53 | Cook Islands=CK 54 | Costa Rica=CR 55 | Côte d'Ivoire=CI 56 | Croatia=HR 57 | Cuba=CU 58 | Curaçao=CW 59 | Cyprus=CY 60 | Czechia=CZ 61 | Denmark=DK 62 | Djibouti=DJ 63 | Dominica=DM 64 | Dominican Republic=DO 65 | Ecuador=EC 66 | Egypt=EG 67 | El Salvador=SV 68 | Equatorial Guinea=GQ 69 | Eritrea=ER 70 | Estonia=EE 71 | Eswatini=SZ 72 | Ethiopia=ET 73 | Falkland Islands=FK 74 | Faroe Islands=FO 75 | Fiji=FJ 76 | Finland=FI 77 | France=FR 78 | French Guiana=GF 79 | French Polynesia=PF 80 | French Southern Territories=TF 81 | Gabon=GA 82 | Gambia=GM 83 | Georgia=GE 84 | Germany=DE 85 | Ghana=GH 86 | Gibraltar=GI 87 | Greece=GR 88 | Greenland=GL 89 | Grenada=GD 90 | Guadeloupe=GP 91 | Guam=GU 92 | Guatemala=GT 93 | Guernsey=GG 94 | Guinea=GN 95 | Guinea-Bissau=GW 96 | Guyana=GY 97 | Haiti=HT 98 | Heard Island and McDonald Islands=HM 99 | Holy See=VA 100 | Honduras=HN 101 | Hong Kong=HK 102 | Hungary=HU 103 | Iceland=IS 104 | India=IN 105 | Indonesia=ID 106 | Iran=IR 107 | Iraq=IQ 108 | Ireland=IE 109 | Isle of Man=IM 110 | Israel=IL 111 | Italy=IT 112 | Jamaica=JM 113 | Japan=JP 114 | Jersey=JE 115 | Jordan=JO 116 | Kazakhstan=KZ 117 | Kenya=KE 118 | Kiribati=KI 119 | North Korea=KP 120 | South Korea=KR 121 | Kuwait=KW 122 | Kyrgyzstan=KG 123 | Laos=LA 124 | Latvia=LV 125 | Lebanon=LB 126 | Lesotho=LS 127 | Liberia=LR 128 | Libya=LY 129 | Liechtenstein=LI 130 | Lithuania=LT 131 | Luxembourg=LU 132 | Macao=MO 133 | Madagascar=MG 134 | Malawi=MW 135 | Malaysia=MY 136 | Maldives=MV 137 | Mali=ML 138 | Malta=MT 139 | Marshall Islands=MH 140 | Martinique=MQ 141 | Mauritania=MR 142 | Mauritius=MU 143 | Mayotte=YT 144 | Mexico=MX 145 | Micronesia=FM 146 | Moldova=MD 147 | Monaco=MC 148 | Mongolia=MN 149 | Montenegro=ME 150 | Montserrat=MS 151 | Morocco=MA 152 | Mozambique=MZ 153 | Myanmar=MM 154 | Namibia=NA 155 | Nauru=NR 156 | Nepal=NP 157 | Netherlands=NL 158 | New Caledonia=NC 159 | New Zealand=NZ 160 | Nicaragua=NI 161 | Niger=NE 162 | Nigeria=NG 163 | Niue=NU 164 | Norfolk Island=NF 165 | North Macedonia=MK 166 | Northern Mariana Islands=MP 167 | Norway=NO 168 | Oman=OM 169 | Pakistan=PK 170 | Palau=PW 171 | Palestine, State of=PS 172 | Panama=PA 173 | Papua New Guinea=PG 174 | Paraguay=PY 175 | Peru=PE 176 | Philippines=PH 177 | Pitcairn=PN 178 | Poland=PL 179 | Portugal=PT 180 | Puerto Rico=PR 181 | Qatar=QA 182 | Réunion=RE 183 | Romania=RO 184 | Russia=RU 185 | Rwanda=RW 186 | Saint Barthélemy=BL 187 | Saint Helena=SH 188 | Saint Kitts and Nevis=KN 189 | Saint Lucia=LC 190 | Saint Martin=MF 191 | Saint Pierre and Miquelon=PM 192 | Saint Vincent=VC 193 | Samoa=WS 194 | San Marino=SM 195 | Sao Tome and Principe=ST 196 | Saudi Arabia=SA 197 | Senegal=SN 198 | Serbia=RS 199 | Seychelles=SC 200 | Sierra Leone=SL 201 | Singapore=SG 202 | Sint Maarten (Dutch part)=SX 203 | Slovakia=SK 204 | Slovenia=SI 205 | Solomon Islands=SB 206 | Somalia=SO 207 | South Africa=ZA 208 | South Georgia and the South Sandwich Islands=GS 209 | South Sudan=SS 210 | Spain=ES 211 | Sri Lanka=LK 212 | Sudan=SD 213 | Suriname=SR 214 | Svalbard and Jan Mayen=SJ 215 | Sweden=SE 216 | Switzerland=CH 217 | Syrian Arab Republic=SY 218 | Taiwan=TW 219 | Tajikistan=TJ 220 | Tanzania=TZ 221 | Thailand=TH 222 | Timor-Leste=TL 223 | Togo=TG 224 | Tokelau=TK 225 | Tonga=TO 226 | Trinidad and Tobago=TT 227 | Tunisia=TN 228 | Türkiye=TR 229 | Turkmenistan=TM 230 | Turks and Caicos Islands=TC 231 | Tuvalu=TV 232 | Uganda=UG 233 | Ukraine=UA 234 | United Arab Emirates=AE 235 | United Kingdom=GB 236 | United States of America=US 237 | United States Minor Outlying Islands=UM 238 | Uruguay=UY 239 | Uzbekistan=UZ 240 | Vanuatu=VU 241 | Venezuela=VE 242 | Viet Nam=VN 243 | Virgin Islands (British)=VG 244 | Virgin Islands (U.S.)=VI 245 | Wallis and Futuna=WF 246 | Western Sahara=EH 247 | Yemen=YE 248 | Zambia=ZM 249 | Zimbabwe=ZW 250 | -------------------------------------------------------------------------------- /data/images/liwan-desktop-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/data/images/liwan-desktop-dark.png -------------------------------------------------------------------------------- /data/images/liwan-desktop-full-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/data/images/liwan-desktop-full-dark.png -------------------------------------------------------------------------------- /data/images/liwan-desktop-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/data/images/liwan-desktop-full.png -------------------------------------------------------------------------------- /data/images/liwan-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/data/images/liwan-desktop.png -------------------------------------------------------------------------------- /data/referrer_icons.txt: -------------------------------------------------------------------------------- 1 | Ozone=tencentqq 2 | Foursquare=foursquare 3 | Vkontakte=vk 4 | Weibo=sinaweibo 5 | Telegram=telegram 6 | Pixelfed=pixelfed 7 | Workplace=workplace 8 | X=x 9 | Threads=threads 10 | Mail.Ru=maildotru 11 | Hacker News=ycombinator 12 | TikTok=tiktok 13 | Facebook=facebook 14 | Last.fm=lastdotfm 15 | LinkedIn=linkedin 16 | Dribbble=dribbble 17 | reddit=reddit 18 | Flickr=flickr 19 | GitHub=github 20 | Pinterest=pinterest 21 | StackOverflow=stackoverflow 22 | Bluesky=bluesky 23 | LiveJournal=livejournal 24 | V2EX=v2ex 25 | Douban=douban 26 | Renren=renren 27 | tumblr=tumblr 28 | Snapchat=snapchat 29 | Badoo=badoo 30 | YouTube=youtube 31 | Instagram=instagram 32 | Viadeo=viadeo 33 | Odnoklassniki=odnoklassniki 34 | Vimeo=vimeo 35 | Mastodon=mastodon 36 | Sourceforge=sourceforge 37 | Twitch=twitch 38 | XING=xing 39 | Google=google 40 | DuckDuckGo=duckduckgo -------------------------------------------------------------------------------- /scripts/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:12-slim AS downloader 2 | ARG TAR_URL_AMD64 3 | ARG TAR_URL_ARM64 4 | ARG TARGETPLATFORM 5 | 6 | RUN apt-get update && apt-get install -y curl tar 7 | RUN echo "Downloading liwan for ${TARGETPLATFORM}..." \ 8 | && TAR_URL=$(if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then echo ${TAR_URL_ARM64}; else echo ${TAR_URL_AMD64}; fi) \ 9 | && curl -fsSL $TAR_URL -o /tmp/package.tar.gz \ 10 | && mkdir -p /app \ 11 | && tar -xzf /tmp/package.tar.gz -C /app \ 12 | && chmod +x /app/liwan 13 | 14 | FROM alpine:3 15 | 16 | ENV LIWAN_CONFIG=/liwan.config.toml 17 | ENV LIWAN_DATA_DIR=/data 18 | 19 | COPY --from=downloader /app/liwan /liwan 20 | ENTRYPOINT ["/liwan"] 21 | EXPOSE 9042 -------------------------------------------------------------------------------- /scripts/build-licenses.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cd "$(dirname "$0")" && cd .. 3 | cargo about generate --format json -o ./data/licenses-cargo.json 4 | -------------------------------------------------------------------------------- /scripts/build-tracker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cd "$(dirname "$0")" 3 | esbuild ../tracker/script.ts --minify --format=esm --target=chrome123,edge123,firefox124,safari17 --outfile=../tracker/script.min.js 4 | bunx tsc ../tracker/script.ts --target ESNext --module ESNext --declaration --emitDeclarationOnly --outFile ../tracker/script.d.ts 5 | -------------------------------------------------------------------------------- /scripts/deploy-demo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cd "$(dirname "$0")" && cd .. 3 | cd web && bun run build 4 | cd .. 5 | cargo zigbuild --release --target x86_64-unknown-linux-musl 6 | rsync -avzP target/x86_64-unknown-linux-musl/release/liwan pegasus:~/.local/bin/liwan 7 | -------------------------------------------------------------------------------- /scripts/screenshot/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /scripts/screenshot/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/scripts/screenshot/bun.lockb -------------------------------------------------------------------------------- /scripts/screenshot/index.ts: -------------------------------------------------------------------------------- 1 | import captureWebsite from "capture-website"; 2 | import { join } from "node:path"; 3 | 4 | const geoCardMargin = ".geocard { margin-bottom: 2rem !important; }"; 5 | 6 | await captureWebsite.file( 7 | "http://localhost:4321/p/public-project", 8 | join(__dirname, "../../data/images/liwan-desktop.png"), 9 | { 10 | overwrite: true, 11 | width: 1100, 12 | height: 1480, 13 | quality: 0.8, 14 | styles: [geoCardMargin], 15 | }, 16 | ); 17 | 18 | await captureWebsite.file( 19 | "http://localhost:4321/p/public-project", 20 | join(__dirname, "../../data/images/liwan-desktop-dark.png"), 21 | { 22 | darkMode: true, 23 | overwrite: true, 24 | width: 1100, 25 | height: 1480, 26 | quality: 0.8, 27 | styles: [geoCardMargin], 28 | }, 29 | ); 30 | 31 | await captureWebsite.file( 32 | "http://localhost:4321/p/public-project", 33 | join(__dirname, "../../data/images/liwan-desktop-full.png"), 34 | { 35 | overwrite: true, 36 | width: 1100, 37 | fullPage: true, 38 | quality: 0.8, 39 | }, 40 | ); 41 | 42 | await captureWebsite.file( 43 | "http://localhost:4321/p/public-project", 44 | join(__dirname, "../../data/images/liwan-desktop-full-dark.png"), 45 | { 46 | darkMode: true, 47 | overwrite: true, 48 | width: 1100, 49 | fullPage: true, 50 | quality: 0.8, 51 | }, 52 | ); 53 | -------------------------------------------------------------------------------- /scripts/screenshot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "screenshot", 3 | "module": "index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest", 7 | "capture-website": "^4.1.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/core.rs: -------------------------------------------------------------------------------- 1 | pub mod entities; 2 | pub mod events; 3 | pub mod onboarding; 4 | pub mod projects; 5 | pub mod reports; 6 | mod reports_cached; 7 | pub mod sessions; 8 | pub mod users; 9 | 10 | pub use entities::LiwanEntities; 11 | pub use events::LiwanEvents; 12 | pub use onboarding::LiwanOnboarding; 13 | pub use projects::LiwanProjects; 14 | pub use sessions::LiwanSessions; 15 | pub use users::LiwanUsers; 16 | 17 | #[cfg(feature = "geoip")] 18 | pub mod geoip; 19 | -------------------------------------------------------------------------------- /src/app/core/entities.rs: -------------------------------------------------------------------------------- 1 | use eyre::{Result, bail}; 2 | 3 | use crate::app::{SqlitePool, models}; 4 | use crate::utils::validate; 5 | 6 | #[derive(Clone)] 7 | pub struct LiwanEntities { 8 | pool: SqlitePool, 9 | } 10 | 11 | impl LiwanEntities { 12 | pub fn new(pool: SqlitePool) -> Self { 13 | Self { pool } 14 | } 15 | 16 | /// Get all entities 17 | pub fn all(&self) -> Result> { 18 | let conn = self.pool.get()?; 19 | let mut stmt = conn.prepare("select id, display_name from entities")?; 20 | let entities = stmt 21 | .query_map([], |row| Ok(models::Entity { id: row.get("id")?, display_name: row.get("display_name")? }))?; 22 | Ok(entities.collect::, rusqlite::Error>>()?) 23 | } 24 | 25 | /// Create a new entity 26 | pub fn create(&self, entity: &models::Entity, initial_projects: &[String]) -> Result<()> { 27 | if !validate::is_valid_id(&entity.id) { 28 | bail!("invalid entity ID"); 29 | } 30 | 31 | let mut conn = self.pool.get()?; 32 | let tx = conn.transaction()?; 33 | tx.execute( 34 | "insert into entities (id, display_name) values (?, ?)", 35 | rusqlite::params![entity.id, entity.display_name], 36 | )?; 37 | for project_id in initial_projects { 38 | tx.execute( 39 | "insert into project_entities (project_id, entity_id) values (?, ?)", 40 | rusqlite::params![project_id, entity.id], 41 | )?; 42 | } 43 | tx.commit()?; 44 | Ok(()) 45 | } 46 | 47 | /// Update an entity 48 | pub fn update(&self, entity: &models::Entity) -> Result { 49 | let conn = self.pool.get()?; 50 | let mut stmt = conn.prepare_cached("update entities set display_name = ? where id = ?")?; 51 | stmt.execute(rusqlite::params![entity.display_name, entity.id])?; 52 | Ok(entity.clone()) 53 | } 54 | 55 | /// Update an entity's projects 56 | pub fn update_projects(&self, entity_id: &str, project_ids: &[String]) -> Result<()> { 57 | let mut conn = self.pool.get()?; 58 | let tx = conn.transaction()?; 59 | tx.execute("delete from project_entities where entity_id = ?", rusqlite::params![entity_id])?; 60 | for project_id in project_ids { 61 | tx.execute( 62 | "insert into project_entities (project_id, entity_id) values (?, ?)", 63 | rusqlite::params![project_id, entity_id], 64 | )?; 65 | } 66 | tx.commit()?; 67 | Ok(()) 68 | } 69 | 70 | /// Delete an entity (does not remove associated events) 71 | pub fn delete(&self, id: &str) -> Result<()> { 72 | let mut conn = self.pool.get()?; 73 | let tx = conn.transaction()?; 74 | tx.execute("delete from entities where id = ?", rusqlite::params![id])?; 75 | tx.execute("delete from project_entities where entity_id = ?", rusqlite::params![id])?; 76 | tx.commit()?; 77 | Ok(()) 78 | } 79 | 80 | /// Get all projects associated with an entity 81 | pub fn projects(&self, entity_id: &str) -> Result> { 82 | let conn = self.pool.get()?; 83 | let mut stmt = conn.prepare_cached( 84 | "select p.id, p.display_name, p.public, p.secret from projects p join project_entities pe on p.id = pe.project_id where pe.entity_id = ?", 85 | )?; 86 | let projects = stmt.query_map(rusqlite::params![entity_id], |row| { 87 | Ok(models::Project { 88 | id: row.get("id")?, 89 | display_name: row.get("display_name")?, 90 | public: row.get("public")?, 91 | secret: row.get("secret")?, 92 | }) 93 | })?; 94 | Ok(projects.collect::, rusqlite::Error>>()?) 95 | } 96 | 97 | /// Check if an entity exists 98 | pub fn exists(&self, id: &str) -> Result { 99 | let conn = self.pool.get()?; 100 | let mut stmt = conn.prepare_cached("select 1 from entities where id = ? limit 1")?; 101 | Ok(stmt.exists([id])?) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app/core/onboarding.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crossbeam_utils::sync::ShardedLock; 4 | use eyre::Result; 5 | 6 | use crate::{app::SqlitePool, utils::hash::onboarding_token}; 7 | 8 | #[derive(Clone)] 9 | pub struct LiwanOnboarding { 10 | token: Arc>>, 11 | } 12 | 13 | impl LiwanOnboarding { 14 | pub fn try_new(pool: &SqlitePool) -> Result { 15 | let onboarding = { 16 | tracing::debug!("Checking if an onboarding token needs to be generated"); 17 | let conn = pool.get()?; 18 | let mut stmt = conn.prepare("select 1 from users limit 1")?; 19 | ShardedLock::new(if stmt.exists([])? { None } else { Some(onboarding_token()) }) 20 | }; 21 | 22 | Ok(Self { token: onboarding.into() }) 23 | } 24 | 25 | /// Get the onboarding token, if it exists 26 | pub fn token(&self) -> Result> { 27 | Ok(self 28 | .token 29 | .read() 30 | .map_err(|_| eyre::eyre!("Failed to acquire onboarding token read lock"))? 31 | .as_ref() 32 | .cloned()) 33 | } 34 | 35 | /// Clear the onboarding token to prevent it from being used again 36 | pub fn clear(&self) -> Result<()> { 37 | let mut onboarding = 38 | self.token.write().map_err(|_| eyre::eyre!("Failed to acquire onboarding token write lock"))?; 39 | *onboarding = None; 40 | Ok(()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/core/sessions.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{SqlitePool, models}; 2 | use eyre::Result; 3 | use rusqlite::params; 4 | use time::OffsetDateTime; 5 | 6 | #[derive(Clone)] 7 | pub struct LiwanSessions { 8 | pool: SqlitePool, 9 | } 10 | 11 | impl LiwanSessions { 12 | pub fn new(pool: SqlitePool) -> Self { 13 | Self { pool } 14 | } 15 | 16 | /// Create a new session 17 | pub fn create(&self, session_id: &str, username: &str, expires_at: OffsetDateTime) -> Result<()> { 18 | let conn = self.pool.get()?; 19 | let mut stmt = conn.prepare_cached("insert into sessions (id, username, expires_at) values (?, ?, ?)")?; 20 | stmt.execute(rusqlite::params![session_id, username, expires_at])?; 21 | Ok(()) 22 | } 23 | 24 | /// Get the user associated with a session ID, if the session is still valid. 25 | /// Returns None if the session is expired 26 | pub fn get(&self, session_id: &str) -> Result> { 27 | let conn = self.pool.get()?; 28 | 29 | let mut stmt = conn.prepare_cached( 30 | r#"--sql 31 | select u.username, u.role, u.projects 32 | from sessions s 33 | join users u 34 | on lower(u.username) = lower(s.username) 35 | where 36 | s.id = ? 37 | and s.expires_at > ? 38 | "#, 39 | )?; 40 | 41 | let user = stmt.query_row(params![session_id, time::OffsetDateTime::now_utc()], |row| { 42 | Ok(models::User { 43 | username: row.get("username")?, 44 | role: row.get::<_, String>("role")?.try_into().unwrap_or_default(), 45 | projects: row 46 | .get::<_, String>("projects")? 47 | .split(',') 48 | .filter(|s| !s.is_empty()) 49 | .map(str::to_string) 50 | .collect(), 51 | }) 52 | }); 53 | 54 | user.map(Some).or_else( 55 | |err| { 56 | if err == rusqlite::Error::QueryReturnedNoRows { Ok(None) } else { Err(err.into()) } 57 | }, 58 | ) 59 | } 60 | 61 | /// Delete a session 62 | pub fn delete(&self, session_id: &str) -> Result<()> { 63 | let conn = self.pool.get()?; 64 | let mut stmt = conn.prepare_cached("update sessions set expires_at = ? where id = ?")?; 65 | stmt.execute(rusqlite::params![OffsetDateTime::now_utc(), session_id])?; 66 | Ok(()) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/core/users.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{SqlitePool, models}; 2 | use crate::utils::hash::{hash_password, verify_password}; 3 | use crate::utils::validate; 4 | use eyre::{Result, bail}; 5 | 6 | #[derive(Clone)] 7 | pub struct LiwanUsers { 8 | pool: SqlitePool, 9 | } 10 | 11 | impl LiwanUsers { 12 | pub fn new(pool: SqlitePool) -> Self { 13 | Self { pool } 14 | } 15 | 16 | /// Check if a users password is correct 17 | pub fn check_login(&self, username: &str, password: &str) -> Result { 18 | let username = username.to_lowercase(); 19 | let conn = self.pool.get()?; 20 | let mut stmt = conn.prepare("select password_hash from users where username = ?")?; 21 | let hash: String = stmt.query_row([username], |row| row.get(0))?; 22 | Ok(verify_password(password, &hash).is_ok()) 23 | } 24 | 25 | /// Get a user by username 26 | pub fn get(&self, username: &str) -> Result { 27 | let username = username.to_lowercase(); 28 | let conn = self.pool.get()?; 29 | let mut stmt = conn.prepare("select username, password_hash, role, projects from users where username = ?")?; 30 | let user = stmt.query_row([username], |row| { 31 | Ok(models::User { 32 | username: row.get("username")?, 33 | role: row.get::<_, String>("role")?.try_into().unwrap_or_default(), 34 | projects: row 35 | .get::<_, String>("projects")? 36 | .split(',') 37 | .filter(|s| !s.is_empty()) 38 | .map(str::to_string) 39 | .collect(), 40 | }) 41 | }); 42 | user.map_err(|_| eyre::eyre!("user not found")) 43 | } 44 | 45 | /// Get all users 46 | pub fn all(&self) -> Result> { 47 | let conn = self.pool.get()?; 48 | let mut stmt = conn.prepare("select username, password_hash, role, projects from users")?; 49 | let users = stmt.query_map([], |row| { 50 | Ok(models::User { 51 | username: row.get("username")?, 52 | role: row.get::<_, String>("role")?.try_into().unwrap_or_default(), 53 | projects: row 54 | .get::<_, String>("projects")? 55 | .split(',') 56 | .filter(|s| !s.is_empty()) 57 | .map(str::to_string) 58 | .collect(), 59 | }) 60 | })?; 61 | Ok(users.collect::, rusqlite::Error>>()?) 62 | } 63 | 64 | /// Create a new user 65 | pub fn create(&self, username: &str, password: &str, role: models::UserRole, projects: &[&str]) -> Result<()> { 66 | if !validate::is_valid_username(username) { 67 | bail!("invalid username"); 68 | } 69 | let username = username.to_lowercase(); 70 | let password_hash = hash_password(password)?; 71 | let conn = self.pool.get()?; 72 | let mut stmt = 73 | conn.prepare_cached("insert into users (username, password_hash, role, projects) values (?, ?, ?, ?)")?; 74 | stmt.execute([username, password_hash, role.to_string(), projects.join(",")])?; 75 | Ok(()) 76 | } 77 | 78 | /// Update a user 79 | pub fn update(&self, username: &str, role: models::UserRole, projects: &[String]) -> Result<()> { 80 | let conn = self.pool.get()?; 81 | let mut stmt = conn.prepare_cached("update users set role = ?, projects = ? where username = ?")?; 82 | stmt.execute([&role.to_string(), &projects.join(","), username])?; 83 | Ok(()) 84 | } 85 | 86 | /// Update a user's password 87 | pub fn update_password(&self, username: &str, password: &str) -> Result<()> { 88 | let conn = self.pool.get()?; 89 | let password_hash = hash_password(password)?; 90 | let mut stmt = conn.prepare_cached("update users set password_hash = ? where username = ?")?; 91 | stmt.execute([&password_hash, username])?; 92 | Ok(()) 93 | } 94 | 95 | /// Delete a user 96 | pub fn delete(&self, username: &str) -> Result<()> { 97 | let conn = self.pool.get()?; 98 | let mut stmt = conn.prepare_cached("delete from users where username = ?")?; 99 | stmt.execute([username])?; 100 | Ok(()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/app/db.rs: -------------------------------------------------------------------------------- 1 | use crate::config::DuckdbConfig; 2 | use crate::utils::refinery_duckdb::DuckDBConnection; 3 | use crate::utils::refinery_sqlite::RqlConnection; 4 | 5 | use duckdb::DuckdbConnectionManager; 6 | use eyre::{Result, bail}; 7 | use r2d2_sqlite::SqliteConnectionManager; 8 | use refinery::Runner; 9 | use std::path::PathBuf; 10 | 11 | pub(super) fn init_duckdb( 12 | path: &PathBuf, 13 | duckdb_config: Option, 14 | mut migrations_runner: Runner, 15 | ) -> Result> { 16 | let mut flags = duckdb::Config::default() 17 | .enable_autoload_extension(true)? 18 | .access_mode(duckdb::AccessMode::ReadWrite)? 19 | .with("enable_fsst_vectors", "true")? 20 | .with("allocator_background_threads", "true")?; 21 | 22 | if let Some(duckdb_config) = duckdb_config { 23 | if let Some(memory_limit) = duckdb_config.memory_limit { 24 | flags = flags.max_memory(&memory_limit)?; 25 | } 26 | 27 | if let Some(threads) = duckdb_config.threads { 28 | flags = flags.threads(threads.get().into())?; 29 | } 30 | } 31 | 32 | let conn = DuckdbConnectionManager::file_with_flags(path, flags).map_err(|e| { 33 | tracing::warn!("Failed to create DuckDB connection. If you've just upgraded to Liwan 1.2, please downgrade to version 1.1.1 first, start and stop the server, and then upgrade to 1.2 again."); 34 | eyre::eyre!("Failed to create DuckDB connection: {}", e) 35 | })?; 36 | 37 | let pool = r2d2::Pool::new(conn)?; 38 | { 39 | let conn = pool.get()?; 40 | conn.execute("PRAGMA enable_checkpoint_on_shutdown", [])?; 41 | conn.pragma_update(None, "autoload_known_extensions", &"true")?; 42 | conn.pragma_update(None, "allow_community_extensions", &"false")?; 43 | } 44 | 45 | { 46 | let conn = pool.get()?; 47 | migrations_runner.set_migration_table_name("migrations"); 48 | for migration in migrations_runner.run_iter(&mut DuckDBConnection(conn)) { 49 | match migration { 50 | Ok(migration) => { 51 | tracing::info!("Applied migration: {}", migration); 52 | } 53 | Err(err) => { 54 | bail!("Failed to apply migration: {}", err); 55 | } 56 | } 57 | } 58 | } 59 | 60 | Ok(pool) 61 | } 62 | 63 | pub fn init_duckdb_mem(mut migrations_runner: Runner) -> Result> { 64 | let conn = DuckdbConnectionManager::memory()?; 65 | let pool = r2d2::Pool::new(conn)?; 66 | migrations_runner.set_migration_table_name("migrations"); 67 | migrations_runner.run(&mut DuckDBConnection(pool.get()?))?; 68 | 69 | { 70 | let conn = pool.get()?; 71 | conn.pragma_update(None, "allow_community_extensions", &"false")?; 72 | conn.pragma_update(None, "enable_fsst_vectors", &"true")?; 73 | } 74 | 75 | Ok(pool) 76 | } 77 | 78 | pub(super) fn init_sqlite( 79 | path: &PathBuf, 80 | mut migrations_runner: Runner, 81 | ) -> Result> { 82 | let conn = SqliteConnectionManager::file(path); 83 | let pool = r2d2::Pool::new(conn)?; 84 | migrations_runner.set_migration_table_name("migrations"); 85 | migrations_runner.run(&mut RqlConnection(pool.get()?))?; 86 | 87 | { 88 | let conn = pool.get()?; 89 | conn.pragma_update(None, "foreign_keys", "ON")?; 90 | conn.pragma_update(None, "journal_mode", "WAL")?; 91 | conn.pragma_update(None, "synchronous", "NORMAL")?; 92 | conn.pragma_update(None, "mmap_size", "268435456")?; 93 | conn.pragma_update(None, "journal_size_limit", "268435456")?; 94 | conn.pragma_update(None, "cache_size", "2000")?; 95 | } 96 | 97 | Ok(pool) 98 | } 99 | 100 | pub fn init_sqlite_mem(mut migrations_runner: Runner) -> Result> { 101 | let conn = SqliteConnectionManager::memory(); 102 | let pool = r2d2::Pool::new(conn)?; 103 | migrations_runner.set_migration_table_name("migrations"); 104 | migrations_runner.run(&mut RqlConnection(pool.get()?))?; 105 | 106 | { 107 | let conn = pool.get()?; 108 | conn.pragma_update(None, "foreign_keys", "ON")?; 109 | } 110 | 111 | Ok(pool) 112 | } 113 | -------------------------------------------------------------------------------- /src/app/models.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone)] 2 | pub struct Event { 3 | pub entity_id: String, 4 | pub visitor_id: String, 5 | pub event: String, 6 | pub created_at: OffsetDateTime, 7 | pub fqdn: Option, 8 | pub path: Option, 9 | pub referrer: Option, 10 | pub platform: Option, 11 | pub browser: Option, 12 | pub mobile: Option, 13 | pub country: Option, 14 | pub city: Option, 15 | pub utm_source: Option, 16 | pub utm_medium: Option, 17 | pub utm_campaign: Option, 18 | pub utm_content: Option, 19 | pub utm_term: Option, 20 | } 21 | 22 | #[derive(Debug, Clone)] 23 | pub struct Project { 24 | pub id: String, 25 | pub display_name: String, 26 | pub public: bool, 27 | pub secret: Option, // enable public access with password protection 28 | } 29 | 30 | #[derive(Debug, Clone)] 31 | pub struct Entity { 32 | pub id: String, 33 | pub display_name: String, 34 | } 35 | 36 | #[derive(Debug, Clone)] 37 | pub struct User { 38 | pub username: String, 39 | pub role: UserRole, 40 | pub projects: Vec, 41 | } 42 | 43 | #[derive(Debug, Enum, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default)] 44 | #[oai(rename_all = "snake_case")] 45 | pub enum UserRole { 46 | #[serde(rename = "admin")] 47 | Admin, 48 | #[serde(rename = "user")] 49 | #[default] 50 | User, 51 | } 52 | 53 | impl TryFrom for UserRole { 54 | type Error = String; 55 | 56 | fn try_from(value: String) -> Result { 57 | match value.as_str() { 58 | "admin" => Ok(Self::Admin), 59 | "user" => Ok(Self::User), 60 | _ => Err(format!("invalid role: {value}")), 61 | } 62 | } 63 | } 64 | 65 | impl UserRole { 66 | #[allow(clippy::inherent_to_string)] 67 | pub fn to_string(self) -> String { 68 | match self { 69 | Self::Admin => "admin".to_string(), 70 | Self::User => "user".to_string(), 71 | } 72 | } 73 | } 74 | 75 | #[macro_export] 76 | macro_rules! event_params { 77 | ($event:expr) => { 78 | duckdb::params![ 79 | $event.entity_id, 80 | $event.visitor_id, 81 | $event.event, 82 | $event.created_at, 83 | $event.fqdn, 84 | $event.path, 85 | $event.referrer, 86 | $event.platform, 87 | $event.browser, 88 | $event.mobile, 89 | $event.country, 90 | $event.city, 91 | $event.utm_source, 92 | $event.utm_medium, 93 | $event.utm_campaign, 94 | $event.utm_content, 95 | $event.utm_term, 96 | None::, 97 | None::, 98 | ] 99 | }; 100 | } 101 | 102 | pub use event_params; 103 | use poem_openapi::Enum; 104 | use serde::{Deserialize, Serialize}; 105 | use time::OffsetDateTime; 106 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{Liwan, models::UserRole}, 3 | config::{Config, DEFAULT_CONFIG}, 4 | }; 5 | use argh::FromArgs; 6 | use colored::Colorize; 7 | use eyre::Result; 8 | 9 | #[derive(FromArgs)] 10 | /// liwan - lightweight web analytics 11 | pub struct Args { 12 | #[argh(option)] 13 | /// path to the configuration file 14 | pub config: Option, 15 | 16 | #[argh(option, default = "tracing::Level::INFO")] 17 | /// set the log level (default: INFO) 18 | pub log_level: tracing::Level, 19 | 20 | #[argh(subcommand)] 21 | pub cmd: Option, 22 | } 23 | 24 | pub fn args() -> Args { 25 | argh::from_env() 26 | } 27 | 28 | #[derive(FromArgs)] 29 | #[argh(subcommand)] 30 | pub enum Command { 31 | GenerateConfig(GenConfig), 32 | UpdatePassword(UpdatePassword), 33 | AddUser(AddUser), 34 | Users(ListUsers), 35 | #[cfg(any(debug_assertions, test, feature = "_enable_seeding"))] 36 | SeedDatabase(SeedDatabase), 37 | } 38 | 39 | #[cfg(any(debug_assertions, test, feature = "_enable_seeding"))] 40 | #[derive(FromArgs)] 41 | #[argh(subcommand, name = "seed-database")] 42 | /// Seed the database with some test data 43 | pub struct SeedDatabase {} 44 | 45 | #[derive(FromArgs)] 46 | #[argh(subcommand, name = "generate-config")] 47 | /// Save a default configuration file to `liwan.config.toml` 48 | pub struct GenConfig { 49 | #[argh(option, short = 'o')] 50 | /// the path to write the configuration file to 51 | output: Option, 52 | } 53 | 54 | #[derive(FromArgs)] 55 | #[argh(subcommand, name = "update-password")] 56 | /// Update a user's password 57 | pub struct UpdatePassword { 58 | #[argh(positional)] 59 | /// the username of the user to update 60 | username: String, 61 | 62 | #[argh(positional)] 63 | /// the new password 64 | password: String, 65 | } 66 | 67 | #[derive(FromArgs)] 68 | #[argh(subcommand, name = "users")] 69 | /// List all registered users 70 | pub struct ListUsers {} 71 | 72 | #[derive(FromArgs)] 73 | #[argh(subcommand, name = "add-user")] 74 | /// Create a new user 75 | pub struct AddUser { 76 | #[argh(positional)] 77 | /// the username of the new user 78 | username: String, 79 | 80 | #[argh(positional)] 81 | /// the password of the new user 82 | password: String, 83 | 84 | #[argh(option, default = "false")] 85 | /// assign the user the admin role 86 | admin: bool, 87 | } 88 | 89 | pub fn handle_command(mut config: Config, cmd: Command) -> Result<()> { 90 | config.geoip = None; // disable GeoIP support in CLI 91 | 92 | match cmd { 93 | Command::UpdatePassword(update) => { 94 | let app = Liwan::try_new(config)?; 95 | app.users.update_password(&update.username, &update.password)?; 96 | println!("Password updated for user {}", update.username); 97 | } 98 | Command::Users(_) => { 99 | let app = Liwan::try_new(config)?; 100 | let users = app.users.all()?; 101 | if users.is_empty() { 102 | println!("{}", "No users found".bold()); 103 | println!("Use `liwan add-user` to create a new user"); 104 | return Ok(()); 105 | } 106 | 107 | println!("{}", "Users:".bold()); 108 | for user in users { 109 | println!(" - {} ({:?})", user.username.underline(), user.role); 110 | } 111 | } 112 | Command::AddUser(add) => { 113 | let app = Liwan::try_new(config)?; 114 | app.users.create( 115 | &add.username, 116 | &add.password, 117 | if add.admin { UserRole::Admin } else { UserRole::User }, 118 | &[], 119 | )?; 120 | 121 | println!("User {} created", add.username); 122 | } 123 | Command::GenerateConfig(GenConfig { output }) => { 124 | let output = output.unwrap_or_else(|| "liwan.config.toml".to_string()); 125 | if std::path::Path::new(&output).exists() { 126 | println!("Configuration file already exists"); 127 | return Ok(()); 128 | } 129 | 130 | std::fs::write(&output, DEFAULT_CONFIG)?; 131 | println!("Configuration file written to liwan.config.toml"); 132 | } 133 | #[cfg(any(debug_assertions, test, feature = "_enable_seeding"))] 134 | Command::SeedDatabase(_) => { 135 | let app = Liwan::try_new(config)?; 136 | app.seed_database(10_000_000)?; 137 | println!("Database seeded with test data"); 138 | } 139 | } 140 | 141 | Ok(()) 142 | } 143 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod cli; 3 | pub mod config; 4 | pub mod utils; 5 | pub mod web; 6 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![warn(rust_2018_idioms)] 3 | 4 | use eyre::Result; 5 | 6 | use liwan::app::{Liwan, models::Event}; 7 | use liwan::{cli, config::Config, web}; 8 | use tracing_subscriber::EnvFilter; 9 | 10 | #[tokio::main(flavor = "multi_thread")] 11 | async fn main() -> Result<()> { 12 | rustls::crypto::aws_lc_rs::default_provider().install_default().expect("failed to install crypto provider"); 13 | 14 | let args = cli::args(); 15 | setup_logger(args.log_level)?; 16 | 17 | let config = Config::load(args.config)?; 18 | let (s, r) = crossbeam_channel::unbounded::(); 19 | 20 | if let Some(cmd) = args.cmd { 21 | return cli::handle_command(config, cmd); 22 | } 23 | 24 | let app = Liwan::try_new(config)?; 25 | let app_copy = app.clone(); 26 | app.run_background_tasks(); 27 | 28 | tokio::select! { 29 | _ = tokio::signal::ctrl_c() => app_copy.shutdown(), 30 | res = web::start_webserver(app.clone(), s) => res, 31 | res = tokio::task::spawn_blocking(move || app.clone().events.process(r)) => res? 32 | } 33 | } 34 | 35 | fn setup_logger(log_level: tracing::Level) -> Result<()> { 36 | // external crates should use WARN 37 | let filter = EnvFilter::from_default_env() 38 | .add_directive(format!("{}={}", env!("CARGO_PKG_NAME"), log_level).parse()?) 39 | .add_directive(tracing::Level::WARN.into()); 40 | 41 | tracing_subscriber::fmt().with_env_filter(filter).compact().init(); 42 | 43 | #[cfg(debug_assertions)] 44 | tracing::info!("Running in debug mode"); 45 | Ok(()) 46 | } 47 | 48 | #[cfg(not(target_env = "msvc"))] 49 | use tikv_jemallocator::Jemalloc; 50 | 51 | #[cfg(not(target_env = "msvc"))] 52 | #[global_allocator] 53 | static GLOBAL: Jemalloc = Jemalloc; 54 | -------------------------------------------------------------------------------- /src/migrations/app/V1__initial.sql: -------------------------------------------------------------------------------- 1 | create table users ( 2 | username text primary key not null, 3 | password_hash text not null, 4 | role text not null, 5 | projects text not null -- comma separated list of project ids 6 | ); 7 | 8 | create table sessions ( 9 | id text primary key not null, 10 | expires_at timestamp not null, 11 | username text not null 12 | ); 13 | 14 | create table entities ( 15 | id text primary key not null, 16 | display_name text not null 17 | ); 18 | 19 | create table projects ( 20 | id text primary key not null, 21 | display_name text not null, 22 | public boolean not null, 23 | secret text 24 | ); 25 | 26 | create table project_entities ( 27 | project_id text not null, 28 | entity_id text not null, 29 | primary key (project_id, entity_id) 30 | ); 31 | 32 | create table salts ( 33 | id integer primary key default 1, 34 | salt text not null, 35 | updated_at timestamp not null default (datetime('now')) 36 | ); 37 | 38 | insert into salts (id, salt, updated_at) values (1, '', '1970-01-01 00:00:00'); -------------------------------------------------------------------------------- /src/migrations/events/V1__initial.sql: -------------------------------------------------------------------------------- 1 | create table events ( 2 | entity_id text not null, 3 | visitor_id text not null, 4 | event text not null, 5 | created_at timestamp not null default now(), 6 | 7 | -- metadata 8 | fqdn text, 9 | path text, 10 | referrer text, 11 | platform text, 12 | browser text, 13 | mobile boolean, 14 | country text, 15 | city text, 16 | ); 17 | 18 | -- todo: evaluate if these indexes are necessary 19 | create index events_event_idx on events (event); 20 | create index events_entity_id_idx on events (entity_id); 21 | create index events_visitor_id_idx on events (visitor_id); 22 | create index events_created_at_idx on events (created_at); 23 | create index events_entity_id_created_at_idx on events (entity_id, created_at); 24 | create index events_visitor_id_created_at_idx on events (visitor_id, created_at); 25 | -------------------------------------------------------------------------------- /src/migrations/events/V2__remove_indexes.sql: -------------------------------------------------------------------------------- 1 | drop index events_event_idx; 2 | drop index events_entity_id_idx; 3 | drop index events_visitor_id_idx; 4 | drop index events_created_at_idx; 5 | drop index events_entity_id_created_at_idx; 6 | drop index events_visitor_id_created_at_idx; 7 | -------------------------------------------------------------------------------- /src/migrations/events/V3__utm_params.sql: -------------------------------------------------------------------------------- 1 | alter table events add column utm_source text; 2 | alter table events add column utm_medium text; 3 | alter table events add column utm_campaign text; 4 | alter table events add column utm_content text; 5 | alter table events add column utm_term text; 6 | -------------------------------------------------------------------------------- /src/migrations/events/V4__session_intervals.sql: -------------------------------------------------------------------------------- 1 | alter table events add column time_from_last_event interval; 2 | alter table events add column time_to_next_event interval; 3 | 4 | with cte as ( 5 | select 6 | visitor_id, 7 | created_at, 8 | created_at - lag(created_at) over (partition by visitor_id order by created_at) as time_from_last_event, 9 | lead(created_at) over (partition by visitor_id order by created_at) - created_at as time_to_next_event 10 | from events 11 | ) 12 | update events 13 | set 14 | time_from_last_event = cte.time_from_last_event, 15 | time_to_next_event = cte.time_to_next_event 16 | from cte 17 | where events.visitor_id = cte.visitor_id and events.created_at = cte.created_at; -------------------------------------------------------------------------------- /src/utils/duckdb.rs: -------------------------------------------------------------------------------- 1 | use duckdb::ToSql; 2 | 3 | #[derive(Default)] 4 | pub struct ParamVec<'a>(Vec>); 5 | 6 | impl<'a> ParamVec<'a> { 7 | pub fn new() -> Self { 8 | Self::default() 9 | } 10 | 11 | pub fn push(&mut self, value: T) { 12 | self.0.push(Box::new(value)); 13 | } 14 | 15 | pub fn extend_from_params(&mut self, params: Self) { 16 | self.0.extend(params.0); 17 | } 18 | 19 | pub fn extend(&mut self, iter: impl IntoIterator) { 20 | self.0.extend(iter.into_iter().map(|v| Box::new(v) as Box)); 21 | } 22 | } 23 | 24 | impl<'a> IntoIterator for ParamVec<'a> { 25 | type Item = Box; 26 | type IntoIter = std::vec::IntoIter; 27 | fn into_iter(self) -> Self::IntoIter { 28 | self.0.into_iter() 29 | } 30 | } 31 | 32 | /// # Panics 33 | /// Panics if `count` is 0 - this needs to be handled by the caller 34 | pub fn repeat_vars(count: usize) -> String { 35 | assert_ne!(count, 0); 36 | let mut s = "?,".repeat(count); 37 | // Remove trailing comma 38 | s.pop(); 39 | s 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/geo.rs: -------------------------------------------------------------------------------- 1 | use ahash::HashMap; 2 | use std::sync::LazyLock; 3 | 4 | pub fn get_country_name(iso_2_code: &str) -> Option { 5 | static COUNTRIES: LazyLock> = LazyLock::new(|| { 6 | include_str!("../../data/countries.txt") 7 | .lines() 8 | .map(|line| { 9 | let mut parts = line.split('='); 10 | let name = parts.next().expect("countries.txt is malformed").to_string(); 11 | let fqdn = parts.next().expect("countries.txt is malformed").to_string(); 12 | (fqdn, name) 13 | }) 14 | .collect() 15 | }); 16 | 17 | COUNTRIES.get(iso_2_code).map(ToString::to_string) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/hash.rs: -------------------------------------------------------------------------------- 1 | use argon2::Argon2; 2 | use argon2::PasswordVerifier; 3 | use argon2::password_hash::rand_core::{OsRng, RngCore}; 4 | use argon2::password_hash::{PasswordHasher, SaltString}; 5 | 6 | use eyre::Result; 7 | use sha3::Digest; 8 | use std::net::IpAddr; 9 | 10 | pub fn hash_password(password: &str) -> Result { 11 | let salt = SaltString::generate(&mut OsRng); 12 | let hash = Argon2::default() 13 | .hash_password(password.as_bytes(), &salt) 14 | .map_err(|_| eyre::eyre!("Failed to hash password"))?; 15 | Ok(hash.to_string()) 16 | } 17 | 18 | pub fn verify_password(password: &str, hash: &str) -> Result<()> { 19 | let hash = argon2::PasswordHash::new(hash).map_err(|_| eyre::eyre!("Invalid hash"))?; 20 | let argon2 = Argon2::default(); 21 | argon2.verify_password(password.as_bytes(), &hash).map_err(|_| eyre::eyre!("Failed to verify password")) 22 | } 23 | 24 | pub fn generate_salt() -> String { 25 | SaltString::generate(&mut OsRng).to_string() 26 | } 27 | 28 | pub fn hash_ip(ip: &IpAddr, user_agent: &str, daily_salt: &str, entity_id: &str) -> String { 29 | let mut hasher = sha3::Sha3_256::new(); 30 | hasher.update(ip.to_string()); 31 | hasher.update(user_agent); 32 | hasher.update(daily_salt); 33 | hasher.update(entity_id); 34 | let hash = hasher.finalize(); 35 | format!("{hash:02x}")[..32].to_string() 36 | } 37 | 38 | pub fn visitor_id() -> String { 39 | // random 32 byte hex string 40 | let mut rng = OsRng; 41 | let mut bytes = [0u8; 32]; 42 | rng.fill_bytes(&mut bytes); 43 | bs58::encode(bytes).into_string() 44 | } 45 | 46 | pub fn session_token() -> String { 47 | // random 32 byte hex string 48 | let mut rng = OsRng; 49 | let mut bytes = [0u8; 32]; 50 | rng.fill_bytes(&mut bytes); 51 | bs58::encode(bytes).into_string() 52 | } 53 | 54 | pub fn onboarding_token() -> String { 55 | let mut rng = OsRng; 56 | let mut bytes = [0u8; 8]; 57 | rng.fill_bytes(&mut bytes); 58 | bs58::encode(bytes).into_string() 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod duckdb; 2 | pub mod geo; 3 | pub mod hash; 4 | pub mod referrer; 5 | pub mod refinery_duckdb; 6 | pub mod refinery_sqlite; 7 | pub mod seed; 8 | pub mod useragent; 9 | pub mod validate; 10 | 11 | pub fn to_sorted(v: &[T]) -> Vec { 12 | let mut v = v.to_vec(); 13 | v.sort_unstable(); 14 | v 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/referrer.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | use std::sync::LazyLock; 3 | 4 | use ahash::{HashMap, HashSet}; 5 | 6 | static REFERERS: LazyLock> = LazyLock::new(|| { 7 | include_str!("../../data/referrers.txt") 8 | .lines() 9 | .map(|line| { 10 | let mut parts = line.split('='); 11 | let name = parts.next().expect("referrers.txt is malformed: missing key"); 12 | let fqdn = parts.next().expect("referrers.txt is malformed: missing value"); 13 | (fqdn.to_string(), name.to_string()) 14 | }) 15 | .collect() 16 | }); 17 | 18 | static SPAMMERS: LazyLock> = 19 | LazyLock::new(|| include_str!("../../data/spammers.txt").lines().map(ToString::to_string).collect()); 20 | 21 | static REFERRER_ICONS: LazyLock> = LazyLock::new(|| { 22 | include_str!("../../data/referrer_icons.txt") 23 | .lines() 24 | .map(|line| { 25 | let mut parts = line.split('='); 26 | let fqdn = parts.next().expect("referrer_icons.txt is malformed: missing key"); 27 | let icon = parts.next().expect("referrer_icons.txt is malformed: missing value"); 28 | (fqdn.to_string(), icon.to_string()) 29 | }) 30 | .collect() 31 | }); 32 | 33 | pub fn get_referer_name(fqdn: &str) -> Option { 34 | REFERERS.get(fqdn).map(ToString::to_string) 35 | } 36 | 37 | pub fn get_referer_icon(name: &str) -> Option { 38 | REFERRER_ICONS.get(name).map(ToString::to_string) 39 | } 40 | 41 | pub fn is_spammer(fqdn: &str) -> bool { 42 | SPAMMERS.contains(fqdn) 43 | } 44 | 45 | #[derive(Debug, PartialEq)] 46 | pub enum Referrer { 47 | Fqdn(String), 48 | Unknown(Option), 49 | Spammer, 50 | } 51 | 52 | pub fn process_referer(referer: Option<&str>) -> Referrer { 53 | match referer.map(poem::http::Uri::from_str) { 54 | // valid referer are stripped to the FQDN 55 | Some(Ok(referer_uri)) => { 56 | // ignore localhost / IP addresses 57 | if referer_uri.host().is_some_and(|host| { 58 | host == "localhost" || host.ends_with(".localhost") || host.parse::().is_ok() 59 | }) { 60 | return Referrer::Unknown(None); 61 | } 62 | 63 | let referer_fqn = referer_uri.host().unwrap_or_default(); 64 | if is_spammer(referer_fqn) { 65 | return Referrer::Spammer; 66 | } 67 | Referrer::Fqdn(referer_fqn.to_string()) 68 | } 69 | // invalid referer are kept as is (e.g. when using custom referer values outside of the browser) 70 | Some(Err(_)) => Referrer::Unknown(referer.map(std::string::ToString::to_string)), 71 | None => Referrer::Unknown(None), 72 | } 73 | } 74 | 75 | #[cfg(test)] 76 | mod test { 77 | use super::*; 78 | 79 | #[test] 80 | fn test_process_referer() { 81 | assert_eq!(process_referer(None), Referrer::Unknown(None), "Should return None when no referer is provided"); 82 | 83 | assert_eq!( 84 | process_referer(Some("https://example.com/path?query=string")), 85 | Referrer::Fqdn("example.com".to_string()), 86 | "Should return the FQDN for a valid referer that is not a spammer" 87 | ); 88 | 89 | assert_eq!( 90 | process_referer(Some("https://adf.ly/path")), 91 | Referrer::Spammer, 92 | "Should return an error for a referer identified as a spammer" 93 | ); 94 | 95 | assert_eq!(process_referer(Some("google.com")), Referrer::Fqdn("google.com".to_string())); 96 | assert_eq!(process_referer(Some("127.0.0.1")), Referrer::Unknown(None)); 97 | assert_eq!(process_referer(Some("1.1.1.1")), Referrer::Unknown(None)); 98 | assert_eq!(process_referer(Some("localhost")), Referrer::Unknown(None)); 99 | assert_eq!(process_referer(Some("asdf.localhost")), Referrer::Unknown(None)); 100 | 101 | assert_eq!( 102 | process_referer(Some("invalid referrer")), 103 | Referrer::Unknown(Some("invalid referrer".to_string())), 104 | "Should return the original referrer if it is invalid" 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/utils/refinery_duckdb.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use refinery::{Migration, error::WrapMigrationError}; 3 | use refinery_core::{ 4 | Migrate, 5 | traits::sync::{Query, Transaction}, 6 | }; 7 | use std::ops::DerefMut; 8 | 9 | pub struct DuckDBConnection>(pub T); 10 | impl> From for DuckDBConnection { 11 | fn from(conn: T) -> Self { 12 | Self(conn) 13 | } 14 | } 15 | 16 | impl> Transaction for DuckDBConnection { 17 | type Error = duckdb::Error; 18 | fn execute(&mut self, queries: &[&str]) -> Result { 19 | let transaction = self.0.transaction()?; 20 | let count = queries.iter().try_fold(0, |count, query| { 21 | transaction.execute_batch(query)?; 22 | Ok::<_, Self::Error>(count + 1) 23 | })?; 24 | transaction.commit()?; 25 | Ok(count) 26 | } 27 | } 28 | 29 | impl> Query> for DuckDBConnection { 30 | fn query(&mut self, query: &str) -> Result, Self::Error> { 31 | let mut stmt = self.0.prepare(query)?; 32 | let applied: Vec = stmt 33 | .query_map([], |row| { 34 | let version = row.get(0)?; 35 | let name: String = row.get(1)?; 36 | let applied_on: time::OffsetDateTime = row.get(2)?; 37 | let checksum: u64 = row.get(3)?; 38 | Ok(Migration::applied(version, name, applied_on, checksum)) 39 | })? 40 | .collect::, _>>()?; 41 | Ok(applied) 42 | } 43 | } 44 | 45 | impl> Migrate for DuckDBConnection { 46 | fn assert_migrations_table(&mut self, migration_table_name: &str) -> std::result::Result { 47 | let query = format!( 48 | "create table if not exists {migration_table_name} ( 49 | version int primary key, 50 | name text not null, 51 | applied_on timestamp not null, 52 | checksum text not null 53 | )" 54 | ); 55 | self.execute(&[&query]).migration_err("error asserting migrations table", None)?; 56 | Ok(0) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/refinery_sqlite.rs: -------------------------------------------------------------------------------- 1 | use std::ops::DerefMut; 2 | 3 | use refinery::Migration; 4 | use refinery_core::traits::sync::{Migrate, Query, Transaction}; 5 | use rusqlite::{Connection, Error as RqlError}; 6 | use time::OffsetDateTime; 7 | use time::format_description::well_known::Rfc3339; 8 | 9 | pub struct RqlConnection>(pub T); 10 | impl> From for RqlConnection { 11 | fn from(conn: T) -> Self { 12 | Self(conn) 13 | } 14 | } 15 | 16 | fn query_applied_migrations(transaction: &rusqlite::Transaction, query: &str) -> Result, RqlError> { 17 | let mut stmt = transaction.prepare(query)?; 18 | let mut rows = stmt.query([])?; 19 | let mut applied = Vec::new(); 20 | while let Some(row) = rows.next()? { 21 | let version = row.get(0)?; 22 | let applied_on: String = row.get(2)?; 23 | // Safe to call unwrap, as we stored it in RFC3339 format on the database 24 | let applied_on = OffsetDateTime::parse(&applied_on, &Rfc3339).unwrap(); 25 | 26 | let checksum: String = row.get(3)?; 27 | applied.push(Migration::applied( 28 | version, 29 | row.get(1)?, 30 | applied_on, 31 | checksum.parse::().expect("checksum must be a valid u64"), 32 | )); 33 | } 34 | Ok(applied) 35 | } 36 | 37 | impl> Transaction for RqlConnection { 38 | type Error = RqlError; 39 | fn execute(&mut self, queries: &[&str]) -> Result { 40 | let transaction = self.0.transaction()?; 41 | let mut count = 0; 42 | for query in queries { 43 | transaction.execute_batch(query)?; 44 | count += 1; 45 | } 46 | transaction.commit()?; 47 | Ok(count) 48 | } 49 | } 50 | 51 | impl> Query> for RqlConnection { 52 | fn query(&mut self, query: &str) -> Result, Self::Error> { 53 | let transaction = self.0.transaction()?; 54 | let applied = query_applied_migrations(&transaction, query)?; 55 | transaction.commit()?; 56 | Ok(applied) 57 | } 58 | } 59 | 60 | impl> Migrate for RqlConnection {} 61 | -------------------------------------------------------------------------------- /src/utils/seed.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | use time::OffsetDateTime; 3 | 4 | use crate::app::models::Event; 5 | 6 | const PATHS: &[&str] = &["/", "/about", "/contact", "/pricing", "/blog", "/login", "/signup"]; 7 | const REFERRERS: &[&str] = &["", "google.com", "twitter.com", "liwan.dev", "example.com", "henrygressmann.de"]; 8 | const PLATFORMS: &[&str] = &["", "Windows", "macOS", "Linux", "Android", "iOS"]; 9 | const BROWSERS: &[&str] = &["", "Chrome", "Firefox", "Safari", "Edge", "Opera"]; 10 | const CITIES: &[(&str, &str)] = &[ 11 | ("", ""), 12 | ("Paris", "FR"), 13 | ("London", "GB"), 14 | ("Berlin", "DE"), 15 | ("Frankfurt", "DE"), 16 | ("New York", "US"), 17 | ("San Francisco", "US"), 18 | ("Tokyo", "JP"), 19 | ("Sydney", "AU"), 20 | ]; 21 | const UTM_CAMPAIGNS: &[&str] = &["", "summer_sale", "black_friday", "christmas", "new_year"]; 22 | const UTM_CONTENTS: &[&str] = &["", "banner", "sidebar", "footer", "popup"]; 23 | const UTM_MEDIUMS: &[&str] = &["", "cpc", "organic", "referral", "email"]; 24 | const UTM_SOURCES: &[&str] = &["", "google", "bing", "facebook", "twitter"]; 25 | const UTM_TERMS: &[&str] = &["", "liwan", "analytics", "tracking", "web"]; 26 | 27 | pub fn random_events( 28 | time_range: (OffsetDateTime, OffsetDateTime), 29 | entity_id: &str, 30 | fqdn: &str, 31 | count: usize, 32 | ) -> impl Iterator + use<> { 33 | let mut rng = rand::rng(); 34 | let mut generated = 0; 35 | let entity_id = entity_id.to_string(); 36 | let fqdn = fqdn.to_string(); 37 | let visitor_ids: Vec = (0..count / 5).map(|_| rng.random::().to_string()).collect(); 38 | 39 | std::iter::from_fn(move || { 40 | if generated >= count { 41 | return None; 42 | } 43 | generated += 1; 44 | 45 | let created_at = random_date(time_range.0, time_range.1, 0.5); 46 | 47 | // let time_slice = time_range.1 - time_range.0; 48 | // let skew_factor = 2.0; 49 | // let normalized = 1.0 - (1.0 - (generated as f64 / count as f64)).powf(skew_factor); 50 | // let created_at = time_range.0 + time_slice * normalized; 51 | 52 | let path = random_el(PATHS, 0.5); 53 | let referrer = random_el(REFERRERS, 0.5); 54 | let platform = random_el(PLATFORMS, -0.5); 55 | let browser = random_el(BROWSERS, -0.5); 56 | let mobile = rng.random_bool(0.7); 57 | let (city, country) = random_el(CITIES, 0.5); 58 | 59 | Some(Event { 60 | browser: if browser.is_empty() { None } else { Some(browser.to_string()) }, 61 | city: if city.is_empty() { None } else { Some(city.to_string()) }, 62 | country: if country.is_empty() { None } else { Some(country.to_string()) }, 63 | created_at, 64 | entity_id: entity_id.clone(), 65 | event: "pageview".to_string(), 66 | fqdn: Some(fqdn.clone()), 67 | mobile: Some(mobile), 68 | platform: if platform.is_empty() { None } else { Some(platform.to_string()) }, 69 | referrer: if referrer.is_empty() { None } else { Some(referrer.to_string()) }, 70 | path: Some(path.to_string()), 71 | visitor_id: random_el(&visitor_ids, 0.7).to_string(), 72 | utm_campaign: Some(random_el(UTM_CAMPAIGNS, 0.5).to_string()), 73 | utm_content: Some(random_el(UTM_CONTENTS, 0.5).to_string()), 74 | utm_medium: Some(random_el(UTM_MEDIUMS, 0.5).to_string()), 75 | utm_source: Some(random_el(UTM_SOURCES, 0.5).to_string()), 76 | utm_term: Some(random_el(UTM_TERMS, 0.5).to_string()), 77 | }) 78 | }) 79 | } 80 | 81 | fn random_date(min: OffsetDateTime, max: OffsetDateTime, scale: f64) -> OffsetDateTime { 82 | let mut rng = rand::rng(); 83 | let uniform_random: f64 = rng.random(); 84 | let weighted_random = (uniform_random.powf(1.0 - scale)).min(1.0); 85 | let duration = max - min; 86 | let duration_seconds = duration.as_seconds_f64(); 87 | let weighted_duration_seconds = duration_seconds * weighted_random; 88 | let weighted_duration = time::Duration::seconds(weighted_duration_seconds as i64); 89 | min + weighted_duration 90 | } 91 | 92 | fn random_el(slice: &[T], scale: f64) -> &T { 93 | let mut rng = rand::rng(); 94 | let len = slice.len(); 95 | 96 | assert!(len != 0, "Cannot choose from an empty slice"); 97 | 98 | let uniform_random: f64 = rng.random(); 99 | let weighted_random = (uniform_random.powf(1.0 - scale)).min(1.0); 100 | let index = (weighted_random * (len as f64)) as usize; 101 | &slice[index.min(len - 1)] 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/useragent.rs: -------------------------------------------------------------------------------- 1 | use quick_cache::sync::Cache; 2 | use std::sync::LazyLock; 3 | use uaparser::{Parser, UserAgentParser}; 4 | 5 | #[derive(Clone, Debug, Default)] 6 | pub struct UserAgent { 7 | pub device_family: String, 8 | pub os_family: String, 9 | pub ua_family: String, 10 | } 11 | 12 | static PARSER: LazyLock = LazyLock::new(|| { 13 | UserAgentParser::builder() 14 | .with_unicode_support(false) 15 | .build_from_bytes(include_bytes!("../../data/ua_regexes.yaml")) 16 | .expect("Parser creation failed") 17 | }); 18 | 19 | static UAP_CACHE: LazyLock> = LazyLock::new(|| Cache::new(1024)); 20 | 21 | pub fn parse(header: &str) -> UserAgent { 22 | if let Some(client) = UAP_CACHE.get(header) { 23 | return client; 24 | } 25 | 26 | let client = PARSER.parse(header); 27 | let uap = UserAgent { 28 | device_family: client.device.family.to_string(), 29 | os_family: client.os.family.replace("Mac OS X", "macOS").replace("Other", "Unknown"), 30 | ua_family: client.user_agent.family.to_string(), 31 | }; 32 | 33 | UAP_CACHE.insert(header.to_string(), uap.clone()); 34 | uap 35 | } 36 | 37 | impl UserAgent { 38 | pub fn from_header(header: &str) -> Self { 39 | if let Some(client) = UAP_CACHE.get(header) { 40 | return client.clone(); 41 | } 42 | 43 | let client = PARSER.parse(header); 44 | let uap = UserAgent { 45 | device_family: client.device.family.to_string(), 46 | os_family: client.os.family.replace("Mac OS X", "macOS").replace("Other", "Unknown"), 47 | ua_family: client.user_agent.family.to_string(), 48 | }; 49 | 50 | UAP_CACHE.insert(header.to_string(), uap.clone()); 51 | uap 52 | } 53 | 54 | pub fn is_bot(&self) -> bool { 55 | self.device_family == "Spider" || self.ua_family == "HeadlessChrome" 56 | } 57 | 58 | pub fn is_mobile(&self) -> bool { 59 | ["iOS", "Android"].contains(&self.os_family.as_str()) // good enough for 99% of cases 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod test { 65 | use super::*; 66 | 67 | #[test] 68 | fn test_ua_parser() { 69 | let user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"; 70 | let client = parse(user_agent); 71 | assert_eq!(client.os_family, "iOS", "Expected OS family to be iOS"); 72 | assert_eq!(client.device_family, "iPhone", "Expected device family to be iPhone"); 73 | assert!(client.is_mobile(), "Expected device to be mobile"); 74 | assert!(!client.is_bot(), "Expected device to not be a bot"); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/validate.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::models::{Project, UserRole}, 3 | web::SessionUser, 4 | }; 5 | 6 | pub const MAX_DATAPOINTS: u32 = 2000; 7 | 8 | pub fn is_valid_id(id: &str) -> bool { 9 | id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == ':') 10 | } 11 | 12 | pub fn is_valid_username(name: &str) -> bool { 13 | name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') && name.len() <= 64 && name.len() >= 3 14 | } 15 | 16 | pub fn can_access_project(project: &Project, user: Option<&SessionUser>) -> bool { 17 | project.public || user.is_some_and(|u| u.0.role == UserRole::Admin || u.0.projects.contains(&project.id)) 18 | } 19 | 20 | #[cfg(test)] 21 | mod tests { 22 | use super::*; 23 | use crate::app::models::{Project, User}; 24 | 25 | #[test] 26 | fn test_can_access_project() { 27 | let project = Project { 28 | id: "public_project".to_string(), 29 | display_name: "Public Project".to_string(), 30 | secret: None, 31 | public: true, 32 | }; 33 | assert!(can_access_project(&project, None), "Public project should be accessible without a user."); 34 | 35 | let user = SessionUser(User { 36 | username: "test".to_string(), 37 | role: UserRole::User, 38 | projects: vec!["other".to_string()], 39 | }); 40 | assert!(can_access_project(&project, Some(&user)), "Public project should be accessible with any user."); 41 | 42 | let project = Project { 43 | id: "admin_test_project".to_string(), 44 | display_name: "Admin Test Project".to_string(), 45 | secret: None, 46 | public: false, 47 | }; 48 | let admin_user = SessionUser(User { username: "admin".to_string(), role: UserRole::Admin, projects: vec![] }); 49 | assert!(can_access_project(&project, Some(&admin_user)), "Admin should have access to any project."); 50 | 51 | let project = Project { 52 | id: "private_project".to_string(), 53 | display_name: "Private Project".to_string(), 54 | secret: None, 55 | public: false, 56 | }; 57 | assert!(!can_access_project(&project, None), "Private project should not be accessible without a user."); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/web/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod routes; 2 | pub mod session; 3 | pub mod webext; 4 | 5 | use std::sync::Arc; 6 | 7 | use crate::app::Liwan; 8 | use crate::app::models::Event; 9 | use crossbeam_channel::Sender; 10 | use routes::{dashboard_service, event_service}; 11 | use webext::{EmbeddedFilesEndpoint, PoemErrExt, catch_error}; 12 | 13 | pub use session::SessionUser; 14 | 15 | use colored::Colorize; 16 | use eyre::{Context, Result}; 17 | use rust_embed::RustEmbed; 18 | 19 | use poem::endpoint::EmbeddedFileEndpoint; 20 | use poem::listener::TcpListener; 21 | use poem::middleware::{AddData, Compression, CookieJarManager, Cors, SetHeader}; 22 | use poem::web::CompressionAlgo; 23 | use poem::{EndpointExt, IntoEndpoint, Route, Server}; 24 | 25 | #[derive(RustEmbed, Clone)] 26 | #[folder = "./web/dist"] 27 | struct Files; 28 | 29 | #[derive(RustEmbed, Clone)] 30 | #[folder = "./tracker"] 31 | struct Script; 32 | 33 | #[cfg(debug_assertions)] 34 | fn save_spec() -> Result<()> { 35 | use std::path::Path; 36 | 37 | let path = Path::new("./web/src/api/dashboard.ts"); 38 | if path.exists() { 39 | let spec = serde_json::to_string(&serde_json::from_str::(&dashboard_service().spec())?)? 40 | .replace(r#""servers":[],"#, "") // fets doesn't work with an empty servers array 41 | .replace("; charset=utf-8", "") // fets doesn't detect the json content type correctly 42 | .replace(r#""format":"int64","#, ""); // fets uses bigint for int64 43 | 44 | // check if the spec has changed 45 | let old_spec = std::fs::read_to_string(path)?; 46 | if old_spec == format!("export default {spec} as const;\n") { 47 | return Ok(()); 48 | } 49 | 50 | tracing::info!("API has changed, updating the openapi spec..."); 51 | std::fs::write(path, format!("export default {spec} as const;\n"))?; 52 | } 53 | Ok(()) 54 | } 55 | 56 | pub fn create_router(app: Arc, events: Sender) -> impl IntoEndpoint { 57 | let handle_events = event_service().with(Cors::new().allow_method("POST").allow_credentials(false)); 58 | 59 | let serve_script = EmbeddedFileEndpoint:: 18 | ``` 19 | 20 | ### Custom events 21 | 22 | ```ts 23 | import { event } from "liwan-tracker"; 24 | 25 | await event("pageview", { 26 | url: "https://example.com", 27 | referrer: "https://google.com", 28 | endpoint: "https://liwan.example.com/api/event", 29 | entity: "example", 30 | }); 31 | ``` 32 | 33 | ## API 34 | 35 | ```ts 36 | export type EventOptions = { 37 | /** 38 | * The URL of the page where the event occurred. 39 | * 40 | * If not provided, the current page URL with hash and search parameters removed will be used. 41 | */ 42 | url?: string; 43 | 44 | /** 45 | * The referrer of the page where the event occurred. 46 | * 47 | * If not provided, `document.referrer` will be used if available. 48 | */ 49 | referrer?: string; 50 | 51 | /** 52 | * The API endpoint to send the event to. 53 | * 54 | * If not provided, either the `data-api` attribute or the url where the script is loaded from will be used. 55 | * Required in server-side environments. 56 | */ 57 | endpoint?: string; 58 | 59 | /** 60 | * The entity that the event is associated with. 61 | * 62 | * If not provided, the `data-entity` attribute will be used. 63 | * Required for custom events. 64 | */ 65 | entity?: string; 66 | }; 67 | 68 | /** 69 | * Sends an event to the Liwan API. 70 | * 71 | * @param name The name of the event. Defaults to "pageview". 72 | * @param options Additional options for the event. See {@link EventOptions}. 73 | * @returns A promise that resolves with the status code of the response or void if the event was ignored. 74 | * @throws If {@link EventOptions.endpoint} is not provided in server-side environments. 75 | */ 76 | export function event( 77 | name?: string, // = "pageview" 78 | options?: EventOptions 79 | ): Promise; 82 | ``` 83 | 84 | ## License 85 | 86 | The Liwan tracker is licensed under the [MIT License](LICENSE.md). Liwan itself is available under the [Apache-2.0 License](https://github.com/explodingcamera/liwan/blob/main/LICENSE.md). 87 | -------------------------------------------------------------------------------- /tracker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liwan-tracker", 3 | "version": "0.2.0", 4 | "homepage": "https://liwan.dev", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/explodingcamera/liwan.git", 8 | "directory": "tracker" 9 | }, 10 | "license": "MIT", 11 | "author": { 12 | "name": "Henry Gressmann", 13 | "email": "mail@henrygressmann.de", 14 | "url": "https://henrygressmann.de" 15 | }, 16 | "type": "module", 17 | "exports": { 18 | ".": { 19 | "import": "./script.min.js", 20 | "types": "./script.min.d.ts" 21 | } 22 | }, 23 | "module": "script.min.js", 24 | "types": "script.min.d.ts" 25 | } 26 | -------------------------------------------------------------------------------- /tracker/script.d.ts: -------------------------------------------------------------------------------- 1 | declare module "script" { 2 | global { 3 | interface Window { 4 | __liwan_loaded?: boolean; 5 | navigation?: any; 6 | } 7 | } 8 | export type EventOptions = { 9 | /** 10 | * The URL of the page where the event occurred. 11 | * 12 | * If not provided, the current page URL with hash and search parameters removed will be used. 13 | */ 14 | url?: string; 15 | /** 16 | * The referrer of the page where the event occurred. 17 | * 18 | * If not provided, `document.referrer` will be used if available. 19 | */ 20 | referrer?: string; 21 | /** 22 | * The API endpoint to send the event to. 23 | * 24 | * If not provided, either the `data-api` attribute or the url where the script is loaded from will be used. 25 | * Required in server-side environments. 26 | */ 27 | endpoint?: string; 28 | /** 29 | * The entity that the event is associated with. 30 | * 31 | * If not provided, the `data-entity` attribute will be used. 32 | * Required for custom events. 33 | */ 34 | entity?: string; 35 | }; 36 | /** 37 | * Sends an event to the Liwan API. 38 | * 39 | * @param name The name of the event. Defaults to "pageview". 40 | * @param options Additional options for the event. See {@link EventOptions}. 41 | * @returns A promise that resolves with the status code of the response or void if the event was ignored. 42 | * @throws If {@link EventOptions.endpoint} is not provided in server-side environments. 43 | * 44 | * @example 45 | * ```ts 46 | * // Send a pageview event 47 | * await event("pageview", { 48 | * url: "https://example.com", 49 | * referrer: "https://google.com", 50 | * endpoint: "https://liwan.example.com/api/event" 51 | * }).then(({ status }) => { 52 | * console.log(`Event response: ${status}`); 53 | * }); 54 | * ``` 55 | */ 56 | export function event(name?: string, options?: EventOptions): Promise; 57 | } 58 | -------------------------------------------------------------------------------- /tracker/script.min.js: -------------------------------------------------------------------------------- 1 | let n=null,a=null,l=null,s=null;const u=typeof window>"u";typeof document<"u"&&(n=document.querySelector(`script[src^="${import.meta.url}"]`),a=n?.getAttribute("data-api")||n&&new URL(n.src).origin+"/api/event",l=n?.getAttribute("data-entity")||null,s=document.referrer);const d=e=>console.info("[liwan]: "+e),c=e=>d("Ignoring event: "+e),g=e=>{throw new Error("Failed to send event: "+e)};async function p(e="pageview",t){const r=t?.endpoint||a;if(!r)return g("endpoint is required");if(typeof localStorage<"u"&&localStorage.getItem("disable-liwan"))return c("localStorage flag");if(/^localhost$|^127(?:\.\d+){0,2}\.\d+$|^\[::1?\]$/.test(location.hostname)||location.protocol==="file:")return c("localhost");const i=await fetch(r,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e,entity_id:t?.entity||l,referrer:t?.referrer||s,url:t?.url||location.origin+location.pathname,utm:{...["campaign","content","medium","source","term"].map(o=>[o,new URLSearchParams(location.search).get("utm_"+o)])}})});i.ok||(d("Failed to send event: "+i.statusText),g(i.statusText))}const w=()=>{window.__liwan_loaded=!0;let e;const t=()=>{e!==location.pathname&&(e=location.pathname,p("pageview"))};window.navigation?window.navigation.addEventListener("navigate",()=>t()):(window.history.pushState=new Proxy(window.history.pushState,{apply:(r,i,o)=>{r.apply(i,o),t()}}),window.addEventListener("popstate",t),document.addEventListener("astro:page-load",()=>t())),t()};!u&&!window.__liwan_loaded&&n&&w();export{p as event}; 2 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | -------------------------------------------------------------------------------- /web/astro.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import react from "@astrojs/react"; 3 | import { defineConfig } from "astro/config"; 4 | import license from "rollup-plugin-license"; 5 | import type { AstroIntegration } from "astro"; 6 | const dirname = path.dirname(new URL(import.meta.url).pathname); 7 | 8 | const proxy = { 9 | "/api": { 10 | target: "http://localhost:9042", 11 | changeOrigin: true, 12 | cookieDomainRewrite: "localhost:4321", 13 | }, 14 | }; 15 | 16 | function setPrerender(): AstroIntegration { 17 | let isDev = false; 18 | return { 19 | name: "set-prerender", 20 | hooks: { 21 | "astro:config:setup": ({ command }) => { 22 | isDev = command === "dev"; 23 | }, 24 | "astro:route:setup": ({ route }) => { 25 | if (isDev && route.component.endsWith("/pages/p/[...project].astro")) { 26 | route.prerender = false; 27 | } 28 | }, 29 | }, 30 | }; 31 | } 32 | 33 | // https://astro.build/config 34 | export default defineConfig({ 35 | vite: { 36 | server: { proxy }, 37 | preview: { proxy }, 38 | plugins: [ 39 | license({ 40 | thirdParty: { 41 | allow: 42 | "(MIT OR Apache-2.0 OR ISC OR BSD-3-Clause OR 0BSD OR CC0-1.0 OR Unlicense)", 43 | output: { 44 | file: path.join(dirname, "../", "data", "licenses-npm.json"), 45 | template: (dependencies) => JSON.stringify(dependencies), 46 | }, 47 | }, 48 | // biome-ignore lint/suspicious/noExplicitAny: wrong type 49 | }) as any, 50 | ], 51 | }, 52 | integrations: [react(), setPrerender()], 53 | redirects: { 54 | "/settings": "/settings/projects", 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liwan-web", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "scripts": { 6 | "build": "bun run --bun astro build", 7 | "dev": "bun run --bun astro dev", 8 | "preview": "bun run --bun astro preview", 9 | "typecheck": "bun run --bun tsc --noEmit" 10 | }, 11 | "dependencies": { 12 | "@astrojs/react": "4.3.0", 13 | "@explodingcamera/css": "^0.0.4", 14 | "@fontsource-variable/outfit": "^5.2.5", 15 | "@icons-pack/react-simple-icons": "^12.8.0", 16 | "@picocss/pico": "^2.1.1", 17 | "@radix-ui/react-accordion": "^1.2.11", 18 | "@radix-ui/react-dialog": "^1.1.14", 19 | "@radix-ui/react-tabs": "^1.1.12", 20 | "@tanstack/react-query": "^5.76.2", 21 | "d3-array": "^3.2.4", 22 | "d3-axis": "^3.0.0", 23 | "d3-ease": "^3.0.1", 24 | "d3-geo": "^3.1.1", 25 | "d3-scale": "^4.0.2", 26 | "d3-selection": "^3.0.0", 27 | "d3-shape": "^3.2.0", 28 | "d3-transition": "^3.0.1", 29 | "d3-zoom": "^3.0.0", 30 | "date-fns": "^4.1.0", 31 | "fets": "^0.8.5", 32 | "fuzzysort": "^3.1.0", 33 | "little-date": "^1.0.0", 34 | "lucide-react": "0.511.0", 35 | "react": "19.1.0", 36 | "react-dom": "19.1.0", 37 | "react-tag-autocomplete": "^7.5.0", 38 | "react-tooltip": "^5.28.1", 39 | "topojson-client": "^3.1.0" 40 | }, 41 | "devDependencies": { 42 | "@astrojs/check": "^0.9.4", 43 | "@biomejs/biome": "1.9.4", 44 | "@million/lint": "^1.0.14", 45 | "@types/bun": "^1.2.14", 46 | "@types/d3-array": "^3.2.1", 47 | "@types/d3-axis": "^3.0.6", 48 | "@types/d3-ease": "^3.0.2", 49 | "@types/d3-geo": "^3.1.0", 50 | "@types/d3-scale": "^4.0.9", 51 | "@types/d3-selection": "^3.0.11", 52 | "@types/d3-shape": "^3.1.7", 53 | "@types/d3-transition": "^3.0.9", 54 | "@types/d3-zoom": "^3.0.8", 55 | "@types/react": "^19.1.5", 56 | "@types/react-dom": "^19.1.5", 57 | "@types/topojson-client": "^3.1.5", 58 | "@types/topojson-specification": "^1.0.5", 59 | "astro": "5.8.0", 60 | "rollup-plugin-license": "^3.6.0", 61 | "typescript": "^5.8.3" 62 | }, 63 | "packageManager": "bun@1.2.14", 64 | "trustedDependencies": ["@biomejs/biome", "esbuild", "sharp"] 65 | } 66 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /web/src/api/client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "fets"; 2 | import type { DashboardSpec } from "./types"; 3 | 4 | export const api = createClient({ 5 | globalParams: { credentials: "same-origin" }, 6 | fetchFn(input, init) { 7 | return fetch(input, init).then((res) => { 8 | if (!res.ok) { 9 | return res 10 | .json() 11 | .catch((_) => Promise.reject({ status: res.status, message: res.statusText })) 12 | .then((body) => Promise.reject({ status: res.status, message: body?.message ?? res.statusText })); 13 | } 14 | return res; 15 | }); 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /web/src/api/constants.ts: -------------------------------------------------------------------------------- 1 | import type { Dimension, DimensionFilter, Metric } from "./types"; 2 | 3 | export const metricNames: Record = { 4 | views: "Total Views", 5 | unique_visitors: "Unique Visitors", 6 | avg_time_on_site: "Avg Time on Site", 7 | bounce_rate: "Bounce Rate", 8 | }; 9 | 10 | export const dimensionNames: Record = { 11 | platform: "Platform", 12 | browser: "Browser", 13 | url: "URL", 14 | path: "Path", 15 | mobile: "Device Type", 16 | referrer: "Referrer", 17 | city: "City", 18 | country: "Country", 19 | fqdn: "Domain", 20 | utm_campaign: "UTM Campaign", 21 | utm_content: "UTM Content", 22 | utm_medium: "UTM Medium", 23 | utm_source: "UTM Source", 24 | utm_term: "UTM Term", 25 | }; 26 | 27 | export const filterNames: Record = { 28 | contains: "contains", 29 | equal: "equals", 30 | is_null: "is null", 31 | ends_with: "ends with", 32 | is_false: "is false", 33 | is_true: "is true", 34 | starts_with: "starts with", 35 | }; 36 | 37 | export const filterNamesInverted: Record = { 38 | contains: "does not contain", 39 | equal: "is not", 40 | is_null: "is not null", 41 | ends_with: "does not end with", 42 | is_false: "is not false", 43 | is_true: "is not true", 44 | starts_with: "does not start with", 45 | }; 46 | -------------------------------------------------------------------------------- /web/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./query"; 2 | export * from "./constants"; 3 | export * from "./types"; 4 | export * from "./hooks"; 5 | export * from "./client"; 6 | -------------------------------------------------------------------------------- /web/src/api/query.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DefaultError, 3 | QueryClient, 4 | type QueryKey, 5 | type UseQueryOptions, 6 | type UseQueryResult, 7 | useMutation as _useMutation, 8 | useQuery as _useQuery, 9 | } from "@tanstack/react-query"; 10 | 11 | export const queryClient = new QueryClient(); 12 | export const useMutation: typeof _useMutation = (options, c) => _useMutation(options, c || queryClient); 13 | 14 | export function useQuery< 15 | TQueryFnData = unknown, 16 | TError = DefaultError, 17 | TData = TQueryFnData, 18 | TQueryKey extends QueryKey = QueryKey, 19 | >(options: UseQueryOptions, c?: QueryClient): UseQueryResult { 20 | return _useQuery( 21 | { 22 | enabled(query) { 23 | if (typeof window === "undefined") return false; 24 | return typeof options.enabled === "function" ? options.enabled(query) : (options.enabled ?? true); 25 | }, 26 | ...options, 27 | }, 28 | c || queryClient, 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /web/src/api/ranges.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "bun:test"; 2 | import { startOfDay, subDays } from "date-fns"; 3 | import { DateRange } from "./ranges"; 4 | 5 | describe("DateRange", () => { 6 | it("should initialize with a range name", () => { 7 | const range = new DateRange("today"); 8 | expect(range.isCustom()).toBe(false); 9 | expect(range.serialize()).toBe("today"); 10 | }); 11 | 12 | it("should initialize with a custom date range", () => { 13 | const start = new Date(2024, 10, 1); 14 | const end = new Date(2024, 10, 15); 15 | const range = new DateRange({ start, end }); 16 | expect(range.isCustom()).toBe(true); 17 | expect(range.value).toEqual({ start, end }); 18 | }); 19 | 20 | it("should serialize and deserialize correctly", () => { 21 | const start = new Date(2024, 10, 1); 22 | const end = new Date(2024, 10, 15); 23 | const range = new DateRange({ start, end }); 24 | const serialized = range.serialize(); 25 | const deserialized = DateRange.deserialize(serialized); 26 | expect(deserialized.value).toEqual({ start, end }); 27 | }); 28 | 29 | it("should format well-known ranges", () => { 30 | const range = new DateRange("today"); 31 | expect(range.format()).toBeDefined(); // Ensure a format exists 32 | }); 33 | 34 | it("should calculate if the range ends today", () => { 35 | const now = startOfDay(new Date()); 36 | const range = new DateRange({ start: now, end: now }); 37 | expect(range.endsToday()).toBe(true); 38 | 39 | const yesterday = subDays(now, 1); 40 | const pastRange = new DateRange({ start: yesterday, end: yesterday }); 41 | expect(pastRange.endsToday()).toBe(false); 42 | }); 43 | 44 | it("should calculate graph range and data points", () => { 45 | const start = new Date(2024, 10, 1); 46 | const end = new Date(2024, 10, 15); 47 | const range = new DateRange({ start, end }); 48 | expect(range.getGraphRange()).toBe("day"); 49 | expect(range.getGraphDataPoints()).toBe(14); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /web/src/api/types.ts: -------------------------------------------------------------------------------- 1 | import type { NormalizeOAS, OASModel } from "fets"; 2 | import type dashboardspec from "./dashboard"; 3 | 4 | export type DashboardSpec = NormalizeOAS; 5 | export type Metric = OASModel; 6 | export type Dimension = OASModel; 7 | export type DimensionFilter = OASModel; 8 | export type DimensionTableRow = OASModel; 9 | export type FilterType = OASModel; 10 | 11 | export const dimensions = [ 12 | "platform", 13 | "browser", 14 | "url", 15 | "path", 16 | "mobile", 17 | "referrer", 18 | "city", 19 | "country", 20 | "fqdn", 21 | "utm_campaign", 22 | "utm_content", 23 | "utm_medium", 24 | "utm_source", 25 | "utm_term", 26 | ] as const satisfies Dimension[]; 27 | 28 | export const filterTypes = [ 29 | "contains", 30 | "equal", 31 | "is_null", 32 | "ends_with", 33 | "is_false", 34 | "is_true", 35 | "starts_with", 36 | ] as const satisfies FilterType[]; 37 | 38 | export type ProjectResponse = OASModel; 39 | export type EntityResponse = OASModel; 40 | export type UserResponse = OASModel; 41 | export type StatsResponse = OASModel; 42 | -------------------------------------------------------------------------------- /web/src/components/ThemeSwitcher.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { MoonIcon, SunIcon } from "lucide-react"; 3 | --- 4 | 5 | 9 | 10 | 40 | 41 | 97 | -------------------------------------------------------------------------------- /web/src/components/card.module.css: -------------------------------------------------------------------------------- 1 | a.card { 2 | all: unset; 3 | cursor: pointer; 4 | 5 | position: relative; 6 | box-sizing: border-box; 7 | user-select: none; 8 | z-index: 0; 9 | display: inline-flex; 10 | align-items: center; 11 | gap: 0.3rem; 12 | 13 | margin: 0; 14 | padding: 0.3rem 0.4rem 0.2rem 0.4rem; 15 | 16 | transition: background-color 0.2s ease; 17 | 18 | &::before { 19 | content: ""; 20 | position: absolute; 21 | z-index: -1; 22 | top: 0; 23 | left: 0; 24 | right: 0; 25 | bottom: 0; 26 | border-radius: calc(var(--pico-border-radius) - 3px); 27 | background-color: var(--pico-card-background-color); 28 | box-shadow: var(--pico-card-box-shadow); 29 | opacity: 0; 30 | transition: opacity 0.2s ease; 31 | } 32 | 33 | &:hover { 34 | &::before { 35 | opacity: 1; 36 | } 37 | } 38 | } 39 | 40 | button.card { 41 | all: unset; 42 | cursor: pointer; 43 | 44 | position: relative; 45 | box-sizing: border-box; 46 | user-select: none; 47 | z-index: 0; 48 | 49 | margin: 0; 50 | padding: 0.5rem 0.5rem 0.3rem 0.5rem; 51 | 52 | transition: background-color 0.2s ease; 53 | border-radius: var(--pico-border-radius); 54 | 55 | &::before { 56 | content: ""; 57 | position: absolute; 58 | z-index: -1; 59 | top: 0; 60 | left: 0; 61 | right: 0; 62 | bottom: 0; 63 | border-radius: var(--pico-border-radius); 64 | background-color: var(--pico-card-background-color); 65 | opacity: 0; 66 | transform: scale(0.94) scaleX(1.04); 67 | transition: opacity 0.2s ease, transform 0.2s ease; 68 | } 69 | 70 | &[data-active="true"] { 71 | &::before { 72 | box-shadow: var(--pico-card-box-shadow); 73 | opacity: 1; 74 | transform: scale(1) scaleX(1); 75 | } 76 | } 77 | 78 | &:hover:not([data-active="true"]) { 79 | &::before { 80 | opacity: 0.5; 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /web/src/components/card.tsx: -------------------------------------------------------------------------------- 1 | import { cls } from "../utils"; 2 | import styles from "./card.module.css"; 3 | 4 | export const CardButton = ({ 5 | active, 6 | children, 7 | onClick, 8 | className, 9 | type = "button", 10 | disabled, 11 | }: { 12 | children?: React.ReactNode; 13 | active?: boolean; 14 | onClick?: () => void; 15 | className?: string; 16 | type?: "button" | "submit" | "reset"; 17 | disabled?: boolean; 18 | }) => { 19 | return ( 20 | 29 | ); 30 | }; 31 | 32 | export const CardLink = ({ 33 | href, 34 | target, 35 | children, 36 | className, 37 | }: { 38 | href: string; 39 | target?: string; 40 | children?: React.ReactNode; 41 | className?: string; 42 | }) => { 43 | return ( 44 | 45 | {children} 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /web/src/components/daterange/daterange.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | 6 | label { 7 | width: 100%; 8 | &:first-of-type { 9 | margin-bottom: 1rem; 10 | } 11 | } 12 | 13 | input { 14 | margin-bottom: 0; 15 | } 16 | 17 | button { 18 | margin-top: 1rem; 19 | width: 100%; 20 | } 21 | 22 | > div { 23 | display: flex; 24 | width: 100%; 25 | gap: 0.5rem; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web/src/components/daterange/index.tsx: -------------------------------------------------------------------------------- 1 | import { endOfDay, startOfDay, subWeeks } from "date-fns"; 2 | import { useRef, useState } from "react"; 3 | import { DateRange } from "../../api/ranges"; 4 | import { Dialog } from "../dialog"; 5 | import styles from "./daterange.module.css"; 6 | 7 | export const DatePickerRange = ({ 8 | onSelect, 9 | }: { 10 | onSelect: (range: DateRange) => void; 11 | }) => { 12 | const [start, setStart] = useState(() => toHtmlDate(subWeeks(new Date(), 1))); 13 | const [end, setEnd] = useState(() => toHtmlDate(new Date())); 14 | const closeRef = useRef(null); 15 | 16 | const handleSelect = () => { 17 | onSelect(new DateRange({ start: startOfDay(start), end: endOfDay(end) })); 18 | closeRef.current?.click(); 19 | }; 20 | 21 | return ( 22 |
23 | 35 | 47 | 48 |
49 | 50 | 53 | 54 | 55 | 58 |
59 |
60 | ); 61 | }; 62 | 63 | const toHtmlDate = (date: Date) => date.toISOString().split("T")[0]; 64 | -------------------------------------------------------------------------------- /web/src/components/dialog.module.css: -------------------------------------------------------------------------------- 1 | @keyframes overlayShow { 2 | from { 3 | opacity: 0; 4 | } 5 | to { 6 | opacity: 1; 7 | } 8 | } 9 | 10 | @keyframes contentShow { 11 | from { 12 | opacity: 0; 13 | transform: translate(-50%, -48%) scale(0.96); 14 | } 15 | to { 16 | opacity: 1; 17 | transform: translate(-50%, -50%) scale(1); 18 | } 19 | } 20 | 21 | .overlay { 22 | background-color: rgba(0, 0, 0, 0.5); 23 | position: fixed; 24 | inset: 0; 25 | animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); 26 | z-index: 100; 27 | } 28 | 29 | .title { 30 | white-space: preserve-breaks; 31 | font-size: 1.6rem; 32 | } 33 | 34 | .content { 35 | position: fixed; 36 | z-index: 100; 37 | top: 50%; 38 | left: 50%; 39 | transform: translate(-50%, -50%); 40 | width: 90vw; 41 | max-width: 25rem; 42 | max-height: 85vh; 43 | padding: 1rem; 44 | animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); 45 | } 46 | 47 | .autoOverflow { 48 | overflow-y: auto; 49 | } 50 | 51 | .close { 52 | position: fixed; 53 | z-index: 1000; 54 | background-color: white; 55 | border: none; 56 | right: 0; 57 | margin: 1rem; 58 | pointer-events: auto; 59 | border-radius: 1rem; 60 | display: flex; 61 | width: 2.5rem; 62 | height: 2.5rem; 63 | justify-content: center; 64 | align-items: center; 65 | } 66 | -------------------------------------------------------------------------------- /web/src/components/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as Dia from "@radix-ui/react-dialog"; 2 | import { XIcon } from "lucide-react"; 3 | 4 | import { cls } from "../utils"; 5 | import styles from "./dialog.module.css"; 6 | 7 | export type DialogProps = { 8 | title: string; 9 | description?: string; 10 | hideDescription?: boolean; 11 | trigger: React.ReactNode | (() => React.ReactNode); 12 | children: React.ReactNode; 13 | onOpenChange?: (open: boolean) => void; 14 | className?: string; 15 | showClose?: boolean; 16 | hideTitle?: boolean; 17 | autoOverflow?: boolean; 18 | }; 19 | 20 | export const Dialog = ({ 21 | title, 22 | description, 23 | hideDescription, 24 | trigger, 25 | children, 26 | onOpenChange, 27 | className, 28 | showClose, 29 | hideTitle, 30 | autoOverflow, 31 | }: DialogProps) => { 32 | return ( 33 | 34 | {typeof trigger === "function" ? trigger() : trigger} 35 | 36 | 37 | {showClose && ( 38 | 39 | 42 | 43 | )} 44 | 45 | 46 |
47 | 50 | {description && ( 51 | 54 | )} 55 | {children} 56 |
57 |
58 |
59 |
60 | ); 61 | }; 62 | 63 | Dialog.Close = Dia.Close; 64 | -------------------------------------------------------------------------------- /web/src/components/dimensions/dimensions.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | padding-bottom: 0.6rem; 3 | display: flex; 4 | flex-direction: column; 5 | margin-bottom: 0; 6 | } 7 | 8 | .tabs { 9 | .tabsList { 10 | display: flex; 11 | gap: 1rem; 12 | margin-bottom: 1rem; 13 | 14 | .dimensionSelect { 15 | all: unset; 16 | user-select: none; 17 | appearance: menulist; 18 | cursor: pointer; 19 | margin-right: auto; 20 | outline: none; 21 | padding-bottom: 0.2rem; 22 | padding-left: 0.2rem; 23 | box-shadow: none !important; 24 | } 25 | } 26 | 27 | .tabsList > button { 28 | all: unset; 29 | cursor: pointer; 30 | user-select: none; 31 | 32 | &[aria-selected="true"] { 33 | text-decoration: underline; 34 | font-weight: 600; 35 | } 36 | 37 | &:last-of-type { 38 | margin-right: auto; 39 | } 40 | } 41 | 42 | .tabsContent { 43 | display: flex; 44 | flex-direction: column; 45 | } 46 | } 47 | 48 | .percentage { 49 | flex: 1; 50 | position: relative; 51 | z-index: 1; 52 | padding-left: 0.5rem; 53 | padding-bottom: 0.5rem; 54 | display: flex; 55 | align-items: center; 56 | gap: 0.4rem; 57 | width: 0; 58 | 59 | &:hover { 60 | &::after { 61 | opacity: 0.3; 62 | } 63 | } 64 | 65 | &::after { 66 | content: ""; 67 | position: absolute; 68 | left: 0; 69 | width: var(--percentage); 70 | height: 100%; 71 | background: var(--pico-h5-color); 72 | opacity: 0.09; 73 | z-index: -1; 74 | transition: width 0.3s ease-in-out, opacity 0.1s ease-in-out; 75 | border-radius: 1rem; 76 | } 77 | } 78 | 79 | .dimensionHeader { 80 | display: flex; 81 | justify-content: space-between; 82 | gap: 1rem; 83 | color: var(--pico-h5-color); 84 | margin-bottom: 1rem; 85 | 86 | > div:first-of-type { 87 | font-weight: 600; 88 | color: var(--pico-color); 89 | } 90 | } 91 | 92 | .dimensionTable { 93 | flex: 1; 94 | display: flex; 95 | flex-direction: column; 96 | min-height: calc(var(--count) * (2.1rem + 0.2rem)); 97 | position: relative; 98 | 99 | .spinner { 100 | position: absolute; 101 | height: 100%; 102 | width: 100%; 103 | } 104 | 105 | &.loading { 106 | .dimensionRow { 107 | opacity: 0.2; 108 | } 109 | } 110 | } 111 | 112 | .showMore { 113 | all: unset; 114 | cursor: pointer; 115 | color: var(--pico-contrast); 116 | margin-top: 0.2rem; 117 | display: flex; 118 | justify-content: center; 119 | align-items: center; 120 | gap: 0.3rem; 121 | transition: opacity 0.15s ease-in-out; 122 | opacity: 0.6; 123 | user-select: none; 124 | 125 | &:hover { 126 | opacity: 1; 127 | } 128 | } 129 | 130 | .showMoreHidden { 131 | opacity: 0; 132 | pointer-events: none; 133 | } 134 | 135 | .dimensionRow { 136 | height: 2.1rem; 137 | display: flex; 138 | justify-content: space-between; 139 | gap: 1rem; 140 | margin-bottom: 0.2rem; 141 | } 142 | 143 | .external { 144 | display: flex; 145 | align-items: center; 146 | } 147 | 148 | .hostname { 149 | opacity: 0.7; 150 | font-size: 0.6rem; 151 | } 152 | 153 | .dimensionItemSelect { 154 | all: unset; 155 | position: relative; 156 | color: var(--pico-h2-color); 157 | padding: 0.5rem 0; 158 | max-width: calc(100% - 3rem); 159 | overflow: hidden; 160 | text-overflow: ellipsis; 161 | 162 | span { 163 | white-space: nowrap; 164 | } 165 | 166 | &:hover { 167 | text-decoration: underline; 168 | text-decoration-color: var(--pico-h4-color); 169 | text-decoration-style: dotted; 170 | text-decoration-thickness: 0.1rem; 171 | } 172 | 173 | cursor: pointer; 174 | } 175 | 176 | .dimensionEmpty { 177 | flex: 1; 178 | display: flex; 179 | justify-content: center; 180 | align-items: center; 181 | margin-bottom: 1rem; 182 | opacity: 0.6; 183 | } 184 | 185 | .detailsModal.detailsModal { 186 | max-width: 35em; 187 | } 188 | -------------------------------------------------------------------------------- /web/src/components/dimensions/modal.tsx: -------------------------------------------------------------------------------- 1 | import fuzzysort from "fuzzysort"; 2 | import { ZoomInIcon } from "lucide-react"; 3 | import styles from "./dimensions.module.css"; 4 | 5 | import { useDeferredValue, useMemo, useState } from "react"; 6 | import { DimensionLabel, DimensionValueBar } from "."; 7 | import { type Dimension, dimensionNames, metricNames, useDimension } from "../../api"; 8 | import { cls, formatMetricVal } from "../../utils"; 9 | import { Dialog } from "../dialog"; 10 | import type { ProjectQuery } from "../project"; 11 | 12 | export const DetailsModal = ({ 13 | dimension, 14 | query, 15 | }: { 16 | dimension: Dimension; 17 | query: ProjectQuery; 18 | }) => { 19 | const { data, biggest, order, isLoading } = useDimension({ dimension, ...query }); 20 | 21 | const [filter, setFilter] = useState(""); 22 | const deferredFilter = useDeferredValue(filter); 23 | 24 | const results = useMemo(() => { 25 | if (!deferredFilter || !data) return data; 26 | return fuzzysort.go(deferredFilter, data, { keys: ["displayName", "dimensionValue", "value"] }).map((r) => r.obj); 27 | }, [deferredFilter, data]); 28 | 29 | return ( 30 | ( 39 | 43 | )} 44 | > 45 |
46 |
47 |
{dimensionNames[dimension]}
48 |
{metricNames[query.metric]}
49 |
50 | setFilter(e.target.value)} 55 | className={styles.search} 56 | /> 57 | {results?.map((d) => { 58 | return ( 59 |
64 | 65 | 66 | 67 |
{formatMetricVal(d.value, query.metric)}
68 |
69 | ); 70 | })} 71 | {isLoading && data?.length === 0 && ( 72 |
73 |
Loading...
74 |
75 | )} 76 | {!isLoading && data?.length === 0 && ( 77 |
78 |
No data available
79 |
80 | )} 81 |
82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /web/src/components/graph/graph.module.css: -------------------------------------------------------------------------------- 1 | .graph { 2 | height: 100%; 3 | background-color: transparent; 4 | 5 | :global(.tick) { 6 | pointer-events: none; 7 | } 8 | } 9 | 10 | .tooltip { 11 | background-color: var(--pico-secondary-background); 12 | padding: 0.4rem 0.5rem; 13 | border-radius: 0.4rem; 14 | min-width: 7rem; 15 | 16 | h2 { 17 | margin-bottom: 0.3rem; 18 | font-size: 1rem; 19 | color: var(--pico-contrast); 20 | } 21 | 22 | h3 { 23 | font-size: 1rem; 24 | display: flex; 25 | justify-content: space-between; 26 | margin: 0; 27 | color: var(--pico-contrast); 28 | font-weight: 800; 29 | align-items: center; 30 | 31 | span { 32 | color: var(--pico-h3-color); 33 | padding: 0.1rem 0.2rem; 34 | font-weight: 500; 35 | border-radius: 0.2rem; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/src/components/graph/index.tsx: -------------------------------------------------------------------------------- 1 | import { differenceInSeconds, endOfDay, endOfHour, endOfMonth, endOfYear } from "date-fns"; 2 | import _graph from "./graph.module.css"; 3 | 4 | import { lazy } from "react"; 5 | import type { Metric } from "../../api"; 6 | import type { DateRange } from "../../api/ranges.ts"; 7 | 8 | export const LineGraph = lazy(() => import("./graph.tsx").then(({ LineGraph }) => ({ default: LineGraph }))); 9 | 10 | export type DataPoint = { 11 | x: Date; 12 | y: number; 13 | }; 14 | 15 | export const toDataPoints = (data: number[], range: DateRange, metric: Metric): DataPoint[] => { 16 | const step = differenceInSeconds(range.value.end, range.value.start) / data.length; 17 | return data 18 | .map((value, i) => ({ 19 | x: new Date(range.value.start.getTime() + i * step * 1000 + 1000), 20 | y: value, 21 | })) 22 | .filter((p) => { 23 | if (range.getGraphRange() === "hour") { 24 | // filter out points after this hour 25 | return p.x < endOfHour(new Date()); 26 | } 27 | if (range.getGraphRange() === "day") { 28 | // filter out points after today 29 | return p.x < endOfDay(new Date()); 30 | } 31 | if (range.getGraphRange() === "month") { 32 | // filter out points after this month 33 | return p.x < endOfMonth(new Date()); 34 | } 35 | if (range.getGraphRange() === "year") { 36 | // filter out points after this year 37 | return p.x < endOfYear(new Date()); 38 | } 39 | return true; 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /web/src/components/icons.module.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | color: var(--light); 3 | } 4 | 5 | @media only screen and (prefers-color-scheme: dark) { 6 | :root:not([data-theme="light"]) { 7 | .icon { 8 | color: var(--dark); 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /web/src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from "../api"; 2 | import styles from "./icons.module.css"; 3 | 4 | // biome-ignore format: 5 | import { SiAndroid, SiBadoo, SiBluesky, SiDouban, SiDribbble, SiDuckduckgo, SiFacebook, SiFirefox, SiFlickr, SiFoursquare, SiGithub, SiGoogle, SiGooglechrome, SiInstagram, SiIos, SiLastdotfm, SiLinux, SiLivejournal, SiMacos, SiMaildotru, SiMastodon, SiOdnoklassniki, SiOpera, SiPinterest, SiPixelfed, SiReddit, SiRenren, SiSafari, SiSinaweibo, SiSnapchat, SiSourceforge, SiStackoverflow, SiTelegram, SiThreads, SiTiktok, SiTumblr, SiTwitch, SiV2ex, SiViadeo, SiVimeo, SiVk, SiWorkplace, SiX, SiXing, SiYcombinator, SiYoutube } from "@icons-pack/react-simple-icons"; 6 | // biome-ignore format: 7 | import { AppWindowIcon, EarthIcon, LayoutGridIcon, MonitorIcon, SearchIcon, SmartphoneIcon, TabletIcon } from "lucide-react"; 8 | // biome-ignore format: 9 | const brandIcons = { foursquare: SiFoursquare, vk: SiVk, sinaweibo: SiSinaweibo, telegram: SiTelegram, pixelfed: SiPixelfed, workplace: SiWorkplace, x: SiX, threads: SiThreads, Ru: SiMaildotru, News: SiYcombinator, tiktok: SiTiktok, facebook: SiFacebook, lastdotfm: SiLastdotfm, dribbble: SiDribbble, reddit: SiReddit, flickr: SiFlickr, github: SiGithub, pinterest: SiPinterest, stackoverflow: SiStackoverflow, bluesky: SiBluesky, livejournal: SiLivejournal, v2ex: SiV2ex, douban: SiDouban, renren: SiRenren, tumblr: SiTumblr, snapchat: SiSnapchat, badoo: SiBadoo, youtube: SiYoutube, instagram: SiInstagram, viadeo: SiViadeo, odnoklassniki: SiOdnoklassniki, vimeo: SiVimeo, mastodon: SiMastodon, sourceforge: SiSourceforge, twitch: SiTwitch, xing: SiXing, google: SiGoogle, duckduckgo: SiDuckduckgo,}; 10 | 11 | const genericIcons = { 12 | search: SearchIcon, 13 | }; 14 | 15 | // microsoft icons are not included in simple-icons 16 | const browserIcons = { 17 | chrome: [SiGooglechrome, "#ffffff", "#000000"], 18 | firefox: [SiFirefox, "#FF7139", "#FF7139"], 19 | safari: [SiSafari, "#1E90FF", "#1E90FF"], 20 | opera: [SiOpera, "#FF4B2B", "#FF1B2D"], 21 | edge: [EarthIcon, "#0078D7", "#0078D7"], 22 | }; 23 | const browsers = Object.keys(browserIcons); 24 | 25 | const osIcons = { 26 | android: [SiAndroid, "#3DDC84", "#3DDC84"], 27 | ios: [SiIos, "#FFFFFF", "#000000"], 28 | macos: [SiMacos, "#FFFFFF", "#000000"], 29 | linux: [SiLinux, "#FCC624", "#000000"], 30 | kindle: [TabletIcon, "#FFFFFF", "#000000"], 31 | }; 32 | const oses = Object.keys(osIcons); 33 | 34 | const deviceIcons = { 35 | phone: SmartphoneIcon, 36 | tablet: TabletIcon, 37 | desktop: MonitorIcon, 38 | }; 39 | 40 | type IconProps = { 41 | size?: number; 42 | color?: string; 43 | }; 44 | 45 | export const BrowserIcon = ({ browser, ...props }: { browser: string } & IconProps) => { 46 | for (const b of browsers) { 47 | if (browser.toLowerCase().replaceAll(" ", "").includes(b)) { 48 | const [Icon, dark, light] = browserIcons[b as keyof typeof browserIcons]; 49 | return ( 50 | 51 | ); 52 | } 53 | } 54 | 55 | return ; 56 | }; 57 | 58 | export const OSIcon = ({ os, ...props }: { os: string } & IconProps) => { 59 | if (os.toLowerCase() === "windows") { 60 | return ; 61 | } 62 | for (const o of oses) { 63 | if (os.toLowerCase().replaceAll(" ", "").includes(o)) { 64 | const [Icon, dark, light] = osIcons[o as keyof typeof osIcons]; 65 | return ( 66 | 67 | ); 68 | } 69 | } 70 | return ; 71 | }; 72 | 73 | export const MobileDeviceIcon = ({ isMobile, ...props }: { isMobile: boolean } & IconProps) => { 74 | const Icon = isMobile ? deviceIcons.phone : deviceIcons.desktop; 75 | return ; 76 | }; 77 | 78 | export const ReferrerIcon = ({ referrer, icon, ...props }: { referrer: string; icon?: string } & IconProps) => { 79 | if (icon && Object.hasOwnProperty.call(brandIcons, icon)) { 80 | const Icon = brandIcons[icon as keyof typeof brandIcons]; 81 | return ; 82 | } 83 | 84 | if (icon && Object.hasOwnProperty.call(genericIcons, icon)) { 85 | const Icon = genericIcons[icon as keyof typeof genericIcons]; 86 | return ; 87 | } 88 | 89 | if (referrer === "Unknown") { 90 | return ; 91 | } 92 | 93 | return ; 94 | }; 95 | 96 | export const Favicon = ({ 97 | size, 98 | fqdn, 99 | }: IconProps & { 100 | fqdn: string; 101 | }) => { 102 | const config = useConfig(); 103 | if (config.isLoading || config.config?.disableFavicons) return ; 104 | 105 | fqdn = fqdn.replace(/[^a-zA-Z0-9.-]/g, ""); 106 | return favicon; 107 | }; 108 | -------------------------------------------------------------------------------- /web/src/components/project.module.css: -------------------------------------------------------------------------------- 1 | .project { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.8rem; 5 | } 6 | 7 | .projectHeader { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | margin-bottom: 0.4rem; 12 | gap: 1rem; 13 | 14 | details { 15 | margin: 0; 16 | } 17 | 18 | > h1 { 19 | font-size: 1.5rem; 20 | margin: 0; 21 | align-items: first baseline; 22 | } 23 | 24 | @media (max-width: 700px) { 25 | flex-direction: column-reverse; 26 | align-items: flex-start; 27 | padding-bottom: 0.5rem; 28 | gap: 0.5rem; 29 | 30 | > div { 31 | width: 100%; 32 | justify-content: end; 33 | } 34 | } 35 | } 36 | 37 | .tables { 38 | display: grid; 39 | grid-template-columns: 1fr 1fr; 40 | gap: 0.8rem; 41 | 42 | @media (max-width: 40rem) { 43 | grid-template-columns: 1fr; 44 | } 45 | } 46 | 47 | .graphCard { 48 | height: 20rem; 49 | margin-top: -0.2rem; 50 | padding: 0; 51 | } 52 | 53 | .graphCard2 { 54 | height: 20rem; 55 | padding: 0 !important; 56 | } 57 | 58 | .geoCard.geoCard { 59 | padding: 0; 60 | grid-column: span 2; 61 | display: flex; 62 | flex-direction: row; 63 | 64 | > div { 65 | flex-direction: column; 66 | gap: 0.5rem; 67 | } 68 | 69 | .geoMap { 70 | display: flex; 71 | position: relative; 72 | width: calc(50% + 1rem); 73 | 74 | &::after { 75 | content: ""; 76 | position: absolute; 77 | --direction: right; 78 | background: linear-gradient( 79 | to var(--direction), 80 | hsla(var(--card-background-base) / 0) 0%, 81 | hsla(var(--card-background-base) / 0.018) 9.5%, 82 | hsla(var(--card-background-base) / 0.058) 17.9%, 83 | hsla(var(--card-background-base) / 0.116) 25.6%, 84 | hsla(var(--card-background-base) / 0.188) 32.5%, 85 | hsla(var(--card-background-base) / 0.273) 38.8%, 86 | hsla(var(--card-background-base) / 0.365) 44.7%, 87 | hsla(var(--card-background-base) / 0.462) 50.3%, 88 | hsla(var(--card-background-base) / 0.56) 55.7%, 89 | hsla(var(--card-background-base) / 0.657) 61.1%, 90 | hsla(var(--card-background-base) / 0.747) 66.5%, 91 | hsla(var(--card-background-base) / 0.829) 72.2%, 92 | hsla(var(--card-background-base) / 0.899) 78.3%, 93 | hsla(var(--card-background-base) / 0.953) 84.8%, 94 | hsla(var(--card-background-base) / 0.988) 92%, 95 | hsl(var(--card-background-base)) 100% 96 | ); 97 | 98 | right: 0; 99 | top: 0; 100 | bottom: 0; 101 | width: 3rem; 102 | } 103 | } 104 | 105 | .geoTable { 106 | width: calc(50% - 1rem); 107 | 108 | > div { 109 | padding: 1rem; 110 | padding-left: 0; 111 | } 112 | } 113 | 114 | @media (max-width: 40rem) { 115 | grid-column: span 1; 116 | flex-direction: column; 117 | 118 | .geoTable, 119 | .geoMap { 120 | width: 100%; 121 | } 122 | 123 | .geoMap { 124 | &::after { 125 | top: unset; 126 | width: 100%; 127 | left: 0; 128 | height: 3rem; 129 | --direction: bottom; 130 | } 131 | } 132 | 133 | .geoTable { 134 | padding: 1rem; 135 | padding-top: 0; 136 | 137 | > div { 138 | padding: 0; 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /web/src/components/project/filter.module.css: -------------------------------------------------------------------------------- 1 | .filters { 2 | display: flex; 3 | margin-top: 0.6rem; 4 | gap: 0.3rem; 5 | flex-wrap: wrap; 6 | } 7 | 8 | .formInvertable { 9 | display: flex; 10 | gap: 0.8rem; 11 | align-items: flex-end; 12 | margin-bottom: 0.5rem; 13 | 14 | select, 15 | fieldset { 16 | margin: 0; 17 | margin-top: 0.2rem; 18 | } 19 | 20 | > label, 21 | > div { 22 | flex: 1; 23 | display: flex; 24 | flex-direction: column; 25 | } 26 | } 27 | 28 | .filterField { 29 | display: flex; 30 | gap: 0.2rem; 31 | align-items: center; 32 | padding: 0.3rem 0.5rem; 33 | padding-right: 0; 34 | 35 | span { 36 | font-size: 0.8rem; 37 | font-weight: 600; 38 | } 39 | 40 | span.filterType { 41 | font-weight: 500; 42 | color: var(--pico-contrast); 43 | } 44 | 45 | span.filterValue { 46 | text-decoration: underline; 47 | color: var(--pico-contrast); 48 | } 49 | } 50 | 51 | .filter { 52 | display: flex; 53 | margin: 0; 54 | opacity: 0.9; 55 | transition: opacity 0.2s; 56 | padding: 0; 57 | 58 | &:hover { 59 | opacity: 1; 60 | } 61 | 62 | h2 { 63 | font-size: 0.8rem; 64 | font-weight: 400; 65 | margin: 0; 66 | } 67 | 68 | button { 69 | all: unset; 70 | padding: 0.5rem 0.5rem; 71 | cursor: pointer; 72 | display: flex; 73 | align-items: center; 74 | gap: 0.4rem; 75 | height: fit-content; 76 | } 77 | 78 | .remove { 79 | padding-left: 0.2rem; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /web/src/components/project/metric.module.css: -------------------------------------------------------------------------------- 1 | .metrics { 2 | position: relative; 3 | z-index: 1; 4 | display: flex; 5 | gap: 0.7rem; 6 | 7 | @media (max-width: 820px) { 8 | flex-wrap: wrap; 9 | 10 | button.metric { 11 | width: calc(50% - 0.4rem); 12 | } 13 | } 14 | } 15 | 16 | button.metric { 17 | min-width: 7rem; 18 | font-size: 0.8rem; 19 | 20 | h3 { 21 | margin: 0; 22 | font-size: 1.2rem; 23 | font-weight: 500; 24 | display: inline-flex; 25 | gap: 1rem; 26 | align-items: center; 27 | color: var(--pico-h5-color); 28 | } 29 | 30 | h2 { 31 | color: var(--pico-h4-color); 32 | margin: 0; 33 | font-size: 0.9rem; 34 | font-weight: 500; 35 | margin-bottom: 0.3rem; 36 | } 37 | 38 | span.change { 39 | font-size: 0.8rem; 40 | font-weight: 500; 41 | } 42 | 43 | &[data-active="true"] { 44 | h2, 45 | h2, 46 | h3 { 47 | color: var(--pico-h1-color); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /web/src/components/project/metric.tsx: -------------------------------------------------------------------------------- 1 | import { TrendingDownIcon, TrendingUpIcon } from "lucide-react"; 2 | import styles from "./metric.module.css"; 3 | 4 | import type { Metric, StatsResponse } from "../../api"; 5 | import { cls, formatMetricVal, formatPercent } from "../../utils"; 6 | import { CardButton } from "../card"; 7 | 8 | export const SelectMetrics = ({ 9 | data, 10 | metric, 11 | setMetric, 12 | className, 13 | }: { 14 | data?: StatsResponse; 15 | metric: Metric; 16 | setMetric: (value: Metric) => void; 17 | className?: string; 18 | }) => { 19 | return ( 20 |
21 | setMetric("views")} 27 | selected={metric === "views"} 28 | /> 29 | setMetric("unique_visitors")} 35 | selected={metric === "unique_visitors"} 36 | /> 37 | setMetric("avg_time_on_site")} 43 | selected={metric === "avg_time_on_site"} 44 | /> 45 | setMetric("bounce_rate")} 51 | selected={metric === "bounce_rate"} 52 | /> 53 |
54 | ); 55 | }; 56 | 57 | export const SelectMetric = ({ 58 | title, 59 | value = 0, 60 | prevValue = 0, 61 | metric, 62 | onSelect, 63 | selected, 64 | }: { 65 | title: string; 66 | value?: number; 67 | metric: Metric; 68 | prevValue?: number; 69 | decimals?: number; 70 | onSelect: () => void; 71 | selected: boolean; 72 | }) => { 73 | const change = value - prevValue; 74 | const changePercent = prevValue ? (change / prevValue) * 100 : value ? -1 : 0; 75 | const color = change > 0 ? "#22c55e" : change < 0 ? "red" : "gray"; 76 | const icon = change > 0 ? : change < 0 ? : "—"; 77 | 78 | return ( 79 | 80 |

{title}

81 |

82 | {formatMetricVal(value, metric)} 83 | 84 | {icon} {formatPercent(changePercent)} 85 | 86 |

87 |
88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /web/src/components/project/project.module.css: -------------------------------------------------------------------------------- 1 | .statsHeader { 2 | font-size: 1rem; 3 | margin-bottom: 0; 4 | align-items: center; 5 | display: flex; 6 | 7 | > span.online { 8 | margin-left: 0.2rem; 9 | > svg { 10 | margin-right: 0.2rem; 11 | } 12 | 13 | .pulse { 14 | position: absolute; 15 | animation: pulse 2s infinite; 16 | } 17 | 18 | line-height: normal; 19 | margin-left: 0.5rem; 20 | display: inline-flex; 21 | gap: 0.2rem; 22 | align-items: center; 23 | font-size: 0.8rem; 24 | font-weight: 500; 25 | } 26 | } 27 | 28 | @keyframes pulse { 29 | 0% { 30 | transform: scale(1); 31 | opacity: 1; 32 | } 33 | 100% { 34 | transform: scale(2); 35 | opacity: 0; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/components/project/project.tsx: -------------------------------------------------------------------------------- 1 | import { CircleIcon, LockIcon } from "lucide-react"; 2 | import styles from "./project.module.css"; 3 | 4 | import type { ProjectResponse, StatsResponse } from "../../api"; 5 | import { formatMetricVal } from "../../utils"; 6 | import { CardLink } from "../card"; 7 | 8 | export const ProjectHeader = ({ 9 | project, 10 | stats, 11 | }: { 12 | stats?: StatsResponse; 13 | project: ProjectResponse; 14 | }) => { 15 | return ( 16 |

17 | 18 | 19 | {project.public ? null : } 20 | {project.displayName} 21 | 22 |   23 | 24 | {stats && } 25 |

26 | ); 27 | }; 28 | 29 | export const LiveVisitorCount = ({ count }: { count: number }) => { 30 | return ( 31 | 32 | 33 | 34 | {formatMetricVal(count, "unique_visitors")} {count === 1 ? "Current Visitor" : "Current Visitors"} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /web/src/components/project/range.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | gap: 0.2rem; 4 | 5 | button { 6 | display: flex; 7 | background: transparent; 8 | opacity: 0.6; 9 | border: none; 10 | justify-content: center; 11 | align-items: center; 12 | cursor: pointer; 13 | color: var(--pico-contrast); 14 | transition: background-color 0.1s, opacity 0.1s; 15 | 16 | &:hover { 17 | background-color: var(--pico-card-background-color); 18 | opacity: 1; 19 | } 20 | } 21 | 22 | ul { 23 | li { 24 | padding: 0; 25 | 26 | button { 27 | box-sizing: border-box; 28 | width: 100%; 29 | padding: 0.4rem var(--pico-form-element-spacing-horizontal); 30 | } 31 | } 32 | } 33 | } 34 | 35 | details.selectRange { 36 | min-width: 11rem; 37 | max-width: 16rem; 38 | line-height: 1; 39 | 40 | button { 41 | all: unset; 42 | cursor: pointer; 43 | } 44 | 45 | .selected { 46 | font-weight: 700; 47 | color: var(--pico-h1-color); 48 | text-decoration: underline; 49 | } 50 | 51 | summary { 52 | display: flex; 53 | justify-content: space-between; 54 | align-items: center; 55 | --pico-form-element-spacing-vertical: 0.5rem; 56 | --pico-form-element-spacing-horizontal: 0.6rem; 57 | } 58 | } 59 | 60 | article.rangeDialog { 61 | width: unset; 62 | } 63 | -------------------------------------------------------------------------------- /web/src/components/project/range.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./range.module.css"; 2 | 3 | import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; 4 | import { useRef } from "react"; 5 | 6 | import { endOfDay, startOfDay } from "date-fns"; 7 | import { api, useQuery } from "../../api"; 8 | import { DateRange, type RangeName, wellKnownRanges } from "../../api/ranges"; 9 | import { cls } from "../../utils"; 10 | import { DatePickerRange } from "../daterange"; 11 | import { Dialog } from "../dialog"; 12 | 13 | export const SelectRange = ({ 14 | onSelect, 15 | range, 16 | projectId, 17 | }: { onSelect: (range: DateRange) => void; range: DateRange; projectId?: string }) => { 18 | const detailsRef = useRef(null); 19 | 20 | const handleSelect = (range: DateRange) => () => { 21 | if (detailsRef.current) detailsRef.current.open = false; 22 | onSelect(range); 23 | }; 24 | 25 | const allTime = useQuery({ 26 | queryKey: ["allTime", projectId], 27 | enabled: !!projectId, 28 | staleTime: 7 * 24 * 60 * 60 * 1000, 29 | queryFn: () => 30 | api["/api/dashboard/project/{project_id}/earliest"].get({ params: { project_id: projectId || "" } }).json(), 31 | }); 32 | 33 | const selectAllTime = async () => { 34 | if (!projectId) return; 35 | if (!allTime.data) return; 36 | const from = new Date(allTime.data); 37 | const range = new DateRange({ start: startOfDay(from), end: endOfDay(new Date()) }); 38 | range.variant = "allTime"; 39 | onSelect(range); 40 | if (detailsRef.current) detailsRef.current.open = false; 41 | }; 42 | 43 | return ( 44 |
45 | 48 | 51 |
52 | {range.format()} 53 |
    54 | {Object.entries(wellKnownRanges).map(([key, value]) => ( 55 |
  • 56 | 63 |
  • 64 | ))} 65 | {projectId && allTime.data && ( 66 |
  • 67 | 74 |
  • 75 | )} 76 |
  • 77 | 83 | Custom 84 | 85 | } 86 | onOpenChange={(open) => { 87 | if (open && detailsRef.current) detailsRef.current.open = false; 88 | }} 89 | title="Custom Range" 90 | showClose 91 | hideTitle 92 | autoOverflow 93 | > 94 | handleSelect(range)()} /> 95 | 96 |
  • 97 |
98 |
99 |
100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /web/src/components/projects.module.css: -------------------------------------------------------------------------------- 1 | .info { 2 | min-height: 100%; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | 8 | .project { 9 | position: relative; 10 | padding: 0; 11 | 12 | .graph { 13 | height: 14rem; 14 | margin: 0; 15 | margin-top: -1rem; 16 | } 17 | 18 | .projectHeader { 19 | padding: 0.7rem; 20 | display: flex; 21 | justify-content: space-between; 22 | flex-direction: column; 23 | 24 | --pico-card-background-color: var(--pico-form-element-border-color); 25 | 26 | .projectTitle { 27 | display: flex; 28 | justify-content: space-between; 29 | align-items: flex-start; 30 | position: relative; 31 | z-index: 0; 32 | margin-bottom: 0.4rem; 33 | } 34 | } 35 | 36 | .stats, 37 | .graph { 38 | opacity: 1; 39 | transition: opacity 0.2s ease; 40 | } 41 | 42 | .error { 43 | position: absolute; 44 | top: 0; 45 | left: 0; 46 | right: 0; 47 | bottom: 0; 48 | display: flex; 49 | justify-content: center; 50 | align-items: center; 51 | font-size: 1.5rem; 52 | font-weight: 500; 53 | } 54 | } 55 | 56 | .header { 57 | display: flex; 58 | justify-content: space-between; 59 | align-items: center; 60 | margin-bottom: 1rem; 61 | 62 | h1, 63 | details { 64 | margin: 0; 65 | } 66 | 67 | a { 68 | cursor: pointer; 69 | } 70 | 71 | @media (max-width: 500px) { 72 | h1 { 73 | display: none; 74 | } 75 | 76 | flex-direction: column; 77 | gap: 1rem; 78 | align-items: flex-start; 79 | > div { 80 | justify-content: right; 81 | width: 100%; 82 | } 83 | } 84 | } 85 | 86 | .AccordionTrigger { 87 | all: unset; 88 | display: flex; 89 | cursor: pointer; 90 | padding: 0.9rem; 91 | margin: -1.1rem; 92 | transition: transform 300ms cubic-bezier(0.87, 0, 0.13, 1); 93 | } 94 | .AccordionTrigger[data-state="open"] { 95 | transform: rotate(180deg); 96 | } 97 | 98 | .AccordionContent { 99 | overflow: hidden; 100 | } 101 | .AccordionContent[data-state="open"] { 102 | animation: slideDown 400ms cubic-bezier(0.22, 1, 0.36, 1); 103 | } 104 | .AccordionContent[data-state="closed"] { 105 | animation: slideUp 400ms cubic-bezier(0.22, 1, 0.36, 1); 106 | } 107 | 108 | @keyframes slideDown { 109 | from { 110 | height: 0; 111 | } 112 | to { 113 | height: var(--radix-accordion-content-height); 114 | } 115 | } 116 | 117 | @keyframes slideUp { 118 | from { 119 | height: var(--radix-accordion-content-height); 120 | } 121 | to { 122 | height: 0; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /web/src/components/projects.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, useState } from "react"; 2 | import styles from "./projects.module.css"; 3 | 4 | import { ChevronDownIcon } from "lucide-react"; 5 | 6 | import * as Accordion from "@radix-ui/react-accordion"; 7 | import { 8 | type Metric, 9 | type ProjectResponse, 10 | api, 11 | metricNames, 12 | useMe, 13 | useProjectGraph, 14 | useProjectStats, 15 | useQuery, 16 | } from "../api"; 17 | import type { DateRange } from "../api/ranges"; 18 | import { useMetric, useRange } from "../hooks/persist"; 19 | import { getUsername } from "../utils"; 20 | import { LineGraph } from "./graph"; 21 | import { SelectMetrics } from "./project/metric"; 22 | import { ProjectHeader } from "./project/project"; 23 | import { SelectRange } from "./project/range"; 24 | 25 | const signedIn = getUsername(); 26 | 27 | // Only load the role if no projects are available 28 | const NoProjects = () => { 29 | const { role } = useMe(); 30 | return ( 31 |
32 | {role === "admin" ? ( 33 |

34 | You do not have any projects yet. 35 |
36 | Create a new project 37 |  to get started. 38 |

39 | ) : ( 40 |

41 | You do not have any projects yet. 42 |
43 | Contact an admin to create a new project. 44 |

45 | )} 46 |
47 | ); 48 | }; 49 | 50 | export const Projects = () => { 51 | const { data, isLoading, isError } = useQuery({ 52 | queryKey: ["projects"], 53 | placeholderData: (previous) => previous, 54 | queryFn: () => api["/api/dashboard/projects"].get().json(), 55 | }); 56 | 57 | const { metric, setMetric } = useMetric(); 58 | const { range, setRange } = useRange(); 59 | const [hiddenProjects, setHiddenProjects] = useState([]); 60 | 61 | if (isLoading) return null; 62 | if (isError) 63 | return ( 64 |
65 |

Failed to load data

66 |
67 | ); 68 | 69 | if (data?.projects.length === 0 && !isLoading && signedIn) return ; 70 | if (data?.projects.length === 0 && !signedIn) 71 | return ( 72 |
73 |

74 | There are no public projects available. 75 |
76 | Sign in to view all projects. 77 |

78 |
79 | ); 80 | 81 | return ( 82 |
83 |
84 |

Dashboard

85 | 86 |
87 | 88 | 89 | 93 | setHiddenProjects(data?.projects.map((p) => p.id).filter((id) => !value.includes(id)) ?? []) 94 | } 95 | value={data?.projects.map((p) => p.id).filter((id) => !hiddenProjects.includes(id))} 96 | > 97 | {data?.projects.map((project) => ( 98 | 99 | 100 | 101 | ))} 102 | 103 | 104 |
105 | ); 106 | }; 107 | 108 | const Project = ({ 109 | project, 110 | metric, 111 | setMetric, 112 | range, 113 | }: { project: ProjectResponse; metric: Metric; setMetric: (value: Metric) => void; range: DateRange }) => { 114 | const { 115 | graph, 116 | isError: graphError, 117 | isLoading: graphLoading, 118 | } = useProjectGraph({ projectId: project.id, metric, range }); 119 | 120 | const { 121 | stats, 122 | isError: statsError, 123 | isLoading: statsLoading, 124 | } = useProjectStats({ projectId: project.id, metric, range }); 125 | 126 | const isLoading = graphLoading || statsLoading; 127 | const isError = graphError || statsError; 128 | 129 | return ( 130 |
131 |
132 |
133 | 134 | 135 | 136 | 137 |
138 | 139 |
140 | 141 |
142 | 143 |
144 |
145 |
146 | ); 147 | }; 148 | -------------------------------------------------------------------------------- /web/src/components/settings/dialogs.module.css: -------------------------------------------------------------------------------- 1 | .error { 2 | position: absolute; 3 | color: rgb(255, 85, 85); 4 | width: 100%; 5 | text-align: center; 6 | top: -0.5rem; 7 | right: 0; 8 | transform: translateY(-100%); 9 | } 10 | 11 | .new { 12 | padding: 0.4rem 0.5rem; 13 | height: fit-content; 14 | animation: newLoad 0.4s ease; 15 | } 16 | 17 | @keyframes newLoad { 18 | from { 19 | opacity: 0; 20 | transform: translateY(0.2rem); 21 | } 22 | to { 23 | opacity: 1; 24 | transform: translateY(0); 25 | } 26 | } 27 | 28 | .danger { 29 | --pico-background-color: var(--pico-del-color); 30 | --pico-border-color: var(--pico-del-color); 31 | box-shadow: none; 32 | 33 | &:hover { 34 | --pico-background-color: var(--pico-del-color); 35 | --pico-border-color: var(--pico-del-color); 36 | box-shadow: none; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/src/components/settings/me.module.css: -------------------------------------------------------------------------------- 1 | form.password { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: self-start; 5 | --pico-spacing: 0.4rem; 6 | label { 7 | width: 100%; 8 | } 9 | } 10 | 11 | .header { 12 | display: flex; 13 | background-color: var(--pico-background-color); 14 | border-radius: var(--pico-border-radius); 15 | padding: 1rem; 16 | align-items: center; 17 | gap: 0.5rem; 18 | 19 | h2 { 20 | font-size: 1.3rem; 21 | margin: 0; 22 | } 23 | 24 | p { 25 | margin: 0; 26 | font-size: 0.9rem; 27 | color: var(--pico-text-color-secondary); 28 | } 29 | } 30 | 31 | .container { 32 | flex: 1; 33 | article { 34 | display: flex; 35 | flex-direction: column; 36 | width: 100%; 37 | } 38 | 39 | h2 { 40 | font-size: 1.2rem; 41 | } 42 | 43 | code { 44 | font-size: 0.9rem; 45 | background-color: var(--pico-background-color); 46 | padding: 1rem 1rem; 47 | border-radius: var(--pico-border-radius); 48 | font-weight: 500; 49 | 50 | .tag { 51 | color: var(--pico-primary-hover); 52 | font-weight: 900; 53 | } 54 | .entity { 55 | font-weight: 900; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /web/src/components/settings/me.tsx: -------------------------------------------------------------------------------- 1 | import { User2Icon } from "lucide-react"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { api, useMe, useMutation } from "../../api"; 4 | import { createToast } from "../toast"; 5 | import styles from "./me.module.css"; 6 | 7 | export const MyAccount = () => { 8 | const formRef = useRef(null); 9 | const { role, username, isLoading } = useMe(); 10 | const { mutate } = useMutation({ 11 | mutationFn: api["/api/dashboard/user/{username}/password"].put, 12 | onSuccess: () => { 13 | createToast("Password updated successfully", "success"); 14 | formRef.current?.reset(); 15 | }, 16 | onError: console.error, 17 | }); 18 | 19 | const [loading, setLoading] = useState(true); 20 | useEffect(() => setLoading(isLoading), [isLoading]); 21 | if (loading || !username) return
; 22 | 23 | const handleSubmit = (e: React.FormEvent) => { 24 | e.preventDefault(); 25 | const data = new FormData(e.currentTarget); 26 | const newPassword = data.get("newPassword") as string; 27 | const confirmNewPassword = data.get("confirmNewPassword") as string; 28 | if (newPassword !== confirmNewPassword) { 29 | createToast("Passwords do not match", "error"); 30 | return; 31 | } 32 | mutate({ json: { password: newPassword }, params: { username } }); 33 | }; 34 | 35 | return ( 36 |
37 |
38 | 41 | 42 |
43 | 44 |
45 |

{username}

46 |

Role: {role === "admin" ? "Administrator" : "User"}

47 |
48 |
49 |
50 |
51 |

Snippet code

52 |

53 | You can copy the tracking snippet for a specific entity here, use the{" "} 54 | liwan-tracker npm package, or use the following code: 55 |

56 | 57 | {" type="module" data-entity=" 58 | YOUR_ENTITY_ID" src=" 59 | {window.location.origin}/script.js" 60 | 61 | {">"} 62 | {""} 63 | 64 | 65 |
66 |
67 |
68 |

Update Password

69 | 80 | 81 | 92 | 93 |
94 | 97 |
98 |
99 |
100 |
101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /web/src/components/settings/tables.module.css: -------------------------------------------------------------------------------- 1 | details.edit { 2 | margin-bottom: 0; 3 | } 4 | 5 | details.edit summary { 6 | all: unset; 7 | cursor: pointer; 8 | user-select: none; 9 | 10 | &::after { 11 | all: unset; 12 | } 13 | } 14 | 15 | details.edit summary + ul { 16 | right: 0; 17 | top: 0; 18 | left: unset; 19 | overflow: hidden; 20 | 21 | button { 22 | all: unset; 23 | cursor: pointer; 24 | margin: calc(var(--pico-form-element-spacing-vertical) * -0.5) 25 | calc(var(--pico-form-element-spacing-horizontal) * -1); 26 | padding: calc(var(--pico-form-element-spacing-vertical) * 0.5) var(--pico-form-element-spacing-horizontal); 27 | width: 100%; 28 | 29 | align-items: center; 30 | gap: 0.6rem; 31 | display: flex; 32 | } 33 | 34 | button:hover { 35 | background-color: var(--pico-dropdown-hover-background-color); 36 | } 37 | } 38 | 39 | .danger { 40 | svg { 41 | color: var(--pico-del-color); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /web/src/components/table.module.css: -------------------------------------------------------------------------------- 1 | .full { 2 | width: 100%; 3 | } 4 | 5 | .nowrap { 6 | white-space: nowrap; 7 | } 8 | 9 | .container { 10 | margin-top: 0.5rem; 11 | animation: tableLoad 0.3s ease; 12 | overflow-x: auto; 13 | height: 100%; 14 | } 15 | 16 | @keyframes tableLoad { 17 | from { 18 | opacity: 0; 19 | transform: translateY(0.2rem); 20 | } 21 | to { 22 | opacity: 1; 23 | transform: translateY(0); 24 | } 25 | } 26 | 27 | .table { 28 | background: var(--pico-background-color); 29 | border-radius: var(--pico-border-radius); 30 | margin-bottom: 0; 31 | th { 32 | div.icon { 33 | display: inline-flex; 34 | align-items: center; 35 | gap: 0.2rem; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/src/components/table.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactElement, useEffect, useState } from "react"; 2 | import styles from "./table.module.css"; 3 | 4 | export type Column = { 5 | id: string; 6 | header?: string | ReactElement; 7 | icon?: ReactElement; 8 | render?: (row: T) => ReactElement | string; 9 | full?: boolean; 10 | nowrap?: boolean; 11 | }; 12 | 13 | export const Table = ({ 14 | rows, 15 | columns, 16 | isLoading, 17 | }: { 18 | rows: T[]; 19 | columns: Column[]; 20 | isLoading: boolean; 21 | }) => { 22 | // prevent hydration mismatch 23 | const [loading, setLoading] = useState(true); 24 | useEffect(() => setLoading(isLoading), [isLoading]); 25 | 26 | if (loading) return
; 27 | 28 | return ( 29 |
30 | 31 | 32 | 33 | {columns?.map((col) => ( 34 | 44 | ))} 45 | 46 | 47 | 48 | {rows?.length ? ( 49 | rows.map((row) => ( 50 | 51 | {columns?.map((col) => ( 52 | 55 | ))} 56 | 57 | )) 58 | ) : ( 59 | 60 | 63 | 64 | )} 65 | 66 |
35 | {col.icon ? ( 36 |
37 | {col.icon} 38 | {col.header ?? null} 39 |
40 | ) : ( 41 | (col.header ?? null) 42 | )} 43 |
53 | {col.render ? col.render(row) : null} 54 |
61 | No results. 62 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /web/src/components/tags.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | import styles from "./tags.module.css"; 3 | 4 | export type { Tag } from "react-tag-autocomplete"; 5 | import { ReactTags, type TagSelected, type TagSuggestion } from "react-tag-autocomplete"; 6 | 7 | const classNames = { 8 | root: styles["react-tags"], 9 | rootIsActive: styles["is-active"], 10 | rootIsDisabled: styles["is-disabled"], 11 | rootIsInvalid: styles["is-invalid"], 12 | label: styles["react-tags__label"], 13 | tagList: styles["react-tags__list"], 14 | tagListItem: styles["react-tags__list-item"], 15 | tag: styles["react-tags__tag"], 16 | tagName: styles["react-tags__tag-name"], 17 | comboBox: styles["react-tags__combobox"], 18 | input: styles["react-tags__combobox-input"], 19 | listBox: styles["react-tags__listbox"], 20 | option: styles["react-tags__listbox-option"], 21 | optionIsActive: styles["is-active"], 22 | highlight: styles["react-tags__listbox-option-highlight"], 23 | }; 24 | 25 | export const Tags = ({ 26 | onAdd, 27 | onDelete, 28 | selected, 29 | suggestions, 30 | labelText, 31 | placeholderText, 32 | noOptionsText, 33 | }: { 34 | onAdd: (tag: TagSuggestion) => void; 35 | onDelete: (i: number) => void; 36 | selected: TagSelected[]; 37 | suggestions: TagSuggestion[]; 38 | noOptionsText: string; 39 | placeholderText?: string; 40 | labelText?: string | React.ReactNode; 41 | }) => { 42 | const id = useId(); 43 | return ( 44 | <> 45 | {labelText && ( 46 | 49 | )} 50 | !selected.some((tag) => tag.value === suggestion.value))} 56 | noOptionsText={noOptionsText ?? "No matching options..."} 57 | placeholderText={placeholderText ?? "Type to search..."} 58 | collapseOnSelect 59 | activateFirstOption 60 | renderInput={({ classNames, inputWidth, "aria-invalid": _, ...props }) => ( 61 | 66 | )} 67 | classNames={classNames} 68 | /> 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /web/src/components/toast.module.css: -------------------------------------------------------------------------------- 1 | .toastContainer { 2 | position: fixed; 3 | bottom: 2rem; 4 | right: 2rem; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: flex-end; 8 | gap: 0.5rem; 9 | z-index: 9999; 10 | } 11 | 12 | .toast { 13 | padding: 0.8rem 1.1rem; 14 | border-radius: 0.5rem; 15 | font-size: 0.9rem; 16 | color: #fff; 17 | box-shadow: var(--pico-card-box-shadow); 18 | border-radius: var(--pico-border-radius); 19 | } 20 | 21 | .success { 22 | background-color: #4caf50; 23 | } 24 | 25 | .error { 26 | background-color: #f44336; 27 | } 28 | 29 | .info { 30 | background-color: var(--pico-primary-background); 31 | } 32 | 33 | .warning { 34 | background-color: #ff9800; 35 | } 36 | -------------------------------------------------------------------------------- /web/src/components/toast.ts: -------------------------------------------------------------------------------- 1 | import styles from "./toast.module.css"; 2 | 3 | type ToastType = "success" | "error" | "info" | "warning"; 4 | 5 | export const createToast = (message: string, type: ToastType = "info") => { 6 | let toastContainer = document.getElementById("toast-container"); 7 | if (!toastContainer) { 8 | toastContainer = document.createElement("div"); 9 | toastContainer.id = "toast-container"; 10 | toastContainer.className = styles.toastContainer; 11 | toastContainer.setAttribute("role", "alert"); 12 | toastContainer.setAttribute("aria-live", "assertive"); 13 | toastContainer.setAttribute("aria-atomic", "true"); 14 | document.body.appendChild(toastContainer); 15 | } 16 | 17 | const toast = document.createElement("div"); 18 | toast.className = `${styles.toast} ${styles[type]}`; 19 | toast.textContent = message; 20 | 21 | toastContainer.appendChild(toast); 22 | toast.animate( 23 | [ 24 | { opacity: 0, transform: "translateY(0.5rem)" }, 25 | { opacity: 1, transform: "translateY(0)" }, 26 | ], 27 | { 28 | duration: 300, 29 | easing: "ease-out", 30 | }, 31 | ); 32 | 33 | setTimeout(() => { 34 | const fadeOut = toast.animate( 35 | [ 36 | { opacity: 1, transform: "translateY(0)" }, 37 | { opacity: 0, transform: "translateY(0.5rem)" }, 38 | ], 39 | { 40 | duration: 500, 41 | easing: "ease-out", 42 | }, 43 | ); 44 | 45 | fadeOut.onfinish = () => { 46 | toast.remove(); 47 | if (toastContainer && toastContainer.childElementCount === 0) { 48 | toastContainer.remove(); 49 | } 50 | }; 51 | }, 2500); 52 | }; 53 | -------------------------------------------------------------------------------- /web/src/components/userInfo.module.css: -------------------------------------------------------------------------------- 1 | details.user { 2 | margin: 0; 3 | 4 | summary { 5 | display: flex; 6 | justify-content: space-between; 7 | align-items: center; 8 | --pico-form-element-spacing-vertical: 0.5rem; 9 | --pico-form-element-spacing-horizontal: 0.6rem; 10 | border: none; 11 | 12 | svg { 13 | margin-right: 0.5rem; 14 | } 15 | } 16 | 17 | summary + ul { 18 | margin-top: 0.3rem; 19 | padding-top: 0.1rem; 20 | padding-bottom: 0.1rem; 21 | 22 | li a { 23 | display: flex; 24 | gap: 0.6rem; 25 | align-items: center; 26 | } 27 | } 28 | } 29 | 30 | .external { 31 | margin-left: auto; 32 | } 33 | -------------------------------------------------------------------------------- /web/src/components/userInfo.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./userInfo.module.css"; 2 | 3 | import { HelpCircle, LogOutIcon, SettingsIcon, SquareArrowOutUpRightIcon, UserIcon } from "lucide-react"; 4 | import { api } from "../api"; 5 | import { cls, getUsername } from "../utils"; 6 | 7 | export const LoginButton = () => { 8 | const username = getUsername(); 9 | if (!username) 10 | return ( 11 | <> 12 | 13 | 16 | 17 |    18 | 19 | ); 20 | 21 | return ( 22 |
23 | 24 | 25 | {username} 26 | 27 | 65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /web/src/components/worldmap/map.module.css: -------------------------------------------------------------------------------- 1 | .tooltipContainer.tooltipContainer { 2 | padding: 0; 3 | background: none; 4 | } 5 | 6 | button.reset { 7 | all: unset; 8 | cursor: pointer; 9 | transition: opacity 0.3s; 10 | display: flex; 11 | position: absolute; 12 | top: 0.7rem; 13 | left: 0.7rem; 14 | pointer-events: none; 15 | opacity: 0; 16 | 17 | &.moved { 18 | pointer-events: auto; 19 | opacity: 0.4; 20 | &:hover { 21 | opacity: 1; 22 | } 23 | } 24 | } 25 | 26 | .worldmap { 27 | display: flex; 28 | flex: 1; 29 | 30 | > svg { 31 | flex: 1; 32 | } 33 | } 34 | 35 | .geo { 36 | stroke: var(--pico-card-background-color); 37 | stroke-width: 1; 38 | transition: fill 0.3s, opacity 0.3s; 39 | outline: none; 40 | 41 | fill: hsl(94, calc(0% + 80% * var(--percent) * var(--percent)), calc(40% + 4% * var(--percent))); 42 | 43 | &:hover { 44 | opacity: 0.8; 45 | } 46 | } 47 | 48 | div.tooltip { 49 | background-color: var(--pico-secondary-background); 50 | padding: 0.4rem 0.5rem; 51 | border-radius: 0.4rem; 52 | min-width: 7rem; 53 | 54 | h2 { 55 | margin-bottom: 0.3rem; 56 | font-size: 1rem; 57 | color: var(--pico-contrast); 58 | } 59 | 60 | h3 { 61 | font-size: 1rem; 62 | display: flex; 63 | justify-content: space-between; 64 | margin: 0; 65 | color: var(--pico-contrast); 66 | font-weight: 800; 67 | align-items: center; 68 | 69 | span { 70 | color: var(--pico-h3-color); 71 | padding: 0.1rem 0.2rem; 72 | font-weight: 500; 73 | border-radius: 0.2rem; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /web/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /web/src/global.css: -------------------------------------------------------------------------------- 1 | @import "@explodingcamera/css/all.css" layer(base); 2 | @import "@picocss/pico/css/pico.lime.min.css" layer(pico); 3 | @import "react-tooltip/dist/react-tooltip.css" layer(tooltip); 4 | 5 | :root { 6 | --pico-background-color: #f7f8ff; 7 | --card-background-base: 0deg 0% 100%; 8 | --pico-card-background-color: hsl(var(--card-background-base)); 9 | --pico-block-spacing-vertical: 1rem; 10 | --pico-block-spacing-horizontal: 1rem; 11 | --pico-card-box-shadow: 0px 0px 24px #15233610; 12 | --pico-dropdown-background-color: #ffffff81; 13 | 14 | --pico-form-element-active-border-color: var(--pico-secondary-hover); 15 | --pico-form-element-focus-color: var(--pico-secondary-hover); 16 | --pico-border-radius: 0.7rem; 17 | --pico-spacing: 0.75rem; 18 | --pico-form-element-spacing-vertical: 0.5rem; 19 | --pico-form-element-spacing-horizontal: 0.6rem; 20 | 21 | --pico-font-family: "Outfit Variable", system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, Helvetica, Arial, 22 | "Helvetica Neue", sans-serif, var(--pico-font-family-emoji); 23 | --pico-font-family-sans-serif: var(--pico-font-family); 24 | font-variant-numeric: tabular-nums; 25 | } 26 | 27 | :root[data-theme="dark"] { 28 | --pico-background-color: #0a0c10; 29 | --card-background-base: 218.8 23.9% 13.9%; 30 | --pico-dropdown-background-color: #181c25d4; 31 | /* --pico-card-box-shadow: 0 0 0.5rem 0.1rem hsla(0, 0%, 0%, 0.1); */ 32 | } 33 | 34 | @media only screen and (prefers-color-scheme: dark) { 35 | :root:not([data-theme="light"]) { 36 | --pico-background-color: #0a0c10; 37 | --card-background-base: 218.8 23.9% 13.9%; 38 | --pico-dropdown-background-color: #181c25d4; 39 | } 40 | } 41 | 42 | h1, 43 | h2, 44 | h3, 45 | h4, 46 | h5, 47 | h6 { 48 | --pico-font-weight: 500; 49 | } 50 | 51 | html body[data-scroll-locked] { 52 | margin-right: 0 !important; 53 | } 54 | 55 | .full { 56 | min-height: 100%; 57 | } 58 | 59 | .react-tooltip { 60 | z-index: 10; 61 | } 62 | 63 | [type="search"] { 64 | --pico-border-radius: 0.7rem; 65 | } 66 | 67 | [type="button"], 68 | [type="reset"], 69 | [type="submit"] { 70 | margin-bottom: 0; 71 | } 72 | 73 | hr { 74 | background-color: var(--pico-form-element-border-color); 75 | height: 1px; 76 | border: 0; 77 | } 78 | 79 | details.dropdown.right summary + ul { 80 | left: unset; 81 | right: 0; 82 | transform: translateX(4px) translateY(-2px); 83 | min-width: 11rem; 84 | } 85 | 86 | details.dropdown summary + ul { 87 | backdrop-filter: blur(3px); 88 | } 89 | 90 | details.dropdown summary + ul li:has(hr) { 91 | &::before { 92 | display: none; 93 | } 94 | 95 | hr { 96 | margin: 0; 97 | } 98 | } 99 | 100 | td, 101 | th { 102 | background: unset; 103 | } 104 | 105 | .loading-spinner { 106 | flex: 1; 107 | background-image: var(--pico-icon-loading); 108 | background-size: 1.5em auto; 109 | background-repeat: no-repeat; 110 | background-position: center; 111 | } 112 | -------------------------------------------------------------------------------- /web/src/hooks/persist.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import type { Metric } from "../api"; 3 | import { DateRange } from "../api/ranges"; 4 | 5 | export const useMetric = () => { 6 | const [metric, _setMetric] = useState( 7 | () => (localStorage.getItem("liwan/selected-metric") ?? "views") as Metric, 8 | ); 9 | const setMetric = useCallback((metric: Metric) => { 10 | _setMetric(metric); 11 | localStorage.setItem("liwan/selected-metric", metric); 12 | }, []); 13 | return { metric, setMetric }; 14 | }; 15 | 16 | export const useRange = () => { 17 | const [range, _setRange] = useState(() => 18 | DateRange.deserialize(localStorage.getItem("liwan/date-range") || "last30Days"), 19 | ); 20 | const setRange = useCallback((range: DateRange) => { 21 | _setRange(range); 22 | localStorage.setItem("liwan/date-range", range.serialize()); 23 | }, []); 24 | return { range, setRange }; 25 | }; 26 | -------------------------------------------------------------------------------- /web/src/layouts/Base.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import "@fontsource-variable/outfit"; 3 | import "../global.css"; 4 | import ThemeSwitcher from "../components/ThemeSwitcher.astro"; 5 | 6 | import { ClientRouter } from "astro:transitions"; 7 | 8 | type Props = { title: string; hideFooter?: boolean }; 9 | --- 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {Astro.props.title} | Liwan 20 | 21 | 22 | 23 | { 24 | !Astro.props.hideFooter && ( 25 | 38 | ) 39 | } 40 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /web/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { LoginButton } from "../components/userInfo.tsx"; 3 | import Base from "./Base.astro"; 4 | 5 | type Props = { 6 | title: string; 7 | }; 8 | 9 | const { title } = Astro.props; 10 | --- 11 | 12 | 13 |
14 | 28 |
29 |
30 | 31 |
32 | 33 | 34 | 66 | 67 | -------------------------------------------------------------------------------- /web/src/pages/attributions.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import licensesCargo from "../../../data/licenses-cargo.json" assert { type: "json" }; 3 | import licensesNpm from "../../../data/licenses-npm.json" assert { type: "json" }; 4 | import Layout from "../layouts/Layout.astro"; 5 | 6 | const getRepositoryUrl = (repository: (typeof licensesNpm)[0]["repository"]) => { 7 | let repo = typeof repository === "object" ? (repository?.url ?? "") : repository; 8 | if (repo.startsWith("git+")) repo = repo.slice(4); 9 | if (repo.endsWith(".git")) repo = repo.slice(0, -4); 10 | repo = repo.replace("git://", "https://"); 11 | if (!repo.startsWith("https://")) repo = `https://github.com/${repo}`; // Some npm packages don't have the full URL and only have the repo name 12 | return repo; 13 | }; 14 | 15 | const removeEmails = (authors: string[]) => 16 | authors 17 | .map((author) => author.replace(/<.*>/, "")) 18 | .map((author) => author.trim()) 19 | .filter((author) => author.length > 0); 20 | 21 | const licenseText = (text: string) => { 22 | const lines = text.split("\n"); 23 | 24 | // remove the minimum number of leading spaces from each line (ignoring empty lines) 25 | const leadingSpaces = lines 26 | .filter((line) => line.trim().length > 0) 27 | .map((line) => line.match(/^\s*/)?.[0].length ?? 0); 28 | const minLeadingSpaces = Math.min(...leadingSpaces); 29 | lines.forEach((line, i) => { 30 | lines[i] = line.slice(minLeadingSpaces); 31 | }); 32 | 33 | if (lines[0].trim().length === 0) { 34 | lines.shift(); 35 | } 36 | if (lines[0].startsWith("Copyright")) { 37 | lines.shift(); 38 | } 39 | if (lines[0].startsWith("All rights reserved.")) { 40 | lines.shift(); 41 | } 42 | 43 | return lines.join("\n"); 44 | }; 45 | --- 46 | 47 | 48 |

Open Source Licenses

49 | 50 |

Liwan

51 |

52 | Liwan is an open source project, available under the Apache-2.0 license. 53 | See liwan.dev and GitHub for more information. 56 |

57 | 58 |

Attributions

59 | 60 | Liwan is built on top of a number of amazing open source projects. Following 61 | is a list of all packages used on the frontend and backend, along with their 62 | licenses. Only the first occurrence of each license text is shown here, the 63 | full license text can be found in the respective package's repository 64 | (linked). 65 | 66 |

Frontend

67 | { 68 | licensesNpm.map((license) => ( 69 |
70 |

71 | 72 | {license.name} ({license.version}) 73 | 74 | {license.author?.name && `by ${license.author.name}`}, available under 75 | the {license.license} license 76 | {license.repository && ( 77 | [source] 78 | )} 79 | {license.homepage && [homepage]} 80 |

81 |
82 | )) 83 | } 84 | 85 |

Backend

86 | { 87 | licensesCargo.crates.map(({ license, package: pkg }) => ( 88 |
89 |

90 | 91 | {pkg.name} ({pkg.version}) 92 | 93 | {pkg.authors.length 94 | ? `by ${removeEmails(pkg.authors).join(", ")}` 95 | : ""} 96 | , available under the {license} license 97 | {pkg.repository && ( 98 | [source] 99 | )} 100 |

101 |
102 | )) 103 | } 104 |
105 |

106 | Useragent Regexes 107 | by Google Inc., available under the Apache-2.0 license 108 | [source] 111 |

112 |
113 | 114 |

License Texts

115 | { 116 | licensesCargo.licenses 117 | .filter((license) => license.first_of_kind) 118 | .map((license) => ( 119 |
120 |

{license.name}

121 |
{licenseText(license.text)}
122 |
123 | )) 124 | } 125 |
126 | 127 | 162 | -------------------------------------------------------------------------------- /web/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Projects } from "../components/projects"; 3 | import Layout from "../layouts/Layout.astro"; 4 | --- 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /web/src/pages/login.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ThemeSwitcher from "../components/ThemeSwitcher.astro"; 3 | import Layout from "../layouts/Base.astro"; 4 | --- 5 | 6 | 7 |
8 | 9 |

Sign in

10 |
11 | 19 | 27 | 28 | 36 |
37 |
38 |
39 | 40 | 75 | 76 | 108 | -------------------------------------------------------------------------------- /web/src/pages/p/[...project].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { GetStaticPaths } from "astro"; 3 | 4 | import { Project } from "../../components/project"; 5 | import Layout from "../../layouts/Layout.astro"; 6 | 7 | export const getStaticPaths = (() => { 8 | return [{ params: { project: "project" } }]; 9 | }) satisfies GetStaticPaths; 10 | --- 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /web/src/pages/settings/entities.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { CreateEntity } from "../../components/settings/dialogs"; 3 | import { EntitiesTable } from "../../components/settings/tables"; 4 | import Settings from "../../layouts/Settings.astro"; 5 | --- 6 | 7 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/pages/settings/me.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { MyAccount } from "../../components/settings/me"; 3 | import Settings from "../../layouts/Settings.astro"; 4 | --- 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /web/src/pages/settings/projects.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { CreateProject } from "../../components/settings/dialogs"; 3 | import { ProjectsTable } from "../../components/settings/tables"; 4 | import Settings from "../../layouts/Settings.astro"; 5 | --- 6 | 7 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/pages/settings/users.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { CreateUser } from "../../components/settings/dialogs"; 3 | import { UsersTable } from "../../components/settings/tables"; 4 | import Settings from "../../layouts/Settings.astro"; 5 | --- 6 | 7 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/pages/setup.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ThemeSwitcher from "../components/ThemeSwitcher.astro"; 3 | import Layout from "../layouts/Base.astro"; 4 | --- 5 | 6 | 7 |
8 | 9 |

Let's get started

10 |

11 | Enter a new username and password to create your administrator account. 12 |

13 |
14 | 26 | 37 | 38 | 46 |
47 |
48 |
49 | 50 | 85 | 86 | 118 | -------------------------------------------------------------------------------- /web/src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { capitalizeAll, cls, countryCodeToFlag, formatMetricVal, formatPercent } from "./utils"; 3 | 4 | describe("utils", () => { 5 | test("capitalizeAll", () => { 6 | expect(capitalizeAll("hello world")).toBe("Hello World"); 7 | expect(capitalizeAll("hello-world")).toBe("Hello-world"); 8 | expect(capitalizeAll("hello_world")).toBe("Hello_world"); 9 | expect(capitalizeAll("helloWorld")).toBe("HelloWorld"); 10 | expect(capitalizeAll("HELLO WORLD")).toBe("HELLO WORLD"); 11 | }); 12 | 13 | test("cls", () => { 14 | expect(cls("a", "b", "c", null)).toBe("a b c"); 15 | expect(cls(["a", "b", undefined, null, "c"])).toBe("a b c"); 16 | expect(cls(undefined, [null], ["a", "b", undefined, null, "c"])).toBe("a b c"); 17 | }); 18 | 19 | test("countryCodeToFlag", () => { 20 | expect(countryCodeToFlag("us")).toBe("🇺🇸"); 21 | expect(countryCodeToFlag("gb")).toBe("🇬🇧"); 22 | expect(countryCodeToFlag("de")).toBe("🇩🇪"); 23 | expect(countryCodeToFlag("fr")).toBe("🇫🇷"); 24 | expect(countryCodeToFlag("es")).toBe("🇪🇸"); 25 | expect(countryCodeToFlag("")).toBe("🇽🇽"); 26 | }); 27 | 28 | test("formatMetricVal", () => { 29 | expect(formatMetricVal(0.1, "views")).toBe("0.1"); 30 | expect(formatMetricVal(0, "views")).toBe("0"); 31 | expect(formatMetricVal(1, "views")).toBe("1"); 32 | expect(formatMetricVal(1000, "views")).toBe("1k"); 33 | expect(formatMetricVal(1000000, "views")).toBe("1M"); 34 | expect(formatMetricVal(1000000000, "views")).toBe("1000M"); 35 | expect(formatMetricVal(0.1, "avg_time_on_site")).toBe("00:00"); 36 | expect(formatMetricVal(1, "avg_time_on_site")).toBe("00:01"); 37 | expect(formatMetricVal(60, "avg_time_on_site")).toBe("01:00"); 38 | expect(formatMetricVal(3600, "avg_time_on_site")).toBe("01:00:00"); 39 | expect(formatMetricVal(1, "bounce_rate")).toBe("100%"); 40 | expect(formatMetricVal(0.92, "bounce_rate")).toBe("92%"); 41 | expect(formatMetricVal(0.999, "bounce_rate")).toBe("99.9%"); 42 | }); 43 | 44 | test("formatPercent", () => { 45 | expect(formatPercent(0)).toBe("0%"); 46 | expect(formatPercent(1)).toBe("1%"); 47 | expect(formatPercent(0.1)).toBe("0.1%"); 48 | expect(formatPercent(0.01)).toBe("0%"); 49 | expect(formatPercent(0.001)).toBe("0%"); 50 | expect(formatPercent(1000)).toBe("1000%"); 51 | expect(formatPercent(10000)).toBe("100x"); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /web/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Metric } from "./api"; 2 | 3 | type ClassName = string | undefined | null | false; 4 | 5 | // biome-ignore lint/suspicious/noExplicitAny: required 6 | export const debounce = any>(func: T, wait: number) => { 7 | let timeout: number; 8 | return function (this: ThisParameterType, ...args: Parameters) { 9 | clearTimeout(timeout); 10 | timeout = window.setTimeout(() => func.apply(this, args), wait); 11 | }; 12 | }; 13 | 14 | export const capitalizeAll = (str: string) => str.replace(/(?:^|\s)\S/g, (a) => a.toUpperCase()); 15 | 16 | export const cls = (class1: ClassName | ClassName[], ...classes: (ClassName | ClassName[])[]) => 17 | [class1, ...classes.flat()] 18 | .flat() 19 | .filter((cls): cls is string => typeof cls === "string" && cls.length > 0) 20 | .join(" "); 21 | 22 | // get the username cookie or undefined if not set 23 | export const getUsername = () => document.cookie.match(/username=(.*?)(;|$)/)?.[1]; 24 | 25 | export const formatMetricValEvenly = (value: number, metric: Metric, biggest: number) => { 26 | if (metric === "bounce_rate") return formatPercent(Math.floor(value * 1000) / 10); 27 | if (metric === "avg_time_on_site") return formatDuration(value); 28 | if (value === 0) return "0"; 29 | 30 | if (biggest > 999999) { 31 | return `${(value / 1000000).toFixed(1).replace(/\.0$/, "")}M`; 32 | } 33 | 34 | if (biggest > 999) { 35 | return `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k`; 36 | } 37 | 38 | return value.toFixed(1).replace(/\.0$/, "") || "0"; 39 | }; 40 | 41 | export const formatMetricVal = (value: number, metric: Metric) => { 42 | if (metric === "bounce_rate") return formatPercent(Math.floor(value * 1000) / 10); 43 | if (metric === "avg_time_on_site") return formatDuration(value); 44 | 45 | if (value > 999999) { 46 | return `${(value / 1000000).toFixed(1).replace(/\.0$/, "")}M`; 47 | } 48 | 49 | if (value > 999) { 50 | return `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k`; 51 | } 52 | 53 | return value.toFixed(1).replace(/\.0$/, "") || "0"; 54 | }; 55 | 56 | export const formatPercent = (value: number) => { 57 | if (value === -1) return "∞"; 58 | if (value >= 10000 || value <= -10000) return `${(value / 100).toFixed(0)}x`; 59 | if (value >= 1000 || value <= -1000) return `${value.toFixed(0).replace(/\.0$/, "") || "0"}%`; 60 | return `${value.toFixed(1).replace(/\.0$/, "") || "0"}%`; 61 | }; 62 | 63 | export const formatDuration = (value: number) => { 64 | const totalSeconds = Math.floor(value); 65 | const hours = Math.floor(totalSeconds / 3600); 66 | const minutes = Math.floor((totalSeconds % 3600) / 60); 67 | const remainingSeconds = totalSeconds % 60; 68 | 69 | if (hours > 0) { 70 | return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; 71 | } 72 | 73 | return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; 74 | }; 75 | 76 | export const tryParseUrl = (url: string) => { 77 | try { 78 | return new URL(url); 79 | } catch { 80 | try { 81 | return new URL(`https://${url}`); 82 | } catch { 83 | return url; 84 | } 85 | } 86 | }; 87 | 88 | export const formatHost = (url: string | URL) => { 89 | if (typeof url === "string") return url; 90 | return url.hostname; 91 | }; 92 | 93 | export const formatFullUrl = (url: string | URL) => { 94 | if (typeof url === "string") return url; 95 | return `${url.hostname}${url.pathname}${url.search}`; 96 | }; 97 | 98 | export const formatPath = (url: string | URL) => { 99 | if (typeof url === "string") return url; 100 | return url.pathname; 101 | }; 102 | 103 | export const getHref = (url: string | URL) => { 104 | if (typeof url === "string") { 105 | if (!url.startsWith("http")) return `https://${url}`; 106 | return url; 107 | } 108 | 109 | return url.href; 110 | }; 111 | 112 | export const countryCodeToFlag = (countryCode: string) => { 113 | const code = countryCode.length === 2 ? countryCode : "XX"; 114 | const codePoints = code 115 | .toUpperCase() 116 | .split("") 117 | .map((char) => 127397 + char.charCodeAt(0)); 118 | return String.fromCodePoint(...codePoints); 119 | }; 120 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | --------------------------------------------------------------------------------