From 54c4e40a896dc488a01e2d4e95d697336b7d7958 Mon Sep 17 00:00:00 2001 From: lalabuy948 Date: Mon, 23 Sep 2024 17:09:58 +0200 Subject: [PATCH] 0.2.0 (#19) * Add support for distributed apps (#10) --- .github/workflows/tests.yml | 37 +- CHANGELOG.md | 2 + README.md | 70 +- config/config.exs | 5 +- config/dev.exs | 2 + config/test.exs | 2 +- docker/minio-compose.yml | 15 + docker/postgres-compose.yml | 11 + examples/duck_only/.formatter.exs | 5 + examples/duck_only/.gitignore | 39 + examples/duck_only/README.md | 57 ++ examples/duck_only/assets/css/app.css | 5 + examples/duck_only/assets/js/app.js | 44 ++ examples/duck_only/assets/tailwind.config.js | 74 ++ examples/duck_only/assets/vendor/topbar.js | 165 +++++ examples/duck_only/config/config.exs | 69 ++ examples/duck_only/config/dev.exs | 75 ++ examples/duck_only/config/prod.exs | 20 + examples/duck_only/config/runtime.exs | 102 +++ examples/duck_only/config/test.exs | 24 + examples/duck_only/lib/duck_only.ex | 9 + .../duck_only/lib/duck_only/application.ex | 35 + examples/duck_only/lib/duck_only/mailer.ex | 3 + examples/duck_only/lib/duck_only_web.ex | 113 +++ .../components/core_components.ex | 676 ++++++++++++++++++ .../lib/duck_only_web/components/layouts.ex | 14 + .../components/layouts/app.html.heex | 32 + .../components/layouts/root.html.heex | 17 + .../duck_only_web/controllers/error_html.ex | 24 + .../duck_only_web/controllers/error_json.ex | 21 + .../controllers/page_controller.ex | 9 + .../duck_only_web/controllers/page_html.ex | 10 + .../controllers/page_html/home.html.heex | 222 ++++++ .../duck_only/lib/duck_only_web/endpoint.ex | 54 ++ .../duck_only/lib/duck_only_web/gettext.ex | 24 + .../duck_only/lib/duck_only_web/router.ex | 46 ++ .../duck_only/lib/duck_only_web/telemetry.ex | 69 ++ examples/duck_only/mix.exs | 81 +++ examples/duck_only/mix.lock | 48 ++ .../priv/gettext/en/LC_MESSAGES/errors.po | 11 + examples/duck_only/priv/gettext/errors.pot | 10 + examples/duck_only/priv/static/favicon.ico | Bin 0 -> 152 bytes .../duck_only/priv/static/images/logo.svg | 6 + examples/duck_only/priv/static/robots.txt | 5 + .../controllers/error_html_test.exs | 14 + .../controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 8 + examples/duck_only/test/support/conn_case.ex | 37 + examples/duck_only/test/test_helper.exs | 1 + examples/duck_postgres/.formatter.exs | 6 + examples/duck_postgres/.gitignore | 39 + examples/duck_postgres/README.md | 72 ++ examples/duck_postgres/assets/css/app.css | 5 + examples/duck_postgres/assets/js/app.js | 44 ++ .../duck_postgres/assets/tailwind.config.js | 74 ++ .../duck_postgres/assets/vendor/topbar.js | 165 +++++ examples/duck_postgres/config/config.exs | 72 ++ examples/duck_postgres/config/dev.exs | 85 +++ examples/duck_postgres/config/prod.exs | 21 + examples/duck_postgres/config/runtime.exs | 117 +++ examples/duck_postgres/config/test.exs | 37 + examples/duck_postgres/lib/duck_postgres.ex | 9 + .../lib/duck_postgres/application.ex | 36 + .../duck_postgres/lib/duck_postgres/mailer.ex | 3 + .../duck_postgres/lib/duck_postgres/repo.ex | 5 + .../duck_postgres/lib/duck_postgres_web.ex | 113 +++ .../components/core_components.ex | 676 ++++++++++++++++++ .../duck_postgres_web/components/layouts.ex | 14 + .../components/layouts/app.html.heex | 32 + .../components/layouts/root.html.heex | 17 + .../controllers/error_html.ex | 24 + .../controllers/error_json.ex | 21 + .../controllers/page_controller.ex | 9 + .../controllers/page_html.ex | 10 + .../controllers/page_html/home.html.heex | 222 ++++++ .../lib/duck_postgres_web/endpoint.ex | 55 ++ .../lib/duck_postgres_web/gettext.ex | 24 + .../lib/duck_postgres_web/router.ex | 46 ++ .../lib/duck_postgres_web/telemetry.ex | 92 +++ examples/duck_postgres/mix.exs | 87 +++ examples/duck_postgres/mix.lock | 52 ++ examples/duck_postgres/postgres-compose.yml | 11 + .../priv/gettext/en/LC_MESSAGES/errors.po | 112 +++ .../duck_postgres/priv/gettext/errors.pot | 109 +++ .../priv/repo/migrations/.formatter.exs | 4 + .../20240912135104_add_requests_table.exs | 6 + examples/duck_postgres/priv/repo/seeds.exs | 11 + .../duck_postgres/priv/static/favicon.ico | Bin 0 -> 152 bytes .../duck_postgres/priv/static/images/logo.svg | 6 + examples/duck_postgres/priv/static/robots.txt | 5 + .../controllers/error_html_test.exs | 14 + .../controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 8 + .../duck_postgres/test/support/conn_case.ex | 38 + .../duck_postgres/test/support/data_case.ex | 58 ++ examples/duck_postgres/test/test_helper.exs | 2 + lib/phoenix_analytics/entities/request_log.ex | 2 +- lib/phoenix_analytics/migration.ex | 13 +- .../queries/analytics/stats/per_period.ex | 4 +- lib/phoenix_analytics/queries/insert.ex | 15 +- lib/phoenix_analytics/queries/table.ex | 11 +- lib/phoenix_analytics/repo.ex | 63 +- lib/phoenix_analytics/services/batcher.ex | 7 +- lib/phoenix_analytics/services/bridge.ex | 47 ++ lib/phoenix_analytics/services/cache.ex | 10 +- lib/phoenix_analytics/services/pubsub.ex | 10 +- lib/phoenix_analytics/services/utility.ex | 30 + .../live/components/charts/device_chart.ex | 12 +- .../live/components/charts/popular_chart.ex | 6 +- .../live/components/charts/requests_chart.ex | 11 +- .../web/live/components/charts/res_chart.ex | 6 +- .../live/components/charts/status_chart.ex | 9 +- .../live/components/charts/visits_chart.ex | 9 +- .../web/live/components/stats/single_stat.ex | 18 +- mix.exs | 2 +- priv/repo/dev.exs | 103 --- priv/repo/duck_s3.exs | 26 + priv/repo/seed_data.exs | 12 +- priv/repo/seeds.exs | 2 +- priv/repo/seeds_postgres.exs | 86 +++ test/phoenix_analytics_test.exs | 4 +- 121 files changed, 5417 insertions(+), 215 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docker/minio-compose.yml create mode 100644 docker/postgres-compose.yml create mode 100644 examples/duck_only/.formatter.exs create mode 100644 examples/duck_only/.gitignore create mode 100644 examples/duck_only/README.md create mode 100644 examples/duck_only/assets/css/app.css create mode 100644 examples/duck_only/assets/js/app.js create mode 100644 examples/duck_only/assets/tailwind.config.js create mode 100644 examples/duck_only/assets/vendor/topbar.js create mode 100644 examples/duck_only/config/config.exs create mode 100644 examples/duck_only/config/dev.exs create mode 100644 examples/duck_only/config/prod.exs create mode 100644 examples/duck_only/config/runtime.exs create mode 100644 examples/duck_only/config/test.exs create mode 100644 examples/duck_only/lib/duck_only.ex create mode 100644 examples/duck_only/lib/duck_only/application.ex create mode 100644 examples/duck_only/lib/duck_only/mailer.ex create mode 100644 examples/duck_only/lib/duck_only_web.ex create mode 100644 examples/duck_only/lib/duck_only_web/components/core_components.ex create mode 100644 examples/duck_only/lib/duck_only_web/components/layouts.ex create mode 100644 examples/duck_only/lib/duck_only_web/components/layouts/app.html.heex create mode 100644 examples/duck_only/lib/duck_only_web/components/layouts/root.html.heex create mode 100644 examples/duck_only/lib/duck_only_web/controllers/error_html.ex create mode 100644 examples/duck_only/lib/duck_only_web/controllers/error_json.ex create mode 100644 examples/duck_only/lib/duck_only_web/controllers/page_controller.ex create mode 100644 examples/duck_only/lib/duck_only_web/controllers/page_html.ex create mode 100644 examples/duck_only/lib/duck_only_web/controllers/page_html/home.html.heex create mode 100644 examples/duck_only/lib/duck_only_web/endpoint.ex create mode 100644 examples/duck_only/lib/duck_only_web/gettext.ex create mode 100644 examples/duck_only/lib/duck_only_web/router.ex create mode 100644 examples/duck_only/lib/duck_only_web/telemetry.ex create mode 100644 examples/duck_only/mix.exs create mode 100644 examples/duck_only/mix.lock create mode 100644 examples/duck_only/priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 examples/duck_only/priv/gettext/errors.pot create mode 100644 examples/duck_only/priv/static/favicon.ico create mode 100644 examples/duck_only/priv/static/images/logo.svg create mode 100644 examples/duck_only/priv/static/robots.txt create mode 100644 examples/duck_only/test/duck_only_web/controllers/error_html_test.exs create mode 100644 examples/duck_only/test/duck_only_web/controllers/error_json_test.exs create mode 100644 examples/duck_only/test/duck_only_web/controllers/page_controller_test.exs create mode 100644 examples/duck_only/test/support/conn_case.ex create mode 100644 examples/duck_only/test/test_helper.exs create mode 100644 examples/duck_postgres/.formatter.exs create mode 100644 examples/duck_postgres/.gitignore create mode 100644 examples/duck_postgres/README.md create mode 100644 examples/duck_postgres/assets/css/app.css create mode 100644 examples/duck_postgres/assets/js/app.js create mode 100644 examples/duck_postgres/assets/tailwind.config.js create mode 100644 examples/duck_postgres/assets/vendor/topbar.js create mode 100644 examples/duck_postgres/config/config.exs create mode 100644 examples/duck_postgres/config/dev.exs create mode 100644 examples/duck_postgres/config/prod.exs create mode 100644 examples/duck_postgres/config/runtime.exs create mode 100644 examples/duck_postgres/config/test.exs create mode 100644 examples/duck_postgres/lib/duck_postgres.ex create mode 100644 examples/duck_postgres/lib/duck_postgres/application.ex create mode 100644 examples/duck_postgres/lib/duck_postgres/mailer.ex create mode 100644 examples/duck_postgres/lib/duck_postgres/repo.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/components/core_components.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/components/layouts.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/components/layouts/app.html.heex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/components/layouts/root.html.heex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/controllers/error_html.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/controllers/error_json.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/controllers/page_controller.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/controllers/page_html.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/controllers/page_html/home.html.heex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/endpoint.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/gettext.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/router.ex create mode 100644 examples/duck_postgres/lib/duck_postgres_web/telemetry.ex create mode 100644 examples/duck_postgres/mix.exs create mode 100644 examples/duck_postgres/mix.lock create mode 100644 examples/duck_postgres/postgres-compose.yml create mode 100644 examples/duck_postgres/priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 examples/duck_postgres/priv/gettext/errors.pot create mode 100644 examples/duck_postgres/priv/repo/migrations/.formatter.exs create mode 100644 examples/duck_postgres/priv/repo/migrations/20240912135104_add_requests_table.exs create mode 100644 examples/duck_postgres/priv/repo/seeds.exs create mode 100644 examples/duck_postgres/priv/static/favicon.ico create mode 100644 examples/duck_postgres/priv/static/images/logo.svg create mode 100644 examples/duck_postgres/priv/static/robots.txt create mode 100644 examples/duck_postgres/test/duck_postgres_web/controllers/error_html_test.exs create mode 100644 examples/duck_postgres/test/duck_postgres_web/controllers/error_json_test.exs create mode 100644 examples/duck_postgres/test/duck_postgres_web/controllers/page_controller_test.exs create mode 100644 examples/duck_postgres/test/support/conn_case.ex create mode 100644 examples/duck_postgres/test/support/data_case.ex create mode 100644 examples/duck_postgres/test/test_helper.exs create mode 100644 lib/phoenix_analytics/services/bridge.ex delete mode 100644 priv/repo/dev.exs create mode 100644 priv/repo/duck_s3.exs create mode 100644 priv/repo/seeds_postgres.exs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 48380e8..155f976 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,33 +7,32 @@ name: Tests on: push: - branches: [ "master" ] + branches: ["master"] pull_request: - branches: [ "master" ] + branches: ["master"] permissions: contents: read jobs: build: - name: Build and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Elixir - uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0 - with: - elixir-version: '1.15.2' # [Required] Define the Elixir version - otp-version: '26.0' # [Required] Define the Erlang/OTP version - - name: Restore dependencies cache - uses: actions/cache@v3 - with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- - - name: Install dependencies - run: mix deps.get - - name: Run tests - run: mix test + - uses: actions/checkout@v4 + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: "1.17.2" + otp-version: "26" + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + - name: Install dependencies + run: mix deps.get + - name: Run tests + run: mix test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b0d7275 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +> [!NOTE] +> Please take a look on [releases description](https://github.com/lalabuy948/PhoenixAnalytics/releases). diff --git a/README.md b/README.md index 839d9b4..db99e38 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ View documentation

-![](/github/hero.png) +![](https://raw.githubusercontent.com/lalabuy948/PhoenixAnalytics/master/github/hero.png) Phoenix Analytics is embedded plug and play tool designed for Phoenix applications. It provides a simple and efficient way to track and analyze user behavior and application performance without impacting your main application's performance and database. @@ -30,7 +30,7 @@ by adding `phoenix_analytics` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:phoenix_analytics, "~> 0.1.3"} + {:phoenix_analytics, "~> 0.2"} ] end ``` @@ -39,10 +39,31 @@ Update `config/config.exs` ```exs config :phoenix_analytics, - database_path: System.get_env("DUCK_PATH") || "analytics.duckdb", + duckdb_path: System.get_env("DUCKDB_PATH") || "analytics.duckdb", app_domain: System.get_env("PHX_HOST") || "example.com" ``` +> [!IMPORTANT] +> In case you have dynamic cluster, you can use your PostgresDB as backend. + +```exs +config :phoenix_analytics, + duckdb_path: System.get_env("DUCKDB_PATH") || "analytics.duckdb", + app_domain: System.get_env("PHX_HOST") || "example.com", + postgres_conn: System.get_env("POSTGRES_CONN") || "dbname=postgres user=phoenix password=analytics host=localhost" +``` + +> [!IMPORTANT] +> In case you would like to proceed with Postgres option, consider enabling caching. + +```exs +config :phoenix_analytics, + duckdb_path: System.get_env("DUCKDB_PATH") || "analytics.duckdb", + app_domain: System.get_env("PHX_HOST") || "example.com", + postgres_conn: System.get_env("POSTGRES_CONN") || "dbname=postgres user=phoenix password=analytics host=localhost", + cache_ttl: System.get_env("CACHE_TTL") || 120 # seconds +``` + Add migration file > In case you have ecto less / no migrations project you can do the following: @@ -53,8 +74,15 @@ Add migration file mix ecto.gen.migration add_phoenix_analytics ``` +> [!TIP] +> Based on your configuration migration will be run in appropriate database. +> If only `duckdb_path` then in duckdb file. +> If `duckdb_path` and `postgres_conn` provided then in your Postgres database. + ```elixir defmodule MyApp.Repo.Migrations.AddPhoenixAnalytics do + use Ecto.Migration + def up, do: PhoenixAnalytics.Migration.up() def down, do: PhoenixAnalytics.Migration.down() end @@ -85,6 +113,7 @@ Update your `.gitignore` *.duckdb.* ``` +> [!WARNING] > ‼️ Please test thoroughly before proceeding to production! ## Documentation @@ -119,19 +148,31 @@ mix setup Then you would need some database with seeds. Here is command for this: ```sh -DUCK_PATH="dev.duckdb" mix run priv/repo/seeds.exs +DUCKDB_PATH="analytics.duckdb" mix run priv/repo/seeds.exs ``` -Lastly you can start dev server: +or if you would like to test with Postgres backend: ```sh -DUCK_PATH="dev.duckdb" elixir priv/repo/dev.exs +cd examples/duck_postgres/ + +docker compose -f postgres-compose.yml up + +# from project root +mix run priv/repo/seeds_postgres.exs ``` -or +> [!NOTE] +> Move database with seeds to example project which you going to use. + +Lastly you can use one of example applications to start server. ```sh -DUCK_PATH="dev.duckdb" iex priv/repo/dev.exs +cd examples/duck_only/ + +mix deps.get + +mix phx.server ``` You can navigate to `http://localhost:4000/dev/analytics` @@ -147,14 +188,11 @@ Script can be found here: `vegeta/vegeta.sh` ## For whom this library -- [x] Single instance Phoenix app -- [x] Multiple instances of Phoenix app without auto scaling group - -- [ ] Multiple instances of Phoenix app **with** auto scaling group - -There is a plan to build a separate backend to be powered by ClickHouse in order to track requests across multiple nodes in orchestrated scenarios. +- [x] Single instance Phoenix app (duckdb only recommended) +- [x] Multiple instances of Phoenix app **without** auto scaling group (duckdb or postgres option can be used) +- [x] Multiple instances of Phoenix app **with** auto scaling group (only postgres powered apps supported at the moment) ### Heavily inspired by -- [https://github.com/elixir-error-tracker/error-tracker](https://github.com/elixir-error-tracker/error-tracker) -- [https://plausible.io](https://plausible.io) +- [error-tracker](https://github.com/elixir-error-tracker/error-tracker) +- [plausible.io](https://plausible.io) diff --git a/config/config.exs b/config/config.exs index 0e87512..69213c7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,7 +1,10 @@ import Config config :phoenix_analytics, - database_path: System.get_env("DUCK_PATH") || "analytics.duckdb", + duckdb_path: System.get_env("DUCKDB_PATH") || "analytics.duckdb", app_domain: System.get_env("PHX_HOST") || "example.com" +# postgres_conn: "dbname=postgres user=phoenix password=analytics host=localhost" +# cache_ttl: System.get_env("CACHE_TTL") || 120 # seconds + import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index 13378fb..dbf0f53 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,5 +1,7 @@ import Config +config :phoenix_analytics, duckdb_path: System.get_env("DUCKDB_PATH") || "analytics.duckdb" + config :esbuild, :version, "0.17.11" config :tailwind, :version, "3.2.7" diff --git a/config/test.exs b/config/test.exs index ab725a2..4b17712 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,3 +1,3 @@ import Config -config :phoenix_analytics, database_path: "test.duckdb" +config :phoenix_analytics, duckdb_path: "test.duckdb" diff --git a/docker/minio-compose.yml b/docker/minio-compose.yml new file mode 100644 index 0000000..c06fa84 --- /dev/null +++ b/docker/minio-compose.yml @@ -0,0 +1,15 @@ +services: + minio: + image: minio/minio + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_storage:/data + environment: + MINIO_ROOT_USER: phoenix + MINIO_ROOT_PASSWORD: analytics + command: server --console-address ":9001" /data + +volumes: + minio_storage: {} diff --git a/docker/postgres-compose.yml b/docker/postgres-compose.yml new file mode 100644 index 0000000..b9d34bd --- /dev/null +++ b/docker/postgres-compose.yml @@ -0,0 +1,11 @@ +services: + database: + image: "postgres:14.13-alpine" + + ports: + - 5432:5432 + + environment: + POSTGRES_USER: phoenix + POSTGRES_PASSWORD: analytics + POSTGRES_DB: postgres diff --git a/examples/duck_only/.formatter.exs b/examples/duck_only/.formatter.exs new file mode 100644 index 0000000..e945e12 --- /dev/null +++ b/examples/duck_only/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] +] diff --git a/examples/duck_only/.gitignore b/examples/duck_only/.gitignore new file mode 100644 index 0000000..2058ba3 --- /dev/null +++ b/examples/duck_only/.gitignore @@ -0,0 +1,39 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +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"). +duck_only-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + +*.duckdb +*.duckdb.* diff --git a/examples/duck_only/README.md b/examples/duck_only/README.md new file mode 100644 index 0000000..e6e4b60 --- /dev/null +++ b/examples/duck_only/README.md @@ -0,0 +1,57 @@ +# Duck_Only + +## Installation + +If [available in Hex](https://hex.pm/packages/phoenix_analytics), the package can be installed +by adding `phoenix_analytics` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:phoenix_analytics, "~> 0.1.2"} + ] +end +``` + +Update `config/config.exs` + +```exs +config :phoenix_analytics, + duckdb_path: System.get_env("DUCKDB_PATH") || "analytics.duckdb", + app_domain: System.get_env("PHX_HOST") || "example.com" +``` + +Add plug to enable tracking to `endpoint.ex`, ‼️ add it straight after your `Plug.Static` + +```elixir +plug PhoenixAnalytics.Plugs.RequestTracker +``` + +Add dashboard route to your `router.ex` + +```elixir +use PhoenixAnalytics.Web, :router + +phoenix_analytics_dashboard "/analytics" +``` + +Update your `.gitignore` + +```.gitignore +*.duckdb +*.duckdb.* +``` + +## Start Server + +Install dependancies + +```sh +mix deps.get +``` + +Run server + +```sh +mix phx.server +``` diff --git a/examples/duck_only/assets/css/app.css b/examples/duck_only/assets/css/app.css new file mode 100644 index 0000000..378c8f9 --- /dev/null +++ b/examples/duck_only/assets/css/app.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ diff --git a/examples/duck_only/assets/js/app.js b/examples/duck_only/assets/js/app.js new file mode 100644 index 0000000..d5e278a --- /dev/null +++ b/examples/duck_only/assets/js/app.js @@ -0,0 +1,44 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken} +}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/examples/duck_only/assets/tailwind.config.js b/examples/duck_only/assets/tailwind.config.js new file mode 100644 index 0000000..8d473df --- /dev/null +++ b/examples/duck_only/assets/tailwind.config.js @@ -0,0 +1,74 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/duck_only_web.ex", + "../lib/duck_only_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + + // Embeds Heroicons (https://heroicons.com) into your app.css bundle + // See your `CoreComponents.icon/1` for more information. + // + plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) + }) + ] +} diff --git a/examples/duck_only/assets/vendor/topbar.js b/examples/duck_only/assets/vendor/topbar.js new file mode 100644 index 0000000..4195727 --- /dev/null +++ b/examples/duck_only/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/examples/duck_only/config/config.exs b/examples/duck_only/config/config.exs new file mode 100644 index 0000000..3a74d41 --- /dev/null +++ b/examples/duck_only/config/config.exs @@ -0,0 +1,69 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :phoenix_analytics, + app_domain: "example.com", + duckdb_path: "analytics.duckdb" + +config :duck_only, + generators: [timestamp_type: :utc_datetime] + +# Configures the endpoint +config :duck_only, DuckOnlyWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: DuckOnlyWeb.ErrorHTML, json: DuckOnlyWeb.ErrorJSON], + layout: false + ], + pubsub_server: DuckOnly.PubSub, + live_view: [signing_salt: "lH2GjLc0"] + +# Configures the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :duck_only, DuckOnly.Mailer, adapter: Swoosh.Adapters.Local + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.17.11", + duck_only: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "3.4.3", + duck_only: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/examples/duck_only/config/dev.exs b/examples/duck_only/config/dev.exs new file mode 100644 index 0000000..61b4f81 --- /dev/null +++ b/examples/duck_only/config/dev.exs @@ -0,0 +1,75 @@ +import Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :duck_only, DuckOnlyWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "5YifUfLRlCWg8CYgxzUo8wm81zJZyEKeqb9YuOHcx/uy5qcS7hgnVvrjfz9ztUtl", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:duck_only, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:duck_only, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :duck_only, DuckOnlyWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/duck_only_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +# Enable dev routes for dashboard and mailbox +config :duck_only, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include HEEx debug annotations as HTML comments in rendered markup + debug_heex_annotations: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/examples/duck_only/config/prod.exs b/examples/duck_only/config/prod.exs new file mode 100644 index 0000000..e826989 --- /dev/null +++ b/examples/duck_only/config/prod.exs @@ -0,0 +1,20 @@ +import Config + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix assets.deploy` task, +# which you should run after static files are built and +# before starting your production server. +config :duck_only, DuckOnlyWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" + +# Configures Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: DuckOnly.Finch + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/examples/duck_only/config/runtime.exs b/examples/duck_only/config/runtime.exs new file mode 100644 index 0000000..eb3c3ce --- /dev/null +++ b/examples/duck_only/config/runtime.exs @@ -0,0 +1,102 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/duck_only start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :duck_only, DuckOnlyWeb.Endpoint, server: true +end + +if config_env() == :prod do + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :duck_only, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :duck_only, DuckOnlyWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :duck_only, DuckOnlyWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :duck_only, DuckOnlyWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Also, you may need to configure the Swoosh API client of your choice if you + # are not using SMTP. Here is an example of the configuration: + # + # config :duck_only, DuckOnly.Mailer, + # adapter: Swoosh.Adapters.Mailgun, + # api_key: System.get_env("MAILGUN_API_KEY"), + # domain: System.get_env("MAILGUN_DOMAIN") + # + # For this example you need include a HTTP client required by Swoosh API client. + # Swoosh supports Hackney and Finch out of the box: + # + # config :swoosh, :api_client, Swoosh.ApiClient.Hackney + # + # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. +end diff --git a/examples/duck_only/config/test.exs b/examples/duck_only/config/test.exs new file mode 100644 index 0000000..06ea591 --- /dev/null +++ b/examples/duck_only/config/test.exs @@ -0,0 +1,24 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :duck_only, DuckOnlyWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "ywZ5BVvGmlhW1jF5pGP7rBe4swuSuLKsfqNxNJp1RfwswYMlS0tMTm+IQVkjT2aW", + server: false + +# In test we don't send emails +config :duck_only, DuckOnly.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true diff --git a/examples/duck_only/lib/duck_only.ex b/examples/duck_only/lib/duck_only.ex new file mode 100644 index 0000000..b1d8da2 --- /dev/null +++ b/examples/duck_only/lib/duck_only.ex @@ -0,0 +1,9 @@ +defmodule DuckOnly do + @moduledoc """ + DuckOnly keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/examples/duck_only/lib/duck_only/application.ex b/examples/duck_only/lib/duck_only/application.ex new file mode 100644 index 0000000..789e550 --- /dev/null +++ b/examples/duck_only/lib/duck_only/application.ex @@ -0,0 +1,35 @@ +defmodule DuckOnly.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + DuckOnlyWeb.Telemetry, + {DNSCluster, query: Application.get_env(:duck_only, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: DuckOnly.PubSub}, + # Start the Finch HTTP client for sending emails + {Finch, name: DuckOnly.Finch}, + # Start a worker by calling: DuckOnly.Worker.start_link(arg) + # {DuckOnly.Worker, arg}, + # Start to serve requests, typically the last entry + DuckOnlyWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: DuckOnly.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + DuckOnlyWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/examples/duck_only/lib/duck_only/mailer.ex b/examples/duck_only/lib/duck_only/mailer.ex new file mode 100644 index 0000000..2d919bd --- /dev/null +++ b/examples/duck_only/lib/duck_only/mailer.ex @@ -0,0 +1,3 @@ +defmodule DuckOnly.Mailer do + use Swoosh.Mailer, otp_app: :duck_only +end diff --git a/examples/duck_only/lib/duck_only_web.ex b/examples/duck_only/lib/duck_only_web.ex new file mode 100644 index 0000000..7636462 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web.ex @@ -0,0 +1,113 @@ +defmodule DuckOnlyWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use DuckOnlyWeb, :controller + use DuckOnlyWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: DuckOnlyWeb.Layouts] + + import Plug.Conn + import DuckOnlyWeb.Gettext + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {DuckOnlyWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components and translation + import DuckOnlyWeb.CoreComponents + import DuckOnlyWeb.Gettext + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: DuckOnlyWeb.Endpoint, + router: DuckOnlyWeb.Router, + statics: DuckOnlyWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/examples/duck_only/lib/duck_only_web/components/core_components.ex b/examples/duck_only/lib/duck_only_web/components/core_components.ex new file mode 100644 index 0000000..1123e13 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/components/core_components.ex @@ -0,0 +1,676 @@ +defmodule DuckOnlyWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as modals, tables, and + forms. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The default components use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn + how to customize them or feel free to swap in another framework altogether. + + Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + import DuckOnlyWeb.Gettext + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + This is a modal. + + + JS commands may be passed to the `:on_cancel` to configure + the closing/cancel event, for example: + + <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> + This is another modal. + + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + @doc """ + Renders a label. + """ + attr :for, :string, default: nil + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + @doc """ + Generates a generic error message. + """ + slot :inner_block, required: true + + def error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> + <%= render_slot(@inner_block) %> +

+ """ + end + + @doc """ + Renders a header with title. + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ <%= render_slot(@inner_block) %> +

+

+ <%= render_slot(@subtitle) %> +

+
+
<%= render_slot(@actions) %>
+
+ """ + end + + @doc ~S""" + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id"><%= user.id %> + <:col :let={user} label="username"><%= user.username %> + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" +
+ + + + + + + + + + + + + +
<%= col[:label] %> + <%= gettext("Actions") %> +
+
+ + + <%= render_slot(col, @row_item.(row)) %> + +
+
+
+ + + <%= render_slot(action, @row_item.(row)) %> + +
+
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title"><%= @post.title %> + <:item title="Views"><%= @post.views %> + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
+
+
+
<%= item.title %>
+
<%= render_slot(item) %>
+
+
+
+ """ + end + + @doc """ + Renders a back navigation link. + + ## Examples + + <.back navigate={~p"/posts"}>Back to posts + """ + attr :navigate, :any, required: true + slot :inner_block, required: true + + def back(assigns) do + ~H""" +
+ <.link + navigate={@navigate} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" + > + <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> + <%= render_slot(@inner_block) %> + +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in your `assets/tailwind.config.js`. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :string, default: nil + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all transform ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all transform ease-in duration-200", + "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + def show_modal(js \\ %JS{}, id) when is_binary(id) do + js + |> JS.show(to: "##{id}") + |> JS.show( + to: "##{id}-bg", + time: 300, + transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} + ) + |> show("##{id}-container") + |> JS.add_class("overflow-hidden", to: "body") + |> JS.focus_first(to: "##{id}-content") + end + + def hide_modal(js \\ %JS{}, id) do + js + |> JS.hide( + to: "##{id}-bg", + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + |> hide("##{id}-container") + |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) + |> JS.remove_class("overflow-hidden", to: "body") + |> JS.pop_focus() + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(DuckOnlyWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(DuckOnlyWeb.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/examples/duck_only/lib/duck_only_web/components/layouts.ex b/examples/duck_only/lib/duck_only_web/components/layouts.ex new file mode 100644 index 0000000..489f675 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/components/layouts.ex @@ -0,0 +1,14 @@ +defmodule DuckOnlyWeb.Layouts do + @moduledoc """ + This module holds different layouts used by your application. + + See the `layouts` directory for all templates available. + The "root" layout is a skeleton rendered as part of the + application router. The "app" layout is set as the default + layout on both `use DuckOnlyWeb, :controller` and + `use DuckOnlyWeb, :live_view`. + """ + use DuckOnlyWeb, :html + + embed_templates "layouts/*" +end diff --git a/examples/duck_only/lib/duck_only_web/components/layouts/app.html.heex b/examples/duck_only/lib/duck_only_web/components/layouts/app.html.heex new file mode 100644 index 0000000..e23bfc8 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/components/layouts/app.html.heex @@ -0,0 +1,32 @@ +
+
+
+ + + +

+ v<%= Application.spec(:phoenix, :vsn) %> +

+
+ +
+
+
+
+ <.flash_group flash={@flash} /> + <%= @inner_content %> +
+
diff --git a/examples/duck_only/lib/duck_only_web/components/layouts/root.html.heex b/examples/duck_only/lib/duck_only_web/components/layouts/root.html.heex new file mode 100644 index 0000000..9d031d8 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/components/layouts/root.html.heex @@ -0,0 +1,17 @@ + + + + + + + <.live_title suffix=" · Phoenix Framework"> + <%= assigns[:page_title] || "DuckOnly" %> + + + + + + <%= @inner_content %> + + diff --git a/examples/duck_only/lib/duck_only_web/controllers/error_html.ex b/examples/duck_only/lib/duck_only_web/controllers/error_html.ex new file mode 100644 index 0000000..a8c1275 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule DuckOnlyWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use DuckOnlyWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/duck_only_web/controllers/error_html/404.html.heex + # * lib/duck_only_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/examples/duck_only/lib/duck_only_web/controllers/error_json.ex b/examples/duck_only/lib/duck_only_web/controllers/error_json.ex new file mode 100644 index 0000000..597bccc --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule DuckOnlyWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/examples/duck_only/lib/duck_only_web/controllers/page_controller.ex b/examples/duck_only/lib/duck_only_web/controllers/page_controller.ex new file mode 100644 index 0000000..ef261a4 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/controllers/page_controller.ex @@ -0,0 +1,9 @@ +defmodule DuckOnlyWeb.PageController do + use DuckOnlyWeb, :controller + + def home(conn, _params) do + # The home page is often custom made, + # so skip the default app layout. + render(conn, :home, layout: false) + end +end diff --git a/examples/duck_only/lib/duck_only_web/controllers/page_html.ex b/examples/duck_only/lib/duck_only_web/controllers/page_html.ex new file mode 100644 index 0000000..f1d6ff8 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule DuckOnlyWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use DuckOnlyWeb, :html + + embed_templates "page_html/*" +end diff --git a/examples/duck_only/lib/duck_only_web/controllers/page_html/home.html.heex b/examples/duck_only/lib/duck_only_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..dc1820b --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/controllers/page_html/home.html.heex @@ -0,0 +1,222 @@ +<.flash_group flash={@flash} /> + +
+
+ +

+ Phoenix Framework + + v<%= Application.spec(:phoenix, :vsn) %> + +

+

+ Peace of mind from prototype to production. +

+

+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +

+ +
+
diff --git a/examples/duck_only/lib/duck_only_web/endpoint.ex b/examples/duck_only/lib/duck_only_web/endpoint.ex new file mode 100644 index 0000000..9ea7d8c --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/endpoint.ex @@ -0,0 +1,54 @@ +defmodule DuckOnlyWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :duck_only + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_duck_only_key", + signing_salt: "B97X91oR", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :duck_only, + gzip: false, + only: DuckOnlyWeb.static_paths() + + plug PhoenixAnalytics.Plugs.RequestTracker + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug DuckOnlyWeb.Router +end diff --git a/examples/duck_only/lib/duck_only_web/gettext.ex b/examples/duck_only/lib/duck_only_web/gettext.ex new file mode 100644 index 0000000..73d4bfa --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule DuckOnlyWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import DuckOnlyWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :duck_only +end diff --git a/examples/duck_only/lib/duck_only_web/router.ex b/examples/duck_only/lib/duck_only_web/router.ex new file mode 100644 index 0000000..46aeca7 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/router.ex @@ -0,0 +1,46 @@ +defmodule DuckOnlyWeb.Router do + use DuckOnlyWeb, :router + use PhoenixAnalytics.Web, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {DuckOnlyWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", DuckOnlyWeb do + pipe_through :browser + + get "/", PageController, :home + end + + # Other scopes may use custom stacks. + # scope "/api", DuckOnlyWeb do + # pipe_through :api + # end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:duck_only, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + phoenix_analytics_dashboard("/analytics") + live_dashboard "/dashboard", metrics: DuckOnlyWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/examples/duck_only/lib/duck_only_web/telemetry.ex b/examples/duck_only/lib/duck_only_web/telemetry.ex new file mode 100644 index 0000000..c5696e5 --- /dev/null +++ b/examples/duck_only/lib/duck_only_web/telemetry.ex @@ -0,0 +1,69 @@ +defmodule DuckOnlyWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {DuckOnlyWeb, :count_users, []} + ] + end +end diff --git a/examples/duck_only/mix.exs b/examples/duck_only/mix.exs new file mode 100644 index 0000000..689fd0b --- /dev/null +++ b/examples/duck_only/mix.exs @@ -0,0 +1,81 @@ +defmodule DuckOnly.MixProject do + use Mix.Project + + def project do + [ + app: :duck_only, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {DuckOnly.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.7.14"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"}, + {:phoenix_live_view, "~> 1.0.0-rc.1", override: true}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.5"}, + {:finch, "~> 0.13"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.20"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.1.1"}, + {:bandit, "~> 1.5"}, + {:phoenix_analytics, path: "../../", force: true} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "assets.setup", "assets.build"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind duck_only", "esbuild duck_only"], + "assets.deploy": [ + "tailwind duck_only --minify", + "esbuild duck_only --minify", + "phx.digest" + ] + ] + end +end diff --git a/examples/duck_only/mix.lock b/examples/duck_only/mix.lock new file mode 100644 index 0000000..2acf2f7 --- /dev/null +++ b/examples/duck_only/mix.lock @@ -0,0 +1,48 @@ +%{ + "bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"}, + "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, + "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, + "duckdbex": {:hex, :duckdbex, "0.3.3", "ca15a98ac7007d72afac72fbbeea065555bbcf60971af73f43c36746a633b385", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "b411e1250c27c52e1119ac9d4a6640c6670f099a60bea1d10f4e25e305cd5b2f"}, + "ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, + "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, + "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, + "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, + "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, + "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, + "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, + "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, + "live_react": {:hex, :live_react, "0.1.0", "5b406ee4108125f833938c52d7a0eb4611854d421e9a345def5cca450711dfe8", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 3.3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "1d499253bcaf8f4391454b96af3abfff6cdd35ef99b1104edf6c802e06726050"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, + "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0-rc.6", "47d2669995ea326e5c71f5c1bc9177109cebf211385c638faa7b5862a401e516", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e56e4f1642a0b20edc2488cab30e5439595e0d8b5b259f76ef98b1c4e2e5b527"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, + "swoosh": {:hex, :swoosh, "1.17.0", "4a082a6ce4d60b1f48ffa725c8da0e2304504569ff550f4ed2d088c923039cb0", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "659b8bc25f7483b872d051a7f0731fb8d5312165be0d0302a3c783b566b0a290"}, + "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, + "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, + "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, + "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, +} diff --git a/examples/duck_only/priv/gettext/en/LC_MESSAGES/errors.po b/examples/duck_only/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..cdec3a1 --- /dev/null +++ b/examples/duck_only/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,11 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" diff --git a/examples/duck_only/priv/gettext/errors.pot b/examples/duck_only/priv/gettext/errors.pot new file mode 100644 index 0000000..d6f47fa --- /dev/null +++ b/examples/duck_only/priv/gettext/errors.pot @@ -0,0 +1,10 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. + diff --git a/examples/duck_only/priv/static/favicon.ico b/examples/duck_only/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/examples/duck_only/priv/static/images/logo.svg b/examples/duck_only/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/examples/duck_only/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/examples/duck_only/priv/static/robots.txt b/examples/duck_only/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/examples/duck_only/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/examples/duck_only/test/duck_only_web/controllers/error_html_test.exs b/examples/duck_only/test/duck_only_web/controllers/error_html_test.exs new file mode 100644 index 0000000..8fbeea0 --- /dev/null +++ b/examples/duck_only/test/duck_only_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule DuckOnlyWeb.ErrorHTMLTest do + use DuckOnlyWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template + + test "renders 404.html" do + assert render_to_string(DuckOnlyWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(DuckOnlyWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/examples/duck_only/test/duck_only_web/controllers/error_json_test.exs b/examples/duck_only/test/duck_only_web/controllers/error_json_test.exs new file mode 100644 index 0000000..3423c88 --- /dev/null +++ b/examples/duck_only/test/duck_only_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule DuckOnlyWeb.ErrorJSONTest do + use DuckOnlyWeb.ConnCase, async: true + + test "renders 404" do + assert DuckOnlyWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert DuckOnlyWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/examples/duck_only/test/duck_only_web/controllers/page_controller_test.exs b/examples/duck_only/test/duck_only_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..a8caafd --- /dev/null +++ b/examples/duck_only/test/duck_only_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule DuckOnlyWeb.PageControllerTest do + use DuckOnlyWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end diff --git a/examples/duck_only/test/support/conn_case.ex b/examples/duck_only/test/support/conn_case.ex new file mode 100644 index 0000000..1855678 --- /dev/null +++ b/examples/duck_only/test/support/conn_case.ex @@ -0,0 +1,37 @@ +defmodule DuckOnlyWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use DuckOnlyWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint DuckOnlyWeb.Endpoint + + use DuckOnlyWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import DuckOnlyWeb.ConnCase + end + end + + setup _tags do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/examples/duck_only/test/test_helper.exs b/examples/duck_only/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/examples/duck_only/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/examples/duck_postgres/.formatter.exs b/examples/duck_postgres/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/examples/duck_postgres/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/examples/duck_postgres/.gitignore b/examples/duck_postgres/.gitignore new file mode 100644 index 0000000..6038424 --- /dev/null +++ b/examples/duck_postgres/.gitignore @@ -0,0 +1,39 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +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"). +duck_postgres-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + +*.duckdb +*.duckdb.* diff --git a/examples/duck_postgres/README.md b/examples/duck_postgres/README.md new file mode 100644 index 0000000..1e88ab7 --- /dev/null +++ b/examples/duck_postgres/README.md @@ -0,0 +1,72 @@ +# Duck_Postgres + +## Installation + +If [available in Hex](https://hex.pm/packages/phoenix_analytics), the package can be installed +by adding `phoenix_analytics` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:phoenix_analytics, "~> 0.1.2"} + ] +end +``` + +Update `config/config.exs` + +```exs +config :phoenix_analytics, + duckdb_path: System.get_env("DUCKDB_PATH") || "analytics.duckdb", + app_domain: System.get_env("PHX_HOST") || "example.com", + postgres_conn: "dbname=postgres user=phoenix password=analytics host=localhost" +``` + +Add plug to enable tracking to `endpoint.ex`, ‼️ add it straight after your `Plug.Static` + +```elixir +plug PhoenixAnalytics.Plugs.RequestTracker +``` + +Add dashboard route to your `router.ex` + +```elixir +use PhoenixAnalytics.Web, :router + +phoenix_analytics_dashboard "/analytics" +``` + +Update your `.gitignore` + +```.gitignore +*.duckdb +*.duckdb.* +``` + +## Start Server + +Install dependancies + +```sh +mix deps.get +``` + +Start database container + +```sh +docker compose -f postgres-compose.yml up +``` + +Run migrations + +```sh +mix ecto.create + +mix ecto.migrate +``` + +Run server + +```sh +mix phx.server +``` diff --git a/examples/duck_postgres/assets/css/app.css b/examples/duck_postgres/assets/css/app.css new file mode 100644 index 0000000..378c8f9 --- /dev/null +++ b/examples/duck_postgres/assets/css/app.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ diff --git a/examples/duck_postgres/assets/js/app.js b/examples/duck_postgres/assets/js/app.js new file mode 100644 index 0000000..d5e278a --- /dev/null +++ b/examples/duck_postgres/assets/js/app.js @@ -0,0 +1,44 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken} +}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/examples/duck_postgres/assets/tailwind.config.js b/examples/duck_postgres/assets/tailwind.config.js new file mode 100644 index 0000000..d946f66 --- /dev/null +++ b/examples/duck_postgres/assets/tailwind.config.js @@ -0,0 +1,74 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/duck_postgres_web.ex", + "../lib/duck_postgres_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + // Allows prefixing tailwind classes with LiveView classes to add rules + // only when LiveView classes are applied, for example: + // + //
+ // + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + + // Embeds Heroicons (https://heroicons.com) into your app.css bundle + // See your `CoreComponents.icon/1` for more information. + // + plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) + }) + ] +} diff --git a/examples/duck_postgres/assets/vendor/topbar.js b/examples/duck_postgres/assets/vendor/topbar.js new file mode 100644 index 0000000..4195727 --- /dev/null +++ b/examples/duck_postgres/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/examples/duck_postgres/config/config.exs b/examples/duck_postgres/config/config.exs new file mode 100644 index 0000000..7a2bd37 --- /dev/null +++ b/examples/duck_postgres/config/config.exs @@ -0,0 +1,72 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :phoenix_analytics, + app_domain: "example.com", + duckdb_path: "analytics.duckdb", + postgres_conn: "dbname=postgres user=phoenix password=analytics host=localhost", + cache_ttl: System.get_env("CACHE_TTL") || 120 + +config :duck_postgres, + ecto_repos: [DuckPostgres.Repo], + generators: [timestamp_type: :utc_datetime] + +# Configures the endpoint +config :duck_postgres, DuckPostgresWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: DuckPostgresWeb.ErrorHTML, json: DuckPostgresWeb.ErrorJSON], + layout: false + ], + pubsub_server: DuckPostgres.PubSub, + live_view: [signing_salt: "fHbHgklz"] + +# Configures the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :duck_postgres, DuckPostgres.Mailer, adapter: Swoosh.Adapters.Local + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.17.11", + duck_postgres: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "3.4.3", + duck_postgres: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/examples/duck_postgres/config/dev.exs b/examples/duck_postgres/config/dev.exs new file mode 100644 index 0000000..a69b7e4 --- /dev/null +++ b/examples/duck_postgres/config/dev.exs @@ -0,0 +1,85 @@ +import Config + +# Configure your database +config :duck_postgres, DuckPostgres.Repo, + username: "phoenix", + password: "analytics", + hostname: "localhost", + database: "postgres", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :duck_postgres, DuckPostgresWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "vgqpwyys2uCUAd139Jg7AG8m81gs80da2VuZa64EXTjHtnlYN9PkPvEZGqNsqoyf", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:duck_postgres, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:duck_postgres, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :duck_postgres, DuckPostgresWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/duck_postgres_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +# Enable dev routes for dashboard and mailbox +config :duck_postgres, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include HEEx debug annotations as HTML comments in rendered markup + debug_heex_annotations: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/examples/duck_postgres/config/prod.exs b/examples/duck_postgres/config/prod.exs new file mode 100644 index 0000000..4244ee5 --- /dev/null +++ b/examples/duck_postgres/config/prod.exs @@ -0,0 +1,21 @@ +import Config + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix assets.deploy` task, +# which you should run after static files are built and +# before starting your production server. +config :duck_postgres, DuckPostgresWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +# Configures Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: DuckPostgres.Finch + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/examples/duck_postgres/config/runtime.exs b/examples/duck_postgres/config/runtime.exs new file mode 100644 index 0000000..fc14008 --- /dev/null +++ b/examples/duck_postgres/config/runtime.exs @@ -0,0 +1,117 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/duck_postgres start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :duck_postgres, DuckPostgresWeb.Endpoint, server: true +end + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :duck_postgres, DuckPostgres.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :duck_postgres, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :duck_postgres, DuckPostgresWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :duck_postgres, DuckPostgresWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :duck_postgres, DuckPostgresWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Also, you may need to configure the Swoosh API client of your choice if you + # are not using SMTP. Here is an example of the configuration: + # + # config :duck_postgres, DuckPostgres.Mailer, + # adapter: Swoosh.Adapters.Mailgun, + # api_key: System.get_env("MAILGUN_API_KEY"), + # domain: System.get_env("MAILGUN_DOMAIN") + # + # For this example you need include a HTTP client required by Swoosh API client. + # Swoosh supports Hackney and Finch out of the box: + # + # config :swoosh, :api_client, Swoosh.ApiClient.Hackney + # + # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. +end diff --git a/examples/duck_postgres/config/test.exs b/examples/duck_postgres/config/test.exs new file mode 100644 index 0000000..a91549c --- /dev/null +++ b/examples/duck_postgres/config/test.exs @@ -0,0 +1,37 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :duck_postgres, DuckPostgres.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "duck_postgres_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :duck_postgres, DuckPostgresWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "Rpeb+/n/g8Z+Ic/1HOXHTMZ3xyHcEa8iuG2MjXRLH0/0T0H3rCe5GK2U2eiboG4W", + server: false + +# In test we don't send emails +config :duck_postgres, DuckPostgres.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true diff --git a/examples/duck_postgres/lib/duck_postgres.ex b/examples/duck_postgres/lib/duck_postgres.ex new file mode 100644 index 0000000..b6ac1af --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres.ex @@ -0,0 +1,9 @@ +defmodule DuckPostgres do + @moduledoc """ + DuckPostgres keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/examples/duck_postgres/lib/duck_postgres/application.ex b/examples/duck_postgres/lib/duck_postgres/application.ex new file mode 100644 index 0000000..d94073f --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres/application.ex @@ -0,0 +1,36 @@ +defmodule DuckPostgres.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + DuckPostgresWeb.Telemetry, + DuckPostgres.Repo, + {DNSCluster, query: Application.get_env(:duck_postgres, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: DuckPostgres.PubSub}, + # Start the Finch HTTP client for sending emails + {Finch, name: DuckPostgres.Finch}, + # Start a worker by calling: DuckPostgres.Worker.start_link(arg) + # {DuckPostgres.Worker, arg}, + # Start to serve requests, typically the last entry + DuckPostgresWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: DuckPostgres.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + DuckPostgresWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/examples/duck_postgres/lib/duck_postgres/mailer.ex b/examples/duck_postgres/lib/duck_postgres/mailer.ex new file mode 100644 index 0000000..d61c1c6 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres/mailer.ex @@ -0,0 +1,3 @@ +defmodule DuckPostgres.Mailer do + use Swoosh.Mailer, otp_app: :duck_postgres +end diff --git a/examples/duck_postgres/lib/duck_postgres/repo.ex b/examples/duck_postgres/lib/duck_postgres/repo.ex new file mode 100644 index 0000000..cf02bb0 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres/repo.ex @@ -0,0 +1,5 @@ +defmodule DuckPostgres.Repo do + use Ecto.Repo, + otp_app: :duck_postgres, + adapter: Ecto.Adapters.Postgres +end diff --git a/examples/duck_postgres/lib/duck_postgres_web.ex b/examples/duck_postgres/lib/duck_postgres_web.ex new file mode 100644 index 0000000..7c70b82 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web.ex @@ -0,0 +1,113 @@ +defmodule DuckPostgresWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use DuckPostgresWeb, :controller + use DuckPostgresWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: DuckPostgresWeb.Layouts] + + import Plug.Conn + import DuckPostgresWeb.Gettext + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {DuckPostgresWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components and translation + import DuckPostgresWeb.CoreComponents + import DuckPostgresWeb.Gettext + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: DuckPostgresWeb.Endpoint, + router: DuckPostgresWeb.Router, + statics: DuckPostgresWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/components/core_components.ex b/examples/duck_postgres/lib/duck_postgres_web/components/core_components.ex new file mode 100644 index 0000000..d447bcb --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/components/core_components.ex @@ -0,0 +1,676 @@ +defmodule DuckPostgresWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as modals, tables, and + forms. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The default components use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn + how to customize them or feel free to swap in another framework altogether. + + Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + import DuckPostgresWeb.Gettext + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + This is a modal. + + + JS commands may be passed to the `:on_cancel` to configure + the closing/cancel event, for example: + + <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> + This is another modal. + + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + slot :inner_block, required: true + + def modal(assigns) do + ~H""" + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + @doc """ + Renders a label. + """ + attr :for, :string, default: nil + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + @doc """ + Generates a generic error message. + """ + slot :inner_block, required: true + + def error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> + <%= render_slot(@inner_block) %> +

+ """ + end + + @doc """ + Renders a header with title. + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ <%= render_slot(@inner_block) %> +

+

+ <%= render_slot(@subtitle) %> +

+
+
<%= render_slot(@actions) %>
+
+ """ + end + + @doc ~S""" + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id"><%= user.id %> + <:col :let={user} label="username"><%= user.username %> + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" +
+ + + + + + + + + + + + + +
<%= col[:label] %> + <%= gettext("Actions") %> +
+
+ + + <%= render_slot(col, @row_item.(row)) %> + +
+
+
+ + + <%= render_slot(action, @row_item.(row)) %> + +
+
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title"><%= @post.title %> + <:item title="Views"><%= @post.views %> + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" +
+
+
+
<%= item.title %>
+
<%= render_slot(item) %>
+
+
+
+ """ + end + + @doc """ + Renders a back navigation link. + + ## Examples + + <.back navigate={~p"/posts"}>Back to posts + """ + attr :navigate, :any, required: true + slot :inner_block, required: true + + def back(assigns) do + ~H""" +
+ <.link + navigate={@navigate} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" + > + <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> + <%= render_slot(@inner_block) %> + +
+ """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in your `assets/tailwind.config.js`. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :string, default: nil + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all transform ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all transform ease-in duration-200", + "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + def show_modal(js \\ %JS{}, id) when is_binary(id) do + js + |> JS.show(to: "##{id}") + |> JS.show( + to: "##{id}-bg", + time: 300, + transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} + ) + |> show("##{id}-container") + |> JS.add_class("overflow-hidden", to: "body") + |> JS.focus_first(to: "##{id}-content") + end + + def hide_modal(js \\ %JS{}, id) do + js + |> JS.hide( + to: "##{id}-bg", + transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} + ) + |> hide("##{id}-container") + |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) + |> JS.remove_class("overflow-hidden", to: "body") + |> JS.pop_focus() + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(DuckPostgresWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(DuckPostgresWeb.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/components/layouts.ex b/examples/duck_postgres/lib/duck_postgres_web/components/layouts.ex new file mode 100644 index 0000000..8a7ddfa --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/components/layouts.ex @@ -0,0 +1,14 @@ +defmodule DuckPostgresWeb.Layouts do + @moduledoc """ + This module holds different layouts used by your application. + + See the `layouts` directory for all templates available. + The "root" layout is a skeleton rendered as part of the + application router. The "app" layout is set as the default + layout on both `use DuckPostgresWeb, :controller` and + `use DuckPostgresWeb, :live_view`. + """ + use DuckPostgresWeb, :html + + embed_templates "layouts/*" +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/components/layouts/app.html.heex b/examples/duck_postgres/lib/duck_postgres_web/components/layouts/app.html.heex new file mode 100644 index 0000000..e23bfc8 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/components/layouts/app.html.heex @@ -0,0 +1,32 @@ +
+
+
+ + + +

+ v<%= Application.spec(:phoenix, :vsn) %> +

+
+ +
+
+
+
+ <.flash_group flash={@flash} /> + <%= @inner_content %> +
+
diff --git a/examples/duck_postgres/lib/duck_postgres_web/components/layouts/root.html.heex b/examples/duck_postgres/lib/duck_postgres_web/components/layouts/root.html.heex new file mode 100644 index 0000000..894a60c --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/components/layouts/root.html.heex @@ -0,0 +1,17 @@ + + + + + + + <.live_title suffix=" · Phoenix Framework"> + <%= assigns[:page_title] || "DuckPostgres" %> + + + + + + <%= @inner_content %> + + diff --git a/examples/duck_postgres/lib/duck_postgres_web/controllers/error_html.ex b/examples/duck_postgres/lib/duck_postgres_web/controllers/error_html.ex new file mode 100644 index 0000000..8414938 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule DuckPostgresWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use DuckPostgresWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/duck_postgres_web/controllers/error_html/404.html.heex + # * lib/duck_postgres_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/controllers/error_json.ex b/examples/duck_postgres/lib/duck_postgres_web/controllers/error_json.ex new file mode 100644 index 0000000..1d92646 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule DuckPostgresWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/controllers/page_controller.ex b/examples/duck_postgres/lib/duck_postgres_web/controllers/page_controller.ex new file mode 100644 index 0000000..669d864 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/controllers/page_controller.ex @@ -0,0 +1,9 @@ +defmodule DuckPostgresWeb.PageController do + use DuckPostgresWeb, :controller + + def home(conn, _params) do + # The home page is often custom made, + # so skip the default app layout. + render(conn, :home, layout: false) + end +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/controllers/page_html.ex b/examples/duck_postgres/lib/duck_postgres_web/controllers/page_html.ex new file mode 100644 index 0000000..e7fd8be --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule DuckPostgresWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use DuckPostgresWeb, :html + + embed_templates "page_html/*" +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/controllers/page_html/home.html.heex b/examples/duck_postgres/lib/duck_postgres_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..dc1820b --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/controllers/page_html/home.html.heex @@ -0,0 +1,222 @@ +<.flash_group flash={@flash} /> + +
+
+ +

+ Phoenix Framework + + v<%= Application.spec(:phoenix, :vsn) %> + +

+

+ Peace of mind from prototype to production. +

+

+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +

+ +
+
diff --git a/examples/duck_postgres/lib/duck_postgres_web/endpoint.ex b/examples/duck_postgres/lib/duck_postgres_web/endpoint.ex new file mode 100644 index 0000000..8bd12c5 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/endpoint.ex @@ -0,0 +1,55 @@ +defmodule DuckPostgresWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :duck_postgres + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_duck_postgres_key", + signing_salt: "59sVq8T4", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :duck_postgres, + gzip: false, + only: DuckPostgresWeb.static_paths() + + plug PhoenixAnalytics.Plugs.RequestTracker + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :duck_postgres + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug DuckPostgresWeb.Router +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/gettext.ex b/examples/duck_postgres/lib/duck_postgres_web/gettext.ex new file mode 100644 index 0000000..a5311d0 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule DuckPostgresWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import DuckPostgresWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :duck_postgres +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/router.ex b/examples/duck_postgres/lib/duck_postgres_web/router.ex new file mode 100644 index 0000000..732e084 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/router.ex @@ -0,0 +1,46 @@ +defmodule DuckPostgresWeb.Router do + use DuckPostgresWeb, :router + use PhoenixAnalytics.Web, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {DuckPostgresWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", DuckPostgresWeb do + pipe_through :browser + + get "/", PageController, :home + end + + # Other scopes may use custom stacks. + # scope "/api", DuckPostgresWeb do + # pipe_through :api + # end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:duck_postgres, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + phoenix_analytics_dashboard("/analytics") + live_dashboard "/dashboard", metrics: DuckPostgresWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/examples/duck_postgres/lib/duck_postgres_web/telemetry.ex b/examples/duck_postgres/lib/duck_postgres_web/telemetry.ex new file mode 100644 index 0000000..fd15a76 --- /dev/null +++ b/examples/duck_postgres/lib/duck_postgres_web/telemetry.ex @@ -0,0 +1,92 @@ +defmodule DuckPostgresWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("duck_postgres.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("duck_postgres.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("duck_postgres.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("duck_postgres.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("duck_postgres.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {DuckPostgresWeb, :count_users, []} + ] + end +end diff --git a/examples/duck_postgres/mix.exs b/examples/duck_postgres/mix.exs new file mode 100644 index 0000000..010b37b --- /dev/null +++ b/examples/duck_postgres/mix.exs @@ -0,0 +1,87 @@ +defmodule DuckPostgres.MixProject do + use Mix.Project + + def project do + [ + app: :duck_postgres, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {DuckPostgres.Application, []}, + extra_applications: [:logger, :runtime_tools, :wx, :observer] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.7.14"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.10"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"}, + {:phoenix_live_view, "~> 1.0.0-rc.1", override: true}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.5"}, + {:finch, "~> 0.13"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.20"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.1.1"}, + {:bandit, "~> 1.5"}, + {:phoenix_analytics, path: "../../", force: true} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind duck_postgres", "esbuild duck_postgres"], + "assets.deploy": [ + "tailwind duck_postgres --minify", + "esbuild duck_postgres --minify", + "phx.digest" + ] + ] + end +end diff --git a/examples/duck_postgres/mix.lock b/examples/duck_postgres/mix.lock new file mode 100644 index 0000000..2bd3c81 --- /dev/null +++ b/examples/duck_postgres/mix.lock @@ -0,0 +1,52 @@ +%{ + "bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"}, + "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, + "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, + "duckdbex": {:hex, :duckdbex, "0.3.3", "ca15a98ac7007d72afac72fbbeea065555bbcf60971af73f43c36746a633b385", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "b411e1250c27c52e1119ac9d4a6640c6670f099a60bea1d10f4e25e305cd5b2f"}, + "ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, + "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, + "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, + "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, + "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, + "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, + "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, + "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, + "live_react": {:hex, :live_react, "0.1.0", "5b406ee4108125f833938c52d7a0eb4611854d421e9a345def5cca450711dfe8", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 3.3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "1d499253bcaf8f4391454b96af3abfff6cdd35ef99b1104edf6c802e06726050"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, + "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0-rc.6", "47d2669995ea326e5c71f5c1bc9177109cebf211385c638faa7b5862a401e516", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e56e4f1642a0b20edc2488cab30e5439595e0d8b5b259f76ef98b1c4e2e5b527"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, + "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, + "swoosh": {:hex, :swoosh, "1.17.0", "4a082a6ce4d60b1f48ffa725c8da0e2304504569ff550f4ed2d088c923039cb0", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "659b8bc25f7483b872d051a7f0731fb8d5312165be0d0302a3c783b566b0a290"}, + "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, + "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, + "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, + "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, +} diff --git a/examples/duck_postgres/postgres-compose.yml b/examples/duck_postgres/postgres-compose.yml new file mode 100644 index 0000000..b9d34bd --- /dev/null +++ b/examples/duck_postgres/postgres-compose.yml @@ -0,0 +1,11 @@ +services: + database: + image: "postgres:14.13-alpine" + + ports: + - 5432:5432 + + environment: + POSTGRES_USER: phoenix + POSTGRES_PASSWORD: analytics + POSTGRES_DB: postgres diff --git a/examples/duck_postgres/priv/gettext/en/LC_MESSAGES/errors.po b/examples/duck_postgres/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/examples/duck_postgres/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/examples/duck_postgres/priv/gettext/errors.pot b/examples/duck_postgres/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/examples/duck_postgres/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/examples/duck_postgres/priv/repo/migrations/.formatter.exs b/examples/duck_postgres/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/examples/duck_postgres/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/examples/duck_postgres/priv/repo/migrations/20240912135104_add_requests_table.exs b/examples/duck_postgres/priv/repo/migrations/20240912135104_add_requests_table.exs new file mode 100644 index 0000000..ed6da83 --- /dev/null +++ b/examples/duck_postgres/priv/repo/migrations/20240912135104_add_requests_table.exs @@ -0,0 +1,6 @@ +defmodule DuckPostgres.Repo.Migrations.AddRequestsTable do + use Ecto.Migration + + def up, do: PhoenixAnalytics.Migration.up() + def down, do: PhoenixAnalytics.Migration.down() +end diff --git a/examples/duck_postgres/priv/repo/seeds.exs b/examples/duck_postgres/priv/repo/seeds.exs new file mode 100644 index 0000000..bca2088 --- /dev/null +++ b/examples/duck_postgres/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# DuckPostgres.Repo.insert!(%DuckPostgres.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/examples/duck_postgres/priv/static/favicon.ico b/examples/duck_postgres/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/examples/duck_postgres/priv/static/images/logo.svg b/examples/duck_postgres/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/examples/duck_postgres/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/examples/duck_postgres/priv/static/robots.txt b/examples/duck_postgres/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/examples/duck_postgres/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/examples/duck_postgres/test/duck_postgres_web/controllers/error_html_test.exs b/examples/duck_postgres/test/duck_postgres_web/controllers/error_html_test.exs new file mode 100644 index 0000000..19558d5 --- /dev/null +++ b/examples/duck_postgres/test/duck_postgres_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule DuckPostgresWeb.ErrorHTMLTest do + use DuckPostgresWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template + + test "renders 404.html" do + assert render_to_string(DuckPostgresWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(DuckPostgresWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/examples/duck_postgres/test/duck_postgres_web/controllers/error_json_test.exs b/examples/duck_postgres/test/duck_postgres_web/controllers/error_json_test.exs new file mode 100644 index 0000000..2fdc141 --- /dev/null +++ b/examples/duck_postgres/test/duck_postgres_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule DuckPostgresWeb.ErrorJSONTest do + use DuckPostgresWeb.ConnCase, async: true + + test "renders 404" do + assert DuckPostgresWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert DuckPostgresWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/examples/duck_postgres/test/duck_postgres_web/controllers/page_controller_test.exs b/examples/duck_postgres/test/duck_postgres_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..d0d8046 --- /dev/null +++ b/examples/duck_postgres/test/duck_postgres_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule DuckPostgresWeb.PageControllerTest do + use DuckPostgresWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end diff --git a/examples/duck_postgres/test/support/conn_case.ex b/examples/duck_postgres/test/support/conn_case.ex new file mode 100644 index 0000000..7340bdf --- /dev/null +++ b/examples/duck_postgres/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule DuckPostgresWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use DuckPostgresWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint DuckPostgresWeb.Endpoint + + use DuckPostgresWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import DuckPostgresWeb.ConnCase + end + end + + setup tags do + DuckPostgres.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/examples/duck_postgres/test/support/data_case.ex b/examples/duck_postgres/test/support/data_case.ex new file mode 100644 index 0000000..7316f5a --- /dev/null +++ b/examples/duck_postgres/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule DuckPostgres.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use DuckPostgres.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias DuckPostgres.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import DuckPostgres.DataCase + end + end + + setup tags do + DuckPostgres.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(DuckPostgres.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/examples/duck_postgres/test/test_helper.exs b/examples/duck_postgres/test/test_helper.exs new file mode 100644 index 0000000..f91e193 --- /dev/null +++ b/examples/duck_postgres/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(DuckPostgres.Repo, :manual) diff --git a/lib/phoenix_analytics/entities/request_log.ex b/lib/phoenix_analytics/entities/request_log.ex index 44fb55e..ac4f5f3 100644 --- a/lib/phoenix_analytics/entities/request_log.ex +++ b/lib/phoenix_analytics/entities/request_log.ex @@ -19,7 +19,7 @@ defmodule PhoenixAnalytics.Entities.RequestLog do @type status_code :: non_neg_integer() @typedoc "Duration of the request in milliseconds" - @type duration_ms :: float() + @type duration_ms :: integer() @typedoc "User agent string of the client" @type user_agent :: String.t() | nil diff --git a/lib/phoenix_analytics/migration.ex b/lib/phoenix_analytics/migration.ex index e75287a..d3d1b92 100644 --- a/lib/phoenix_analytics/migration.ex +++ b/lib/phoenix_analytics/migration.ex @@ -2,14 +2,17 @@ defmodule PhoenixAnalytics.Migration do @moduledoc false alias PhoenixAnalytics.Queries + alias PhoenixAnalytics.Services.Bridge - @db_path Application.compile_env(:phoenix_analytics, :database_path) || - System.get_env("DUCK_PATH") + @db_path Application.compile_env(:phoenix_analytics, :duckdb_path) || + System.get_env("DUCKDB_PATH") - def up() do + def up do {:ok, db} = Duckdbex.open(@db_path) {:ok, conn} = Duckdbex.connection(db) + Bridge.attach_postgres(db, conn) + query = Queries.Table.create_requests() case Duckdbex.query(conn, query) do @@ -23,10 +26,12 @@ defmodule PhoenixAnalytics.Migration do end end - def down() do + def down do {:ok, db} = Duckdbex.open(@db_path) {:ok, conn} = Duckdbex.connection(db) + Bridge.attach_postgres(db, conn) + query = Queries.Table.drop_requests() case Duckdbex.query(conn, query) do diff --git a/lib/phoenix_analytics/queries/analytics/stats/per_period.ex b/lib/phoenix_analytics/queries/analytics/stats/per_period.ex index b0e89d2..f3f4de2 100644 --- a/lib/phoenix_analytics/queries/analytics/stats/per_period.ex +++ b/lib/phoenix_analytics/queries/analytics/stats/per_period.ex @@ -112,7 +112,7 @@ defmodule PhoenixAnalytics.Queries.Analytics.Stats.PerPeriod do )) AS period ) SELECT * FROM ( - SELECT date_trunc('#{interval}', ds.period)::VARCHAR AS date, count(*) as hits, + SELECT date_trunc('#{interval}', ds.period)::VARCHAR AS date, count(request_id) as hits, FROM date_series ds LEFT JOIN #{@table} ON date_trunc('day', inserted_at) = ds.period AND method = 'GET' @@ -138,7 +138,7 @@ defmodule PhoenixAnalytics.Queries.Analytics.Stats.PerPeriod do )) AS period ) SELECT * FROM ( - SELECT date_trunc('#{interval}', ds.period)::VARCHAR AS date, count(*) as hits, + SELECT date_trunc('#{interval}', ds.period)::VARCHAR AS date, count(request_id) as hits, FROM date_series ds LEFT JOIN #{@table} ON date_trunc('day', inserted_at) = ds.period diff --git a/lib/phoenix_analytics/queries/insert.ex b/lib/phoenix_analytics/queries/insert.ex index 9b09b86..f89aee2 100644 --- a/lib/phoenix_analytics/queries/insert.ex +++ b/lib/phoenix_analytics/queries/insert.ex @@ -5,7 +5,7 @@ defmodule PhoenixAnalytics.Queries.Insert do alias PhoenixAnalytics.Entities.RequestLog @table Table.name() - @columns ~w(request_id method path status_code duration_ms user_agent remote_ip referer device session_id session_duration session_page_views) + @columns ~w(request_id method path status_code duration_ms user_agent remote_ip referer device session_id session_page_views inserted_at) @placeholders List.duplicate("?", length(@columns)) |> Enum.join(", ") @query "INSERT INTO #{@table} (#{Enum.join(@columns, ", ")}) VALUES (#{@placeholders});" @@ -14,6 +14,19 @@ defmodule PhoenixAnalytics.Queries.Insert do {@query, prepare_values(request_data)} end + @spec insert_many(list(RequestLog.t())) :: {String.t(), list()} + def insert_many(request_data_list) when is_list(request_data_list) do + batch_size = length(request_data_list) + values_placeholders = List.duplicate("(#{@placeholders})", batch_size) |> Enum.join(", ") + + batch_query = + "INSERT INTO #{@table} (#{Enum.join(@columns, ", ")}) VALUES #{values_placeholders};" + + values = Enum.flat_map(request_data_list, &prepare_values/1) + + {batch_query, values} + end + @spec prepare_values(RequestLog.t()) :: list() defp prepare_values(%RequestLog{} = request_data) do [ diff --git a/lib/phoenix_analytics/queries/table.ex b/lib/phoenix_analytics/queries/table.ex index 58fe577..2cc8d9e 100644 --- a/lib/phoenix_analytics/queries/table.ex +++ b/lib/phoenix_analytics/queries/table.ex @@ -1,7 +1,9 @@ defmodule PhoenixAnalytics.Queries.Table do @moduledoc false + alias PhoenixAnalytics.Services.Utility - @requests "requests" + @db_alias "postgres_db" + @requests if Utility.mode() == :duck_postgres, do: "#{@db_alias}.requests", else: "requests" def name() do @requests @@ -14,7 +16,7 @@ defmodule PhoenixAnalytics.Queries.Table do method VARCHAR NOT NULL, path VARCHAR NOT NULL, status_code SMALLINT NOT NULL, - duration_ms REAL NOT NULL, + duration_ms INTEGER NOT NULL, user_agent VARCHAR, remote_ip VARCHAR, referer VARCHAR, @@ -33,4 +35,9 @@ defmodule PhoenixAnalytics.Queries.Table do query end + + def attach_postgres do + postgres_conn = Application.fetch_env!(:phoenix_analytics, :postgres_conn) + "ATTACH '#{postgres_conn}' AS #{@db_alias} (TYPE POSTGRES);" + end end diff --git a/lib/phoenix_analytics/repo.ex b/lib/phoenix_analytics/repo.ex index a387e63..6d4273e 100644 --- a/lib/phoenix_analytics/repo.ex +++ b/lib/phoenix_analytics/repo.ex @@ -13,11 +13,13 @@ defmodule PhoenixAnalytics.Repo do """ use GenServer - alias PhoenixAnalytics.Services.Telemetry + + alias PhoenixAnalytics.Queries + alias PhoenixAnalytics.Services.{Bridge, Utility, Telemetry} @table PhoenixAnalytics.Queries.Table.name() - @db_path Application.compile_env(:phoenix_analytics, :database_path) || - System.get_env("DUCK_PATH") + @db_path Application.compile_env(:phoenix_analytics, :duckdb_path) || + System.get_env("DUCKDB_PATH") @doc false def start_link(_) do @@ -46,15 +48,15 @@ defmodule PhoenixAnalytics.Repo do end @doc """ - Retrieves the current `read` DuckDB connection from the GenServer state. + Creates and returns a new DuckDB read connection. - This function sends a synchronous call to the GenServer to get the current - database connection stored in its state. + This function opens a new connection to the DuckDB database for read operations, + allowing for increased read throughput by not relying on the GenServer state. ## Returns - * `{:ok, connection}` - If the connection is successfully retrieved. - * `{:error, reason}` - If there's an error retrieving the connection. + * `{:ok, connection}` - If a new connection is successfully created. + * `{:error, reason}` - If there's an error creating the connection. ## Examples @@ -63,7 +65,11 @@ defmodule PhoenixAnalytics.Repo do """ def get_read_connection do - GenServer.call(__MODULE__, :get_read_connection) + with {:ok, db} <- Duckdbex.open(@db_path), + {:ok, connection} <- Duckdbex.connection(db), + {:ok, _} <- Bridge.attach_postgres(db, connection) do + {:ok, connection} + end end # --- server callbacks --- @@ -88,8 +94,9 @@ defmodule PhoenixAnalytics.Repo do """ def init(_state) do with {:ok, db} <- Duckdbex.open(@db_path), - {:ok, conn} = Duckdbex.connection(db), - {:ok, read_conn} = Duckdbex.connection(db) do + {:ok, conn} <- Duckdbex.connection(db), + {:ok, read_conn} <- Duckdbex.connection(db), + {:ok, _} <- Bridge.attach_postgres(db, conn) do {:ok, %{connection: conn, read_connection: read_conn}} else {:error, reason} -> @@ -103,11 +110,6 @@ defmodule PhoenixAnalytics.Repo do {:reply, {:ok, state.connection}, state} end - @doc false - def handle_call(:get_read_connection, _from, state) do - {:reply, {:ok, state.read_connection}, state} - end - @doc """ Executes an unsafe query on the DuckDB connection. @@ -177,15 +179,15 @@ defmodule PhoenixAnalytics.Repo do end @doc """ - Inserts multiple rows into the database using the DuckDB appender. + Inserts multiple RequestLog entries into the database using the DuckDB appender. This function retrieves the database connection, creates an appender for the specified table, - and adds multiple rows to the table in a batch operation. This method is more efficient + and adds multiple RequestLog entries to the table in a batch operation. This method is more efficient for inserting large amounts of data compared to individual inserts. ## Parameters - * `batch` - A list of rows to be inserted into the table. + * `batch` - A list of RequestLog.t() to be inserted into the table. ## Returns @@ -194,7 +196,7 @@ defmodule PhoenixAnalytics.Repo do ## Examples - iex> batch = [["John", 30], ["Jane", 25]] + iex> batch = [%RequestLog{method: "GET", path: "/home"}, %RequestLog{method: "POST", path: "/api"}] iex> PhoenixAnalytics.Repo.insert_many(batch) :ok @@ -203,9 +205,16 @@ defmodule PhoenixAnalytics.Repo do def insert_many(batch) do case get_connection() do {:ok, conection} -> - {:ok, appender} = Duckdbex.appender(conection, @table) + if Utility.mode() == :duck_postgres do + {query, params} = Queries.Insert.insert_many(batch) - Duckdbex.appender_add_rows(appender, batch) + {:ok, stmt_ref} = Duckdbex.prepare_statement(conection, query) + {:ok, _result_ref} = Duckdbex.execute_statement(stmt_ref, params) + else + prepared = prepare_requests(batch) + {:ok, appender} = Duckdbex.appender(conection, @table) + Duckdbex.appender_add_rows(appender, prepared) + end {:error, reason} -> Telemetry.log_error(:repo, reason) @@ -213,6 +222,15 @@ defmodule PhoenixAnalytics.Repo do end end + @doc false + defp prepare_requests(batch) do + Enum.map(batch, fn request_log -> + # as for batch insert we use appender, there is no need for query + {_, params} = Queries.Insert.insert_one(request_log) + params + end) + end + @doc """ Executes a safe (parameterized) query on the DuckDB connection and fetches all results. @@ -239,7 +257,6 @@ defmodule PhoenixAnalytics.Repo do {:ok, conection} -> {:ok, stmt_ref} = Duckdbex.prepare_statement(conection, query) {:ok, result_ref} = Duckdbex.execute_statement(stmt_ref, params) - Duckdbex.fetch_all(result_ref) {:error, reason} -> diff --git a/lib/phoenix_analytics/services/batcher.ex b/lib/phoenix_analytics/services/batcher.ex index e942311..ee2ac2b 100644 --- a/lib/phoenix_analytics/services/batcher.ex +++ b/lib/phoenix_analytics/services/batcher.ex @@ -26,7 +26,6 @@ defmodule PhoenixAnalytics.Services.Batcher do use GenServer alias PhoenixAnalytics.Services.PubSub - alias PhoenixAnalytics.Queries alias PhoenixAnalytics.Repo @batch_size 1_000 @@ -74,13 +73,11 @@ defmodule PhoenixAnalytics.Services.Batcher do @doc false @impl true def handle_cast({:insert, request_log}, state) do - # as for batch insert we use appender, there is no need for query - {_, params} = Queries.Insert.insert_one(request_log) - - new_batch = [params | state.batch] + new_batch = [request_log | state.batch] if length(new_batch) >= @batch_size do send_batch(new_batch) + {:noreply, %{state | batch: [], last_insert_time: :os.system_time(:millisecond)}} else {:noreply, %{state | batch: new_batch}} diff --git a/lib/phoenix_analytics/services/bridge.ex b/lib/phoenix_analytics/services/bridge.ex new file mode 100644 index 0000000..f802dfb --- /dev/null +++ b/lib/phoenix_analytics/services/bridge.ex @@ -0,0 +1,47 @@ +defmodule PhoenixAnalytics.Services.Bridge do + alias PhoenixAnalytics.Services.Utility + + @doc """ + Attaches PostgreSQL to the DuckDB connection if the mode is set to :duck_postgres. + + This function performs the following steps: + 1. Installs the PostgreSQL extension + 2. Loads the PostgreSQL extension + 3. Executes the attach_postgres query + + ## Parameters + + - conn: The DuckDB connection + + ## Returns + + Returns the result of the attach_postgres query. + + """ + def attach_postgres(db, conn) do + if Utility.mode() == :duck_postgres do + Duckdbex.query(conn, "INSTALL postgres;") + Duckdbex.query(conn, "LOAD postgres;") + + unless Duckdbex.extension_is_loaded(db, "postgres") do + {:error, "duckdb: failed to load postgres extension"} + end + + postgres_conn = Application.fetch_env!(:phoenix_analytics, :postgres_conn) + + case Duckdbex.query(conn, "ATTACH '#{postgres_conn}' AS postgres_db (TYPE POSTGRES);") do + {:ok, _} -> + {:ok, _ref} = Duckdbex.query(conn, "SET pg_experimental_filter_pushdown=TRUE;") + {:ok, _ref} = Duckdbex.query(conn, "SET pg_pages_per_task = 9876543;") + {:ok, _ref} = Duckdbex.query(conn, "SET pg_use_ctid_scan=false;") + + {:ok, "duckdb: postgres database connected"} + + {:error, error} -> + {:error, "duckdb: postgres connection failed: #{inspect(error)}"} + end + end + + {:ok, "duckdb: postgres database not used"} + end +end diff --git a/lib/phoenix_analytics/services/cache.ex b/lib/phoenix_analytics/services/cache.ex index ceeea74..7d225ae 100644 --- a/lib/phoenix_analytics/services/cache.ex +++ b/lib/phoenix_analytics/services/cache.ex @@ -11,7 +11,7 @@ defmodule PhoenixAnalytics.Services.Cache do """ @cache :pa_cache - @ttl 60 * 2 + @ttl Application.compile_env(:phoenix_analytics, :cache_ttl, 0) @doc false def name() do @@ -84,6 +84,12 @@ defmodule PhoenixAnalytics.Services.Cache do {:ok, "cached_value"} """ def fetch(key, callback) do - Cachex.fetch(@cache, key, fn _ -> {:commit, callback.()} end, ttl: :timer.seconds(@ttl)) + cond do + @ttl > 0 -> + Cachex.fetch(@cache, key, fn _ -> {:commit, callback.()} end, ttl: :timer.seconds(@ttl)) + + true -> + {:ok, callback.()} + end end end diff --git a/lib/phoenix_analytics/services/pubsub.ex b/lib/phoenix_analytics/services/pubsub.ex index 2cd6dd1..4f06fd0 100644 --- a/lib/phoenix_analytics/services/pubsub.ex +++ b/lib/phoenix_analytics/services/pubsub.ex @@ -9,6 +9,7 @@ defmodule PhoenixAnalytics.Services.PubSub do """ + alias PhoenixAnalytics.Services.Utility alias Phoenix.PubSub @pubsub :pa_pubsub @@ -63,7 +64,9 @@ defmodule PhoenixAnalytics.Services.PubSub do Broadcasts an event to all subscribers of the request topic. This function sends the provided event to all processes that have subscribed to the request topic. - It's typically used to distribute information about new requests or updates to existing requests. + It's typically used to distribute information about new requests. + + In case of Postgres backend local broadcast is used to avoid any data duplication across nodes. ## Parameters @@ -85,6 +88,9 @@ defmodule PhoenixAnalytics.Services.PubSub do """ @spec broadcast(PhoenixAnalytics.Entities.RequestLog.t()) :: :ok | {:error, term()} def broadcast(event) do - PubSub.broadcast(@pubsub, @topic, {:request_sent, event}) + case Utility.mode() do + :duck_postgres -> PubSub.local_broadcast(@pubsub, @topic, {:request_sent, event}) + _ -> PubSub.broadcast(@pubsub, @topic, {:request_sent, event}) + end end end diff --git a/lib/phoenix_analytics/services/utility.ex b/lib/phoenix_analytics/services/utility.ex index f97d9af..a3a5302 100644 --- a/lib/phoenix_analytics/services/utility.ex +++ b/lib/phoenix_analytics/services/utility.ex @@ -82,4 +82,34 @@ defmodule PhoenixAnalytics.Services.Utility do Returns a string representation of the UUID. """ def uuid, do: UUID.uuid4() + + @doc """ + Determines the current mode of operation based on database configurations. + + This function checks the application environment for DuckDB and PostgreSQL + configurations to determine the current mode of operation. + + ## Returns + + An atom indicating the mode: + * `:duck_only` if only DuckDB is configured + * `:duck_postgres` if both DuckDB and PostgreSQL are configured + * `:duck_only` as a fallback if no valid configuration is found + + ## Examples + + iex> PhoenixAnalytics.Services.Utility.mode() + :duck_only + + """ + def mode() do + duckdb_path = Application.fetch_env(:phoenix_analytics, :duckdb_path) + postgre_repo = Application.fetch_env(:phoenix_analytics, :postgres_conn) + + cond do + duckdb_path != :error and postgre_repo == :error -> :duck_only + duckdb_path != :error and postgre_repo != :error -> :duck_postgres + true -> :duck_only + end + end end diff --git a/lib/phoenix_analytics/web/live/components/charts/device_chart.ex b/lib/phoenix_analytics/web/live/components/charts/device_chart.ex index 9795164..b23149e 100644 --- a/lib/phoenix_analytics/web/live/components/charts/device_chart.ex +++ b/lib/phoenix_analytics/web/live/components/charts/device_chart.ex @@ -12,7 +12,12 @@ defmodule PhoenixAnalytics.Web.Live.Components.DeviceChart do def render(assigns) do ~H"""
- <.react name="DeviceChart" dateRange={@date_range} chartData={@chart_data} socket={@socket} /> + <.react + name="DeviceChart" + dateRange={@date_range} + chartData={@chart_data.result || []} + socket={@socket} + />
""" end @@ -20,11 +25,12 @@ defmodule PhoenixAnalytics.Web.Live.Components.DeviceChart do @impl true def update(assigns, socket) do date_range = assigns.date_range - chart_data = chart_data(date_range) {:ok, assign(socket, assigns) - |> assign(:chart_data, chart_data)} + |> assign_async(:chart_data, fn -> + {:ok, %{chart_data: chart_data(date_range)}} + end)} end defp chart_data(%{from: from, to: to} = _date_range) do diff --git a/lib/phoenix_analytics/web/live/components/charts/popular_chart.ex b/lib/phoenix_analytics/web/live/components/charts/popular_chart.ex index eeb2072..c4cabf2 100644 --- a/lib/phoenix_analytics/web/live/components/charts/popular_chart.ex +++ b/lib/phoenix_analytics/web/live/components/charts/popular_chart.ex @@ -15,7 +15,7 @@ defmodule PhoenixAnalytics.Web.Live.Components.PopularChart do <.react name="PopularChart" dateRange={@date_range} - chartData={@chart_data} + chartData={@chart_data.result || []} chartTitle={@chart_title} socket={@socket} /> @@ -30,7 +30,9 @@ defmodule PhoenixAnalytics.Web.Live.Components.PopularChart do {:ok, assign(socket, assigns) - |> assign(:chart_data, chart_data(data_source, date_range))} + |> assign_async(:chart_data, fn -> + {:ok, %{chart_data: chart_data(data_source, date_range)}} + end)} end defp chart_data(source, %{from: from, to: to} = _date_range) do diff --git a/lib/phoenix_analytics/web/live/components/charts/requests_chart.ex b/lib/phoenix_analytics/web/live/components/charts/requests_chart.ex index ef85afe..3a2adf5 100644 --- a/lib/phoenix_analytics/web/live/components/charts/requests_chart.ex +++ b/lib/phoenix_analytics/web/live/components/charts/requests_chart.ex @@ -12,7 +12,12 @@ defmodule PhoenixAnalytics.Web.Live.Components.RequestsChart do def render(assigns) do ~H"""
- <.react name="RequestsChart" dateRange={@date_range} chartData={@chart_data} socket={@socket} /> + <.react + name="RequestsChart" + dateRange={@date_range} + chartData={@chart_data.result || []} + socket={@socket} + />
""" end @@ -24,7 +29,9 @@ defmodule PhoenixAnalytics.Web.Live.Components.RequestsChart do {:ok, assign(socket, assigns) - |> assign(:chart_data, chart_data(date_range, interval))} + |> assign_async(:chart_data, fn -> + {:ok, %{chart_data: chart_data(date_range, interval)}} + end)} end def chart_data(%{from: from, to: to} = _date_range, interval) do diff --git a/lib/phoenix_analytics/web/live/components/charts/res_chart.ex b/lib/phoenix_analytics/web/live/components/charts/res_chart.ex index afe28f1..a522c22 100644 --- a/lib/phoenix_analytics/web/live/components/charts/res_chart.ex +++ b/lib/phoenix_analytics/web/live/components/charts/res_chart.ex @@ -15,7 +15,7 @@ defmodule PhoenixAnalytics.Web.Live.Components.ResChart do <.react name="ResChart" dateRange={@date_range} - chartData={@chart_data} + chartData={@chart_data.result || []} chartTitle={@chart_title} socket={@socket} /> @@ -30,7 +30,9 @@ defmodule PhoenixAnalytics.Web.Live.Components.ResChart do {:ok, assign(socket, assigns) - |> assign(:chart_data, chart_data(data_source, date_range))} + |> assign_async(:chart_data, fn -> + {:ok, %{chart_data: chart_data(data_source, date_range)}} + end)} end defp chart_data(source, %{from: from, to: to} = _date_range) do diff --git a/lib/phoenix_analytics/web/live/components/charts/status_chart.ex b/lib/phoenix_analytics/web/live/components/charts/status_chart.ex index 7f4df68..feb1e25 100644 --- a/lib/phoenix_analytics/web/live/components/charts/status_chart.ex +++ b/lib/phoenix_analytics/web/live/components/charts/status_chart.ex @@ -12,7 +12,12 @@ defmodule PhoenixAnalytics.Web.Live.Components.StatusChart do def render(assigns) do ~H"""
- <.react name="StatusChart" dateRange={@date_range} chartData={@chart_data} socket={@socket} /> + <.react + name="StatusChart" + dateRange={@date_range} + chartData={@chart_data.result || []} + socket={@socket} + />
""" end @@ -24,7 +29,7 @@ defmodule PhoenixAnalytics.Web.Live.Components.StatusChart do {:ok, assign(socket, assigns) - |> assign(:chart_data, chart_data(date_range, interval))} + |> assign_async(:chart_data, fn -> {:ok, %{chart_data: chart_data(date_range, interval)}} end)} end defp chart_data(%{from: from, to: to} = _date_range, interval) do diff --git a/lib/phoenix_analytics/web/live/components/charts/visits_chart.ex b/lib/phoenix_analytics/web/live/components/charts/visits_chart.ex index 3e26c21..afa05bc 100644 --- a/lib/phoenix_analytics/web/live/components/charts/visits_chart.ex +++ b/lib/phoenix_analytics/web/live/components/charts/visits_chart.ex @@ -12,7 +12,12 @@ defmodule PhoenixAnalytics.Web.Live.Components.VisitsChart do def render(assigns) do ~H"""
- <.react name="VisitsChart" dateRange={@date_range} chartData={@chart_data} socket={@socket} /> + <.react + name="VisitsChart" + dateRange={@date_range} + chartData={@chart_data.result || []} + socket={@socket} + />
""" end @@ -24,7 +29,7 @@ defmodule PhoenixAnalytics.Web.Live.Components.VisitsChart do {:ok, assign(socket, assigns) - |> assign(:chart_data, chart_data(date_range, interval))} + |> assign_async(:chart_data, fn -> {:ok, %{chart_data: chart_data(date_range, interval)}} end)} end defp chart_data(%{from: from, to: to} = _date_range, interval) do diff --git a/lib/phoenix_analytics/web/live/components/stats/single_stat.ex b/lib/phoenix_analytics/web/live/components/stats/single_stat.ex index 3daacfb..4cfcacb 100644 --- a/lib/phoenix_analytics/web/live/components/stats/single_stat.ex +++ b/lib/phoenix_analytics/web/live/components/stats/single_stat.ex @@ -13,11 +13,11 @@ defmodule PhoenixAnalytics.Web.Live.Components.SingleStat do
<.react name="SingleStat" - statData={@stat_data} statUnit={@stat_unit} statTitle={@stat_title} dateRange={@date_range} - chartData={@chart_data} + statData={@stat_data.result || 0} + chartData={@chart_data.result || []} socket={@socket} />
@@ -25,11 +25,11 @@ defmodule PhoenixAnalytics.Web.Live.Components.SingleStat do end def update(assigns, socket) do - stat_data = stat_data(assigns.source, assigns.date_range) - chart_data = chart_data(assigns.source, assigns.date_range) + source = assigns.source + date_range = assigns.date_range stat_title = - case assigns.source do + case source do :unique_visitors -> "Unique visitors" :total_pageviews -> "Total Pageviews" :total_requests -> "Total Requests" @@ -41,8 +41,12 @@ defmodule PhoenixAnalytics.Web.Live.Components.SingleStat do {:ok, assign(socket, assigns) |> assign(:stat_title, stat_title) - |> assign(:stat_data, stat_data) - |> assign(:chart_data, chart_data)} + |> assign_async(:stat_data, fn -> + {:ok, %{stat_data: stat_data(source, date_range)}} + end) + |> assign_async(:chart_data, fn -> + {:ok, %{chart_data: chart_data(source, date_range)}} + end)} end defp stat_data(source, %{from: from, to: to} = _date_range) do diff --git a/mix.exs b/mix.exs index 6e252eb..c4c9662 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule PhoenixAnalytics.MixProject do use Mix.Project - @version "0.1.3" + @version "0.2.0" def project do [ diff --git a/priv/repo/dev.exs b/priv/repo/dev.exs deleted file mode 100644 index efbc1e0..0000000 --- a/priv/repo/dev.exs +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env elixir -Mix.install([ - {:phoenix_playground, "~> 0.1.5"}, - {:phoenix_analytics, path: ".", force: true} -]) - -IO.puts("DUCK_PATH: #{System.get_env("DUCK_PATH")}") -PhoenixAnalytics.Migration.up() - -defmodule DevLive do - use Phoenix.LiveView - - def mount(_params, _session, socket) do - {:ok, assign(socket, count: 0)} - end - - def render(assigns) do - ~H""" - <%= @count %> - - - - - """ - end - - def handle_event("inc", _params, socket) do - {:noreply, assign(socket, count: socket.assigns.count + 1)} - end - - def handle_event("dec", _params, socket) do - {:noreply, assign(socket, count: socket.assigns.count - 1)} - end -end - -defmodule DevController do - use Phoenix.Controller, formats: [:html] - use Phoenix.Component - plug :put_layout, false - plug :put_view, __MODULE__ - - def index(conn, params) do - count = - case Integer.parse(params["count"] || "") do - {n, ""} -> n - _ -> 0 - end - - render(conn, :index, count: count) - end - - def index(assigns) do - ~H""" - <%= @count %> - - - - - """ - end -end - -defmodule DevRouter do - use Phoenix.Router - import Phoenix.LiveView.Router - - use PhoenixAnalytics.Web, :router - - pipeline :browser do - plug :accepts, ["html"] - plug :fetch_session - plug :put_secure_browser_headers - # plug PhoenixAnalytics.Plugs.RequestTracker - end - - scope "/" do - pipe_through :browser - - live_session :default do - live "/", DevLive - end - end - - scope "/dev" do - pipe_through :browser - - phoenix_analytics_dashboard("/analytics") - end -end - -PhoenixPlayground.start( - plug: DevRouter, - endpoint_options: [ - secret_key_base: "gpaTilt0aZo38EYNrPqIA8rNGhsuysCMe8GxMps6/HZQ3xnjtIiG0UyKIHBaI+FM", - ip: {0, 0, 0, 0}, - ], - open_browser: false, - child_specs: [] -) diff --git a/priv/repo/duck_s3.exs b/priv/repo/duck_s3.exs new file mode 100644 index 0000000..cefe24c --- /dev/null +++ b/priv/repo/duck_s3.exs @@ -0,0 +1,26 @@ +PhoenixAnalytics.Repo.execute_unsafe("INSTALL httpfs;") +PhoenixAnalytics.Repo.execute_unsafe("LOAD httpfs;") + +PhoenixAnalytics.Repo.execute_unsafe("INSTALL aws;") +PhoenixAnalytics.Repo.execute_unsafe("LOAD aws;") + +PhoenixAnalytics.Repo.execute_unsafe("INSTALL parquet;") +PhoenixAnalytics.Repo.execute_unsafe("LOAD parquet;") + +PhoenixAnalytics.Repo.execute_unsafe("SET s3_region='us-east-1';") +PhoenixAnalytics.Repo.execute_unsafe("SET s3_url_style='path';") +PhoenixAnalytics.Repo.execute_unsafe("SET s3_endpoint='127.0.0.1:9000';") +PhoenixAnalytics.Repo.execute_unsafe("SET s3_use_ssl = false;") +PhoenixAnalytics.Repo.execute_unsafe("SET s3_access_key_id='mBcDR5Wy1JlZyTFEIccf' ;") + +PhoenixAnalytics.Repo.execute_unsafe( + "SET s3_secret_access_key='ndr2cAKIpNSv3sk1inBPfnILzEk56UpciM4HFiUG';" +) + +# query = "COPY requests TO 's3://test/analytics.parquet';" +# {:ok, ref} = PhoenixAnalytics.Repo.execute_unsafe(query) +# Duckdbex.fetch_all(ref) |> IO.inspect() + +query = "SELECT count(*) FROM 's3://test/analytics.parquet' WHERE method = 'GET';" +{:ok, ref} = PhoenixAnalytics.Repo.execute_unsafe(query) +Duckdbex.fetch_all(ref) |> IO.inspect() diff --git a/priv/repo/seed_data.exs b/priv/repo/seed_data.exs index 0443246..7e6071a 100644 --- a/priv/repo/seed_data.exs +++ b/priv/repo/seed_data.exs @@ -56,14 +56,14 @@ defmodule SeedData do method: Enum.random(@methods), path: Enum.random(@paths), status_code: Enum.random([200, 201, 400, 401, 403, 404, 500, 301, 302]), - duration_ms: :rand.uniform() * 1000, + duration_ms: :rand.uniform(486) + 15, user_agent: Enum.random(@user_agents), remote_ip: Enum.random(generate_random_ips()), referer: Enum.random(@referers), device_type: Utility.get_device_type(Enum.random(@user_agents)), session_id: UUID.uuid4(), session_page_views: if(:rand.uniform() < 0.9, do: 1, else: :rand.uniform(5) + 1), - inserted_at: PhoenixAnalytics.Services.Utility.inserted_at() + inserted_at: random_inserted_at() } end @@ -82,14 +82,10 @@ defmodule SeedData do end defp random_inserted_at do - now = NaiveDateTime.utc_now() random_seconds = :rand.uniform(360 * 24 * 60 * 60) - random_time = NaiveDateTime.add(now, -random_seconds, :second) - format_timestamp(random_time) - end - defp format_timestamp(datetime) do - datetime + NaiveDateTime.utc_now() + |> NaiveDateTime.add(-random_seconds, :second) |> NaiveDateTime.truncate(:millisecond) |> NaiveDateTime.to_string() |> String.replace("T", " ") diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 0b70c0c..c98ae0e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -4,7 +4,7 @@ Code.require_file("./priv/repo/seed_data.exs") PhoenixAnalytics.Migration.up() -db_path = System.get_env("DUCK_PATH") || "analytics.duckdb" +db_path = System.get_env("DUCKDB_PATH") || "analytics.duckdb" {:ok, db} = Duckdbex.open(db_path) {:ok, conn} = Duckdbex.connection(db) {:ok, appender} = Duckdbex.appender(conn, "requests") diff --git a/priv/repo/seeds_postgres.exs b/priv/repo/seeds_postgres.exs new file mode 100644 index 0000000..b08a76e --- /dev/null +++ b/priv/repo/seeds_postgres.exs @@ -0,0 +1,86 @@ +# Example seed data + +Code.require_file("./priv/repo/seed_data.exs") + +{:ok, db} = Duckdbex.open() +{:ok, conn} = Duckdbex.connection(db) + +Duckdbex.query(conn, "INSTALL postgres_scanner;") |> IO.inspect() +Duckdbex.query(conn, "LOAD postgres_scanner;") |> IO.inspect() + +Duckdbex.query( + conn, + "ATTACH 'dbname=postgres user=phoenix password=analytics host=localhost' AS postgres_db (TYPE POSTGRES);" +) +|> IO.inspect() + +Duckdbex.query(conn, "SET pg_experimental_filter_pushdown=TRUE;") |> IO.inspect() +Duckdbex.query(conn, "SET pg_pages_per_task = 100000;") |> IO.inspect() + +query = """ +CREATE TABLE IF NOT EXISTS postgres_db.requests ( + request_id UUID PRIMARY KEY, + method VARCHAR NOT NULL, + path VARCHAR NOT NULL, + status_code SMALLINT NOT NULL, + duration_ms INTEGER NOT NULL, + user_agent VARCHAR, + remote_ip VARCHAR, + referer VARCHAR, + device VARCHAR, + session_id UUID, + session_page_views INTEGER, + inserted_at TIMESTAMP +); +""" + +Duckdbex.query(conn, query) |> IO.inspect() + +batch_size = 1_000 + +1..1_000_000 +|> Enum.chunk_every(batch_size) +|> Enum.with_index(1) +|> Enum.each(fn {data, index} -> + batch = + Task.async_stream(data, fn _ -> + SeedData.generate_request_data() + end) + |> Enum.map(fn {:ok, result} -> result end) + + columns = + ~w(request_id method path status_code duration_ms user_agent remote_ip referer device session_id session_page_views inserted_at) + + placeholders = List.duplicate("?", length(columns)) |> Enum.join(", ") + batch_size = length(batch) + values_placeholders = List.duplicate("(#{placeholders})", batch_size) |> Enum.join(", ") + + query = + "INSERT INTO postgres_db.requests (#{Enum.join(columns, ", ")}) VALUES #{values_placeholders};" + + params = + Enum.flat_map( + batch, + fn request_data -> + [ + request_data.request_id, + request_data.method, + request_data.path, + request_data.status_code, + request_data.duration_ms, + request_data.user_agent, + request_data.remote_ip, + request_data.referer, + request_data.device_type, + request_data.session_id, + request_data.session_page_views, + request_data.inserted_at + ] + end + ) + + {:ok, stmt_ref} = Duckdbex.prepare_statement(conn, query) + {:ok, _result_ref} = Duckdbex.execute_statement(stmt_ref, params) + + IO.inspect(label: "Chunk #{index} - Number of Records: #{index * batch_size}") +end) diff --git a/test/phoenix_analytics_test.exs b/test/phoenix_analytics_test.exs index 70a3d08..71e24a3 100644 --- a/test/phoenix_analytics_test.exs +++ b/test/phoenix_analytics_test.exs @@ -18,10 +18,8 @@ defmodule PhoenixAnalyticsTest do request_log = SeedData.generate_request_data() - {_, params} = PhoenixAnalytics.Queries.Insert.insert_one(request_log) - result = - case Batcher.send_batch([params]) do + case Batcher.send_batch([request_log]) do :ok -> :ok {:error, reason} -> reason end