diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 000000000000..23d01f85ba4a --- /dev/null +++ b/.codespellignore @@ -0,0 +1,9 @@ +caf +Linz +linz +Taht +taht +referer +referers +statics +firs diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 000000000000..b02a03d5050b --- /dev/null +++ b/.credo.exs @@ -0,0 +1,211 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "test/", + "extra/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 0]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, false}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, false}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + + # + ## Warnings + # + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.WrongTestFileExtension, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000000..4deda942d86c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,74 @@ +# This file excludes paths from the Docker build context. +# +# By default, Docker's build context includes all files (and folders) in the +# current directory. Even if a file isn't copied into the container it is still sent to +# the Docker daemon. +# +# There are multiple reasons to exclude files from the build context: +# +# 1. Prevent nested folders from being copied into the container (ex: exclude +# /assets/node_modules when copying /assets) +# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) +# 3. Avoid sending files containing sensitive information +# +# More information on using .dockerignore is available here: +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +.dockerignore + +# Ignore git, but keep git HEAD and refs to access current commit hash if needed: +# +# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat +# d0b8727759e1e0e7aa3d41707d12376e373d5ecc +.git +!.git/HEAD +!.git/refs + +# Common development/test artifacts +/cover/ +/doc/ +/test/ +/tmp/ +.elixir_ls +plausible-report.xml +.env +.idea +*.iml +*.log +*.code-workspace +.vscode + +# Mix artifacts +/_build/ +/deps/ +*.ez + +# Generated on crash by the VM +erl_crash.dump + +# If NPM crashes, it generates a log, let's ignore it too. +npm-debug.log + +# Static artifacts - These should be fetched and built inside the Docker image +/assets/node_modules/ +/tracker/node_modules/ +/priv/static/cache_manifest.json +/priv/static/css +/priv/static/js +/priv/version.json + +# Auto-generated tracker files +/priv/tracker/js/*.js +/priv/tracker/installation_support/ + +# Dializer +/priv/plts/*.plt +/priv/plts/*.plt.hash + +# Geolocation databases +/priv/geodb/*.mmdb +/priv/geodb/*.mmdb.gz + +# Docker volumes +.clickhouse_db_vol* +plausible_db* diff --git a/.formatter.exs b/.formatter.exs index 8a6391c6a6ba..06ece05b6a92 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,12 @@ [ - import_deps: [:ecto, :phoenix], - inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], - subdirectories: ["priv/*/migrations"] + plugins: [Phoenix.LiveView.HTMLFormatter], + import_deps: [:ecto, :ecto_sql, :phoenix, :polymorphic_embed], + subdirectories: ["priv/*/migrations"], + inputs: [ + "*.{heex,ex,exs}", + "{config,lib,test,extra}/**/*.{heex,ex,exs}", + "priv/*/seeds.exs", + "storybook/**/*.exs" + ], + locals_without_parens: [assert_matches: 1] ] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 74e95ecbbb04..000000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve Plausible Analytics -title: '' -assignees: '' - ---- - -### Precheck - -Please note that this tracker is only for bugs. Do not use the issue tracker for help, support or feature requests. - -[Our docs](https://docs.plausible.io/) are a great place for most answers, but if you canโ€™t find your answer there, you can [contact us](https://plausible.io/contact). - -Have a feature request? Please search the ideas [on our forum](https://github.com/plausible/analytics/discussions) to make sure that the feature has not yet been requested. If you cannot find what you had in mind, please [submit your feature request here](https://github.com/plausible/analytics/discussions/new). - -Have an issue with your self-hosted install? You can ask in [our community forum](https://plausible.discourse.group/). Thanks! - -## Prerequisites -- [ ] I have searched open and closed issues to make sure that the bug has not yet been reported. - -## Bug report -**Describe the bug** -A clear and concise description of what the bug is. - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots (If applicable)** -If applicable, add screenshots to help explain your problem. - -**Environment (If applicable):** - - OS: [e.g. macos] - - Browser [e.g. safari, firefox] - - Version [e.g. 78] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 000000000000..7675fdd4e0d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,78 @@ +name: "๐Ÿ› Bug Report" +description: Create a report to help us improve Plausible Analytics +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + Please note that this tracker is only for bugs. Do not use the issue tracker for help, customer support or feature requests. + + [Our docs](https://plausible.io/docs) are a great place for most answers, but if you canโ€™t find your answer there, you can [contact our customer support](https://plausible.io/contact). + + Have a feature request? Please check our [feedback board](https://feedback.plausible.io). + + Have an issue with your self-hosted install? Self-hosted is community supported and you can ask in [our self-hosted forum](https://github.com/plausible/analytics/discussions/categories/self-hosted-support). + + Note that self-hosted is a long term release published twice per year so latest features wonโ€™t be immediately available. You can see all the [currently unreleased features in the changelog](https://github.com/plausible/analytics/blob/master/CHANGELOG.md). + + **Thanks!** + - type: checkboxes + attributes: + label: Past Issues Searched + options: + - label: >- + I have searched open and closed issues to make sure that the bug has + not yet been reported + required: true + - type: checkboxes + attributes: + label: Issue is a Bug Report + options: + - label: >- + This is a bug report and not a feature request, nor asking for self-hosted support + required: true + - type: dropdown + id: version + attributes: + label: Using official Plausible Cloud hosting or self-hosting? + options: + - Plausible Cloud from plausible.io + - Self-hosting + validations: + required: true + - type: textarea + id: bug-description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is + placeholder: Tell us what happened! + validations: + required: true + - type: textarea + id: bug-expectation + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen + placeholder: Tell us what you expected + validations: + required: true + - type: textarea + id: bug-screenshots + attributes: + label: Screenshots + description: 'If applicable, add screenshots to help explain your problem' + placeholder: Insert screenshots here + - type: textarea + attributes: + label: Environment + description: | + examples: + - **OS**: MacOS + - **Browser**: Firefox + - **Browser Version**: 88 + value: | + - OS: + - Browser: + - Browser Version: + render: markdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..3d722de818c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ๐Ÿš‘ Self-Hosted help and support + url: https://github.com/plausible/analytics/discussions/categories/self-hosted-support + about: Please use the self-hosted community forum for any self-hosted issues and questions + - name: ๐Ÿ’ก Feature requests and ideas + url: https://feedback.plausible.io + about: Please vote for and post new feature ideas on our feedback board + - name: ๐Ÿ“– Documentation + url: https://plausible.io/docs + about: A great place to find instructions and answers on how to get the most out of your Plausible experience diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 097388bf1752..47ded27aa24e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,3 +15,7 @@ Below you'll find a checklist. For each item on the list, check one option and d ### Documentation - [ ] [Docs](https://github.com/plausible/docs) have been updated - [ ] This change does not need a documentation update + +### Dark mode +- [ ] The UI has been tested both in dark and light mode +- [ ] This PR does not change the UI diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..9c7cf2318959 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: "mix" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 1 + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 1 + - package-ecosystem: "npm" + directory: "/assets" + schedule: + interval: "daily" + open-pull-requests-limit: 1 + - package-ecosystem: "npm" + directory: "/tracker" + schedule: + interval: "daily" + open-pull-requests-limit: 1 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/all-checks-pass.yml b/.github/workflows/all-checks-pass.yml new file mode 100644 index 000000000000..7a3db246f6a1 --- /dev/null +++ b/.github/workflows/all-checks-pass.yml @@ -0,0 +1,16 @@ +name: All checks pass + +on: + pull_request: + merge_group: + +jobs: + enforce-all-checks: + runs-on: ubuntu-latest + permissions: + checks: read + steps: + - name: GitHub Checks + uses: poseidon/wait-for-status-checks@v0.6.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/build-private-images-ghcr.yml b/.github/workflows/build-private-images-ghcr.yml new file mode 100644 index 000000000000..16cdc28520bd --- /dev/null +++ b/.github/workflows/build-private-images-ghcr.yml @@ -0,0 +1,102 @@ +name: Build Private Images GHCR + +on: + push: + branches: [master, stable] + tags: ['r*'] + pull_request: + types: [synchronize, labeled] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'preview') }} + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Docker meta + id: meta + uses: docker/metadata-action@v5.0.0 + env: + DOCKER_METADATA_PR_HEAD_SHA: true + with: + images: ghcr.io/plausible/analytics/ee + tags: | + type=ref,event=pr + type=ref,event=branch + type=ref,event=tag + type=sha + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v6 + with: + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + MIX_ENV=prod + BUILD_METADATA=${{ steps.meta.outputs.json }} + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} + + - name: Notify team on failure + if: ${{ failure() && github.ref == 'refs/heads/master' }} + uses: fjogeleit/http-request-action@v1 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "Build failed"}' + + - name: Get first line and Co-Authored-By lines of the commit message + if: ${{ success() && github.ref == 'refs/heads/master' }} + id: commitmsg + run: | + first_line=$(echo "${{ github.event.head_commit.message }}" | head -n1) + co_authors=$(echo "${{ github.event.head_commit.message }}" | grep -h 'Co-authored-by:' | sort -u | cut -d: -f2- | paste -sd, -) + { + echo "first_line=$first_line" + echo "co_authors=$co_authors" + } >> $GITHUB_OUTPUT + + - name: Notify team on success + if: ${{ success() && github.ref == 'refs/heads/master' }} + uses: fjogeleit/http-request-action@v1 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + escapeData: 'true' + data: | + { + "content": "

๐Ÿš€ Deploying ${{ steps.commitmsg.outputs.first_line }}

Author(s): ${{ github.event.head_commit.author.name }}
${{ steps.commitmsg.outputs.co_authors}}

Commit: ${{ github.sha }}

" + } + + - name: Set Honeycomb marker on success + if: ${{ success() && github.ref == 'refs/heads/master' }} + uses: cnkk/honeymarker-action@1bd92aec746e38efe43a0faee94ced1ebb930712 + with: + apikey: ${{ secrets.HONEYCOMB_MARKER_APIKEY }} + dataset: 'plausible-prod' + message: "${{ github.sha }}" diff --git a/.github/workflows/build-public-images-ghcr.yml b/.github/workflows/build-public-images-ghcr.yml new file mode 100644 index 000000000000..f4452859178f --- /dev/null +++ b/.github/workflows/build-public-images-ghcr.yml @@ -0,0 +1,125 @@ +name: Build Public Images GHCR + +on: + push: + tags: ["v*"] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GHCR_REPO: ghcr.io/plausible/community-edition + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-22.04 + - platform: linux/arm64 + runner: ubuntu-22.04-arm + + runs-on: ${{ matrix.runner || 'ubuntu-22.04' }} + + steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.GHCR_REPO }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + id: docker_build + uses: docker/build-push-action@v6 + with: + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.GHCR_REPO }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + MIX_ENV=ce + BUILD_METADATA=${{ steps.meta.outputs.json }} + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.docker_build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v6 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + - name: Notify team on failure + if: ${{ failure() }} + uses: fjogeleit/http-request-action@v1 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: "POST" + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "Build failed"}' + + push: + runs-on: ubuntu-latest + needs: + - build + + steps: + - name: Download digests + uses: actions/download-artifact@v7 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.GHCR_REPO }} + tags: | + type=semver,pattern={{version}},prefix=v + type=semver,pattern={{major}}.{{minor}},prefix=v + type=semver,pattern={{major}},prefix=v + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 000000000000..bd6ee992bd2f --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,18 @@ +name: Check spelling + +on: + pull_request: + push: + branches: [ master ] + merge_group: + +jobs: + codespell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: codespell-project/actions-codespell@v2 + with: + check_filenames: true + ignore_words_file: .codespellignore + path: lib test extra diff --git a/.github/workflows/comment-preview-url.yml b/.github/workflows/comment-preview-url.yml new file mode 100644 index 000000000000..d41f0e13b5a5 --- /dev/null +++ b/.github/workflows/comment-preview-url.yml @@ -0,0 +1,28 @@ +name: Add preview environment URL to PR + +on: + pull_request: + types: [labeled] + +permissions: + pull-requests: write + +env: + PR_NUMBER: ${{ github.event.number }} + +jobs: + comment: + if: ${{ contains(github.event.pull_request.labels.*.name, 'preview') }} + runs-on: ubuntu-latest + steps: + - name: Comment with preview URL + uses: thollander/actions-comment-pull-request@v3.0.1 + with: + message: | +
+ + |Preview environment๐Ÿ‘ท๐Ÿผโ€โ™€๏ธ๐Ÿ—๏ธ | + |:-:| + | [PR-${{env.PR_NUMBER}}](https://pr-${{env.PR_NUMBER}}.review.plausible.io) + +
diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml new file mode 100644 index 000000000000..b00395580bcd --- /dev/null +++ b/.github/workflows/elixir.yml @@ -0,0 +1,160 @@ +name: Elixir CI + +on: + pull_request: + push: + branches: [master, stable] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CACHE_VERSION: v17 + PERSISTENT_CACHE_DIR: cached + +jobs: + build: + name: "Build and test (${{ matrix.mix_env }}, ${{ matrix.postgres_image }})" + runs-on: ubuntu-latest + strategy: + matrix: + mix_env: ["test", "ce_test"] + postgres_image: ["postgres:18"] + mix_test_partition: [1, 2, 3, 4, 5, 6] + + env: + MIX_ENV: ${{ matrix.mix_env }} + services: + postgres: + image: ${{ matrix.postgres_image }} + ports: + - 5432:5432 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + clickhouse: + image: clickhouse/clickhouse-server:24.12.2.29-alpine + ports: + - 8123:8123 + env: + options: >- + --health-cmd nc -zw3 localhost 8124 + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: marocchino/tool-versions-action@v1 + id: versions + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ steps.versions.outputs.elixir }} + otp-version: ${{ steps.versions.outputs.erlang }} + + - uses: actions/cache@v5 + with: + path: | + deps + _build + tracker/node_modules + priv/tracker/js + priv/tracker/installation_support + ${{ env.PERSISTENT_CACHE_DIR }} + key: ${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}- + ${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-refs/heads/master- + + - name: Check for changes in tracker/** + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + tracker: + - 'tracker/**' + - name: Check if tracker and verifier are built already + run: | + if [ -f priv/tracker/js/plausible-web.js ] && [ -f priv/tracker/installation_support/verifier.js ]; then + echo "HAS_BUILT_TRACKER=true" >> $GITHUB_ENV + else + echo "HAS_BUILT_TRACKER=false" >> $GITHUB_ENV + fi + - run: npm install --prefix ./tracker + if: steps.changes.outputs.tracker == 'true' || env.HAS_BUILT_TRACKER == 'false' + - run: npm run deploy --prefix ./tracker + if: steps.changes.outputs.tracker == 'true' || env.HAS_BUILT_TRACKER == 'false' + + - run: mix deps.get --only $MIX_ENV + - run: mix compile --warnings-as-errors --all-warnings + - run: mix do ecto.create, ecto.migrate + - run: mix run -e "Tzdata.ReleaseUpdater.poll_for_update" + + - run: make minio + if: env.MIX_ENV == 'test' + - run: | + mix test --include slow --include minio --include migrations --max-failures 1 --warnings-as-errors --partitions 6 | tee test_output.log + if grep -E '\.+[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3} \[[^]]+\]' test_output.log | grep -v 'libcluster'; then + echo "The tests are producing output, this usually indicates some error" + exit 1 + fi + shell: bash + if: env.MIX_ENV == 'test' + env: + MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1" + MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }} + + + - run: | + mix test --include slow --include migrations --max-failures 1 --warnings-as-errors --partitions 6 | tee test_output.log + if grep -E '\.+[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3} \[[^]]+\]' test_output.log | grep -v 'libcluster'; then + echo "The tests are producing output, this usually indicates some error" + exit 1 + fi + shell: bash + if: env.MIX_ENV == 'ce_test' + env: + MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }} + + static: + name: Static checks (format, credo, dialyzer) + env: + MIX_ENV: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: marocchino/tool-versions-action@v1 + id: versions + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ steps.versions.outputs.elixir }} + otp-version: ${{ steps.versions.outputs.erlang }} + + - uses: actions/cache@v5 + with: + path: | + deps + _build + priv/plts + key: static-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + static-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}- + static-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-refs/heads/master- + + - run: mix deps.get + - run: mix compile --warnings-as-errors --all-warnings + - run: mix format --check-formatted + - run: mix deps.unlock --check-unused + - run: mix credo diff --from-git-merge-base origin/master + - run: mix dialyzer diff --git a/.github/workflows/migrations-validation.yml b/.github/workflows/migrations-validation.yml new file mode 100644 index 000000000000..2d162f80215c --- /dev/null +++ b/.github/workflows/migrations-validation.yml @@ -0,0 +1,33 @@ +name: Validate migrations + +on: + pull_request: + paths: + - 'priv/repo/migrations/**' + - 'priv/ingest_repo/migrations/**' + +jobs: + validate: + name: App code does not change at the same time + runs-on: ubuntu-latest + + steps: + - uses: dorny/paths-filter@v3 + id: changes + with: + list-files: json + predicate-quantifier: 'every' + filters: | + lib: + - 'lib/**' + - '!lib/plausible/data_migration/**' + - '!lib/plausible/migration_utils.ex' + extra: + - 'extra/**' + config: + - 'config/**' + + - if: steps.changes.outputs.lib == 'true' || steps.changes.outputs.extra == 'true' || steps.changes.outputs.config == 'true' + run: | + echo "::error file=${{ fromJSON(steps.changes.outputs.lib_files)[0] }}::Code and migrations shouldn't be changed at the same time" + exit 1 diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 000000000000..62f2a80ca2b0 --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,36 @@ +name: NPM CI + +on: + push: + branches: [master, stable] + pull_request: + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Read .tool-versions + uses: marocchino/tool-versions-action@v1 + id: versions + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: ${{steps.versions.outputs.nodejs}} + - run: npm install --prefix ./assets + - run: npm install --prefix ./tracker + - run: npm run generate-types --prefix ./assets && git diff --exit-code -- ./assets/js/types + - run: npm run typecheck --prefix ./assets + - run: npm run lint --prefix ./assets + - run: npm run check-format --prefix ./assets + - run: npm run test --prefix ./assets + - run: npm run lint --prefix ./tracker + - run: npm run check-format --prefix ./tracker + - run: npm run deploy --prefix ./tracker diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 000000000000..80a896177c3b --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,57 @@ +name: Publish Elixir documentation + +on: + push: + branches: ["master"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +permissions: + contents: write + pages: write + id-token: write + +# Allow only one concurrent GitHub Pages workflow +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Read .tool-versions + uses: marocchino/tool-versions-action@v1 + id: versions + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{steps.versions.outputs.elixir}} + otp-version: ${{ steps.versions.outputs.erlang}} + + - name: Restore Elixir dependencies cache + uses: actions/cache@v5 + with: + path: | + deps + _build + priv/plts + key: ${{ runner.os }}-mix-v1-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix-v1- + + - name: Install Mix dependencies + run: mix deps.get + + - name: Build documentation + run: mix docs + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./doc diff --git a/.github/workflows/terraform-e2e.yml b/.github/workflows/terraform-e2e.yml new file mode 100644 index 000000000000..a81d9d29ef8a --- /dev/null +++ b/.github/workflows/terraform-e2e.yml @@ -0,0 +1,90 @@ +name: "Terraform E2E Tests" + +on: + push: + branches: + - master + paths: + - 'test/e2e/**.tf' + pull_request: + paths: + - 'test/e2e/**.tf' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + terraform: + concurrency: terraform_checkly + defaults: + run: + working-directory: test/e2e + env: + TF_CLOUD_ORGANIZATION : ${{ secrets.TF_CLOUD_ORGANIZATION }} + + name: "Terraform" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + cli_config_credentials_token: ${{ secrets.TF_CLOUD_CHECKLY_API_TOKEN }} + + - name: Terraform Format + id: fmt + run: terraform fmt -check + + - name: Terraform Init + id: init + run: terraform init + + - name: Terraform Validate + id: validate + run: terraform validate -no-color + + - name: Terraform Plan + id: plan + if: github.event_name == 'pull_request' + run: terraform plan -no-color + continue-on-error: true + + - uses: actions/github-script@v8 + if: github.event_name == 'pull_request' + env: + PLAN: "terraform\n${{ steps.plan.outputs.stdout }}" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const output = `#### Terraform Format and Style ๐Ÿ–Œ\`${{ steps.fmt.outcome }}\` + #### Terraform Initialization โš™๏ธ\`${{ steps.init.outcome }}\` + #### Terraform Validation ๐Ÿค–\`${{ steps.validate.outcome }}\` + #### Terraform Plan ๐Ÿ“–\`${{ steps.plan.outcome }}\` + +
Show Plan + + \`\`\`\n + ${process.env.PLAN} + \`\`\` + +
+ + *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) + + - name: Terraform Plan Status + if: steps.plan.outcome == 'failure' + run: exit 1 + + - name: Terraform Apply + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + run: terraform apply -auto-approve diff --git a/.github/workflows/tracker-script-npm-release.yml b/.github/workflows/tracker-script-npm-release.yml new file mode 100644 index 000000000000..d65fa21c6112 --- /dev/null +++ b/.github/workflows/tracker-script-npm-release.yml @@ -0,0 +1,81 @@ +name: "Tracker: publish NPM release" + +on: + pull_request: + branches: [master] + types: [closed] + +jobs: + tracker-release-npm: + runs-on: ubuntu-latest + permissions: + pull-requests: read + contents: read + if: >- + ${{ github.event.pull_request.merged == true && ( + contains(github.event.pull_request.labels.*.name, 'tracker-release: patch') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: minor') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: major') ) }} + + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + with: + token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} + + - uses: actions/setup-node@v6 + with: + node-version: 23.2.0 + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm install --prefix tracker + + - name: Bump the patch version and update changelog + if: "${{ contains(github.event.pull_request.labels.*.name, 'tracker-release: patch') }}" + run: npm run npm:prepare_release:patch --prefix tracker + + - name: Bump the minor version and update changelog + if: "${{ contains(github.event.pull_request.labels.*.name, 'tracker-release: minor') }}" + run: npm run npm:prepare_release:minor --prefix tracker + + - name: Bump the major version and update changelog + if: "${{ contains(github.event.pull_request.labels.*.name, 'tracker-release: major') }}" + run: npm run npm:prepare_release:major --prefix tracker + + - name: Get the package version from package.json + id: package + run: | + echo "version=$(jq -r .version tracker/npm_package/package.json)" >> $GITHUB_OUTPUT + + - name: Publish tracker script to NPM + run: npm publish + working-directory: tracker/npm_package + env: + NODE_AUTH_TOKEN: ${{ secrets.TRACKER_RELEASE_NPM_TOKEN }} + + - name: Commit and Push changes + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 + with: + message: "Released tracker script version ${{ steps.package.outputs.version }}" + github_token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} + add: | + - tracker/npm_package + + - name: Notify team on success + if: ${{ success() }} + uses: fjogeleit/http-request-action@v1 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "

๐Ÿš€ New tracker script version has been released to NPM!

"}' + + - name: Notify team on failure + if: ${{ failure() }} + uses: fjogeleit/http-request-action@v1 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "NPM release failed"}' diff --git a/.github/workflows/tracker-script-update.yml b/.github/workflows/tracker-script-update.yml new file mode 100644 index 000000000000..6dee2de36e22 --- /dev/null +++ b/.github/workflows/tracker-script-update.yml @@ -0,0 +1,137 @@ +name: "Tracker script update" + +on: + pull_request: + paths: + - 'tracker/src/**' + - 'tracker/package.json' + - 'tracker/package-lock.json' + +jobs: + tracker-script-update: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} + fetch-depth: 1 + + - name: Checkout master for comparison + uses: actions/checkout@v6 + with: + ref: master + path: master-branch + + - name: Install jq and clickhouse-local + run: | + sudo apt-get install apt-transport-https ca-certificates dirmngr + sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 8919F6BD2B48D754 + echo "deb https://packages.clickhouse.com/deb stable main" | sudo tee \ + /etc/apt/sources.list.d/clickhouse.list + sudo apt-get update + + sudo apt-get install jq clickhouse-server -y + + - name: Compare and increment tracker_script_version + id: increment + run: | + cd tracker + # Get current version from PR branch + PR_VERSION=$(jq '.tracker_script_version' package.json) + + # Get version from master, default to 0 if not present + MASTER_VERSION=$(jq '.tracker_script_version // 0' ../master-branch/tracker/package.json) + + echo "PR tracker_script_version: $PR_VERSION" + echo "Master tracker_script_version: $MASTER_VERSION" + + # Calculate new version + NEW_VERSION=$((PR_VERSION + 1)) + + # Check version conditions + if [ $PR_VERSION -lt $MASTER_VERSION ]; then + echo "::error::PR tracker tracker_script_version ($PR_VERSION) is less than master ($MASTER_VERSION) and cannot be incremented." + echo "::error::Rebase or merge master into your PR to fix this." + exit 1 + elif [ $NEW_VERSION -eq $((MASTER_VERSION + 1)) ]; then + echo "Incrementing version from $PR_VERSION to $NEW_VERSION" + jq ".tracker_script_version = $NEW_VERSION" package.json > package.json.tmp + mv package.json.tmp package.json + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "changed=true" >> $GITHUB_OUTPUT + else + echo "Already incremented tracker_script_version in PR, skipping." + echo "version=$PR_VERSION" >> $GITHUB_OUTPUT + echo "changed=false" >> $GITHUB_OUTPUT + fi + + - name: Commit and push changes + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 + if: steps.increment.outputs.changed == 'true' + with: + message: 'chore: Bump tracker_script_version to ${{ steps.increment.outputs.version }}' + github_token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} + add: | + - tracker/package.json + + - name: Compile tracker code + run: | + cd master-branch/tracker + npm install + node compile.js --suffix master + cp ../priv/tracker/js/plausible* ../../priv/tracker/js/ + + cd ../../tracker + npm install + node compile.js --suffix pr + + - name: Run script size analyzer and set output + id: analyze + run: | + cd tracker + OUT=$(node compiler/analyze-sizes.js --baselineSuffix master --currentSuffix pr) + # Set multiline output + echo "sizes<> $GITHUB_OUTPUT + echo "$OUT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Comment script size report on PR + uses: thollander/actions-comment-pull-request@v3.0.1 + with: + message: | + ${{ steps.analyze.outputs.sizes }} + comment-tag: size-report + + - name: Check PR has tracker release label set + if: >- + ${{ !( + contains(github.event.pull_request.labels.*.name, 'tracker-release: patch') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: minor') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: major') || + contains(github.event.pull_request.labels.*.name, 'tracker-release: none') ) }} + + run: | + echo "::error::PR changes tracker script but does not have a 'tracker release:' label. Please add one." + exit 1 + + - name: Get changed files + id: changelog_changed + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 + with: + files: | + tracker/npm_package/CHANGELOG.md + + - name: Error if PR no tracker CHANGELOG.md updates + if: >- + ${{ ( + steps.changelog_changed.outputs.any_changed == 'false' && + !contains(github.event.pull_request.labels.*.name, 'tracker-release: none') ) }} + run: | + echo "::error::PR changes tracker script but does not have a tracker NPM package CHANGELOG.md update." + exit 1 diff --git a/.github/workflows/tracker.yml b/.github/workflows/tracker.yml new file mode 100644 index 000000000000..53cfc5b2129c --- /dev/null +++ b/.github/workflows/tracker.yml @@ -0,0 +1,81 @@ +name: Tracker CI + +on: + workflow_dispatch: + pull_request: + paths: + - "tracker/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + timeout-minutes: 15 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 23.2.0 + cache: 'npm' + cache-dependency-path: tracker/package-lock.json + - name: Install dependencies + run: npm --prefix ./tracker ci + - name: Cache Playwright browsers + uses: actions/cache@v5 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + ~/.cache/ms-playwright-github + key: playwright-${{ runner.os }}-${{ hashFiles('tracker/package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- + - name: Install Playwright system dependencies + working-directory: ./tracker + run: npx playwright install-deps + - name: Install Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: ./tracker + run: npx playwright install + - name: Run Playwright tests + run: npm --prefix ./tracker test -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob + - name: Upload blob report to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 + with: + name: blob-report-${{ matrix.shardIndex }} + path: tracker/blob-report + retention-days: 1 + merge-sharded-test-report: + if: ${{ !cancelled() }} + needs: [test] + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 23.2.0 + cache: 'npm' + cache-dependency-path: tracker/package-lock.json + - name: Install dependencies + run: npm --prefix ./tracker ci + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v7 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge into list report + working-directory: ./tracker + run: npx playwright merge-reports --reporter list ../all-blob-reports diff --git a/.gitignore b/.gitignore index 606074d1df98..7d8b7ba3f1a7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez +# Temporary files, for example, from tests. +/tmp/ + # Ignore package tarball (built via "mix hex.build"). plausible-*.tar @@ -32,11 +35,32 @@ npm-debug.log /assets/node_modules/ /tracker/node_modules/ +# Files generated by Playwright when running tracker tests +/tracker/test-results/ +/tracker/playwright-report/ +/tracker/blob-report/ +/tracker/playwright/.cache/ + +# Stored hash of source tracker files used in development environment +# to detect changes in /tracker/src and avoid unnecessary compilation. +/tracker/compiler/last-hash.txt +# Temporary file used by analyze-sizes.js +/tracker/compiler/.analyze-sizes.json + +# Tracker npm module files that are generated by the compiler for the NPM package +/tracker/npm_package/plausible.js* +/tracker/npm_package/plausible.cjs* +/tracker/npm_package/plausible.d.cts + +# test coverage directory +/assets/coverage + # Since we are building assets from assets/, # we ignore priv/static. You may want to comment # this depending on your deployment strategy. -/priv/static/ -/priv/geolix/ +/priv/static/css +/priv/static/js +/priv/version.json # Files matching config/*.secret.exs pattern contain sensitive # data and you should not commit them into version control. @@ -50,8 +74,28 @@ npm-debug.log .elixir_ls plausible-report.xml -*.sh .idea *.iml *.log -*.code-workspace \ No newline at end of file +*.code-workspace +.vscode + +# Dializer +/priv/plts/*.plt +/priv/plts/*.plt.hash + +.env + +# Geolocation databases +/priv/geodb/*.mmdb +/priv/geodb/*.mmdb.gz + +# Auto-generated tracker files +/priv/tracker/js/plausible*.js* +/priv/tracker/installation_support/*.js + +# Docker volumes +.clickhouse_db_vol* +plausible_db* + +.claude diff --git a/.gitlab/build-scripts/docker-entrypoint.sh b/.gitlab/build-scripts/docker-entrypoint.sh deleted file mode 100755 index 0ab3e8ff2dee..000000000000 --- a/.gitlab/build-scripts/docker-entrypoint.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -set -e - -if [[ "$1" = 'run' ]]; then - exec /app/bin/plausible start - -elif [[ "$1" = 'db' ]]; then - exec /app/"$2".sh - else - exec "$@" - -fi - -exec "$@" - diff --git a/.gitlab/build-scripts/docker.gitlab.sh b/.gitlab/build-scripts/docker.gitlab.sh deleted file mode 100644 index 4cc663ca047a..000000000000 --- a/.gitlab/build-scripts/docker.gitlab.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash - -############################ -function docker_create_config() { -############################ - mkdir -p /kaniko/.docker/ - echo "###############" - echo "Logging into GitLab Container Registry with CI credentials for kaniko..." - echo "###############" - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json - echo "" - -} - - -############################ -function docker_build_image() { -############################ - if [[ -f Dockerfile ]]; then - echo "###############" - echo "Building Dockerfile-based application..." - echo "###############" - - /kaniko/executor \ - --cache=true \ - --context ${CI_PROJECT_DIR} \ - --dockerfile ${CI_PROJECT_DIR}/Dockerfile \ - --destination ${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA} \ - --destination ${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}-latest \ - \ - "$@" - - else - echo "No Dockerfile found." - return 1 - fi -} diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 000000000000..6bcb09472b5e --- /dev/null +++ b/.iex.exs @@ -0,0 +1,19 @@ +alias Plausible.{Repo, ClickhouseRepo, IngestRepo} +alias Plausible.{Site, Sites, Goal, Goals, Stats} + +import_if_available(Ecto.Query) +import_if_available(Plausible.Factory) + +Logger.configure(level: :warning) + +IO.puts( + IO.ANSI.cyan() <> + ~S[ + .*$$$$$$s. + *$$$$$$$$$$$, + :$$SSS#######S *$$$$/. $$ ** $$ l$: + ,$$SSS#######. $$ ,$:$$ $$$$$ .S* $: $$@$* $% $$$$@s :$: s$@$s + .**$$$#####` $@$$$$' $l s-' $:.@* $| '$@s. $% $$ \$ :$:,$$ *$: + ,***/` #$ `$$ %$$%$$ *$$$#`.*sss$ $% $#$$$' %$ *$sss, + ,$$'] <> IO.ANSI.reset() <> "\n\n" +) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000000..39ceee1795a7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://gitlab.com/jvenom/elixir-pre-commit-hooks + rev: v1.0.0 + hooks: + - id: mix-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-case-conflict + - id: check-symlinks + - id: check-yaml + - id: destroyed-symlinks + - id: end-of-file-fixer + exclude: priv/tracker/js + - id: mixed-line-ending + - id: trailing-whitespace diff --git a/.tool-versions b/.tool-versions index 0a6bd54a3f02..d1ef1f421bca 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -erlang 21.1 -elixir 1.11 -nodejs 15.3.0 +erlang 27.3.4.6 +elixir 1.19.4-otp-27 +nodejs 23.2.0 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0d2102fbf5bf..000000000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: elixir -elixir: '1.10.3' -otp_release: '21.1' -services: - - postgresql - - docker -before_install: - - echo "CREATE DATABASE plausible_test" > $HOME/init.sql - - docker pull yandex/clickhouse-server - - docker run -d -p 8123:8123 --ulimit nofile=262144:262144 --volume=$HOME/init.sql:/docker-entrypoint-initdb.d/init.sql yandex/clickhouse-server - # setting up postgres 12 is quite a pain, see: - # https://travis-ci.community/t/test-against-postgres-12/6768/8 - - sudo sed -i 's/port = 5433/port = 5432/' /etc/postgresql/12/main/postgresql.conf - - sudo cp /etc/postgresql/{9.4,12}/main/pg_hba.conf - - sudo pg_ctlcluster 12 main restart -env: - - MIX_ENV=test PGVER=12 -script: mix coveralls.travis -addons: - postgresql: '12' - apt: - packages: - - postgresql-12 - - postgresql-client-12 -cache: - directories: - - _build - - deps -deploy: - provider: script - script: bash docker_push - on: - branch: master diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1425a83fe8..899ad0bf7822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,586 @@ # Changelog + All notable changes to this project will be documented in this file. -## [1.1.2] - Unreleased +## Unreleased + +### Added + +- Adds team_id to query debug metadata (saved in system.query_log log_comment column) +- A visitor percentage breakdown is now shown on all reports, both on the dashboard and in the detailed breakdown +- Shared links can now be limited to a particular segment of the data + +### Removed + +### Changed + +- Segment filters are visible to anyone who can view the dashboard with that segment applied, including personal segments on public dashboards +- Keybind hints are hidden on smaller screens + +### Fixed + +- To make internal stats API requests for password-protected shared links, shared link auth cookie must be set in the requests +- Fixed issue with site guests in Editor role and team members in Editor role not being able to change the domain of site +- Fixed direct dashboard links that use legacy dashboard filters containing URL encoded special characters (e.g. character `รช` in the legacy filter `?page=%C3%AA`) +- Fix bug with tracker script config cache that made requests for certain cached scripts give error 500 + +## v3.1.0 - 2025-11-13 + +### Added + +- Custom events can now be marked as non-interactive in events API and tracker script: events marked as non-interactive are not counted towards bounce rate +- Ability to leave team via Team Settings > Leave Team +- Stats APIv2 now supports `include.trim_relative_date_range` - this option allows trimming empty values after current time for `day`, `month` and `year` date_range values +- Properties are now included in full site exports done via Site Settings > Imports & Exports +- Google Search Console integration settings: properties can be dynamically sought +- Weekly/monthly e-mail reports now contain top goal conversions +- Newly created sites are offered a new dynamic tracking script and snippet that's specific to the site +- Old sites that go to "Review installation" flow are offered the new script and snippet, along with a migration guide from legacy snippets, legacy snippets continue to function as before +- The new tracker script allows configuring `transformRequest` function to change event payloads before they're sent +- The new tracker script allows configuring `customProperties` function hook to derive custom props for events on the fly +- The new tracker script supports tracking form submissions if enabled +- The new tracker script automatically updates to respect site domain if it's changed in "Change domain" flow +- The new tracker script automatically updates to respect the following configuration options available in "New site" flows and "Review installation" flows: whether to track outbound links, file downloads, form submissions +- The new tracker script allows overriding almost all options by changing the snippet on the website, with the function `plausible.init({ ...your overrides... })` - this can be unique page-by-page +- A new `@plausible-analytics/tracker` ESM module is available on NPM - it has near-identical configuration API and identical tracking logic as the script and it receives bugfixes and updates concurrently with the new tracker script +- Ability to enforce enabling 2FA by all team members + +### Removed + +### Changed + +- A session is now marked as a bounce if it has less than 2 pageviews and no interactive custom events +- All dropmenus on dashboard are navigable with Tab (used to be a mix between tab and arrow keys), and no two dropmenus can be open at once on the dashboard +- Special path-based events like "404" don't need `event.props.path` to be explicitly defined when tracking: it is set to be the same as `event.pathname` in event ingestion; if it is explicitly defined, it is not overridden for backwards compatibility +- Main graph no longer shows empty values after current time for `day`, `month` and `year` periods +- Include `bounce_rate` metric in Entry Pages breakdown +- Dark mode theme has been refined with darker color scheme and better visual hierarchy +- Creating shared links now happens in a modal + +### Fixed + +- Make clicking Compare / Disable Comparison in period picker menu close the menu +- Do not log page views for hidden pages (prerendered pages and new tabs), until pages are viewed +- Password-authenticated shared links now carry over dashboard params properly +- Realtime and hourly graphs of visit duration, views per visit no longer overcount due to long-lasting sessions, instead showing each visit when they occurred +- Fixed realtime and hourly graphs of visits overcounting +- When reporting only `visitors` and `visits` per hour, count visits in each hour they were active in +- Fixed unhandled tracker-related exceptions on link clicks within svgs +- Remove Subscription and Invoices menu from CE +- Fix email sending error "Mua.SMTPError" 503 Bad sequence of commands +- Make button to include / exclude imported data visible on Safari + +## v3.0.0 - 2025-04-11 + +### Added + +- Ability to sort by and compare the `exit_rate` metric in the dashboard Exit Pages > Details report +- Add top 3 pages into the traffic spike email +- Two new shorthand time periods `28d` and `91d` available on both dashboard and in public API +- Average scroll depth metric +- Scroll Depth goals +- Dashboard shows comparisons for all reports +- UTM Medium report and API shows (gclid) and (msclkid) for paid searches when no explicit utm medium present. +- Support for `case_sensitive: false` modifiers in Stats API V2 filters for case-insensitive searches. +- Add text version to emails plausible/analytics#4674 +- Add acquisition channels report +- Add filter `is not` for goals in dashboard plausible/analytics#4983 +- Add Segments feature +- Support `["is", "segment", []]` filter in Stats API +- Time on page metric is now sortable in reports +- Plausible tracker script now reports maximum scroll depth reached and time engaged with the site in an `engagement` event. These are reported as `sd` and `e` integer parameters to /api/event endpoint respectively. If you're using a custom proxy for plausible script, please ensure that these parameters are being passed forward. +- Plausible tracker script now reports the version of the script in the `v` parameter sent with each request. +- Add support for creating and managing teams owning multiple sites +- Introduce "billing" team role for users +- Introduce "editor" role with permissions greater than "viewer" but lesser than "admin" +- Support behavioral filters `has_done` and `has_not_done` on the Stats API to allow filtering sessions by other events that have been completed. +- `time_on_page` metric is now graphable, sortable on the dashboard, and available in the Stats API and CSV and GA4 exports/imports + +### Removed + +- Internal stats API routes no longer support legacy dashboard filter format. +- Dashboard no longer shows "Unique visitors" in top stats when filtering by a goal which used to count all users including ones who didn't complete the goal. "Unique conversions" shows the number of unique visitors who completed the goal. + +### Changed + +- Default period for brand new sites is now `today` rather than `last 28 days`. On the next day, the default changes to `last 28 days`. +- Increase decimal precision of the "Exit rate" metric from 0 to 1 (e.g. 67 -> 66.7) +- Increase decimal precision of the "Conversion rate" metric from 1 to 2 (e.g. 16.7 -> 16.67) +- The "Last 30 days" period is now "Last 28 days" on the dashboard and also the new default. Keyboard shortcut `T` still works for last 30 days. +- Last `7d` and `30d` periods do not include today anymore +- Filters appear in the search bar as ?f=is,page,/docs,/blog&f=... instead of ?filters=((is,page,(/docs,/blog)),...) for Plausible links sent on various platforms to work reliably. +- Details modal search inputs are now case-insensitive. +- Improved report performance in cases where site has a lot of unique pathnames +- Plausible script now uses `fetch` with keepalive flag as default over `XMLHttpRequest`. This will ensure more reliable tracking. Reminder to use `compat` script variant if tracking Internet Explorer is required. +- The old `/api/health` healtcheck is soft-deprecated in favour of separate `/api/system/health/live` and `/api/system/health/ready` checks +- Changed top bar filter menu and how applied filters wrap +- Main graph now shows revenue with relevant currency symbol when hovering a data point +- Main graph now shows `-` instead of `0` for visit duration, scroll depth when hovering a data point with no visit data +- Make Stats and Sites API keys scoped to teams they are created in +- Remove permissions to manage sites guests and run destructive actions from team editor and guest editor roles in favour of team admin role +- Time-on-page metric has been reworked. It now uses `engagement` events sent by plausible tracker script. We still use the old calculation methods for periods before the self-hosted instance was upgraded. Warnings are shown in the dashboard and API when legacy calculation methods are used. +- Always set site and team member limits to unlimited for Community Edition +- Stats API now supports more `date_range` shorthand options like `30d`, `3mo`. +- Stop showing Plausible footer when viewing stats, except when viewing a public dashboard or unembedded shared link dashboard. +- Changed Plugins API Token creation flow to only display token once it's created. + +### Fixed + +- Fix fetching favicons from DuckDuckGo when the domain includes a pathname +- Fix `visitors.csv` (in dashboard CSV export) vs dashboard main graph reporting different results for `visitors` and `visits` with a `time:minute` interval. +- The tracker script now sends pageviews when a page gets loaded from bfcache +- Fix returning filter suggestions for multiple custom property values in the dashboard Filter modal +- Fix typo on login screen +- Fix Direct / None details modal not opening +- Fix year over year comparisons being offset by a day for leap years +- Breakdown modals now display correct comparison values instead of 0 after pagination +- Fix database mismatch between event and session user_ids after rotating salts +- `/api/v2/query` no longer returns a 500 when querying percentage metric without `visitors` +- Fix current visitors loading when viewing a dashboard with a shared link +- Fix Conversion Rate graph being unselectable when "Goal is ..." filter is within a segment +- Fix Channels filter input appearing when clicking Sources in filter menu or clicking an applied "Channel is..." filter +- Fix Conversion Rate metrics column disappearing from reports when "Goal is ..." filter is within a segment +- Graph tooltip now shows year when graph has data from multiple years + +## v2.1.5 - 2025-02-03 + +### Added + +- Add text version to emails https://github.com/plausible/analytics/pull/4674 +- Add error logging when email delivery fails https://github.com/plausible/analytics/pull/4885 + +### Removed + +- Remove Plausible Cloud contacts https://github.com/plausible/analytics/pull/4766 +- Remove trial mentions https://github.com/plausible/analytics/pull/4668 +- Remove billings and upgrade tabs from settings https://github.com/plausible/analytics/pull/4897 + +## v2.1.4 - 2024-10-08 + +### Added + +- Add ability to review and revoke particular logged in user sessions +- Add ability to change password from user settings screen +- Add error logs for background jobs plausible/analytics#4657 + +### Changed + +- Revised User Settings UI +- Default to `invite_only` for registration plausible/analytics#4616 + +### Fixed + +- Fix cross-device file move in CSV exports/imports plausible/analytics#4640 + +## v2.1.3 - 2024-09-26 + +### Fixed + +- Change cookie key to resolve login issue plausible/analytics#4621 +- Set secure attribute on cookies when BASE_URL has HTTPS scheme plausible/analytics#4623 +- Don't track custom events in CE plausible/analytics#4627 + +## v2.1.2 - 2024-09-24 + +### Added + +- UI to edit goals along with display names +- Support contains filter for goals +- UI to edit funnels +- Add Details views for browsers, browser versions, os-s, os versions, and screen sizes reports +- Add a search functionality in all Details views +- Icons for browsers plausible/analytics#4239 +- Automatic custom property selection in the dashboard Properties report +- Add `contains_not` filter support to dashboard +- Traffic drop notifications plausible/analytics#4300 +- Add search and pagination functionality into Google Keywords > Details modal +- ClickHouse system.query_log table log_comment column now contains information about source of queries. Useful for debugging +- New /debug/clickhouse route for super admins which shows information on clickhouse queries executed by user +- Typescript support for `/assets` +- Testing framework for `/assets` +- Automatic HTTPS plausible/analytics#4491 +- Make details views on dashboard sortable + +### Removed + +- Deprecate `ECTO_IPV6` and `ECTO_CH_IPV6` env vars in CE plausible/analytics#4245 +- Remove support for importing data from no longer available Universal Analytics +- Soft-deprecate `DATABASE_SOCKET_DIR` plausible/analytics#4202 + +### Changed + +- Support Unix sockets in `DATABASE_URL` plausible/analytics#4202 +- Realtime and hourly graphs now show visits lasting their whole duration instead when specific events occur +- Increase hourly request limit for API keys in CE from 600 to 1000000 (practically removing the limit) plausible/analytics#4200 +- Make TCP connections try IPv6 first with IPv4 fallback in CE plausible/analytics#4245 +- `is` and `is not` filters in dashboard no longer support wildcards. Use contains/does not contain filter instead. +- `bounce_rate` metric now returns 0 instead of null for event:page breakdown when page has never been entry page. +- Make `TOTP_VAULT_KEY` optional plausible/analytics#4317 +- Sources like 'google' and 'facebook' are now stored in capitalized forms ('Google', 'Facebook') plausible/analytics#4417 +- `DATABASE_CACERTFILE` now forces TLS for PostgreSQL connections, so you don't need to add `?ssl=true` in `DATABASE_URL` +- Change auth session cookies to token-based ones with server-side expiration management. +- Improve Google error messages in CE plausible/analytics#4485 +- Better compress static assets in CE plausible/analytics#4476 +- Return domain-less cookies in CE plausible/analytics#4482 +- Internal stats API routes now return a JSON error over HTML in case of invalid access. + +### Fixed + +- Fix access to Stats API feature in CE plausible/analytics#4244 +- Fix filter suggestions when same filter previously applied +- Fix MX lookup when using relays with Bamboo.Mua plausible/analytics#4350 +- Don't include imports when showing time series hourly interval. Previously imported data was shown each midnight +- Fix property filter suggestions 500 error when property hasn't been selected +- Bamboo.Mua: add Date and Message-ID headers if missing plausible/analytics#4474 +- Fix migration order across `plausible_db` and `plausible_events_db` databases plausible/analytics#4466 +- Fix tooltips for countries/cities/regions links in dashboard + +## v2.1.1 - 2024-06-06 + +### Added + +- Snippet integration verification +- Limited filtering support for imported data in the dashboard and via Stats API +- Automatic sites.imported_data -> site_imports data migration in CE plausible/analytics#4155 + +### Fixed + +- Fix CSV import by adding a newline to the INSERT statement plausible/analytics#4172 +- Fix url parameters escaping of = sign plausible/analytics#4185 +- Fix redirect after registration in CE plausible/analytics#4165 +- Fix VersionedSessions migration in ClickHouse v24 plausible/analytics#4162 + +## v2.1.0 - 2024-05-23 ### Added + +- Hostname Allow List in Site Settings +- Pages Block List in Site Settings +- Add `conversion_rate` to Stats API Timeseries and on the main graph +- Add `total_conversions` and `conversion_rate` to `visitors.csv` in a goal-filtered CSV export +- Ability to display total conversions (with a goal filter) on the main graph +- Add `conversion_rate` to Stats API Timeseries and on the main graph +- Add `time_on_page` metric into the Stats API +- County Block List in Site Settings +- Query the `views_per_visit` metric based on imported data as well if possible +- Group `operating_system_versions` by `operating_system` in Stats API breakdown +- Add `operating_system_versions.csv` into the CSV export +- Display `Total visitors`, `Conversions`, and `CR` in the "Details" views of Countries, Regions and Cities (when filtering by a goal) +- Add `conversion_rate` to Regions and Cities reports (when filtering by a goal) +- Add the `conversion_rate` metric to Stats API Breakdown and Aggregate endpoints +- IP Block List in Site Settings +- Allow filtering with `contains`/`matches` operator for Sources, Browsers and Operating Systems. +- Allow filtering by multiple custom properties +- Wildcard and member filtering on the Stats API `event:goal` property +- Allow filtering with `contains`/`matches` operator for custom properties +- Add `referrers.csv` to CSV export +- Add a new Properties section in the dashboard to break down by custom properties +- Add `custom_props.csv` to CSV export (almost the same as the old `prop_breakdown.csv`, but has different column headers, and includes props for pageviews too, not only custom events) +- Add `referrers.csv` to CSV export +- Improve password validation in registration and password reset forms +- Adds Gravatar profile image to navbar +- Enforce email reverification on update +- Add Plugins API Tokens provisioning UI +- Add searching sites by domain in /sites view +- Add last 24h plots to /sites view +- Add site pinning to /sites view +- Add support for JSON logger, via LOG_FORMAT=json environment variable +- Add support for 2FA authentication +- Add 'browser_versions.csv' to CSV export +- Add `CLICKHOUSE_MAX_BUFFER_SIZE_BYTES` env var which defaults to `100000` (100KB) +- Add alternative SMTP adapter plausible/analytics#3654 +- Add `EXTRA_CONFIG_PATH` env var to specify extra Elixir config plausible/analytics#3906 +- Add restrictive `robots.txt` for self-hosted plausible/analytics#3905 +- Add Yesterday as an time range option in the dashboard +- Add dmg extension to the list of default tracked file downloads +- Add support for importing Google Analytics 4 data +- Import custom events from Google Analytics 4 +- Ability to filter Search Console keywords by page, country and device plausible/analytics#4077 +- Add `DATA_DIR` env var for exports/imports plausible/analytics#4100 +- Add custom events support to CSV export and import + +### Removed + +- Removed the nested custom event property breakdown UI when filtering by a goal in Goal Conversions +- Removed the `prop_names` returned in the Stats API `event:goal` breakdown response +- Removed the `prop-breakdown.csv` file from CSV export +- Deprecated `CLICKHOUSE_MAX_BUFFER_SIZE` +- Removed `/app/init-admin.sh` that was deprecated in v2.0.0 plausible/analytics#3903 +- Remove `DISABLE_AUTH` deprecation warning plausible/analytics#3904 + +### Changed + +- A visits `entry_page` and `exit_page` is only set and updated for pageviews, not custom events +- Limit the number of Goal Conversions shown on the dashboard and render a "Details" link when there are more entries to show +- Show Outbound Links / File Downloads / 404 Pages / Cloaked Links instead of Goal Conversions when filtering by the corresponding goal +- Require custom properties to be explicitly added from Site Settings > Custom Properties in order for them to show up on the dashboard +- GA/SC sections moved to new settings: Integrations +- Replace `CLICKHOUSE_MAX_BUFFER_SIZE` with `CLICKHOUSE_MAX_BUFFER_SIZE_BYTES` +- Validate metric isn't queried multiple times +- Filters in dashboard are represented by jsonurl +- `MAILER_EMAIL` now defaults to an address built off of `BASE_URL` plausible/analytics#4538 +- default `MAILER_ADAPTER` has been changed to `Bamboo.Mua` plausible/analytics#4538 + +### Fixed + +- Creating many sites no longer leads to cookie overflow +- Ignore sessions without pageviews for `entry_page` and `exit_page` breakdowns +- Using `VersionedCollapsingMergeTree` to store visit data to avoid rare race conditions that led to wrong visit data being shown +- Fix `conversion_rate` metric in a `browser_versions` breakdown +- Calculate `conversion_rate` percentage change in the same way like `bounce_rate` (subtraction instead of division) +- Calculate `bounce_rate` percentage change in the Stats API in the same way as it's done in the dashboard +- Stop returning custom events in goal breakdown with a pageview goal filter and vice versa +- Only return `(none)` values in custom property breakdown for the first page (pagination) of results +- Fixed weekly/monthly e-mail report [rendering issues](https://github.com/plausible/analytics/issues/284) +- Fix [broken interval selection](https://github.com/plausible/analytics/issues/2982) in the all time view plausible/analytics#3110 +- Fixed [IPv6 problems](https://github.com/plausible/analytics/issues/3173) in data migration plausible/analytics#3179 +- Fixed [long URLs display](https://github.com/plausible/analytics/issues/3158) in Outbound Link breakdown view +- Fixed [Sentry reports](https://github.com/plausible/analytics/discussions/3166) for ingestion requests plausible/analytics#3182 +- Fix breakdown pagination bug in the dashboard details view when filtering by goals +- Update bot detection (matomo 6.1.4, ua_inspector 3.4.0) +- Improved the Goal Settings page (search, autcompletion etc.) +- Log mailer errors plausible/analytics#3336 +- Allow custom event timeseries in stats API plausible/analytics#3505 +- Fixes for sites with UTF characters in domain plausible/analytics#3560 +- Fix crash when using special characters in filter plausible/analytics#3634 +- Fix automatic scrolling to the bottom on the dashboard if previously selected properties tab plausible/analytics#3872 +- Allow running the container with arbitrary UID plausible/analytics#2986 +- Fix `width=manual` in embedded dashboards plausible/analytics#3910 +- Fix URL escaping when pipes are used in UTM tags plausible/analytics#3930 + +## v2.0.0 - 2023-07-12 + +### Added + +- Call to action for tracking Goal Conversions and an option to hide the section from the dashboard +- Add support for `with_imported=true` in Stats API aggregate endpoint +- Ability to use '--' instead of '=' sign in the `tagged-events` classnames +- 'Last updated X seconds ago' info to 'current visitors' tooltips +- Add support for more Bamboo adapters, i.e. `Bamboo.MailgunAdapter`, `Bamboo.MandrillAdapter`, `Bamboo.SendGridAdapter` plausible/analytics#2649 +- Ability to change domain for existing site (requires numeric IDs data migration, instructions will be provided separately) UI + API (`PUT /api/v1/sites`) +- Add `LOG_FAILED_LOGIN_ATTEMPTS` environment variable to enable failed login attempts logs plausible/analytics#2936 +- Add `MAILER_NAME` environment variable support plausible/analytics#2937 +- Add `MAILGUN_BASE_URI` support for `Bamboo.MailgunAdapter` plausible/analytics#2935 +- Add a landing page for self-hosters plausible/analytics#2989 +- Allow optional IPv6 for clickhouse repo plausible/analytics#2970 + +### Fixed + +- Fix tracker bug - call callback function even when event is ignored +- Make goal-filtered CSV export return only unique_conversions timeseries in the 'visitors.csv' file +- Stop treating page filter as an entry page filter +- City report showing N/A instead of city names with imported data plausible/analytics#2675 +- Empty values for Screen Size, OS and Browser are uniformly replaced with "(not set)" +- Fix [more pageviews with session prop filter than with no filters](https://github.com/plausible/analytics/issues/1666) +- Cascade delete sent_renewal_notifications table when user is deleted plausible/analytics#2549 +- Show appropriate top-stat metric labels on the realtime dashboard when filtering by a goal +- Fix breakdown API pagination when using event metrics plausible/analytics#2562 +- Automatically update all visible dashboard reports in the realtime view +- Connect via TLS when using HTTPS scheme in ClickHouse URL plausible/analytics#2570 +- Add error message in case a transfer to an invited (but not joined) user is requested plausible/analytics#2651 +- Fix bug with [showing property breakdown with a prop filter](https://github.com/plausible/analytics/issues/1789) +- Fix bug when combining goal and prop filters plausible/analytics#2654 +- Fix broken favicons when domain includes a slash +- Fix bug when using multiple [wildcard goal filters](https://github.com/plausible/analytics/pull/3015) +- Fix a bug where realtime would fail with imported data +- Fix a bug where the country name was not shown when [filtering through the map](https://github.com/plausible/analytics/issues/3086) + +### Changed + +- Treat page filter as entry page filter for `bounce_rate` +- Reject events with long URIs and data URIs plausible/analytics#2536 +- Always show direct traffic in sources reports plausible/analytics#2531 +- Stop recording XX and T1 country codes plausible/analytics#2556 +- Device type is now determined from the User-Agent instead of window.innerWidth plausible/analytics#2711 +- Add padding by default to embedded dashboards so that shadows are not cut off plausible/analytics#2744 +- Update the User Agents database (https://github.com/matomo-org/device-detector/releases/tag/6.1.1) +- Disable registration in self-hosted setups by default plausible/analytics#3014 + +### Removed + +- Remove Firewall plug and `IP_BLOCKLIST` environment variable +- Remove the ability to collapse the main graph plausible/analytics#2627 +- Remove `custom_dimension_filter` feature flag plausible/analytics#2996 + +## v1.5.1 - 2022-12-06 + +### Fixed + +- Return empty list when breaking down by event:page without events plausible/analytics#2530 +- Fallback to empty build metadata when failing to parse $BUILD_METADATA plausible/analytics#2503 + +## v1.5.0 - 2022-12-02 + +### Added + +- Set a different interval on the top graph plausible/analytics#1574 (thanks to @Vigasaurus for this feature) +- A `tagged-events` script extension for out-of-the-box custom event tracking +- The ability to escape `|` characters with `\` in Stats API filter values +- An upper bound of 1000 to the `limit` parameter in Stats API +- The `exclusions` script extension now also takes a `data-include` attribute tag +- A `file-downloads` script extension for automatically tracking file downloads as custom events +- Integration with [Matomo's referrer spam list](https://github.com/matomo-org/referrer-spam-list/blob/master/spammers.txt) to block known spammers +- API route `PUT /api/v1/sites/goals` with form params `site_id`, `event_name` and/or `page_path`, and `goal_type` with supported types `event` and `page` +- API route `DELETE /api/v1/sites/goals/:goal_id` with form params `site_id` +- The public breakdown endpoint can be queried with the "events" metric +- Data exported via the download button will contain CSV data for all visible graps in a zip file. +- Region and city-level geolocation plausible/analytics#1449 +- The `u` option can now be used in the `manual` extension to specify a URL when triggering events. +- Delete a site and all related data through the Sites API +- Subscribed users can see their Paddle invoices from the last 12 months under the user settings +- Allow custom styles to be passed to embedded iframe plausible/analytics#1522 +- New UTM Tags `utm_content` and `utm_term` plausible/analytics#515 +- If a session was started without a screen_size it is updated if an event with screen_size occurs +- Added `LISTEN_IP` configuration parameter plausible/analytics#1189 +- The breakdown endpoint with the property query `property=event:goal` returns custom goal properties (within `props`) +- Added IPv6 Ecto support (via the environment-variable `ECTO_IPV6`) +- New filter type: `contains`, available for `page`, `entry_page`, `exit_page` +- Add filter for custom property +- Add ability to import historical data from GA: plausible/analytics#1753 +- API route `GET /api/v1/sites/:site_id` +- Hovering on top of list items will now show a [tooltip with the exact number instead of a shortened version](https://github.com/plausible/analytics/discussions/1968) +- Filter goals in realtime filter by clicking goal name +- The time format (12 hour or 24 hour) for graph timelines is now presented based on the browser's defined language +- Choice of metric for main-graph both in UI and API (visitors, pageviews, bounce_rate, visit_duration) plausible/analytics#1364 +- New width=manual mode for embedded dashboards plausible/analytics#2148 +- Add more timezone options +- Add new strategy to recommend timezone when creating a new site +- Alert outgrown enterprise users of their usage plausible/analytics#2197 +- Manually lock and unlock enterprise users plausible/analytics#2197 +- ARM64 support for docker images plausible/analytics#2103 +- Add support for international domain names (IDNs) plausible/analytics#2034 +- Allow self-hosters to register an account on first launch +- Fix ownership transfer invitation link in self-hosted deployments + +### Fixed + +- Plausible script does not prevent default if it's been prevented by an external script [plausible/analytics#1941](https://github.com/plausible/analytics/issues/1941) +- Hash part of the URL can now be used when excluding pages with `script.exclusions.hash.js`. +- UI fix where multi-line text in pills would not be underlined properly on small screens. +- UI fix to align footer columns +- Guests can now use the favicon to toggle additional info about the site bing viewed (such as in public embeds). +- Fix SecurityError in tracking script when user has blocked all local storage +- Prevent dashboard graph from being selected when long pressing on the graph in a mobile browser +- The exported `pages.csv` file now includes pageviews again [plausible/analytics#1878](https://github.com/plausible/analytics/issues/1878) +- Fix a bug where city, region and country filters were filtering stats but not the location list +- Fix a bug where regions were not being saved +- Timezone offset labels now update with time changes +- Render 404 if shared link auth cannot be verified [plausible/analytics#2225](https://github.com/plausible/analytics/pull/2225) +- Restore compatibility with older format of shared links [plausible/analytics#2225](https://github.com/plausible/analytics/pull/2225) +- Fix 'All time' period for sites with no recorded stats [plausible/analytics#2277](https://github.com/plausible/analytics/pull/2277) +- Ensure settings page can be rendered after a form error [plausible/analytics#2278](https://github.com/plausible/analytics/pull/2278) +- Ensure newlines from settings files are trimmed [plausible/analytics#2480](https://github.com/plausible/analytics/pull/2480) + +### Changed + +- `script.file-downloads.outbound-links.js` only sends an outbound link event when an outbound download link is clicked +- Plausible script now uses callback navigation (instead of waiting for 150ms every time) when sending custom events +- Cache the tracking script for 24 hours +- Move `entry_page` and `exit_page` to be part of the `Page` filter group +- Paginate /api/sites results and add a `View all` link to the site-switcher dropdown in the dashboard. +- Remove the `+ Add Site` link to the site-switcher dropdown in the dashboard. +- `DISABLE_REGISTRATIONS` configuration parameter can now accept `invite_only` to allow invited users to register an account while keeping regular registrations disabled plausible/analytics#1841 +- New and improved Session tracking module for higher throughput and lower latency. [PR#1934](https://github.com/plausible/analytics#1934) +- Do not display ZZ country code in countries report [PR#1934](https://github.com/plausible/analytics#2223) +- Add fallback icon for when DDG favicon cannot be fetched [PR#2279](https://github.com/plausible/analytics#2279) + +### Security + +- Add Content-Security-Policy header to favicon path + +## v1.4.1 - 2021-11-29 + +### Fixed + +- Fixes database error when pathname contains a question mark + +## v1.4.0 - 2021-10-27 + +### Added + +- New parameter `metrics` for the `/api/v1/stats/timeseries` endpoint plausible/analytics#952 +- CSV export now includes pageviews, bounce rate and visit duration in addition to visitors plausible/analytics#952 +- Send stats to multiple dashboards by configuring a comma-separated list of domains plausible/analytics#968 +- To authenticate against a local postgresql via socket authentication, the environment-variables + `DATABASE_SOCKET_DIR` & `DATABASE_NAME` were added. +- Time on Page metric available in detailed Top Pages report plausible/analytics#1007 +- Wildcard based page, entry page and exit page filters plausible/analytics#1067 +- Exclusion filters for page, entry page and exit page filters plausible/analytics#1067 +- Menu (with auto-complete) to add new and edit existing filters directly plausible/analytics#1089 +- Added `CLICKHOUSE_FLUSH_INTERVAL_MS` and `CLICKHOUSE_MAX_BUFFER_SIZE` configuration parameters plausible/analytics#1073 +- Ability to invite users to sites with different roles plausible/analytics#1122 +- Option to configure a custom name for the script file +- Add Conversion Rate to Top Sources, Top Pages Devices, Countries when filtered by a goal plausible/analytics#1299 +- Add list view for countries report in dashboard plausible/analytics#1381 +- Add ability to view more than 100 custom goal properties plausible/analytics#1382 + +### Fixed + +- Fix weekly report time range plausible/analytics#951 +- Make sure embedded dashboards can run when user has blocked third-party cookies plausible/analytics#971 +- Sites listing page will paginate if the user has a lot of sites plausible/analytics#994 +- Crash when changing theme on a loaded dashboard plausible/analytics#1123 +- UI fix for details button overlapping content on mobile plausible/analytics#1114 +- UI fix for the main graph on mobile overlapping its tick items on both axis +- UI fixes for text not showing properly in bars across multiple lines. This hides the totals on <768px and only shows the uniques and % to accommodate the goals text too. Larger screens still truncate as usual. +- Turn off autocomplete for name and password inputs in the _New shared link_ form. +- Details modals are now responsive and take up less horizontal space on smaller screens to make it easier to scroll. +- Fix reading config from file +- Fix some links not opening correctly in new tab +- UI fix for more than one row of custom event properties plausible/analytics#1383 +- UI fix for user menu and time picker overlapping plausible/analytics#1352 +- Respect the `path` component of BASE_URL to allow subfolder installatons + +### Removed + +- Removes AppSignal monitoring package + +### Changes + +- Disable email verification by default. Added a configuration option `ENABLE_EMAIL_VERIFICATION=true` if you want to keep the old behaviour + +## [1.3] - 2021-04-14 + +### Added + +- Stats API [currently in beta] plausible/analytics#679 +- Ability to view and filter by entry and exit pages, in addition to regular page hits plausible/analytics#712 +- 30 day and 6 month keybindings (`T` and `S`, respectively) plausible/analytics#709 +- Site switching keybinds (1-9 for respective sites) plausible/analytics#735 +- Glob (wildcard) based pageview goals plausible/analytics#750 +- Support for embedding shared links in an iframe plausible/analytics#812 +- Include a basic IP-To-Country database by default plausible/analytics#906 +- Add name/label to shared links plausible/analytics#910 + +### Fixed + +- Capitalized date/time selection keybinds not working plausible/analytics#709 +- Invisible text on Google Search Console settings page in dark mode plausible/analytics#759 +- Disable analytics tracking when running Cypress tests +- CSV reports can be downloaded via shared links plausible/analytics#884 +- Fixes weekly/monthly email report delivery over SMTP plausible/analytics#889 +- Disable self-tracking with self hosting plausible/analytics#907 +- Fix current visitors request when using shared links + +## [1.2] - 2021-01-26 + +### Added + - Ability to add event metadata plausible/analytics#381 -- Add tracker module to automatically track outbound links plausible/analytics#389 +- Add tracker module to automatically track outbound links plausible/analytics#389 - Display weekday on the visitor graph plausible/analytics#175 - Collect and display browser & OS versions plausible/analytics#397 - Simple notifications around traffic spikes plausible/analytics#453 - Dark theme option/system setting follow plausible/analytics#467 - "Load More" capability to pages modal plausible/analytics#480 +- Unique Visitors (last 30 min) as a top stat in realtime view plausible/analytics#500 +- Pinned filter and date selector rows while scrolling plausible/analytics#472 +- Escape keyboard shortcut to clear all filters plausible/analytics#625 +- Tracking exclusions, see our documentation [here](https://docs.plausible.io/excluding) and [here](https://docs.plausible.io/excluding-pages) for details plausible/analytics#489 +- Keybindings for selecting dates/ranges plausible/analytics#630 ### Changed + - Use alpine as base image to decrease Docker image size plausible/analytics#353 - Ignore automated browsers (Phantom, Selenium, Headless Chrome, etc) - Display domain's favicon on the home page @@ -21,8 +589,15 @@ All notable changes to this project will be documented in this file. - Improve settings UX and design plausible/analytics#412 - Improve site listing UX and design plausible/analytics#438 - Improve onboarding UX and design plausible/analytics#441 +- Allows outbound link tracking script to use new tab redirection plausible/analytics#494 +- "This Month" view is now Month-to-date for the current month plausible/analytics#491 +- My sites now show settings cog at all times on smaller screens plausible/analytics#497 +- Background jobs are enabled by default for self-hosted installations plausible/analytics#603 +- All new users on self-hosted installations have a never-ending trial plausible/analytics#603 +- Changed caret/chevron color in datepicker and filters dropdown ### Fixed + - Do not error when activating an already activated account plausible/analytics#370 - Ignore arrow keys when modifier keys are pressed plausible/analytics#363 - Show correct stats when goal filter is combined with source plausible/analytics#374 @@ -30,22 +605,30 @@ All notable changes to this project will be documented in this file. - Fix URL decoding in query parameters plausible/analytics#416 - Fix overly-sticky date in query parameters plausible/analytics/#439 - Prevent picking dates before site insertion plausible/analtics#446 +- Fix overly-sticky from and to in query parameters plausible/analytics#495 +- Adds support for single-day date selection plausible/analytics#495 +- Goal conversion rate in realtime view is now accurate plausible/analytics#500 +- Various UI/UX issues plausible/analytics#503 ### Security + - Do not run the plausible Docker container as root plausible/analytics#362 ## [1.1.1] - 2020-10-14 ### Fixed + - Revert Dockerfile change that introduced a regression ## [1.1.0] - 2020-10-14 ### Added + - Linkify top pages [plausible/analytics#91](https://github.com/plausible/analytics/issues/91) -- Filter by country, screen size, browser and operating system [plausible/analytics#303](https://github.com/plausible/analytics/issues/303) +- Filter by country, screen size, browser and operating system [plausible/analytics#303](https://github.com/plausible/analytics/issues/303) ### Fixed + - Fix issue with creating a PostgreSQL database when `?ssl=true` [plausible/analytics#347](https://github.com/plausible/analytics/issues/347) - Do no disclose current URL to DuckDuckGo's favicon service [plausible/analytics#343](https://github.com/plausible/analytics/issues/343) - Updated UAInspector database to detect newer devices [plausible/analytics#309](https://github.com/plausible/analytics/issues/309) @@ -53,9 +636,11 @@ All notable changes to this project will be documented in this file. ## [1.0.0] - 2020-10-06 ### Added + - Collect and present link tags (`utm_medium`, `utm_source`, `utm_campaign`) in the dashboard ### Changed + - Replace configuration parameters `CLICKHOUSE_DATABASE_{HOST,NAME,USER,PASSWORD}` with a single `CLICKHOUSE_DATABASE_URL` [plausible/analytics#317](https://github.com/plausible/analytics/pull/317) - Disable subscriptions by default - Remove `CLICKHOUSE_DATABASE_POOLSIZE`, `DATABASE_POOLSIZE` and `DATABASE_TLS_ENABLED` parameters. Use query parameters in `CLICKHOUSE_DATABASE_URL` and `DATABASE_URL` instead. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000000..e7115ddf7ab8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,65 @@ +# Code of Conduct + +Plausible, both the open-source project and the company that develops it, pledges to be a community with a healthy and friendly environment. What follows is a set of guidelines on how we get there. + + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Gracefully accepting constructive criticism and direct feedback, as well as offering it in that spirit. +* Assuming good intentions. Approaching work relationships with a default of trust and positivity. + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Any form of discrimination and harassment. Particularly on the basis of race, color, religion, sex, sexual orientation, gender identity or expression, age, disability, marital status, citizenship, national origin, genetic information, or any other characteristic protected by law. +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +The community leaders at this time are: +* Marko Saric (marko@plausible.io) +* Uku Taht (uku@plausible.io) + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement. + +All complaints will be reviewed and investigated promptly and fairly. Depending on the severity of the offense and the findings of the investigation, the response may include private warning, a ban from the community, or even termination for contractors and employees. The reactive measures taken will be shared with the reporting member of the community when possible. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant code of conduct](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). + +Some inspiration was also taken from [Basecamp's code of conduct](https://basecamp.com/handbook/appendix-07-code-of-conduct) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f96b4fd6c75..255e08a7e5d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,25 +1,63 @@ # Contributing +We welcome everyone to contribute to Plausible. This document is to help you on setting up your environment, finding a task, and opening pull requests. + ## Development setup The easiest way to get up and running is to [install](https://docs.docker.com/get-docker/) and use Docker for running both Postgres and Clickhouse. -Make sure Docker, Elixir, Erlang and Node.js are all installed on your development machine. +Make sure Docker, Elixir, Erlang and Node.js are all installed on your development machine. The [`.tool-versions`](https://github.com/plausible/analytics/blob/master/.tool-versions) file is available to use with [asdf](https://github.com/asdf-vm/asdf) or similar tools. -### Start the environment: +### Start the environment 1. Run both `make postgres` and `make clickhouse`. -2. Run `mix deps.get`. This will download the required Elixir dependencies. -2. Run `mix ecto.create`. This will create the required databases in both Postgres and Clickhouse. -3. Run `mix ecto.migrate` to build the database schema. -4. Run `npm ci --prefix assets` to install the required node dependencies. -5. Run `mix phx.server` to start the Phoenix server. -6. The system is now available on `localhost:8000`. +2. You can set up everything with `make install`, alternatively run each command separately: + 1. Run `mix deps.get`. This will download the required Elixir dependencies. + 2. Run `mix ecto.create`. This will create the required databases in both Postgres and Clickhouse. + 3. Run `mix ecto.migrate` to build the database schema. + 4. Run `mix run priv/repo/seeds.exs` to seed the database. Check the [Seeds](#Seeds) section for more. + 5. Run `npm ci --prefix assets` to install the required client-side dependencies. + 6. Run `npm ci --prefix tracker` to install the required tracker dependencies. + 7. Run `mix assets.setup` to install Tailwind and Esbuild + 8. Run `npm run deploy --prefix tracker` to generate tracker files in `priv/tracker/js` + 9. Run `mix download_country_database` to fetch geolocation database +3. Run `make server` or `mix phx.server` to start the Phoenix server. +4. The system is now available on `localhost:8000`. + +### Seeds + +You can optionally seed your database to automatically create an account and a site with stats: + +1. Run `mix run priv/repo/seeds.exs` to seed the database. +2. Start the server with `make server` and navigate to `http://localhost:8000/login`. +3. Log in with the following e-mail and password combination: `user@plausible.test` and `plausible`. +4. You should now have a `dummy.site` site with generated stats. -### Creating an account +Alternatively, you can manually create a new account: 1. Navigate to `http://localhost:8000/register` and fill in the form. -2. An e-mail won't actually be sent, but you can find the activation in the Phoenix logs in your terminal. Search for `%Bamboo.Email{assigns: %{link: "` and open the link listed. -3. Fill in the rest of the forms and for the domain use `dummy.site` -4. Run `make dummy_event` from the terminal to generate a fake pageview event for the dummy site. +2. Fill in the rest of the forms and for the domain use `dummy.site` +3. Skip the JS snippet and click start collecting data. +4. Run `mix send_pageview` from the terminal to generate a fake pageview event for the dummy site. 5. You should now be all set! + +### Stopping Docker containers + +1. Stop and remove the Postgres container with `make postgres-stop`. +2. Stop and remove the Clickhouse container with `make clickhouse-stop`. + +Volumes are preserved. You'll find that the Postgres and Clickhouse state are retained when you bring them up again the next time: no need to re-register and so on. + +Note: Since we are deleting the containers, be careful when deleting volumes with `docker volume prune`. You might accidentally delete the database and would have to go through re-registration process. + +### Pre-commit hooks + +`pre-commit` requires Python to be available locally and covers Elixir, JavaScript, and CSS. Set up with `pip install --user pre-commit` followed by `pre-commit install`. Conversely, if the prompts are far too bothersome, remove with `pre-commit uninstall`. + +## Finding a task + +Bugs can be found in our [issue tracker](https://github.com/plausible/analytics/issues). Issues are usually up for grabs. + +New features need to be discussed with the core team and the community first. If you're tackling a feature, please make sure it has been already discussed in the [Discussions tab](https://github.com/plausible/analytics/discussions). We kindly ask contributors to use the discussion comment section to propose a solution before opening a pull request. + +Pull requests without an associated issue or discussion may still be merged, but we will focus on changes that have already been talked through. diff --git a/Dockerfile b/Dockerfile index 3e9bfb7a2625..b70727b14e83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,66 +1,89 @@ # we can not use the pre-built tar because the distribution is # platform specific, it makes sense to build it in the docker +ARG ALPINE_VERSION=3.22.2 + #### Builder -FROM elixir:1.10.4-alpine as buildcontainer +FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-${ALPINE_VERSION} AS buildcontainer + +ARG MIX_ENV=ce # preparation -ARG APP_VER=0.0.1 -ENV MIX_ENV=prod +ENV MIX_ENV=$MIX_ENV ENV NODE_ENV=production -ENV APP_VERSION=$APP_VER +ENV NODE_OPTIONS=--openssl-legacy-provider + +# custom ERL_FLAGS are passed for (public) multi-platform builds +# to fix qemu segfault, more info: https://github.com/erlang/otp/pull/6340 +ARG ERL_FLAGS +ENV ERL_FLAGS=$ERL_FLAGS RUN mkdir /app WORKDIR /app # install build dependencies -RUN apk add --no-cache git nodejs yarn python npm ca-certificates wget gnupg make erlang gcc libc-dev && \ - npm install npm@latest -g && \ - npm install -g webpack +RUN apk add --no-cache git "nodejs-current=23.11.1-r0" yarn npm python3 ca-certificates wget gnupg make gcc libc-dev brotli COPY mix.exs ./ COPY mix.lock ./ +COPY config ./config RUN mix local.hex --force && \ - mix local.rebar --force && \ - mix deps.get --only prod && \ - mix deps.compile + mix local.rebar --force && \ + mix deps.get --only ${MIX_ENV} && \ + mix deps.compile COPY assets/package.json assets/package-lock.json ./assets/ COPY tracker/package.json tracker/package-lock.json ./tracker/ -RUN npm audit fix --prefix ./assets && \ - npm install --prefix ./assets && \ - npm install --prefix ./tracker +RUN npm install --prefix ./assets && \ + npm install --prefix ./tracker COPY assets ./assets COPY tracker ./tracker -COPY config ./config COPY priv ./priv COPY lib ./lib +COPY extra ./extra +COPY storybook ./storybook -RUN npm run deploy --prefix ./assets && \ - npm run deploy --prefix ./tracker && \ - mix phx.digest priv/static +RUN npm run deploy --prefix ./tracker && \ + mix assets.deploy && \ + mix phx.digest priv/static && \ + mix download_country_database && \ + mix sentry.package_source_code WORKDIR /app COPY rel rel RUN mix release plausible # Main Docker Image -FROM alpine:3.11 -LABEL maintainer="tckb " +FROM alpine:${ALPINE_VERSION} +LABEL maintainer="plausible.io " + +ARG BUILD_METADATA={} +ENV BUILD_METADATA=$BUILD_METADATA ENV LANG=C.UTF-8 +ARG MIX_ENV=ce +ENV MIX_ENV=$MIX_ENV -RUN apk add --no-cache openssl ncurses +RUN adduser -S -H -u 999 -G nogroup plausible -COPY .gitlab/build-scripts/docker-entrypoint.sh /entrypoint.sh +RUN apk upgrade --no-cache +RUN apk add --no-cache openssl ncurses libstdc++ libgcc ca-certificates \ + && if [ "$MIX_ENV" = "ce" ]; then apk add --no-cache certbot; fi -RUN chmod a+x /entrypoint.sh && \ - adduser -h /app -u 1000 -s /bin/sh -D plausibleuser +COPY --from=buildcontainer --chmod=555 /app/_build/${MIX_ENV}/rel/plausible /app +COPY --chmod=755 ./rel/docker-entrypoint.sh /entrypoint.sh -COPY --from=buildcontainer /app/_build/prod/rel/plausible /app -RUN chown -R plausibleuser:plausibleuser /app -USER plausibleuser +# we need to allow "others" access to app folder, because +# docker container can be started with arbitrary uid +RUN mkdir -p /var/lib/plausible && chmod ugo+rw -R /var/lib/plausible + +USER 999 WORKDIR /app +ENV LISTEN_IP=0.0.0.0 ENTRYPOINT ["/entrypoint.sh"] +EXPOSE 8000 +ENV DEFAULT_DATA_DIR=/var/lib/plausible +VOLUME /var/lib/plausible CMD ["run"] + diff --git a/Makefile b/Makefile index ff3bcb13e908..a178e4d959a7 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,111 @@ -clickhouse: - docker run --detach -p 8123:8123 --ulimit nofile=262144:262144 --volume=$$PWD/.clickhouse_db_vol:/var/lib/clickhouse yandex/clickhouse-server - -postgres: - docker run --detach -e POSTGRES_PASSWORD="postgres" -p 5432:5432 postgres - -dummy_event: - curl 'http://localhost:8000/api/event' \ - -H 'authority: localhost:8000' \ - -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36 OPR/71.0.3770.284' \ - -H 'content-type: text/plain' \ - -H 'accept: */*' \ - -H 'origin: http://dummy.site' \ - -H 'sec-fetch-site: cross-site' \ - -H 'sec-fetch-mode: cors' \ - -H 'sec-fetch-dest: empty' \ - -H 'referer: http://dummy.site' \ - -H 'accept-language: en-US,en;q=0.9' \ - --data-binary '{"n":"pageview","u":"http://dummy.site","d":"dummy.site","r":null,"w":1666}' \ - --compressed +.PHONY: help install server clickhouse clickhouse-prod clickhouse-stop postgres postgres-client postgres-prod postgres-stop + +require = \ + $(foreach 1,$1,$(__require)) +__require = \ + $(if $(value $1),, \ + $(error Provide required parameter: $1$(if $(value 2), ($(strip $2))))) + +help: + @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +install: ## Run the initial setup + mix deps.get + mix ecto.create + mix ecto.migrate + mix download_country_database + npm install --prefix assets + npm install --prefix tracker + npm run deploy --prefix tracker + +server: ## Start the web server + mix phx.server + +CH_FLAGS ?= --detach -p 8123:8123 -p 9000:9000 --ulimit nofile=262144:262144 --name plausible_clickhouse + +clickhouse: ## Start a container with a recent version of clickhouse + docker run $(CH_FLAGS) --network host --volume=$$PWD/.clickhouse_db_vol:/var/lib/clickhouse clickhouse/clickhouse-server:latest-alpine + +clickhouse-client: ## Connect to clickhouse + docker exec -it plausible_clickhouse clickhouse-client -d plausible_events_db + +clickhouse-prod: ## Start a container with the same version of clickhouse as the one in prod + docker run $(CH_FLAGS) --volume=$$PWD/.clickhouse_db_vol_prod:/var/lib/clickhouse clickhouse/clickhouse-server:24.12.2.29-alpine + +clickhouse-stop: ## Stop and remove the clickhouse container + docker stop plausible_clickhouse && docker rm plausible_clickhouse + +PG_FLAGS ?= --detach -e POSTGRES_PASSWORD="postgres" -p 5432:5432 --name plausible_db + +postgres: ## Start a container with a recent version of postgres + docker run $(PG_FLAGS) --volume=plausible_db:/var/lib/postgresql/data postgres:latest + +postgres-client: ## Connect to postgres + docker exec -it plausible_db psql -U postgres -d plausible_dev + +postgres-prod: ## Start a container with the same version of postgres as the one in prod + docker run $(PG_FLAGS) --volume=plausible_db_prod:/var/lib/postgresql/data postgres:18 + +postgres-stop: ## Stop and remove the postgres container + docker stop plausible_db && docker rm plausible_db + +browserless: + docker run -e "TOKEN=dummy_token" -p 3000:3000 --network host ghcr.io/browserless/chromium + +minio: ## Start a transient container with a recent version of minio (s3) + docker run -d --rm -p 10000:10000 -p 10001:10001 --name plausible_minio minio/minio server /data --address ":10000" --console-address ":10001" + while ! docker exec plausible_minio mc alias set local http://localhost:10000 minioadmin minioadmin; do sleep 1; done + docker exec plausible_minio sh -c 'mc mb local/dev-exports && mc ilm add --expiry-days 7 local/dev-exports' + docker exec plausible_minio sh -c 'mc mb local/dev-imports && mc ilm add --expiry-days 7 local/dev-imports' + docker exec plausible_minio sh -c 'mc mb local/test-exports && mc ilm add --expiry-days 7 local/test-exports' + docker exec plausible_minio sh -c 'mc mb local/test-imports && mc ilm add --expiry-days 7 local/test-imports' + +minio-stop: ## Stop and remove the minio container + docker stop plausible_minio + +sso: + $(call require, integration_id) + @echo "Setting up local IdP service..." + @docker run --name=idp \ + -p 8080:8080 \ + -e SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:8000/sso/$(integration_id) \ + -e SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:8000/sso/saml/consume/$(integration_id) \ + -v $$PWD/extra/fixture/authsources.php:/var/www/simplesamlphp/config/authsources.php -d kenchan0130/simplesamlphp + + @sleep 2 + + @echo "Use the following IdP configuration:" + @echo "" + @echo "Sign-in URL: http://localhost:8080/simplesaml/saml2/idp/SSOService.php" + @echo "" + @echo "Entity ID: http://localhost:8080/simplesaml/saml2/idp/metadata.php" + @echo "" + @echo "PEM Certificate:" + @curl http://localhost:8080/simplesaml/module.php/saml/idp/certs.php/idp.crt 2>/dev/null + @echo "" + @echo "" + @echo "Following accounts are configured:" + @echo "- user@plausible.test / plausible" + @echo "- user1@plausible.test / plausible" + @echo "- user2@plausible.test / plausible" + +sso-stop: + docker stop idp + docker remove idp + +generate-corefile: + $(call require, domain_id) + domain_id=$(domain_id) envsubst < $(PWD)/extra/fixture/Corefile.template > $(PWD)/extra/fixture/Corefile.gen.$(domain_id) + +mock-dns: generate-corefile + $(call require, domain_id) + docker run --rm -p 5354:53/udp -v $(PWD)/extra/fixture/Corefile.gen.$(domain_id):/Corefile coredns/coredns:latest -conf Corefile + +loadtest-server: + @echo "Ensure your OTP installation is built with --enable-lock-counter" + MIX_ENV=load ERL_FLAGS="-emu_type lcnt +Mdai max" iex -S mix do phx.digest + phx.server + +loadtest-client: + @echo "Set your limits for file descriptors/ephemeral ports high... Test begins shortly" + @sleep 5 + k6 run test/load/script.js diff --git a/README.md b/README.md index 9d239c79d87a..45d99a163fbf 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,43 @@ # Plausible Analytics -[![Build Status](https://travis-ci.org/plausible/analytics.svg?branch=master)](https://travis-ci.org/plausible/analytics) +

+ + Plausible Analytics + +

+

+ Simple Metrics | + Lightweight Script | + Privacy Focused | + Open Source | + Docs | + Contributing +

+

-[Plausible Analytics](https://plausible.io/) is a simple, lightweight (< 1 KB), open-source and privacy-friendly alternative to Google Analytics. It doesnโ€™t use cookies and is fully compliant with GDPR, CCPA and PECR. You can self-host Plausible or have us run it for you in the Cloud. Here's [the live demo of our own website stats](https://plausible.io/plausible.io). We are completely independent, self-funded and bootstrapped. Made and hosted in the EU ๐Ÿ‡ช๐Ÿ‡บ +[Plausible Analytics](https://plausible.io/) is an easy to use, lightweight, open source and privacy-friendly alternative to Google Analytics. It doesnโ€™t use cookies and is fully compliant with GDPR, CCPA and PECR. You can self-host Plausible Community Edition or have us manage Plausible Analytics for you in the cloud. Here's [the live demo of our own website stats](https://plausible.io/plausible.io). Made and hosted in the EU ๐Ÿ‡ช๐Ÿ‡บ -![](https://docs.plausible.io/img/plausible-analytics.png) +We are dedicated to making web analytics more privacy-friendly. Our mission is to reduce corporate surveillance by providing an alternative web analytics tool which doesnโ€™t come from the AdTech world. We are completely independent and solely funded by our subscribers. -### Why Plausible? +![Plausible Analytics](https://plausible.io/docs/img/plausible-analytics.png) + +## Why Plausible? + +Here's what makes Plausible a great Google Analytics alternative and why we're trusted by thousands of paying subscribers to deliver their website and business insights: - **Clutter Free**: Plausible Analytics provides [simple web analytics](https://plausible.io/simple-web-analytics) and it cuts through the noise. No layers of menus, no need for custom reports. Get all the important insights on one single page. No training necessary. -- **GDPR/CCPA/PECR compliant**: Measure traffic, not individuals. No personal data or IP addresses are ever stored in our database. We don't use cookies either. [Read more about our data policy](https://plausible.io/data-policy) -- **Lightweight**: Plausible Analytics works by loading a script on your website, like Google Analytics. Our script is [45x smaller](https://plausible.io/lightweight-web-analytics), making your website quicker to load. -- **Email reports**: Keep an eye on your traffic with weekly and/or monthly email reports. All the stats are embedded directly in the email and thereโ€™s no need to go to any website. No attachments, no PDFs and no links to click on. -- **Open website stats**: You have the option to be transparent and open your web analytics to everyone. Your website stats are private by default but you can choose to make them public so anyone with your custom link can view them. -- **Define key goals and track conversions**: Set custom events or page URLs as your goals and see how they convert over time to understand and identify the trends that matter. +- **GDPR/CCPA/PECR compliant**: Measure traffic, not individuals. No personal data or IP addresses are ever stored in our database. We don't use cookies or any other persistent identifiers. [Read more about our data policy](https://plausible.io/data-policy) +- **Lightweight**: Plausible Analytics works by loading a script on your website, like Google Analytics. Our script is [small](https://plausible.io/lightweight-web-analytics), making your website quicker to load. You can also send events directly to our [events API](https://plausible.io/docs/events-api). +- **Email or Slack reports**: Keep an eye on your traffic with weekly and/or monthly email or Slack reports. You can also get traffic spike notifications. +- **Invite team members and share stats**: You have the option to be transparent and open your web analytics to everyone. Your website stats are private by default but you can choose to make them public so anyone with your custom link can view them. You can [invite team members](https://plausible.io/docs/users-roles) and assign user roles too. +- **Define key goals and track conversions**: Create custom events with custom dimensions to track conversions and attribution to understand and identify the trends that matter. Track ecommerce revenue, outbound link clicks, form completions, file downloads and 404 error pages. Increase conversions using funnel analysis. - **Search keywords**: Integrate your dashboard with Google Search Console to get the most accurate reporting on your search keywords. -- **SPA support**: Plausible is built with modern web frameworks in mind and it works automatically with any pushState based router on the frontend. We also support frameworks that use the URL hash for routing. See [our documentation](https://docs.plausible.io/hash-based-routing). +- **SPA support**: Plausible is built with modern web frameworks in mind and it works automatically with any pushState based router on the frontend. We also support frameworks that use the URL hash for routing. See [our documentation](https://plausible.io/docs/hash-based-routing). +- **Smooth transition from Google Analytics**: There's a realtime dashboard, entry pages report and integration with Search Console. You can track your paid campaigns and conversions. You can invite team members. You can even [import your historical Google Analytics stats](https://plausible.io/docs/google-analytics-import) and there's [a Google Tag Manager template](https://plausible.io/gtm-template) too. Learn how to [get the most out of your Plausible experience](https://plausible.io/docs/your-plausible-experience) and join thousands who have already migrated from Google Analytics. -Interested to learn more? [Read more on our website](https://plausible.io), learn more about the team and the goals of the project on [our about page](https://plausible.io/about) or explore [the documentation](https://docs.plausible.io). +Interested to learn more? [Read more on our website](https://plausible.io), learn more about the team and the goals of the project on [our about page](https://plausible.io/about) or explore [the documentation](https://plausible.io/docs). -### Why is Plausible Analytics Cloud not free like Google Analytics? +## Why is Plausible Analytics Cloud not free like Google Analytics? Plausible Analytics is an independently owned and actively developed project. To keep the project development going, to stay in business, to continue putting effort into building a better product and to cover our costs, we need to charge a fee. @@ -29,26 +47,56 @@ Plausible has no part in that business model. No personal data is being collecte We choose the subscription business model rather than the business model of surveillance capitalism. See reasons why we believe you should [stop using Google Analytics on your website](https://plausible.io/blog/remove-google-analytics). -### Can Plausible Analytics be self-hosted? +## Getting started with Plausible + +The easiest way to get started with Plausible Analytics is with [our official managed service in the cloud](https://plausible.io/#pricing). It takes 2 minutes to start counting your stats with a worldwide CDN, high availability, backups, security and maintenance all done for you by us. + +In order to be compliant with the GDPR and the Schrems II ruling, all visitor data for our managed service in the cloud is exclusively processed on servers and cloud infrastructure owned and operated by European providers. Your website data never leaves the EU. + +Our managed hosting can save a substantial amount of developer time and resources. For most sites this ends up being the best value option and the revenue goes to funding the maintenance and further development of Plausible. So youโ€™ll be supporting open source software and getting a great service! + +### Can Plausible be self-hosted? -Yes, Plausible is fully [open source web analytics](https://plausible.io/open-source-website-analytics). +Plausible is [open source web analytics](https://plausible.io/open-source-website-analytics) and we have a free as in beer and self-hosted solution called [Plausible Community Edition (CE)](https://plausible.io/self-hosted-web-analytics). Here are the differences between Plausible Analytics managed hosting in the cloud and the Plausible CE: -We have a free as in beer [Plausible Analytics Self-Hosted](https://plausible.io/self-hosted-web-analytics) solution. Itโ€™s exactly the same product as our Cloud solution with a less frequent release schedule. The difference is that the self-hosted version you have to install, host and manage yourself on your own infrastructure while the Cloud version we manage everything for your ease and convenience. Take a look at our [self-hosting installation instructions](https://docs.plausible.io/self-hosting). +| | Plausible Analytics Cloud | Plausible Community Edition | +| ------------- | ------------- | ------------- | +| **Infrastructure management** | Easy and convenient. It takes 2 minutes to start counting your stats with a worldwide CDN, high availability, backups, security and maintenance all done for you by us. We manage everything so you donโ€™t have to worry about anything and can focus on your stats. | You do it all yourself. You need to get a server and you need to manage your infrastructure. You are responsible for installation, maintenance, upgrades, server capacity, uptime, backup, security, stability, consistency, loading time and so on.| +| **Release schedule** | Continuously developed and improved with new features and updates multiple times per week. | [It's a long term release](https://plausible.io/blog/building-open-source) published twice per year so latest features and improvements won't be immediately available.| +| **Premium features** | All features available as listed in [our pricing plans](https://plausible.io/#pricing). | Premium features (marketing funnels, ecommerce revenue goals, SSO and sites API) are not available in order to help support [the project's long-term sustainability](https://plausible.io/blog/community-edition).| +| **Bot filtering** | Advanced bot filtering for more accurate stats. Our algorithm detects and excludes non-human traffic patterns. We also exclude known bots by the User-Agent header and filter out traffic from data centers and referrer spam domains. We exclude ~32K data center IP ranges (i.e. a lot of bot IP addresses) by default. | Basic bot filtering that targets the most common non-human traffic based on the User-Agent header and referrer spam domains.| +| **Server location** | All visitor data is exclusively processed on EU-owned cloud infrastructure. We keep your site data on a secure, encrypted and green energy powered server in Germany. This ensures that your site data is protected by the strict European Union data privacy laws and ensures compliance with GDPR. Your website data never leaves the EU. | You have full control and can host your instance on any server in any country that you wish. Host it on a server in your basement or host it with any cloud provider wherever you want, even those that are not GDPR compliant.| +| **Data portability** | You see all your site stats and metrics on our modern-looking, simple to use and fast loading dashboard. You can only see the stats aggregated in the dashboard. You can download the stats using the [CSV export](https://plausible.io/docs/export-stats), [stats API](https://plausible.io/docs/stats-api) or the [Looker Studio Connector](https://plausible.io/docs/looker-studio). | Do you want access to the raw data? Self-hosting gives you that option. You can take the data directly from the ClickHouse database. The Looker Studio Connector is not available. | +| **Premium support** | Real support delivered by real human beings who build and maintain Plausible. | Premium support is not included. CE is community supported only.| +| **Costs** | There's a cost associated with providing an analytics service so we charge a subscription fee. We choose the subscription business model rather than the business model of surveillance capitalism. Your money funds further development of Plausible. | You need to pay for your server, CDN, backups and whatever other cost there is associated with running the infrastructure. You never have to pay any fees to us. Your money goes to 3rd party companies with no connection to us.| -Plausible Self-Hosted is a community supported project and there are no guarantees that you will get support from the creators of Plausible to troubleshoot your self-hosting issues. There is a [community supported forum](https://github.com/plausible/analytics/discussions?discussions_q=category%3A%22Self-Hosted+Support%22) where you can ask for help. And if you [become a sponsor](https://github.com/sponsors/plausible), you can [reach out to us](https://plausible.io/contact) and get equal support to all the other paying customers. +Interested in self-hosting Plausible CE on your server? Take a look at our [Plausible CE installation instructions](https://github.com/plausible/community-edition/). -### Technology +Plausible CE is a community supported project and there are no guarantees that you will get support from the creators of Plausible to troubleshoot your self-hosting issues. There is a [community supported forum](https://github.com/plausible/analytics/discussions/categories/self-hosted-support) where you can ask for help. + +Our only source of funding is our premium, managed service for running Plausible in the cloud. + +## Technology Plausible Analytics is a standard Elixir/Phoenix application backed by a PostgreSQL database for general data and a Clickhouse database for stats. On the frontend we use [TailwindCSS](https://tailwindcss.com/) for styling and React to make the dashboard interactive. -### Feedback & Roadmap +## Contributors + +For anyone wishing to contribute to Plausible, we recommend taking a look at [our contributor guide](https://github.com/plausible/analytics/blob/master/CONTRIBUTING.md). + + + +## Feedback & Roadmap + +We welcome feedback from our community. We have a public roadmap driven by the features suggested by the community members. Take a look at our [feedback board](https://plausible.io/feedback). Please let us know if you have any requests and vote on open issues so we can better prioritize. + +To stay up to date with all the latest news and product updates, make sure to follow us on [X (formerly Twitter)](https://twitter.com/plausiblehq), [LinkedIn](https://www.linkedin.com/company/plausible-analytics/) or [Mastodon](https://fosstodon.org/@plausible). -We welcome feedback from our community. We have a public roadmap driven by the features suggested by the community members. Take a look at our [feedback board](https://plausible.io/feedback) and our [public roadmap](https://plausible.io/roadmap) directly here on GitHub. Please let us know if you have any requests and vote on open issues so we can better prioritize. +## License & Trademarks -### License +Plausible CE is open source under the GNU Affero General Public License Version 3 (AGPLv3) or any later version. You can [find it here](https://github.com/plausible/analytics/blob/master/LICENSE.md). -Plausible is open-source under the GNU Affero General Public License Version 3 (AGPLv3) or any later version. You can [find it here](https://github.com/plausible/analytics/blob/master/LICENSE.md). +To avoid issues with AGPL virality, we've released the JavaScript tracker which gets included on your website under the MIT license. You can [find it here](https://github.com/plausible/analytics/blob/master/tracker/LICENSE.md). -The only exception is our javascript tracker which gets included on your website. To avoid issues with AGPL virality, we've -released the tracker under the MIT license. You can [find it here](https://github.com/plausible/analytics/blob/master/tracker/LICENSE.md). +Copyright (c) 2018-present Plausible Insights Oรœ. Plausible Analytics name and logo are trademarks of Plausible Insights Oรœ.ย Please see our [trademark guidelines](https://plausible.io/trademark) for info on acceptable usage. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000000..5c1952f97d05 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Supported Versions + +We only add security updates to the latest MAJOR.MINOR version of the project. No security updates are backported to previous versions. If you +want be up to date on security patches, make sure your Plausible image is up to date with `plausible/analytics:latest` + +## Reporting a Vulnerability + +Our software is updated several times per week and we also have a way for you to [report any security vulnerabilities](https://plausible.io/vulnerability-disclosure-program). A more detailed overview about our security practices can be found on [plausible.io/security](https://plausible.io/security). + +If you've found a security vulnerability with the Plausible codebase, you can disclose it responsibly by sending a summary to security@plausible.io. +We will review the potential threat and fix it as fast as we can. + +While we do not have a bounty program in place yet, we are incredibly thankful for people who take the time to share their findings with us. Whether it's a tiny bug that you've found or a security vulnerability, all reports help us to continuously improve Plausible for everyone. Thank you! diff --git a/assets/.babelrc b/assets/.babelrc deleted file mode 100644 index fd6b16f5208c..000000000000 --- a/assets/.babelrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "presets": [ - "@babel/preset-env", - "@babel/preset-react" - ] -} diff --git a/assets/.prettierignore b/assets/.prettierignore new file mode 100644 index 000000000000..74a2872d2781 --- /dev/null +++ b/assets/.prettierignore @@ -0,0 +1,7 @@ +node_modules/ +static/images/ +.*.json +.*rc +*.json +*.config.js +js/types/query-api.d.ts diff --git a/assets/.prettierrc.json b/assets/.prettierrc.json new file mode 100644 index 000000000000..81c51f9894bd --- /dev/null +++ b/assets/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "none", + "semi": false +} diff --git a/assets/.stylelintrc.json b/assets/.stylelintrc.json new file mode 100644 index 000000000000..51fbcf990f81 --- /dev/null +++ b/assets/.stylelintrc.json @@ -0,0 +1,13 @@ +{ + "extends": ["stylelint-config-standard"], + "ignoreFiles": ["./node_modules/**/*.*"], + "rules": { + "import-notation": "string", + "at-rule-no-unknown": [true, { "ignoreAtRules": ["apply", "screen", "plugin", "source", "theme", "utility", "custom-variant"] }], + "at-rule-empty-line-before": [ + "always", + { "except": ["blockless-after-same-name-blockless"], "ignoreAtRules": ["apply"] } + ], + "no-descending-specificity": null + } +} diff --git a/assets/css/app.css b/assets/css/app.css index c9eeda96e655..d55345e46a1b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,26 +1,150 @@ -/* purgecss start ignore */ -@tailwind base; -@tailwind components; -/* purgecss end ignore */ -@import "modal.css"; -@import "loader.css"; -@import "tooltip.css"; -@import "flatpickr.dark.css"; +@import 'tailwindcss' source(none); +@import 'flatpickr/dist/flatpickr.min.css' layer(components); +@import './modal.css' layer(components); +@import './loader.css' layer(components); +@import './tooltip.css' layer(components); +@import './flatpickr-colors.css' layer(components); + +@plugin "@tailwindcss/forms"; + +@source "../css"; +@source "../js"; +@source "../../lib/plausible_web"; +@source "../../extra/lib/plausible_web"; + +/* Tailwind v3 compatibility: restore v3 default border and ring styling */ + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } + + button:not(:disabled), + [role='button']:not(:disabled) { + cursor: pointer; + } + + *:focus-visible { + @apply ring-2 ring-indigo-500 ring-offset-2 dark:ring-offset-gray-900 outline-none; + } + + :focus:not(:focus-visible) { + @apply outline-none; + } +} + +@layer components { + /* Replace Tailwind form plugin's focus with focus-visible */ + [type='checkbox']:focus, + [type='radio']:focus { + outline: none; + box-shadow: none; + } + + [type='checkbox']:focus-visible, + [type='radio']:focus-visible { + @apply ring-2 ring-indigo-500 ring-offset-2 outline-none; + } +} + +@theme { + /* Color aliases from tailwind.config.js */ + + /* yellow: colors.amber - Map yellow to amber colors */ + --color-yellow-50: var(--color-amber-50); + --color-yellow-100: var(--color-amber-100); + --color-yellow-200: var(--color-amber-200); + --color-yellow-300: var(--color-amber-300); + --color-yellow-400: var(--color-amber-400); + --color-yellow-500: var(--color-amber-500); + --color-yellow-600: var(--color-amber-600); + --color-yellow-700: var(--color-amber-700); + --color-yellow-800: var(--color-amber-800); + --color-yellow-900: var(--color-amber-900); + --color-yellow-950: var(--color-amber-950); + + /* green: colors.emerald - Map green to emerald colors */ + --color-green-50: var(--color-emerald-50); + --color-green-100: var(--color-emerald-100); + --color-green-200: var(--color-emerald-200); + --color-green-300: var(--color-emerald-300); + --color-green-400: var(--color-emerald-400); + --color-green-500: var(--color-emerald-500); + --color-green-600: var(--color-emerald-600); + --color-green-700: var(--color-emerald-700); + --color-green-800: var(--color-emerald-800); + --color-green-900: var(--color-emerald-900); + --color-green-950: var(--color-emerald-950); + + /* gray: colors.zinc - Map gray to zinc colors */ + --color-gray-50: var(--color-zinc-50); + --color-gray-100: var(--color-zinc-100); + --color-gray-200: var(--color-zinc-200); + --color-gray-300: var(--color-zinc-300); + --color-gray-400: var(--color-zinc-400); + --color-gray-500: var(--color-zinc-500); + --color-gray-600: var(--color-zinc-600); + --color-gray-700: var(--color-zinc-700); + --color-gray-800: var(--color-zinc-800); + --color-gray-900: var(--color-zinc-900); + --color-gray-950: var(--color-zinc-950); + + /* Custom gray shades from config (override some zinc values) */ + --color-gray-75: rgb(247 247 248); + --color-gray-150: rgb(236 236 238); + --color-gray-750: rgb(50 50 54); + --color-gray-825: rgb(35 35 38); + --color-gray-850: rgb(34 34 38); + + /* Set v3 default ring behavior */ + --default-ring-width: 2px; + --default-ring-color: var(--color-indigo-500); +} + +@media print { + canvas { + width: 100% !important; + height: auto !important; + } +} + +@utility container { + margin-inline: auto; + padding-inline: 1rem; +} + +@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); +@custom-variant phx-hook-loading (.phx-hook-loading&, .phx-hook-loading &); +@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &); +@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); + +[x-cloak] { + display: none; +} .button { - @apply bg-indigo-600 border border-transparent rounded-md py-2 px-4 inline-flex justify-center text-sm leading-5 font-medium text-white transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500; + @apply inline-flex justify-center px-3.5 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md leading-5 transition hover:bg-indigo-700; +} + +.button[disabled] { + @apply bg-gray-400 dark:bg-gray-600; } .button-outline { - @apply bg-transparent border border-indigo-600 text-indigo-600; + @apply text-indigo-600 bg-transparent border border-gray-300 dark:border-gray-600 dark:text-gray-100 hover:bg-transparent hover:text-indigo-700 dark:hover:text-white hover:border-gray-400/70 dark:hover:border-gray-500 transition-all duration-150; } .button-sm { - @apply text-sm py-2 px-4; + @apply px-4 py-2 text-sm; } .button-md { - @apply py-2 px-4; + @apply px-4 py-2; } html { @@ -28,15 +152,17 @@ html { } body { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + width: 100vw; /* Prevents content from jumping when scrollbar is added/removed due to vertical overflow */ + overflow-x: hidden; } blockquote { - @apply my-4 py-2 px-4 border-l-4 border-gray-500; + @apply px-4 py-2 my-4 border-l-4 border-gray-500; } -@screen xl { +@media (width >= 1280px) { .container { max-width: 70rem; } @@ -46,53 +172,27 @@ blockquote { height: 920px; } -@screen md { +@media (width >= 768px) { .pricing-table { height: auto; } } -@tailwind utilities; - -.main-graph { - height: 440px; -} - -@screen md { - .main-graph { - height: 480px; - } -} - -@screen lg { - .main-graph { - height: 440px; - } -} - -@screen xl { - .main-graph { - height: 460px; - } -} - .light-text { - color: #F0F4F8; + color: #f0f4f8; } .transition { - transition: all .1s ease-in; + transition: all 0.1s ease-in; } .pulsating-circle { position: absolute; - left: 50%; - top: 50%; width: 10px; height: 10px; } -.pulsating-circle:before { +.pulsating-circle::before { content: ''; position: relative; display: block; @@ -106,7 +206,8 @@ blockquote { animation: pulse-ring 3s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; @apply bg-green-500; } -.pulsating-circle:after { + +.pulsating-circle::after { content: ''; position: absolute; left: 0; @@ -116,38 +217,45 @@ blockquote { height: 100%; background-color: white; border-radius: 15px; - animation: pulse-dot 3s cubic-bezier(0.455, 0.03, 0.515, 0.955) -.4s infinite; + animation: pulse-dot 3s cubic-bezier(0.455, 0.03, 0.515, 0.955) -0.4s infinite; @apply bg-green-500; } - @keyframes pulse-ring { 0% { - transform: scale(.33); + transform: scale(0.33); } + 50% { transform: scale(1); } - 40%, 100% { + + 40%, + 100% { opacity: 0; } } @keyframes pulse-dot { 0% { - transform: scale(.8); + transform: scale(0.8); } + 25% { transform: scale(1); } - 50%, 100% { - transform: scale(.8); + + 50%, + 100% { + transform: scale(0.8); } } -.just-text h1, .just-text h2, .just-text h3 { +.just-text h1, +.just-text h2, +.just-text h3 { margin-top: 1em; - margin-bottom: .5em; + margin-bottom: 0.5em; } .just-text p { @@ -156,27 +264,26 @@ blockquote { } .dropdown-content::before { - top: -16px; - right: 8px; - left: auto; -} -.dropdown-content::before { - border: 8px solid transparent; - border-bottom-color: rgba(27,31,35,0.15); + top: -16px; + right: 8px; + left: auto; + border: 8px solid transparent; + border-bottom-color: rgb(27 31 35 / 15%); } -.dropdown-content::before, .dropdown-content::after { - position: absolute; - display: inline-block; - content: ""; - } + +.dropdown-content::before, .dropdown-content::after { - border: 7px solid transparent; - border-bottom-color: #fff; + position: absolute; + display: inline-block; + content: ''; } + .dropdown-content::after { - top: -14px; - right: 9px; - left: auto; + top: -14px; + right: 9px; + left: auto; + border: 7px solid transparent; + border-bottom-color: #fff; } .feather { @@ -193,76 +300,65 @@ blockquote { display: inline; } -.table-striped tbody tr:nth-child(odd) { - background-color: #f1f5f8; -} - -.dark .table-striped tbody tr:nth-child(odd) { - background-color: rgb(37, 47, 63); +.table-striped tbody tr:nth-child(odd) td { + background-color: var(--color-gray-75); } -.dark .table-striped tbody tr:nth-child(even) { - background-color: rgb(26, 32, 44); +.dark .table-striped tbody tr:nth-child(odd) td { + background-color: var(--color-gray-850); } -.twitter-icon { - width: 1.25em; - height: 1.25em; - display: inline-block; - background-repeat: no-repeat; - background-size: contain; - vertical-align: text-bottom; - background-image: url(data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2072%2072%22%3E%3Cpath%20fill%3D%22none%22%20d%3D%22M0%200h72v72H0z%22%2F%3E%3Cpath%20class%3D%22icon%22%20fill%3D%22%231da1f2%22%20d%3D%22M68.812%2015.14c-2.348%201.04-4.87%201.744-7.52%202.06%202.704-1.62%204.78-4.186%205.757-7.243-2.53%201.5-5.33%202.592-8.314%203.176C56.35%2010.59%2052.948%209%2049.182%209c-7.23%200-13.092%205.86-13.092%2013.093%200%201.026.118%202.02.338%202.98C25.543%2024.527%2015.9%2019.318%209.44%2011.396c-1.125%201.936-1.77%204.184-1.77%206.58%200%204.543%202.312%208.552%205.824%2010.9-2.146-.07-4.165-.658-5.93-1.64-.002.056-.002.11-.002.163%200%206.345%204.513%2011.638%2010.504%2012.84-1.1.298-2.256.457-3.45.457-.845%200-1.666-.078-2.464-.23%201.667%205.2%206.5%208.985%2012.23%209.09-4.482%203.51-10.13%205.605-16.26%205.605-1.055%200-2.096-.06-3.122-.184%205.794%203.717%2012.676%205.882%2020.067%205.882%2024.083%200%2037.25-19.95%2037.25-37.25%200-.565-.013-1.133-.038-1.693%202.558-1.847%204.778-4.15%206.532-6.774z%22%2F%3E%3C%2Fsvg%3E); +.fade-enter { + opacity: 0; } -.tweet-text a { - @apply text-blue-500; +.fade-enter-active { + opacity: 1; + transition: opacity 100ms ease-in; } -.tweet-text a:hover { - text-decoration: underline; +.datamaps-subunit { + cursor: pointer; } -.stats-item { - @apply mt-6; - width: 100%; -} +.fullwidth-shadow::before { + @apply absolute top-0 w-screen h-full bg-gray-50 dark:bg-gray-850; -@screen md { - .stats-item { - margin-left: 6px; - margin-right: 6px; - width: calc(50% - 6px); - position: relative; - } + box-shadow: 0 4px 2px -2px rgb(0 0 0 / 6%); + content: ''; + z-index: -1; + left: calc(-1 * calc(50vw - 50%)); + background-color: inherit; } -.stats-item:first-child { - margin-left: 0; +iframe[hidden] { + display: none; } -.stats-item:last-child { - margin-right: 0; +.pagination-link[disabled] { + @apply cursor-default bg-gray-100 dark:bg-gray-300 pointer-events-none; } -.fade-enter { - opacity: 0; -} -.fade-enter-active { - opacity: 1; - transition: opacity 100ms ease-in; -} +/* This class is used for styling embedded dashboards. Do not remove. */ +/* stylelint-disable */ +/* prettier-ignore */ +.date-option-group { } +/* stylelint-enable */ -.flatpickr-calendar.static.open { - right: 2px; - top: calc(100% - 12px); +.tooltip-arrow, +.tooltip-arrow::before { + position: absolute; + width: 10px; + height: 10px; + background: inherit; } -.datamaps-subunit { - cursor: pointer; +.tooltip-arrow { + visibility: hidden; } -/* Only because the map handler doesn't expose an easier way to change the shadow color */ -.dark .hoverinfo { - box-shadow: 1px 1px 5px rgb(26, 32, 44); +.tooltip-arrow::before { + visibility: visible; + content: ''; + transform: rotate(45deg) translateY(1px); } diff --git a/assets/css/flatpickr-colors.css b/assets/css/flatpickr-colors.css new file mode 100644 index 000000000000..8336029144f9 --- /dev/null +++ b/assets/css/flatpickr-colors.css @@ -0,0 +1,124 @@ +/* stylelint-disable media-feature-range-notation */ +/* stylelint-disable selector-class-pattern */ + +/* Because Flatpickr offers zero support for dynamic theming on its own (outside of third-party plugins) */ +.dark .flatpickr-calendar { + background-color: #1f2937; +} + +.dark .flatpickr-weekday { + color: #f3f4f6; +} + +.dark .flatpickr-prev-month { + fill: #f3f4f6 !important; +} + +.dark .flatpickr-next-month { + fill: #f3f4f6 !important; +} + +.dark .flatpickr-monthDropdown-months { + color: #f3f4f6 !important; +} + +.dark .numInputWrapper { + color: #f3f4f6; +} + +.dark .numInput[disabled] { + color: #9ca3af !important; +} + +.dark .flatpickr-current-month .numInputWrapper span.arrowUp::after { + border-bottom-color: #f3f4f6 !important; +} + +.dark .flatpickr-current-month .numInputWrapper span.arrowDown::after { + border-top-color: #f3f4f6 !important; +} + +.dark .flatpickr-day.prevMonthDay { + color: #9ca3af; +} + +.dark .flatpickr-day { + color: #e5e7eb; +} + +.dark .flatpickr-day.nextMonthDay { + color: #9ca3af; +} + +.dark .flatpickr-day:hover { + background-color: #374151; +} + +/* stylelint-disable-next-line selector-not-notation */ +.dark :not(.startRange):not(.endRange).flatpickr-day.nextMonthDay:hover { + background-color: #374151; +} + +/* stylelint-disable-next-line selector-not-notation */ +.dark :not(.startRange):not(.endRange).flatpickr-day.prevMonthDay:hover { + background-color: #374151; +} + +.dark .flatpickr-day.flatpickr-disabled { + color: #4b5563; +} + +.dark .flatpickr-day.flatpickr-disabled:hover { + color: #4b5563; +} + +.dark .flatpickr-day.today { + background-color: rgb(167 243 208 / 50%); + border-color: #34d399; +} + +.dark .flatpickr-day.inRange { + background-color: #374151; + box-shadow: + -5px 0 0 #374151, + 5px 0 0 #374151; + border-color: #374151; +} + +.dark .flatpickr-day.prevMonthDay.inRange { + background-color: #374151; + box-shadow: + -5px 0 0 #374151, + 5px 0 0 #374151; + border-color: #374151; +} + +.dark .flatpickr-day.nextMonthDay.inRange { + background-color: #374151; + box-shadow: + -5px 0 0 #374151, + 5px 0 0 #374151; + border-color: #374151; +} + +.flatpickr-day.startRange { + background: #6574cd !important; + border-color: #6574cd !important; +} + +.flatpickr-day.endRange { + background: #6574cd !important; + border-color: #6574cd !important; +} + +.dark .flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n + 1)), +.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n + 1)), +.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n + 1)) { + box-shadow: -10px 0 0 #4556c3 !important; +} + +.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n + 1)), +.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n + 1)), +.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n + 1)) { + box-shadow: -10px 0 0 #4556c3 !important; +} diff --git a/assets/css/flatpickr.dark.css b/assets/css/flatpickr.dark.css deleted file mode 100644 index a4b6ad03e2ab..000000000000 --- a/assets/css/flatpickr.dark.css +++ /dev/null @@ -1,110 +0,0 @@ -/* Because Flatpickr offers zero support for dynamic theming on its own (outside of third-party plugins) */ -.dark .flatpickr-calendar { - background-color: #1f2937; -} - -.dark .flatpickr-weekday { - color: #f3f4f6; -} - -.dark .flatpickr-prev-month { - fill: #f3f4f6 !important; -} - -.dark .flatpickr-next-month { - fill: #f3f4f6 !important; -} - -.dark .flatpickr-monthDropdown-months { - color: #f3f4f6 !important; -} - -.dark .numInputWrapper { - color: #f3f4f6; -} - -.dark .flatpickr-day.prevMonthDay { - color: #94a3af; -} - -.dark .flatpickr-day { - color: #E5E7EB; -} - -.dark .flatpickr-day.prevMonthDay { - color: #9CA3AF; -} - -.dark .flatpickr-day.nextMonthDay { - color: #9CA3AF; -} - -.dark .flatpickr-day:hover { - background-color: #374151; -} - -.dark :not(.startRange):not(.endRange).flatpickr-day.nextMonthDay:hover { - background-color: #374151; -} - -.dark :not(.startRange):not(.endRange).flatpickr-day.prevMonthDay:hover { - background-color: #374151; -} - -.dark .flatpickr-next-month { - fill: #f3f4f6; -} - -.dark .flatpickr-day.flatpickr-disabled { - color: #4B5563; -} - -.dark .flatpickr-day.flatpickr-disabled:hover { - color: #4B5563; -} - -.dark .flatpickr-day.today { - background-color: rgba(167, 243, 208, 0.5); -} - -.dark .flatpickr-day.today { - border-color: #34D399; -} - -.dark .flatpickr-day.inRange { - background-color: #374151; - box-shadow: -5px 0 0 #374151,5px 0 0 #374151; - border-color: #374151; -} - -.dark .flatpickr-day.prevMonthDay.inRange { - background-color: #374151; - box-shadow: -5px 0 0 #374151,5px 0 0 #374151; - border-color: #374151; -} - -.dark .flatpickr-day.nextMonthDay.inRange { - background-color: #374151; - box-shadow: -5px 0 0 #374151,5px 0 0 #374151; - border-color: #374151; -} - -.flatpickr-day.startRange { - background: #6574cd !important; - border-color: #6574cd !important; -} - -.flatpickr-day.endRange { - background: #6574cd !important; - border-color: #6574cd !important; -} - -.dark .flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)), .flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)), .flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) { - -webkit-box-shadow: -10px 0 0 #4556c3 !important; - box-shadow: -10px 0 0 #4556c3 !important; -} - -.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)), .flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)), .flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) { - -webkit-box-shadow: -10px 0 0 #4556c3 !important; - box-shadow: -10px 0 0 #4556c3 !important; -} diff --git a/assets/css/loader.css b/assets/css/loader.css index a5dc562f1cab..2f658fb3eef4 100644 --- a/assets/css/loader.css +++ b/assets/css/loader.css @@ -1,12 +1,12 @@ .loading { width: 50px; height: 50px; - animation: loaderFadein .2s ease-in; + animation: loader-fade-in 0.2s ease-in; } .loading.sm { - width: 25px; - height: 25px; + width: 20px; + height: 20px; } .loading div { @@ -17,7 +17,6 @@ border-radius: 50%; border-top-color: #606f7b; animation: spin 1s ease-in-out infinite; - -webkit-animation: spin 1s ease-in-out infinite; } .dark .loading div { @@ -26,20 +25,26 @@ } .loading.sm div { - width: 25px; - height: 25px; + width: 20px; + height: 20px; } - @keyframes spin { - to { -webkit-transform: rotate(360deg); } -} -@-webkit-keyframes spin { - to { -webkit-transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } -@keyframes loaderFadein { - 0% { opacity: 0; } - 50% { opacity: 0; } - 100% { opacity: 1; } +@keyframes loader-fade-in { + 0% { + opacity: 0; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } } diff --git a/assets/css/modal.css b/assets/css/modal.css index 10028c9eadc0..7fa04121b426 100644 --- a/assets/css/modal.css +++ b/assets/css/modal.css @@ -1,3 +1,4 @@ +/* stylelint-disable selector-class-pattern */ .modal { display: none; } @@ -6,12 +7,12 @@ display: block; } -.modal[aria-hidden="false"] .modal__overlay { - animation: mmfadeIn .2s ease-in; +.modal[aria-hidden='false'] .modal__overlay { + animation: mm-fade-in 0.2s ease-in; } -.modal[aria-hidden="true"] .modal__overlay { - animation: mmfadeOut .2s ease-in; +.modal[aria-hidden='true'] .modal__overlay { + animation: mm-fade-out 0.2s ease-in; } .modal-enter { @@ -25,48 +26,28 @@ .modal__overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0,0,0,0.6); - z-index: 99; - overflow-x: hidden; - overflow-y: auto; + inset: 0; + background: rgb(0 0 0 / 60%); + z-index: 999; + overflow: auto; } -.modal__container { - background-color: #fff; - padding: 1rem 2rem; - max-width: 860px; - border-radius: 4px; - margin: 50px auto; - box-sizing: border-box; - min-height: 509px; - transition: height 200ms ease-in; -} - -.modal__close { - position: fixed; - color: #b8c2cc; - font-size: 48px; - font-weight: bold; - top: 12px; - right: 24px; -} - -.modal__close:before { content: "\2715"; } +@keyframes mm-fade-in { + from { + opacity: 0; + } -.modal__content { - margin-bottom: 2rem; + to { + opacity: 1; + } } -@keyframes mmfadeIn { - from { opacity: 0; } - to { opacity: 1; } -} +@keyframes mm-fade-out { + from { + opacity: 1; + } -@keyframes mmfadeOut { - from { opacity: 1; } - to { opacity: 0; } + to { + opacity: 0; + } } diff --git a/assets/css/storybook.css b/assets/css/storybook.css new file mode 100644 index 000000000000..2764e94fa378 --- /dev/null +++ b/assets/css/storybook.css @@ -0,0 +1,12 @@ +@import 'tailwindcss' source(none); + +/* + * Put your component styling within the Tailwind utilities layer. +* See the https://hexdocs.pm/phoenix_storybook/sandboxing.html guide for more info. + */ + +@layer utilities { + * { + font-family: system-ui; + } +} diff --git a/assets/css/tooltip.css b/assets/css/tooltip.css index 727e78b0a0f2..124d89dad2dc 100644 --- a/assets/css/tooltip.css +++ b/assets/css/tooltip.css @@ -1,43 +1,44 @@ -[tooltip]{ - position:relative; - display:inline-block; +[tooltip] { + position: relative; + display: inline-block; } [tooltip]::before { - transition: .3s; - content: ""; + transition: 0.3s; + content: ''; position: absolute; - top:-6px; - left:50%; + top: -6px; + left: 50%; transform: translateX(-50%); - border-width: 4px 6px 0 6px; + border-width: 4px 6px 0; border-style: solid; - border-color: rgba(0,0,0,0.8) transparent transparent transparent; + border-color: rgba(25 30 56) transparent transparent; z-index: 99; - opacity:0; + opacity: 0; } [tooltip]::after { - transition: .3s; + transition: 0.3s; white-space: nowrap; content: attr(tooltip); position: absolute; - left:50%; - top:-6px; - transform: translateX(-50%) translateY(-100%); - background: rgba(0,0,0,0.8); + left: 50%; + top: -6px; + transform: translateX(-50%) translateY(-100%); + background: rgba(25 30 56); text-align: center; color: #fff; - font-size: .875rem; + font-size: 0.875rem; min-width: 80px; max-width: 420px; border-radius: 3px; pointer-events: none; padding: 4px 8px; - z-index:99; - opacity:0; + z-index: 99; + opacity: 0; } -[tooltip]:hover::after,[tooltip]:hover::before { - opacity:1 +[tooltip]:hover::after, +[tooltip]:hover::before { + opacity: 1; } diff --git a/assets/eslint.config.mjs b/assets/eslint.config.mjs new file mode 100644 index 000000000000..b75a60c4b7f3 --- /dev/null +++ b/assets/eslint.config.mjs @@ -0,0 +1,99 @@ +import { defineConfig } from 'eslint/config' +import globals from 'globals' +import pluginJs from '@eslint/js' +import { configs as pluginTypescriptEslintConfigs } from 'typescript-eslint' +import pluginReact from 'eslint-plugin-react' +import pluginReactHooks from 'eslint-plugin-react-hooks' +import pluginA11y from 'eslint-plugin-jsx-a11y' +import pluginImport from 'eslint-plugin-import' +import pluginJest from 'eslint-plugin-jest' +import prettierEslintInteroperabilityConfig from 'eslint-config-prettier/flat' + +export default defineConfig([ + { + files: ['**/*.{js,ts,jsx,tsx}'], + languageOptions: { globals: globals.browser } + }, + { + files: ['**/*.{js,ts,jsx,tsx}'], + plugins: { js: pluginJs }, + extends: ['js/recommended'] + }, + { + rules: { + 'no-prototype-builtins': ['off'], + 'no-unused-expressions': ['warn', { allowShortCircuit: true }] + } + }, + + { + files: ['**/*.test.{js,ts,jsx,tsx}'], + plugins: { jest: pluginJest }, + languageOptions: { + globals: pluginJest.environments.globals.globals + }, + rules: { + 'jest/no-disabled-tests': 'warn', + 'jest/no-focused-tests': 'error', + 'jest/no-identical-title': 'error', + 'jest/prefer-to-have-length': 'warn', + 'jest/valid-expect': 'error' + } + }, + + pluginTypescriptEslintConfigs.recommended, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true + } + ] + } + }, + + pluginImport.flatConfigs.recommended, + pluginImport.flatConfigs.typescript, + { + settings: { + 'import/resolver': { typescript: true, node: { paths: ['../deps'] } } + } + }, + + pluginReact.configs.flat.recommended, + { + settings: { + react: { + version: 'detect' + } + }, + rules: { + 'react/destructuring-assignment': ['off'], + 'react/self-closing-comp': ['off'], + 'react/jsx-props-no-spreading': ['off'], + 'react/jsx-one-expression-per-line': ['off'], + 'react/display-name': ['off'], + 'react/prop-types': ['off'], + 'react/no-unknown-property': ['error', { ignore: ['tooltip'] }], + 'react/no-did-update-set-state': ['off'] + } + }, + pluginReactHooks.configs['recommended-latest'], + + pluginA11y.flatConfigs.recommended, + { + rules: { + 'jsx-a11y/click-events-have-key-events': ['off'], + 'jsx-a11y/no-static-element-interactions': ['off'] + } + }, + + prettierEslintInteroperabilityConfig +]) diff --git a/assets/jest.config.json b/assets/jest.config.json new file mode 100644 index 000000000000..97f7025e3163 --- /dev/null +++ b/assets/jest.config.json @@ -0,0 +1,20 @@ +{ + "clearMocks": true, + "coverageDirectory": "coverage", + "coverageProvider": "v8", + "testEnvironment": "jsdom", + "globals": { + "BUILD_EXTRA": true + }, + "setupFilesAfterEnv": [ + "/test-utils/extend-expect.ts", + "/test-utils/jsdom-mocks.ts", + "/test-utils/reset-state.ts" + ], + "transform": { + "^.+.[tj]sx?$": ["ts-jest", {}] + }, + "moduleNameMapper": { + "d3": "/node_modules/d3/dist/d3.min.js" + } +} diff --git a/assets/js/app.js b/assets/js/app.js index ea7e58f3c37e..f73084a96689 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,21 +1,43 @@ -import css from "../css/app.css" -import "flatpickr/dist/flatpickr.min.css" -import "./polyfills/closest" +import './polyfills/closest' import 'abortcontroller-polyfill/dist/polyfill-patch-fetch' -import "phoenix_html" -import 'alpinejs' +import Alpine from 'alpinejs' +import './liveview/live_socket' +import comboBox from './liveview/combo-box' +import dropdown from './liveview/dropdown' +import './liveview/phx_events' + +Alpine.data('dropdown', dropdown) +Alpine.data('comboBox', comboBox) +Alpine.start() + +if (document.querySelectorAll('[data-modal]').length > 0) { + window.addEventListener(`phx:close-modal`, (e) => { + document + .getElementById(e.detail.id) + .dispatchEvent( + new CustomEvent('close-modal', { bubbles: true, detail: e.detail.id }) + ) + }) + window.addEventListener(`phx:open-modal`, (e) => { + document + .getElementById(e.detail.id) + .dispatchEvent( + new CustomEvent('open-modal', { bubbles: true, detail: e.detail.id }) + ) + }) +} const triggers = document.querySelectorAll('[data-dropdown-trigger]') for (const trigger of triggers) { - trigger.addEventListener('click', function(e) { + trigger.addEventListener('click', function (e) { e.stopPropagation() e.currentTarget.nextElementSibling.classList.remove('hidden') }) } if (triggers.length > 0) { - document.addEventListener('click', function(e) { + document.addEventListener('click', function (e) { const dropdown = e.target.closest('[data-dropdown]') if (dropdown && e.target.tagName === 'A') { @@ -23,7 +45,7 @@ if (triggers.length > 0) { } }) - document.addEventListener('click', function(e) { + document.addEventListener('click', function (e) { const clickedInDropdown = e.target.closest('[data-dropdown]') if (!clickedInDropdown) { @@ -34,21 +56,72 @@ if (triggers.length > 0) { }) } -const registerForm = document.getElementById('register-form') +const changelogNotification = document.getElementById('changelog-notification') + +if (changelogNotification) { + showChangelogNotification(changelogNotification) -if (registerForm) { - registerForm.addEventListener('submit', function(e) { - e.preventDefault(); - setTimeout(submitForm, 1000); - var formSubmitted = false; + fetch('https://plausible.io/changes.txt', { + headers: { 'Content-Type': 'text/plain' } + }) + .then((res) => res.text()) + .then((res) => { + localStorage.lastChangelogUpdate = new Date(res).getTime() + showChangelogNotification(changelogNotification) + }) +} - function submitForm() { - if (!formSubmitted) { - formSubmitted = true; - registerForm.submit(); +function showChangelogNotification(el) { + const lastUpdated = Number(localStorage.lastChangelogUpdate) + const lastChecked = Number(localStorage.lastChangelogClick) + + const hasNewUpdateSinceLastClicked = lastUpdated > lastChecked + const notOlderThanThreeDays = Date.now() - lastUpdated < 1000 * 60 * 60 * 72 + if ((!lastChecked || hasNewUpdateSinceLastClicked) && notOlderThanThreeDays) { + el.innerHTML = ` + + + + + + + + + ` + const link = el.getElementsByTagName('a')[0] + link.addEventListener('click', function () { + localStorage.lastChangelogClick = Date.now() + setTimeout(() => { + link.remove() + }, 100) + }) + } +} + +const embedButton = document.getElementById('generate-embed') + +if (embedButton) { + embedButton.addEventListener('click', function (_e) { + const baseUrl = document.getElementById('base-url').value + const embedCode = document.getElementById('embed-code') + const theme = document.getElementById('theme').value.toLowerCase() + const background = document.getElementById('background').value + + try { + const embedLink = new URL(document.getElementById('embed-link').value) + embedLink.searchParams.set('embed', 'true') + embedLink.searchParams.set('theme', theme) + if (background) { + embedLink.searchParams.set('background', background) } - } - plausible('Signup', {callback: submitForm}); + embedCode.value = ` +
Stats powered by Plausible Analytics
+` + } catch (e) { + console.error(e) + embedCode.value = + 'ERROR: Please enter a valid URL in the shared link field' + } }) } diff --git a/assets/js/dashboard.tsx b/assets/js/dashboard.tsx new file mode 100644 index 000000000000..b0871d687397 --- /dev/null +++ b/assets/js/dashboard.tsx @@ -0,0 +1,112 @@ +import React, { ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import 'url-search-params-polyfill' + +import { RouterProvider } from 'react-router-dom' +import { createAppRouter } from './dashboard/router' +import ErrorBoundary from './dashboard/error/error-boundary' +import * as api from './dashboard/api' +import * as timer from './dashboard/util/realtime-update-timer' +import { maybeDoFERedirect } from './dashboard/util/url-search-params' +import SiteContextProvider, { + parseSiteFromDataset +} from './dashboard/site-context' +import UserContextProvider, { Role } from './dashboard/user-context' +import ThemeContextProvider from './dashboard/theme-context' +import { + GoBackToDashboard, + GoToSites, + SomethingWentWrongMessage +} from './dashboard/error/something-went-wrong' +import { + getLimitedToSegment, + parseLimitedToSegmentId, + parsePreloadedSegments, + SegmentsContextProvider +} from './dashboard/filtering/segments-context' + +timer.start() + +const container = document.getElementById('stats-react-container') + +if (container && container.dataset) { + let app: ReactNode + + try { + const site = parseSiteFromDataset(container.dataset) + + const sharedLinkAuth = container.dataset.sharedLinkAuth + + if (sharedLinkAuth) { + api.setSharedLinkAuth(sharedLinkAuth) + } + + const limitedToSegmentId = parseLimitedToSegmentId(container.dataset) + const preloadedSegments = parsePreloadedSegments(container.dataset) + const limitedToSegment = getLimitedToSegment( + limitedToSegmentId, + preloadedSegments + ) + + try { + maybeDoFERedirect(window.location, window.history, limitedToSegment) + } catch (e) { + console.error('Error redirecting in a backwards compatible way', e) + } + + const router = createAppRouter(site) + + app = ( + ( + } + /> + )} + > + + + + + + + + + + + ) + } catch (err) { + console.error('Error loading dashboard', err) + app = } /> + } + + const root = createRoot(container) + root.render(app) +} diff --git a/assets/js/dashboard/api.js b/assets/js/dashboard/api.js deleted file mode 100644 index d6fca56a2d2a..000000000000 --- a/assets/js/dashboard/api.js +++ /dev/null @@ -1,44 +0,0 @@ -import {formatISO} from './date' - -let abortController = new AbortController() - -function serialize(obj) { - var str = []; - for (var p in obj) - if (obj.hasOwnProperty(p)) { - str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p])); - } - return str.join("&"); -} - -export function cancelAll() { - abortController.abort() - abortController = new AbortController() -} - -function serializeFilters(filters) { - const cleaned = {} - Object.entries(filters).forEach(([key, val]) => val ? cleaned[key] = val : null); - return JSON.stringify(cleaned) -} - -export function serializeQuery(query, extraQuery=[]) { - const queryObj = {} - if (query.period) { queryObj.period = query.period } - if (query.date) { queryObj.date = formatISO(query.date) } - if (query.from) { queryObj.from = formatISO(query.from) } - if (query.to) { queryObj.to = formatISO(query.to) } - if (query.filters) { queryObj.filters = serializeFilters(query.filters) } - Object.assign(queryObj, ...extraQuery) - - return '?' + serialize(queryObj) -} - -export function get(url, query, ...extraQuery) { - url = url + serializeQuery(query, extraQuery) - return fetch(url, {signal: abortController.signal}) - .then( response => { - if (!response.ok) { throw response } - return response.json() - }) -} diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts new file mode 100644 index 000000000000..fa407178e453 --- /dev/null +++ b/assets/js/dashboard/api.ts @@ -0,0 +1,141 @@ +import { DashboardQuery } from './query' +import { formatISO } from './util/date' +import { serializeApiFilters } from './util/filters' + +let abortController = new AbortController() +let SHARED_LINK_AUTH: null | string = null + +export class ApiError extends Error { + payload: unknown + constructor(message: string, payload: unknown) { + super(message) + this.name = 'ApiError' + this.payload = payload + } +} + +function serializeUrlParams(params: Record) { + const str: string[] = [] + for (const p in params) + if (params.hasOwnProperty(p)) { + str.push(`${encodeURIComponent(p)}=${encodeURIComponent(params[p])}`) + } + return str.join('&') +} + +export function setSharedLinkAuth(auth: string) { + SHARED_LINK_AUTH = auth +} + +export function cancelAll() { + abortController.abort() + abortController = new AbortController() +} + +export function queryToSearchParams( + query: DashboardQuery, + extraQuery: unknown[] = [] +): string { + const queryObj: Record = {} + if (query.period) { + queryObj.period = query.period + } + if (query.date) { + queryObj.date = formatISO(query.date) + } + if (query.from) { + queryObj.from = formatISO(query.from) + } + if (query.to) { + queryObj.to = formatISO(query.to) + } + if (query.filters) { + queryObj.filters = serializeApiFilters(query.filters) + } + if (query.with_imported) { + queryObj.with_imported = String(query.with_imported) + } + + if (query.comparison) { + queryObj.comparison = query.comparison + queryObj.compare_from = query.compare_from + ? formatISO(query.compare_from) + : undefined + queryObj.compare_to = query.compare_to + ? formatISO(query.compare_to) + : undefined + queryObj.match_day_of_week = String(query.match_day_of_week) + } + + const sharedLinkParams = getSharedLinkSearchParams() + if (sharedLinkParams.auth) { + queryObj.auth = sharedLinkParams.auth + } + + Object.assign(queryObj, ...extraQuery) + + return serializeUrlParams(queryObj) +} + +function getHeaders(): Record { + return SHARED_LINK_AUTH ? { 'X-Shared-Link-Auth': SHARED_LINK_AUTH } : {} +} + +async function handleApiResponse(response: Response) { + const payload = await response.json() + if (!response.ok) { + throw new ApiError(payload.error, payload) + } + + return payload +} + +function getSharedLinkSearchParams(): Record { + return SHARED_LINK_AUTH ? { auth: SHARED_LINK_AUTH } : {} +} + +export async function get( + url: string, + query?: DashboardQuery, + ...extraQueryParams: unknown[] +) { + const queryString = query + ? queryToSearchParams(query, [...extraQueryParams]) + : serializeUrlParams(getSharedLinkSearchParams()) + + const response = await fetch(queryString ? `${url}?${queryString}` : url, { + signal: abortController.signal, + headers: { ...getHeaders(), Accept: 'application/json' } + }) + + return handleApiResponse(response) +} + +export const mutation = async < + TBody extends Record = Record +>( + url: string, + options: + | { body: TBody; method: 'PATCH' | 'PUT' | 'POST' } + | { method: 'DELETE' } +) => { + const queryString = serializeUrlParams(getSharedLinkSearchParams()) + const fetchOptions = + options.method === 'DELETE' + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options.body) + } + const response = await fetch(queryString ? `${url}?${queryString}` : url, { + method: options.method, + headers: { + ...getHeaders(), + ...fetchOptions.headers, + Accept: 'application/json' + }, + body: fetchOptions.body, + signal: abortController.signal + }) + return handleApiResponse(response) +} diff --git a/assets/js/dashboard/components/combobox.js b/assets/js/dashboard/components/combobox.js new file mode 100644 index 000000000000..17670f218066 --- /dev/null +++ b/assets/js/dashboard/components/combobox.js @@ -0,0 +1,401 @@ +import React, { + Fragment, + useState, + useCallback, + useEffect, + useRef +} from 'react' +import { Transition } from '@headlessui/react' +import { ChevronDownIcon } from '@heroicons/react/20/solid' +import classNames from 'classnames' +import { useMountedEffect, useDebounce } from '../custom-hooks' + +function Option({ isHighlighted, onClick, onMouseEnter, text, id }) { + const className = classNames( + 'relative cursor-pointer select-none py-2 px-3 text-gray-900 dark:text-gray-300', + { + 'bg-gray-100 dark:bg-gray-700': isHighlighted + } + ) + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
  • + {text} +
  • + ) +} +function scrollTo(wrapper, id) { + if (wrapper) { + const el = wrapper.querySelector('#' + id) + + if (el) { + el.scrollIntoView({ block: 'center' }) + } + } +} + +function optionId(index) { + return `plausible-combobox-option-${index}` +} + +export default function PlausibleCombobox({ + values, + fetchOptions, + singleOption, + isDisabled, + autoFocus, + freeChoice, + disabledOptions, + onSelect, + placeholder, + forceLoading, + className, + boxClass +}) { + const isEmpty = values.length === 0 + const [options, setOptions] = useState([]) + const [isLoading, setLoading] = useState(false) + const [isOpen, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) + const searchRef = useRef(null) + const containerRef = useRef(null) + const listRef = useRef(null) + + const loading = isLoading || !!forceLoading + + const visibleOptions = [...options] + if ( + freeChoice && + search.length > 0 && + options.every((option) => option.value !== search) + ) { + visibleOptions.push({ value: search, label: search, freeChoice: true }) + } + + const afterFetchOptions = useCallback((loadedOptions) => { + setLoading(false) + setHighlightedIndex(0) + setOptions(loadedOptions) + }, []) + + const initialFetchOptions = useCallback(() => { + setLoading(true) + fetchOptions('').then(afterFetchOptions) + }, [fetchOptions, afterFetchOptions]) + + const searchOptions = useCallback(() => { + if (isOpen) { + setLoading(true) + fetchOptions(search).then(afterFetchOptions) + } + }, [search, isOpen, fetchOptions, afterFetchOptions]) + + const debouncedSearchOptions = useDebounce(searchOptions) + + useEffect(() => { + if (isOpen) { + initialFetchOptions() + } + }, [isOpen, initialFetchOptions]) + + useMountedEffect(() => { + debouncedSearchOptions() + }, [search]) + + function highLight(index) { + let newIndex = index + + if (index < 0) { + newIndex = visibleOptions.length - 1 + } else if (index >= visibleOptions.length) { + newIndex = 0 + } + + setHighlightedIndex(newIndex) + scrollTo(listRef.current, optionId(newIndex)) + } + + function onKeyDown(e) { + if (e.key === 'Enter') { + if (!isOpen || loading || visibleOptions.length === 0) return null + selectOption(visibleOptions[highlightedIndex]) + e.preventDefault() + } + if (e.key === 'Escape') { + if (!isOpen || loading) return null + setOpen(false) + searchRef.current?.focus() + e.preventDefault() + } + if (e.key === 'ArrowDown') { + if (isOpen) { + highLight(highlightedIndex + 1) + } else { + setOpen(true) + } + } + if (e.key === 'ArrowUp') { + if (isOpen) { + highLight(highlightedIndex - 1) + } else { + setOpen(true) + } + } + } + + function isOptionDisabled(option) { + const optionAlreadySelected = values.some( + (val) => val.value === option.value + ) + const optionDisabled = (disabledOptions || []).some( + (val) => val?.value === option.value + ) + return optionAlreadySelected || optionDisabled + } + + function onInput(e) { + if (!isOpen) { + setOpen(true) + } + setSearch(e.target.value) + } + + function toggleOpen() { + if (!isOpen) { + setOpen(true) + searchRef.current.focus() + } else { + setSearch('') + setOpen(false) + } + } + + function selectOption(option) { + if (singleOption) { + onSelect([option]) + } else { + searchRef.current.focus() + onSelect([...values, option]) + } + + setOpen(false) + setSearch('') + } + + function removeOption(option, e) { + e.stopPropagation() + const newValues = values.filter((val) => val.value !== option.value) + onSelect(newValues) + searchRef.current.focus() + setOpen(false) + } + + const handleClick = useCallback((e) => { + if (containerRef.current && containerRef.current.contains(e.target)) { + return + } + + setSearch('') + setOpen(false) + }, []) + + useEffect(() => { + document.addEventListener('mousedown', handleClick, false) + return () => { + document.removeEventListener('mousedown', handleClick, false) + } + }, [handleClick]) + + useEffect(() => { + if (singleOption && isEmpty && autoFocus) { + searchRef.current.focus() + } + }, [isEmpty, singleOption, autoFocus]) + + const searchBoxClass = + 'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-hidden focus:ring-0 text-sm' + + const containerClass = classNames('relative w-full', { + [className]: !!className, + 'opacity-30 cursor-default pointer-events-none': isDisabled + }) + + function renderSingleOptionContent() { + const itemSelected = values.length === 1 + + return ( +
    + {itemSelected && renderSingleSelectedItem()} + +
    + ) + } + + function renderSingleSelectedItem() { + if (search === '') { + return ( + + {values[0].label} + + ) + } + } + + function renderMultiOptionContent() { + return ( + <> + {values.map((value) => { + return ( +
    + {value.label} + removeOption(value, e)} + className="cursor-pointer font-bold ml-1" + > + × + +
    + ) + })} + + + ) + } + + function renderDropDownContent() { + const matchesFound = + visibleOptions.length > 0 && + visibleOptions.some((option) => !isOptionDisabled(option)) + + if (loading) { + return ( +
    + Loading options... +
    + ) + } + + if (matchesFound) { + return visibleOptions + .filter((option) => !isOptionDisabled(option)) + .map((option, i) => { + const text = option.freeChoice + ? `Filter by '${option.label}'` + : option.label + + return ( +