├── assets ├── .babelrc ├── static │ ├── favicon.ico │ ├── images │ │ └── phoenix.png │ └── robots.txt ├── package.json ├── webpack.config.js ├── css │ ├── app.scss │ ├── main.scss │ └── phoenix.css └── js │ └── app.js ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20201008073928_add_country.exs │ │ └── 20201007231121_create_apps.exs │ └── seeds.exs └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── test ├── test_helper.exs ├── live_map_app_web │ ├── views │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ └── controllers │ │ └── download_app_controller_test.exs ├── support │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex └── live_map_app │ └── dashboard_test.exs ├── lib ├── live_map_app_web │ ├── views │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── templates │ │ └── layout │ │ │ ├── app.html.eex │ │ │ ├── live.html.leex │ │ │ └── root.html.leex │ ├── live │ │ ├── live_helpers.ex │ │ └── app_live │ │ │ ├── bar_chart.ex │ │ │ ├── index.ex │ │ │ └── index.html.leex │ ├── gettext.ex │ ├── channels │ │ └── user_socket.ex │ ├── router.ex │ ├── endpoint.ex │ ├── telemetry.ex │ └── controllers │ │ └── download_app_controller.ex ├── live_map_app │ ├── repo.ex │ ├── dashboard │ │ └── app.ex │ ├── application.ex │ └── dashboard.ex ├── live_map_app.ex └── live_map_app_web.ex ├── scripts ├── start.sh └── dev.sh ├── .formatter.exs ├── env └── dev.env ├── config ├── test.exs ├── config.exs ├── prod.secret.exs ├── prod.exs └── dev.exs ├── .gitignore ├── docker-compose.yml ├── Dockerfile.local ├── mix.exs ├── README.md └── mix.lock /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(LiveMapApp.Repo, :manual) 3 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrancescoZ/live-map-app/HEAD/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrancescoZ/live-map-app/HEAD/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /lib/live_map_app_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.LayoutView do 2 | use LiveMapAppWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/live_map_app/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.Repo do 2 | use Ecto.Repo, 3 | otp_app: :live_map_app, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /scripts/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | mix deps.get 6 | mix do ecto.create, ecto.migrate 7 | cd assets 8 | npm install 9 | cd .. 10 | mix phx.server 11 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /env/dev.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=livemapapp_dev 2 | POSTGRES_USER=postgres 3 | POSTGRES_PASSWORD=postgres 4 | POSTGRES_HOSTNAME=live-map-app_db_1 5 | POSTGRES_PORT=5432 6 | API_TOKEN=x 7 | PHOENIX_SECRET=AaTdv5ShEI3zjVyztSilpgJOgjwfWsAAJEoMGooHM8fHVbCPGkkuQ13BV1htIuqu 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201008073928_add_country.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.Repo.Migrations.AddCountry do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:apps) do 6 | add :country, :string, default: "Unknown" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/live_map_app_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /scripts/dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | mix deps.get 6 | 7 | npm install --prefix assets 8 | 9 | mix compile 10 | mix do ecto.create, ecto.migrate 11 | 12 | echo "run: docker exec -it ${HOSTNAME} sh in another console to jump into the container!" 13 | 14 | tail -f /dev/null 15 | -------------------------------------------------------------------------------- /lib/live_map_app.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp do 2 | @moduledoc """ 3 | LiveMapApp keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /test/live_map_app_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.LayoutViewTest do 2 | use LiveMapAppWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201007231121_create_apps.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.Repo.Migrations.CreateApps do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:apps) do 6 | add :longitude, :decimal 7 | add :latitude, :decimal 8 | add :app_id, :string 9 | add :download_at, :utc_datetime 10 | 11 | timestamps() 12 | end 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # LiveMapApp.Repo.insert!(%LiveMapApp.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /lib/live_map_app_web/templates/layout/live.html.leex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /test/live_map_app_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.ErrorViewTest do 2 | use LiveMapAppWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(LiveMapAppWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(LiveMapAppWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/live_map_app_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.ErrorView do 2 | use LiveMapAppWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/live_map_app/dashboard/app.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.Dashboard.App do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "apps" do 6 | field :app_id, :string 7 | field :download_at, :utc_datetime 8 | field :latitude, :decimal 9 | field :longitude, :decimal 10 | field :country, :string 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(app, attrs) do 17 | app 18 | |> cast(attrs, [:longitude, :latitude, :app_id, :download_at, :country]) 19 | |> validate_required([:longitude, :latitude, :app_id, :download_at]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/live_map_app_web/live/live_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.LiveHelpers do 2 | @doc """ 3 | Renders a component inside the `LiveMapAppWeb.ModalComponent` component. 4 | 5 | The rendered modal receives a `:return_to` option to properly update 6 | the URL when the modal is closed. 7 | 8 | ## Examples 9 | 10 | <%= live_modal @socket, LiveMapAppWeb.AppLive.FormComponent, 11 | id: @app.id || :new, 12 | action: @live_action, 13 | app: @app, 14 | return_to: Routes.app_index_path(@socket, :index) %> 15 | """ 16 | def live_modal(_socket, _component, _opts) do 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/live_map_app_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import LiveMapAppWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :live_map_app 24 | end 25 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :live_map_app, LiveMapApp.Repo, 9 | username: System.get_env("POSTGRES_USER"), 10 | password: System.get_env("POSTGRES_PASSWORD"), 11 | database: "live_map_app_test#{System.get_env("MIX_TEST_PARTITION")}", 12 | hostname: System.get_env("POSTGRES_HOSTNAME"), 13 | pool: Ecto.Adapters.SQL.Sandbox 14 | 15 | # We don't run a server during test. If one is required, 16 | # you can enable the server option below. 17 | config :live_map_app, LiveMapAppWeb.Endpoint, 18 | http: [port: 4002], 19 | server: false 20 | 21 | # Print only warnings and errors during test 22 | config :logger, level: :warn 23 | 24 | config :tesla, adapter: Tesla.Mock 25 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "description": " ", 4 | "license": "MIT", 5 | "scripts": { 6 | "deploy": "webpack --mode production", 7 | "watch": "webpack --mode development --watch" 8 | }, 9 | "dependencies": { 10 | "phoenix": "file:../deps/phoenix", 11 | "phoenix_html": "file:../deps/phoenix_html", 12 | "phoenix_live_view": "file:../deps/phoenix_live_view", 13 | "nprogress": "^0.2.0" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.0.0", 17 | "@babel/preset-env": "^7.0.0", 18 | "babel-loader": "^8.0.0", 19 | "copy-webpack-plugin": "^5.1.1", 20 | "css-loader": "^3.4.2", 21 | "sass-loader": "^8.0.2", 22 | "node-sass": "^4.13.1", 23 | "hard-source-webpack-plugin": "^0.13.1", 24 | "mini-css-extract-plugin": "^0.9.0", 25 | "optimize-css-assets-webpack-plugin": "^5.0.1", 26 | "terser-webpack-plugin": "^2.3.2", 27 | "webpack": "4.41.5", 28 | "webpack-cli": "^3.3.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | live_map_app-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.2' 2 | services: 3 | service: 4 | environment: 5 | - UID 6 | build: 7 | context: . 8 | dockerfile: Dockerfile.local 9 | args: 10 | uid: 501 11 | # Comment the following line if you want `docker-compose up` to start the phoenix server automatically 12 | command: bash scripts/dev.sh 13 | env_file: ./env/dev.env 14 | ports: 15 | - 4005:4000 16 | volumes: 17 | - .:/home/app/service/ 18 | - elixir-artifacts:/home/app/elixir-artifacts 19 | depends_on: 20 | db: 21 | condition: service_healthy 22 | networks: 23 | - shared 24 | - default 25 | 26 | db: 27 | environment: 28 | POSTGRES_USER: postgres 29 | POSTGRES_PASSWORD: "postgres" 30 | PSQL_TRUST_LOCALNET: 'true' 31 | ENCODING: UTF8 32 | image: postgres:9.6 33 | healthcheck: 34 | test: ["CMD", "pg_isready", "-d", "postgres", "-U", "postgres"] 35 | interval: 10s 36 | timeout: 3s 37 | retries: 10 38 | 39 | networks: 40 | shared: 41 | external: true 42 | 43 | volumes: 44 | elixir-artifacts: {} 45 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | 10 | config :live_map_app, 11 | ecto_repos: [LiveMapApp.Repo], 12 | api_token: System.get_env("API_TOKEN") 13 | 14 | # Configures the endpoint 15 | config :live_map_app, LiveMapAppWeb.Endpoint, 16 | url: [host: "localhost"], 17 | secret_key_base: System.get_env("PHOENIX_SECRET"), 18 | render_errors: [view: LiveMapAppWeb.ErrorView, accepts: ~w(html json), layout: false], 19 | pubsub_server: LiveMapApp.PubSub, 20 | live_view: [signing_salt: "ifCTTFiF"] 21 | 22 | # Configures Elixir's Logger 23 | config :logger, :console, 24 | format: "$time $metadata[$level] $message\n", 25 | metadata: [:request_id] 26 | 27 | # Use Jason for JSON parsing in Phoenix 28 | config :phoenix, :json_library, Jason 29 | 30 | # Import environment specific config. This must remain at the bottom 31 | # of this file so it overrides the configuration defined above. 32 | import_config "#{Mix.env()}.exs" 33 | -------------------------------------------------------------------------------- /lib/live_map_app/application.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | children = [ 10 | # Start the Ecto repository 11 | LiveMapApp.Repo, 12 | # Start the Telemetry supervisor 13 | LiveMapAppWeb.Telemetry, 14 | # Start the PubSub system 15 | {Phoenix.PubSub, name: LiveMapApp.PubSub}, 16 | # Start the Endpoint (http/https) 17 | LiveMapAppWeb.Endpoint 18 | # Start a worker by calling: LiveMapApp.Worker.start_link(arg) 19 | # {LiveMapApp.Worker, arg} 20 | ] 21 | 22 | # See https://hexdocs.pm/elixir/Supervisor.html 23 | # for other strategies and supported options 24 | opts = [strategy: :one_for_one, name: LiveMapApp.Supervisor] 25 | Supervisor.start_link(children, opts) 26 | end 27 | 28 | # Tell Phoenix to update the endpoint configuration 29 | # whenever the application is updated. 30 | def config_change(changed, _new, removed) do 31 | LiveMapAppWeb.Endpoint.config_change(changed, removed) 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/live_map_app_web/templates/layout/root.html.leex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= live_title_tag assigns[:page_title] || "LiveMapApp", suffix: " · Live Map App" %> 9 | <%= csrf_meta_tag() %> 10 | "> 13 | "/> 14 | 15 | 16 | 17 | 18 |
19 |

Live Map App

20 |
21 | <%= @inner_content %> 22 | 23 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /lib/live_map_app_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", LiveMapAppWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | @impl true 19 | def connect(_params, socket, _connect_info) do 20 | {:ok, socket} 21 | end 22 | 23 | # Socket id's are topics that allow you to identify all sockets for a given user: 24 | # 25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 26 | # 27 | # Would allow you to broadcast a "disconnect" event and terminate 28 | # all active sockets and channels for a given user: 29 | # 30 | # LiveMapAppWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 31 | # 32 | # Returning `nil` makes this socket anonymous. 33 | @impl true 34 | def id(_socket), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use LiveMapAppWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import LiveMapAppWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint LiveMapAppWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiveMapApp.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(LiveMapApp.Repo, {:shared, self()}) 36 | end 37 | 38 | :ok 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /Dockerfile.local: -------------------------------------------------------------------------------- 1 | # Elixir 1.10 with Erlang/OTP 22 2 | FROM elixir@sha256:ba981350b63eb016427d12d90dad643eea8e2bfed37e0f2e4f2bce5aa5303eae 3 | 4 | LABEL maintainer="francesco.zanoli@gmail.com" 5 | 6 | ARG run_deps="inotify-tools" 7 | 8 | ARG mix_env="dev" 9 | ARG http_port="4000" 10 | ARG app_path="/home/app/service" 11 | ARG uid="1000" 12 | 13 | USER root 14 | 15 | ENV TERM xterm 16 | 17 | ENV HOME /home/app 18 | ENV APP_PATH ${app_path} 19 | ENV HTTP_PORT ${http_port} 20 | ENV MIX_ENV ${mix_env} 21 | ENV ERL_AFLAGS="-kernel shell_history enabled" 22 | 23 | ENV REFRESHED_AT 2020-10-08 24 | 25 | RUN apt-get -q update && apt-get -qy --no-install-recommends install ${run_deps} 26 | RUN curl -sL https://deb.nodesource.com/setup_14.x 27 | RUN apt-get install -y nodejs npm erlang-dev erlang-parsetools 28 | 29 | RUN adduser --disabled-password --gecos '' app --uid ${uid} 30 | 31 | RUN mkdir -p /home/app/elixir-artifacts 32 | RUN chown -R app:app /home/app/elixir-artifacts 33 | 34 | USER app:app 35 | 36 | RUN /usr/local/bin/mix local.hex --force && \ 37 | /usr/local/bin/mix local.rebar --force && \ 38 | /usr/local/bin/mix hex.info 39 | 40 | RUN echo "PS1=\"\[$(tput setaf 3)$(tput bold)[\]\\u@\\h$:\\w]$ \"" >> /home/app/.bashrc 41 | 42 | COPY --chown=app:app . ${APP_PATH} 43 | 44 | WORKDIR ${APP_PATH} 45 | 46 | EXPOSE ${HTTP_PORT} 47 | 48 | CMD ["sh", "script/start.sh"] 49 | -------------------------------------------------------------------------------- /config/prod.secret.exs: -------------------------------------------------------------------------------- 1 | # In this file, we load production configuration and secrets 2 | # from environment variables. You can also hardcode secrets, 3 | # although such is generally not recommended and you have to 4 | # remember to add this file to your .gitignore. 5 | use Mix.Config 6 | 7 | database_url = 8 | System.get_env("DATABASE_URL") || 9 | raise """ 10 | environment variable DATABASE_URL is missing. 11 | For example: ecto://USER:PASS@HOST/DATABASE 12 | """ 13 | 14 | config :live_map_app, LiveMapApp.Repo, 15 | # ssl: true, 16 | url: database_url, 17 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 18 | 19 | secret_key_base = 20 | System.get_env("SECRET_KEY_BASE") || 21 | raise """ 22 | environment variable SECRET_KEY_BASE is missing. 23 | You can generate one by calling: mix phx.gen.secret 24 | """ 25 | 26 | config :live_map_app, LiveMapAppWeb.Endpoint, 27 | http: [ 28 | port: String.to_integer(System.get_env("PORT") || "4000"), 29 | transport_options: [socket_opts: [:inet6]] 30 | ], 31 | secret_key_base: secret_key_base 32 | 33 | # ## Using releases (Elixir v1.9+) 34 | # 35 | # If you are doing OTP releases, you need to instruct Phoenix 36 | # to start each relevant endpoint: 37 | # 38 | # config :live_map_app, LiveMapAppWeb.Endpoint, server: true 39 | # 40 | # Then you can assemble a release by calling `mix release`. 41 | # See `mix help release` for more information. 42 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use LiveMapAppWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import LiveMapAppWeb.ConnCase 26 | 27 | alias LiveMapAppWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint LiveMapAppWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiveMapApp.Repo) 36 | 37 | unless tags[:async] do 38 | Ecto.Adapters.SQL.Sandbox.mode(LiveMapApp.Repo, {:shared, self()}) 39 | end 40 | 41 | {:ok, conn: Phoenix.ConnTest.build_conn()} 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/live_map_app_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.Router do 2 | use LiveMapAppWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {LiveMapAppWeb.LayoutView, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", LiveMapAppWeb do 18 | post "/add_downaload", DownloadAppController, :add_download 19 | pipe_through :browser 20 | 21 | live "/", AppLive.Index, :index 22 | live "/apps", AppLive.Index, :index 23 | live "/apps/new", AppLive.Index, :new 24 | live "/apps/:id/edit", AppLive.Index, :edit 25 | 26 | live "/apps/:id", AppLive.Show, :show 27 | live "/apps/:id/show/edit", AppLive.Show, :edit 28 | end 29 | 30 | # Other scopes may use custom stacks. 31 | # scope "/api", LiveMapAppWeb do 32 | # pipe_through :api 33 | # end 34 | 35 | # Enables LiveDashboard only for development 36 | # 37 | # If you want to use the LiveDashboard in production, you should put 38 | # it behind authentication and allow only admins to access it. 39 | # If your application does not have an admins-only section yet, 40 | # you can use Plug.BasicAuth to set up some basic authentication 41 | # as long as you are also using SSL (which you should anyway). 42 | if Mix.env() in [:dev, :test] do 43 | import Phoenix.LiveDashboard.Router 44 | 45 | scope "/" do 46 | pipe_through :browser 47 | live_dashboard "/dashboard", metrics: LiveMapAppWeb.Telemetry 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/live_map_app_web/live/app_live/bar_chart.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.AppLive.BarChart do 2 | use Phoenix.LiveComponent 3 | 4 | @default %{ 5 | id: nil, 6 | title: "Dashboard", 7 | list: [], 8 | total: 0, 9 | percentage: false 10 | } 11 | 12 | @doc """ 13 | Renders a BarChart live_component . 14 | id: the id used from the components, 15 | title: Title showed on top of the graph, 16 | list: a list of structure which contains the category 17 | name and the number of them, each element is a bar in 18 | the graph 19 | total: the total number element analysed , 20 | percentage: if true it calculate the percentage of 21 | elements in each group, 22 | ## Examples 23 | 24 | <%= live_component @socket, LiveMapAppWeb.AppLive.BarChart, 25 | id: :new, 26 | title: "Title to show" 27 | list: [{"category", 1},{"categor2", 2}] 28 | total: 3 29 | percentage: false 30 | 31 | """ 32 | def render(assigns) do 33 | ~L""" 34 |
35 |
<%= @title %>
36 | <%= for {countryName, count} <- @list do %> 37 |
38 | 39 | <%= countryName %>: <%= if @percentage do Float.round((count/@total)*100,2) else count end %> 40 | 41 |
42 | <% end %> 43 |
44 | """ 45 | end 46 | 47 | def mount(_assigns, socket) do 48 | {:ok, socket} 49 | end 50 | 51 | def update(assigns, socket) do 52 | {:ok, assign(socket, Map.merge(@default, assigns))} 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const TerserPlugin = require('terser-webpack-plugin'); 6 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 7 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 8 | 9 | module.exports = (env, options) => { 10 | const devMode = options.mode !== 'production'; 11 | 12 | return { 13 | optimization: { 14 | minimizer: [ 15 | new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }), 16 | new OptimizeCSSAssetsPlugin({}) 17 | ] 18 | }, 19 | entry: { 20 | 'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js']) 21 | }, 22 | output: { 23 | filename: '[name].js', 24 | path: path.resolve(__dirname, '../priv/static/js'), 25 | publicPath: '/js/' 26 | }, 27 | devtool: devMode ? 'eval-cheap-module-source-map' : undefined, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.js$/, 32 | exclude: /node_modules/, 33 | use: { 34 | loader: 'babel-loader' 35 | } 36 | }, 37 | { 38 | test: /\.[s]?css$/, 39 | use: [ 40 | MiniCssExtractPlugin.loader, 41 | 'css-loader', 42 | 'sass-loader', 43 | ], 44 | } 45 | ] 46 | }, 47 | plugins: [ 48 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 49 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) 50 | ] 51 | .concat(devMode ? [new HardSourceWebpackPlugin()] : []) 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /lib/live_map_app_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | class: "invalid-feedback", 15 | phx_feedback_for: input_id(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(LiveMapAppWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(LiveMapAppWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use LiveMapApp.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias LiveMapApp.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import LiveMapApp.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiveMapApp.Repo) 32 | 33 | unless tags[:async] do 34 | Ecto.Adapters.SQL.Sandbox.mode(LiveMapApp.Repo, {:shared, self()}) 35 | end 36 | 37 | :ok 38 | end 39 | 40 | @doc """ 41 | A helper that transforms changeset errors into a map of messages. 42 | 43 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 44 | assert "password is too short" in errors_on(changeset).password 45 | assert %{password: ["password is too short"]} = errors_on(changeset) 46 | 47 | """ 48 | def errors_on(changeset) do 49 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 50 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 51 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 52 | end) 53 | end) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/live_map_app_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :live_map_app 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_live_map_app_key", 10 | signing_salt: "QrYfiS+Z" 11 | ] 12 | 13 | socket "/socket", LiveMapAppWeb.UserSocket, 14 | websocket: true, 15 | longpoll: false 16 | 17 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phx.digest 22 | # when deploying your static files in production. 23 | plug Plug.Static, 24 | at: "/", 25 | from: :live_map_app, 26 | gzip: false, 27 | only: ~w(css fonts images js favicon.ico robots.txt) 28 | 29 | # Code reloading can be explicitly enabled under the 30 | # :code_reloader configuration of your endpoint. 31 | if code_reloading? do 32 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 33 | plug Phoenix.LiveReloader 34 | plug Phoenix.CodeReloader 35 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :live_map_app 36 | end 37 | 38 | plug Phoenix.LiveDashboard.RequestLogger, 39 | param_key: "request_logger", 40 | cookie_key: "request_logger" 41 | 42 | plug Plug.RequestId 43 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 44 | 45 | plug Plug.Parsers, 46 | parsers: [:urlencoded, :multipart, :json], 47 | pass: ["*/*"], 48 | json_decoder: Phoenix.json_library() 49 | 50 | plug Plug.MethodOverride 51 | plug Plug.Head 52 | plug Plug.Session, @session_options 53 | plug LiveMapAppWeb.Router 54 | end 55 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | @import "./phoenix.css"; 3 | @import "../node_modules/nprogress/nprogress.css"; 4 | 5 | /* LiveView specific classes for your customizations */ 6 | .phx-no-feedback.invalid-feedback, 7 | .phx-no-feedback .invalid-feedback { 8 | display: none; 9 | } 10 | 11 | .phx-click-loading { 12 | opacity: 0.5; 13 | transition: opacity 1s ease-out; 14 | } 15 | 16 | .phx-disconnected{ 17 | cursor: wait; 18 | } 19 | .phx-disconnected *{ 20 | pointer-events: none; 21 | } 22 | 23 | .phx-modal { 24 | opacity: 1!important; 25 | position: fixed; 26 | z-index: 1; 27 | left: 0; 28 | top: 0; 29 | width: 100%; 30 | height: 100%; 31 | overflow: auto; 32 | background-color: rgb(0,0,0); 33 | background-color: rgba(0,0,0,0.4); 34 | } 35 | 36 | .phx-modal-content { 37 | background-color: #fefefe; 38 | margin: 15% auto; 39 | padding: 20px; 40 | border: 1px solid #888; 41 | width: 80%; 42 | } 43 | 44 | .phx-modal-close { 45 | color: #aaa; 46 | float: right; 47 | font-size: 28px; 48 | font-weight: bold; 49 | } 50 | 51 | .phx-modal-close:hover, 52 | .phx-modal-close:focus { 53 | color: black; 54 | text-decoration: none; 55 | cursor: pointer; 56 | } 57 | 58 | 59 | /* Alerts and form errors */ 60 | .alert { 61 | padding: 15px; 62 | margin-bottom: 20px; 63 | border: 1px solid transparent; 64 | border-radius: 4px; 65 | } 66 | .alert-info { 67 | color: #31708f; 68 | background-color: #d9edf7; 69 | border-color: #bce8f1; 70 | } 71 | .alert-warning { 72 | color: #8a6d3b; 73 | background-color: #fcf8e3; 74 | border-color: #faebcc; 75 | } 76 | .alert-danger { 77 | color: #a94442; 78 | background-color: #f2dede; 79 | border-color: #ebccd1; 80 | } 81 | .alert p { 82 | margin-bottom: 0; 83 | } 84 | .alert:empty { 85 | display: none; 86 | } 87 | .invalid-feedback { 88 | color: #a94442; 89 | display: block; 90 | margin: -1rem 0 2rem; 91 | } 92 | -------------------------------------------------------------------------------- /test/live_map_app/dashboard_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.DashboardTest do 2 | use LiveMapApp.DataCase 3 | 4 | alias LiveMapApp.Dashboard 5 | 6 | describe "apps" do 7 | alias LiveMapApp.Dashboard.App 8 | 9 | @valid_attrs %{ 10 | app_id: "some app_id", 11 | download_at: "2010-04-17T14:00:00Z", 12 | latitude: "120.5", 13 | longitude: "120.5", 14 | country: "Unknown" 15 | } 16 | @invalid_attrs %{app_id: nil, download_at: nil, latitude: nil, longitude: nil} 17 | 18 | def app_fixture(attrs \\ %{}) do 19 | {:ok, app} = 20 | attrs 21 | |> Enum.into(@valid_attrs) 22 | |> Dashboard.create_app() 23 | 24 | app 25 | end 26 | 27 | test "list_apps/0 returns all apps" do 28 | app = app_fixture() 29 | assert Dashboard.list_apps() == [app] 30 | end 31 | 32 | test "get_app!/1 returns the app with given id" do 33 | app = app_fixture() 34 | assert Dashboard.get_app!(app.id) == app 35 | end 36 | 37 | test "create_app/1 with valid data creates a app" do 38 | assert {:ok, %App{} = app} = Dashboard.create_app(@valid_attrs) 39 | assert app.app_id == "some app_id" 40 | assert app.download_at == DateTime.from_naive!(~N[2010-04-17T14:00:00Z], "Etc/UTC") 41 | assert app.latitude == Decimal.new("120.5") 42 | assert app.longitude == Decimal.new("120.5") 43 | end 44 | 45 | test "create_app/1 with invalid data returns error changeset" do 46 | assert {:error, %Ecto.Changeset{}} = Dashboard.create_app(@invalid_attrs) 47 | end 48 | 49 | test "delete_app/1 deletes the app" do 50 | app = app_fixture() 51 | assert {:ok, %App{}} = Dashboard.delete_app(app) 52 | assert_raise Ecto.NoResultsError, fn -> Dashboard.get_app!(app.id) end 53 | end 54 | 55 | test "change_app/1 returns a app changeset" do 56 | app = app_fixture() 57 | assert %Ecto.Changeset{} = Dashboard.change_app(app) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import "../css/app.scss" 5 | import "../css/main.scss" 6 | // webpack automatically bundles all modules in your 7 | // entry points. Those entry points can be configured 8 | // in "webpack.config.js". 9 | // 10 | // Import deps with the dep name or local files with a relative path, for example: 11 | // 12 | // import {Socket} from "phoenix" 13 | // import socket from "./socket" 14 | // 15 | import "phoenix_html" 16 | import {Socket} from "phoenix" 17 | import NProgress from "nprogress" 18 | import {LiveSocket} from "phoenix_live_view" 19 | 20 | let Hooks = {} 21 | 22 | Hooks.MapMarkerHandler = { 23 | mounted() { 24 | 25 | this.handleEvent("new_marker", ({ marker }) => { 26 | 27 | var markerPosition = { lat: parseFloat(marker.latitude), lng: parseFloat(marker.longitude) } 28 | 29 | const mapMarker = new google.maps.Marker({ 30 | position: markerPosition, 31 | animation: google.maps.Animation.DROP, 32 | title: marker.app_id 33 | }) 34 | mapMarker.setMap(window.map) 35 | }); 36 | } 37 | } 38 | 39 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 40 | let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}}) 41 | 42 | // Show progress bar on live navigation and form submits 43 | window.addEventListener("phx:page-loading-start", info => NProgress.start()) 44 | window.addEventListener("phx:page-loading-stop", info => NProgress.done()) 45 | 46 | // connect if there are any LiveViews on the page 47 | liveSocket.connect() 48 | 49 | // expose liveSocket on window for web console debug logs and latency simulation: 50 | // >> liveSocket.enableDebug() 51 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 52 | // >> liveSocket.disableLatencySim() 53 | window.liveSocket = liveSocket 54 | 55 | -------------------------------------------------------------------------------- /lib/live_map_app_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # Database Metrics 34 | summary("live_map_app.repo.query.total_time", unit: {:native, :millisecond}), 35 | summary("live_map_app.repo.query.decode_time", unit: {:native, :millisecond}), 36 | summary("live_map_app.repo.query.query_time", unit: {:native, :millisecond}), 37 | summary("live_map_app.repo.query.queue_time", unit: {:native, :millisecond}), 38 | summary("live_map_app.repo.query.idle_time", unit: {:native, :millisecond}), 39 | 40 | # VM Metrics 41 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 42 | summary("vm.total_run_queue_lengths.total"), 43 | summary("vm.total_run_queue_lengths.cpu"), 44 | summary("vm.total_run_queue_lengths.io") 45 | ] 46 | end 47 | 48 | defp periodic_measurements do 49 | [ 50 | # A module, function and arguments to be invoked periodically. 51 | # This function must call :telemetry.execute/3 and a metric must be added above. 52 | # {LiveMapAppWeb, :count_users, []} 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :live_map_app, 7 | version: "0.1.0", 8 | elixir: "~> 1.7", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {LiveMapApp.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.5.5"}, 37 | {:phoenix_ecto, "~> 4.1"}, 38 | {:ecto_sql, "~> 3.4"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:phoenix_live_view, "~> 0.14.6"}, 41 | {:phoenix_html, "~> 2.11"}, 42 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 43 | {:phoenix_live_dashboard, "~> 0.2"}, 44 | {:telemetry_metrics, "~> 0.4"}, 45 | {:telemetry_poller, "~> 0.4"}, 46 | {:gettext, "~> 0.11"}, 47 | {:jason, "~> 1.0"}, 48 | {:plug_cowboy, "~> 2.0"}, 49 | {:tesla, "~> 1.3.0"}, 50 | {:mox, "~> 1.0", only: :test} 51 | ] 52 | end 53 | 54 | # Aliases are shortcuts or tasks specific to the current project. 55 | # For example, to install project dependencies and perform other setup tasks, run: 56 | # 57 | # $ mix setup 58 | # 59 | # See the documentation for `Mix` for more info on aliases. 60 | defp aliases do 61 | [ 62 | setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], 63 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 64 | "ecto.reset": ["ecto.drop", "ecto.setup"], 65 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :live_map_app, LiveMapAppWeb.Endpoint, 13 | url: [host: "example.com", port: 80], 14 | cache_static_manifest: "priv/static/cache_manifest.json" 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :live_map_app, LiveMapAppWeb.Endpoint, 25 | # ... 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # port: 443, 29 | # cipher_suite: :strong, 30 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 31 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH"), 32 | # transport_options: [socket_opts: [:inet6]] 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :live_map_app, LiveMapAppWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | 53 | # Finally import the config/prod.secret.exs which loads secrets 54 | # and configuration from environment variables. 55 | import_config "prod.secret.exs" 56 | -------------------------------------------------------------------------------- /lib/live_map_app_web/live/app_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.AppLive.Index do 2 | use LiveMapAppWeb, :live_view 3 | 4 | alias LiveMapAppWeb.AppLive.BarChart 5 | alias LiveMapApp.Dashboard 6 | 7 | @impl true 8 | def mount(_params, _session, socket) do 9 | if connected?(socket), do: Dashboard.subscribe() 10 | {:ok, assign(socket, :downloaded_apps, list_apps())} 11 | end 12 | 13 | @impl true 14 | def handle_params(params, _url, socket) do 15 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 16 | end 17 | 18 | defp apply_action(socket, :index, _params) do 19 | socket 20 | |> assign(:page_title, "Dashboards") 21 | |> assign(:app, nil) 22 | end 23 | 24 | @doc """ 25 | Handle the :download_added event generated from the database 26 | It dispatches an update to the app lists to immediately show the result 27 | """ 28 | @impl true 29 | def handle_info({:download_added, app}, socket) do 30 | {:noreply, update(socket, :downloaded_apps, fn apps -> [app | apps] end)} 31 | end 32 | 33 | @doc """ 34 | Handle the :new_marker event generated from the database module everytime a new 35 | download is added 36 | """ 37 | @impl true 38 | def handle_info({:new_marker, app}, socket) do 39 | {:noreply, 40 | push_event(socket, "new_marker", %{ 41 | marker: %{latitude: app.latitude, longitude: app.longitude, app_id: app.app_id} 42 | })} 43 | end 44 | 45 | defp list_apps do 46 | Dashboard.list_apps() 47 | end 48 | 49 | defp get_day_name(day) do 50 | case day do 51 | 1 -> "Monday" 52 | 2 -> "Tuesday" 53 | 3 -> "Wednesday" 54 | 4 -> "Thursday" 55 | 5 -> "Friday" 56 | 6 -> "Saturday" 57 | 7 -> "Sunday" 58 | _ -> "Unknown" 59 | end 60 | end 61 | 62 | defp get_month_name(month) do 63 | case month do 64 | 1 -> "Jan" 65 | 2 -> "Feb" 66 | 3 -> "Mar" 67 | 4 -> "Apr" 68 | 5 -> "May" 69 | 6 -> "Jun" 70 | 7 -> "Jul" 71 | 8 -> "Aug" 72 | 9 -> "Sep" 73 | 10 -> "Oct" 74 | 11 -> "Nov" 75 | 12 -> "Dec" 76 | _ -> "Unknown" 77 | end 78 | end 79 | 80 | defp get_day_time(datetime) do 81 | cond do 82 | datetime.hour < 12 -> "Morning" 83 | datetime.hour < 18 -> "Afternoon" 84 | datetime.hour < 22 -> "Evening" 85 | true -> "Night" 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/live_map_app/dashboard.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapApp.Dashboard do 2 | @moduledoc """ 3 | The Dashboard context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias LiveMapApp.Repo 8 | 9 | alias LiveMapApp.Dashboard.App 10 | 11 | @doc """ 12 | Returns the list of apps. 13 | 14 | ## Examples 15 | 16 | iex> list_apps() 17 | [%App{}, ...] 18 | 19 | """ 20 | def list_apps do 21 | Repo.all(App) 22 | end 23 | 24 | @doc """ 25 | Gets a single app. 26 | 27 | Raises `Ecto.NoResultsError` if the App does not exist. 28 | 29 | ## Examples 30 | 31 | iex> get_app!(123) 32 | %App{} 33 | 34 | iex> get_app!(456) 35 | ** (Ecto.NoResultsError) 36 | 37 | """ 38 | def get_app!(id), do: Repo.get!(App, id) 39 | 40 | @doc """ 41 | Creates a app. 42 | 43 | ## Examples 44 | 45 | iex> create_app(%{field: value}) 46 | {:ok, %App{}} 47 | 48 | iex> create_app(%{field: bad_value}) 49 | {:error, %Ecto.Changeset{}} 50 | 51 | """ 52 | def create_app(attrs \\ %{}) do 53 | %App{} 54 | |> App.changeset(attrs) 55 | |> Repo.insert() 56 | |> broadcast(:download_added) 57 | end 58 | 59 | @doc """ 60 | Deletes a app. 61 | 62 | ## Examples 63 | 64 | iex> delete_app(app) 65 | {:ok, %App{}} 66 | 67 | iex> delete_app(app) 68 | {:error, %Ecto.Changeset{}} 69 | 70 | """ 71 | def delete_app(%App{} = app) do 72 | Repo.delete(app) 73 | end 74 | 75 | @doc """ 76 | Returns an `%Ecto.Changeset{}` for tracking app changes. 77 | 78 | ## Examples 79 | 80 | iex> change_app(app) 81 | %Ecto.Changeset{data: %App{}} 82 | 83 | """ 84 | def change_app(%App{} = app, attrs \\ %{}) do 85 | App.changeset(app, attrs) 86 | end 87 | 88 | def subscribe do 89 | Phoenix.PubSub.subscribe(LiveMapApp.PubSub, "apps") 90 | end 91 | 92 | defp broadcast({:error, _reason} = error, _event), do: error 93 | 94 | defp broadcast({:ok, app}, :download_added = event) do 95 | Phoenix.PubSub.broadcast(LiveMapApp.PubSub, "apps", {event, app}) 96 | Phoenix.PubSub.broadcast(LiveMapApp.PubSub, "apps", {:new_marker, app}) 97 | {:ok, app} 98 | end 99 | 100 | defp broadcast({:ok, app}, event) do 101 | Phoenix.PubSub.broadcast(LiveMapApp.PubSub, "apps", {event, app}) 102 | {:ok, app} 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :live_map_app, LiveMapApp.Repo, 5 | username: System.get_env("POSTGRES_USER"), 6 | password: System.get_env("POSTGRES_PASSWORD"), 7 | database: System.get_env("POSTGRES_DB"), 8 | hostname: System.get_env("POSTGRES_HOSTNAME"), 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with webpack to recompile .js and .css sources. 18 | config :live_map_app, LiveMapAppWeb.Endpoint, 19 | http: [port: 4000], 20 | debug_errors: true, 21 | code_reloader: true, 22 | check_origin: false, 23 | watchers: [ 24 | node: [ 25 | "node_modules/webpack/bin/webpack.js", 26 | "--mode", 27 | "development", 28 | "--watch-stdin", 29 | cd: Path.expand("../assets", __DIR__) 30 | ] 31 | ] 32 | 33 | # ## SSL Support 34 | # 35 | # In order to use HTTPS in development, a self-signed 36 | # certificate can be generated by running the following 37 | # Mix task: 38 | # 39 | # mix phx.gen.cert 40 | # 41 | # Note that this task requires Erlang/OTP 20 or later. 42 | # Run `mix help phx.gen.cert` for more information. 43 | # 44 | # The `http:` config above can be replaced with: 45 | # 46 | # https: [ 47 | # port: 4001, 48 | # cipher_suite: :strong, 49 | # keyfile: "priv/cert/selfsigned_key.pem", 50 | # certfile: "priv/cert/selfsigned.pem" 51 | # ], 52 | # 53 | # If desired, both `http:` and `https:` keys can be 54 | # configured to run both http and https servers on 55 | # different ports. 56 | 57 | # Watch static and templates for browser reloading. 58 | config :live_map_app, LiveMapAppWeb.Endpoint, 59 | live_reload: [ 60 | patterns: [ 61 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 62 | ~r"priv/gettext/.*(po)$", 63 | ~r"lib/live_map_app_web/(live|views)/.*(ex)$", 64 | ~r"lib/live_map_app_web/templates/.*(eex)$" 65 | ] 66 | ] 67 | 68 | # Do not include metadata nor timestamps in development logs 69 | config :logger, :console, format: "[$level] $message\n" 70 | 71 | # Set a higher stacktrace during development. Avoid configuring such 72 | # in production as building large stacktraces may be expensive. 73 | config :phoenix, :stacktrace_depth, 20 74 | 75 | # Initialize plugs at runtime for faster development compilation 76 | config :phoenix, :plug_init_mode, :runtime 77 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /lib/live_map_app_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use LiveMapAppWeb, :controller 9 | use LiveMapAppWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: LiveMapAppWeb 23 | 24 | import Plug.Conn 25 | import LiveMapAppWeb.Gettext 26 | alias LiveMapAppWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/live_map_app_web/templates", 34 | namespace: LiveMapAppWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {LiveMapAppWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | end 52 | end 53 | 54 | def live_component do 55 | quote do 56 | use Phoenix.LiveComponent 57 | 58 | unquote(view_helpers()) 59 | end 60 | end 61 | 62 | def router do 63 | quote do 64 | use Phoenix.Router 65 | 66 | import Plug.Conn 67 | import Phoenix.Controller 68 | import Phoenix.LiveView.Router 69 | end 70 | end 71 | 72 | def channel do 73 | quote do 74 | use Phoenix.Channel 75 | import LiveMapAppWeb.Gettext 76 | end 77 | end 78 | 79 | defp view_helpers do 80 | quote do 81 | # Use all HTML functionality (forms, tags, etc) 82 | use Phoenix.HTML 83 | 84 | # Import LiveView helpers (live_render, live_component, live_patch, etc) 85 | import Phoenix.LiveView.Helpers 86 | import LiveMapAppWeb.LiveHelpers 87 | 88 | # Import basic rendering functionality (render, render_layout, etc) 89 | import Phoenix.View 90 | 91 | import LiveMapAppWeb.ErrorHelpers 92 | import LiveMapAppWeb.Gettext 93 | alias LiveMapAppWeb.Router.Helpers, as: Routes 94 | end 95 | end 96 | 97 | @doc """ 98 | When used, dispatch to the appropriate controller/view/etc. 99 | """ 100 | defmacro __using__(which) when is_atom(which) do 101 | apply(__MODULE__, which, []) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LiveMapApp 2 | 3 | This is a really simple [Phoenix LiveView]() Application to get real time information about app downloads using a map and different dashboards. 4 | 5 | The creation of this project is described in [this article](https://medium.com/@francescozanoli/how-to-use-google-maps-with-ecto-and-phoenix-liveview-2b81bed570a9) 6 | 7 | ## Development 8 | ### Requirements 9 | The solution is using docker so be sure to have installed `docker-compose` and `Docker` on your machine before starting. 10 | 11 | :warning: Important Step :warning: 12 | 13 | You will need a GoogleApiKey, you can get one by following [this tutorial](https://developers.google.com/maps/documentation/javascript/get-api-key). You need to enable it for: 14 | - Geocoding API 15 | - Maps JavaScript API 16 | 17 | Once you have it you have to copy it in the `API_TOKEN` environment variable in the `/env/dev.env` file. 18 | 19 | ### How to launch it 20 | Once you have clone the repo you just need to go into the folder and run: 21 | > docker-compose up --build 22 | 23 | Note: the first time it may take a while 24 | 25 | As default the container is created, dependencies are installed, database is created and migrated but no application is started. This allow you to jump into the container and run test if you want to. 26 | To jump into the container you can copy the output of the `docker-compose up` into another console or run this: 27 | > docker exec -it HOSTNAME sh 28 | 29 | replacing HOSTNAME with the container id. 30 | To start the server you need to go run: 31 | > mix phx.server 32 | 33 | The server will start listening on the port `4000`, which is forwared from the container at the port `4005` 34 | 35 | ### How to run tests 36 | Inside the container you can run unit test using: 37 | > MIX_ENV=test mix test 38 | 39 | ### Troubleshooting 40 | If the process doesn't work try deleting all the following images from docker if presents: 41 | - live-map-app_service 42 | - postgres 43 | - elixir 44 | Try deleting also the folder _build, deps and node_module then start the process again 45 | 46 | ## Usage 47 | To start using the service you need to start it using the instruction above. 48 | 49 | In order send information to the service you have to hit the endpoint with the correct parameters: 50 | - longitude, must be a number 51 | - latitude, must be a number 52 | - downloaded_at, must be a date in the ISO format (i.e T22:31:32.223Z) 53 | 54 | An example: 55 | > curl --location --request POST 'localhost:4005/add_downaload' \ 56 | --form 'longitude=-14.00' \ 57 | --form 'latitude=-50.0' \ 58 | --form 'downloaded_at=2021-10-11T22:31:32.223Z' \ 59 | --form 'app_id=test23' 60 | 61 | To interact with the application you can go to [localhost:4005](http://localhost:4005/) 62 | 63 | ## Future Improvements and welcome MRs 64 | - More unit tests, especially in the View part and hooks, using this 65 | - More explicit error message in the endpoint 66 | - Service level tests 67 | - Heatmap Layer usint [this](https://developers.google.com/maps/documentation/javascript/heatmaplayer) 68 | -------------------------------------------------------------------------------- /lib/live_map_app_web/controllers/download_app_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.DownloadAppController do 2 | use LiveMapAppWeb, :controller 3 | alias LiveMapApp.Dashboard 4 | 5 | @unknown "Unknown" 6 | 7 | @doc """ 8 | Stores a new app object into the database 9 | 10 | it receives: 11 | longitude, has to be a float 12 | latitude, has to be a float 13 | downloaded_at, has to be a date in the iso format 14 | app_id 15 | 16 | it validates the attributes time and it save the value in 17 | the database 18 | Return: 19 | created if it was successfull 20 | invalid_paramters in case of validation error 21 | error in case of storage error 22 | """ 23 | def add_download(conn, %{ 24 | "longitude" => long, 25 | "latitude" => lat, 26 | "downloaded_at" => download_time, 27 | "app_id" => app_name 28 | }) do 29 | with {:ok, date, _} <- DateTime.from_iso8601(download_time), 30 | {longitude, ""} <- Float.parse(long), 31 | {latitude, ""} <- Float.parse(lat), 32 | country <- 33 | Tesla.get("https://maps.googleapis.com/maps/api/geocode/json", 34 | query: [ 35 | latlng: "#{latitude},#{longitude}", 36 | key: Application.get_env(:live_map_app, :api_token) 37 | ] 38 | ) 39 | |> handle_tesla_response() do 40 | case Dashboard.create_app(%{ 41 | latitude: lat, 42 | longitude: long, 43 | app_id: app_name, 44 | download_at: date, 45 | country: country 46 | }) do 47 | {:ok, _result} -> 48 | conn 49 | |> put_status(:created) 50 | |> json(:created) 51 | 52 | {:error, error} -> 53 | conn 54 | |> put_status(500) 55 | |> json(inspect(error)) 56 | end 57 | else 58 | _ -> 59 | conn 60 | |> put_status(400) 61 | |> json(:invalid_paramters) 62 | end 63 | end 64 | 65 | def add_download(conn, _params) do 66 | conn 67 | |> put_status(400) 68 | |> json(:invalid_json_body) 69 | end 70 | 71 | defp handle_tesla_response({:ok, %Tesla.Env{status: 200, body: body}}), 72 | do: 73 | body 74 | |> Jason.decode() 75 | |> parse_results() 76 | 77 | defp handle_tesla_response(_response), do: @unknown 78 | 79 | defp parse_results({:ok, %{"results" => nil}}), do: @unknown 80 | 81 | defp parse_results({:ok, %{"results" => result}}), 82 | do: 83 | result 84 | |> List.first() 85 | |> parse_address() 86 | 87 | defp parse_results(_), do: @unknown 88 | 89 | defp parse_address(nil), do: @unknown 90 | 91 | defp parse_address(components), 92 | do: 93 | components 94 | |> Map.get("address_components") 95 | |> Enum.reduce("", fn %{"types" => types, "long_name" => name}, acc -> 96 | if Enum.member?(types, "country") do 97 | acc <> name 98 | else 99 | acc 100 | end 101 | end) 102 | |> final_country() 103 | 104 | defp final_country(""), do: @unknown 105 | defp final_country(country), do: country 106 | end 107 | -------------------------------------------------------------------------------- /assets/css/main.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | padding: 1rem; 3 | text-align: center; 4 | font-weight: bolder; 5 | } 6 | 7 | .footer { 8 | grid-row-start: 2; 9 | grid-row-end: 3; 10 | text-align: center; 11 | color: grey; 12 | } 13 | 14 | .block{ 15 | box-shadow: 5px 5px 5px 5px #888888; 16 | border-radius: 2em; 17 | margin-bottom: 2em; 18 | } 19 | 20 | .column-name{ 21 | padding: 3rem; 22 | } 23 | 24 | 25 | #map { 26 | height: 400px; 27 | border-radius: 0em 2em 2em 0em; 28 | } 29 | 30 | dl { 31 | display: flex; 32 | background-color: white; 33 | flex-direction: column; 34 | width: 100%; 35 | max-width: 700px; 36 | position: relative; 37 | padding: 20px; 38 | } 39 | 40 | dt { 41 | align-self: flex-start; 42 | width: 100%; 43 | font-weight: 700; 44 | display: block; 45 | text-align: center; 46 | font-size: 1.2em; 47 | text-transform: uppercase; 48 | font-weight: 700; 49 | margin-bottom: 20px; 50 | margin-left: 130px; 51 | } 52 | 53 | .text { 54 | font-weight: 600; 55 | display: flex; 56 | align-items: center; 57 | height: 40px; 58 | width: 130px; 59 | background-color: white; 60 | position: absolute; 61 | left: 0; 62 | justify-content: flex-end; 63 | } 64 | 65 | .percentage { 66 | font-size: .8em; 67 | line-height: 1; 68 | text-transform: uppercase; 69 | width: 100%; 70 | height: 40px; 71 | margin-left: 130px; 72 | background: repeating-linear-gradient( 73 | to right, 74 | #ddd, 75 | #ddd 1px, 76 | #fff 1px, 77 | #fff 5% 78 | ); 79 | 80 | &:after { 81 | content: ""; 82 | display: block; 83 | background-color: #3d9970; 84 | width: 50px; 85 | margin-bottom: 10px; 86 | height: 90%; 87 | position: relative; 88 | top: 50%; 89 | transform: translateY(-50%); 90 | transition: background-color .3s ease; 91 | cursor: pointer; 92 | } 93 | &:hover, 94 | &:focus { 95 | &:after { 96 | background-color: #aaa; 97 | } 98 | } 99 | } 100 | 101 | @for $i from 1 through 100 { 102 | .percentage-#{$i}:after { 103 | $value: ($i * 1%); 104 | width: $value; 105 | } 106 | } 107 | 108 | input[type="radio"] { 109 | position: absolute; 110 | left: -9999px; 111 | } 112 | 113 | :root { 114 | --black: #1a1a1a; 115 | --white: #fff; 116 | --green: #49b293; 117 | } 118 | 119 | .filters { 120 | text-align: center; 121 | display: inline-block; 122 | margin-bottom: 2rem; 123 | padding: 0.5rem 1rem; 124 | margin-bottom: 0.25rem; 125 | border-radius: 2rem; 126 | min-width: 50px; 127 | line-height: normal; 128 | cursor: pointer; 129 | transition: all 0.1s; 130 | } 131 | 132 | .filters:hover { 133 | background: var(--green); 134 | color: var(--white); 135 | } 136 | 137 | .tag{ 138 | display: inline; 139 | font-weight: lighter; 140 | } 141 | 142 | .dashboard-container{ 143 | display: table-caption; 144 | margin: 2em; 145 | } 146 | 147 | [value="All"]:checked ~ .dashboard [data-category] { 148 | display: block; 149 | } 150 | [value="List"]:checked ~ .dashboard:not([data-category*="List"]), 151 | [value="ByCountry"]:checked ~ .dashboard:not([data-category*="ByCountry"]), 152 | [value="ByYear"]:checked ~ .dashboard:not([data-category*="ByYear"]), 153 | [value="ByMonth"]:checked ~ .dashboard:not([data-category*="ByMonth"]), 154 | [value="ByDay"]:checked ~ .dashboard:not([data-category*="ByDay"]), 155 | [value="ByDayTime"]:checked ~ .dashboard:not([data-category*="ByDayTime"]), 156 | [value="ByApp"]:checked ~ .dashboard:not([data-category*="ByApp"]) { 157 | display: none; 158 | } 159 | -------------------------------------------------------------------------------- /lib/live_map_app_web/live/app_live/index.html.leex: -------------------------------------------------------------------------------- 1 |
2 |

Downloads Map

3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |

Dashboards

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | <%= live_component(@socket, BarChart, id: "ByCountry", title: "Dashboard by country", list: Enum.reduce(@downloaded_apps, %{}, fn x, acc -> Map.update(acc, x.country, 1, &(&1 + 1)) end), total: length(@downloaded_apps), percentage: true)%> 30 | <%= live_component(@socket, BarChart, id: "ByDayTime", title: "Dashboard by time of the day", list: Enum.reduce(@downloaded_apps, %{}, fn x, acc -> Map.update(acc, get_day_time(x.download_at), 1, &(&1 + 1)) end), total: length(@downloaded_apps), percentage: true)%> 31 | <%= live_component(@socket, BarChart, id: "ByYear", title: "Dashboard by Year", list: Enum.reduce(@downloaded_apps, %{}, fn x, acc -> Map.update(acc, x.download_at.year, 1, &(&1 + 1)) end), total: length(@downloaded_apps), percentage: true)%> 32 | <%= live_component(@socket, BarChart, id: "ByMonth", title: "Dashboard by Month", list: Enum.reduce(@downloaded_apps, %{}, fn x, acc -> Map.update(acc, get_month_name(x.download_at.month), 1, &(&1 + 1)) end), total: length(@downloaded_apps), percentage: true)%> 33 | <%= live_component(@socket, BarChart, id: "ByDay", title: "Dashboard by Day", list: Enum.reduce(@downloaded_apps, %{}, fn x, acc -> Map.update(acc, get_day_name(Date.day_of_week(x.download_at)), 1, &(&1 + 1)) end), total: length(@downloaded_apps), percentage: true)%> 34 | <%= live_component(@socket, BarChart, id: "ByApp", title: "Dashboard by App", list: Enum.reduce(@downloaded_apps, %{}, fn x, acc -> Map.update(acc, x.app_id, 1, &(&1 + 1)) end), total: length(@downloaded_apps), percentage: false)%> 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | <%= for app <- @downloaded_apps do %> 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | <% end %> 61 | 62 |
AppDownload atCountryLongitudeLatitude
<%= app.app_id %><%= app.download_at %><%= app.country %><%= app.longitude %><%= app.latitude %>
63 |
64 |
65 | 66 | 70 | 71 | 95 | -------------------------------------------------------------------------------- /test/live_map_app_web/controllers/download_app_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveMapAppWeb.DownloadAppControllerTest do 2 | use LiveMapAppWeb.ConnCase, async: false 3 | import Tesla.Mock 4 | 5 | setup %{conn: conn} do 6 | {:ok, conn: put_req_header(conn, "accept", "application/json")} 7 | end 8 | 9 | @geocoding_empty_response %{ 10 | "results" => [ 11 | %{ 12 | "address_components" => [ 13 | %{ 14 | "long_name" => "улица Орхидея", 15 | "short_name" => "улица Орхидея", 16 | "types" => ["route"] 17 | }, 18 | %{ 19 | "long_name" => "Plovdiv", 20 | "short_name" => "Plovdiv", 21 | "types" => ["administrative_area_level_1", "political"] 22 | }, 23 | %{ 24 | "long_name" => "4130", 25 | "short_name" => "4130", 26 | "types" => ["postal_code"] 27 | } 28 | ] 29 | } 30 | ], 31 | "status" => "OK" 32 | } 33 | @geocoding_response %{ 34 | "plus_code" => %{ 35 | "compound_code" => "52XM+X5 Belozem, Bulgaria", 36 | "global_code" => "8GJ752XM+X5" 37 | }, 38 | "results" => [ 39 | %{ 40 | "address_components" => [ 41 | %{ 42 | "long_name" => "улица Орхидея", 43 | "short_name" => "улица Орхидея", 44 | "types" => ["route"] 45 | }, 46 | %{ 47 | "long_name" => "Plovdiv", 48 | "short_name" => "Plovdiv", 49 | "types" => ["administrative_area_level_1", "political"] 50 | }, 51 | %{ 52 | "long_name" => "Bulgaria", 53 | "short_name" => "BG", 54 | "types" => ["country", "political"] 55 | }, 56 | %{ 57 | "long_name" => "4130", 58 | "short_name" => "4130", 59 | "types" => ["postal_code"] 60 | } 61 | ] 62 | } 63 | ], 64 | "status" => "OK" 65 | } 66 | @valid_attributes %{ 67 | "latitude" => "12.5", 68 | "longitude" => "30.5", 69 | "app_id" => "testMeHard", 70 | "downloaded_at" => "2018-09-28T09:31:32.223Z" 71 | } 72 | 73 | @geocoding_url "https://maps.googleapis.com/maps/api/geocode/json" 74 | 75 | describe "/add_download" do 76 | test "creates a downloaded app with all the parameters", %{conn: conn} do 77 | mock(fn 78 | %{method: :get, url: @geocoding_url} -> 79 | %Tesla.Env{status: 200, body: Jason.encode!(@geocoding_response)} 80 | end) 81 | 82 | conn = post(conn, Routes.download_app_path(conn, :add_download, @valid_attributes)) 83 | assert json_response(conn, 201) == "created" 84 | end 85 | 86 | test "creates a downloaded app with all the parameters event if geocoding does not answer", %{ 87 | conn: conn 88 | } do 89 | mock(fn 90 | %{method: :get, url: @geocoding_url} -> 91 | %Tesla.Env{status: 500, body: "ImBroken"} 92 | end) 93 | 94 | conn = post(conn, Routes.download_app_path(conn, :add_download, @valid_attributes)) 95 | assert json_response(conn, 201) == "created" 96 | end 97 | 98 | test "creates a downloaded app with all the parameters event if geocoding answer is empty", %{ 99 | conn: conn 100 | } do 101 | mock(fn 102 | %{method: :get, url: @geocoding_url} -> 103 | %Tesla.Env{status: 200, body: Jason.encode!(%{result: []})} 104 | end) 105 | 106 | conn = post(conn, Routes.download_app_path(conn, :add_download, @valid_attributes)) 107 | assert json_response(conn, 201) == "created" 108 | end 109 | 110 | test "creates a downloaded app with all the parameters event if geocoding answer doesn't have a country", 111 | %{conn: conn} do 112 | mock(fn 113 | %{method: :get, url: @geocoding_url} -> 114 | %Tesla.Env{status: 200, body: Jason.encode!(@geocoding_empty_response)} 115 | end) 116 | 117 | conn = post(conn, Routes.download_app_path(conn, :add_download, @valid_attributes)) 118 | assert json_response(conn, 201) == "created" 119 | end 120 | 121 | test "returns an error with missing parameters", %{conn: conn} do 122 | conn = post(conn, Routes.download_app_path(conn, :add_download, %{parameter: "isInvalid"})) 123 | assert json_response(conn, 400) == "invalid_json_body" 124 | end 125 | 126 | test "returns an error with invalid latitude", %{conn: conn} do 127 | conn = 128 | post( 129 | conn, 130 | Routes.download_app_path(conn, :add_download, %{ 131 | "latitude" => "ciao", 132 | "longitude" => "30.5", 133 | "app_id" => "testMeHard", 134 | "downloaded_at" => "2018-09-28T09:31:32.223Z" 135 | }) 136 | ) 137 | 138 | assert json_response(conn, 400) == "invalid_paramters" 139 | end 140 | 141 | test "returns an error with invalid longitude", %{conn: conn} do 142 | conn = 143 | post( 144 | conn, 145 | Routes.download_app_path(conn, :add_download, %{ 146 | "latitude" => "30.5", 147 | "longitude" => "ciao", 148 | "app_id" => "testMeHard", 149 | "downloaded_at" => "2018-09-28T09:31:32.223Z" 150 | }) 151 | ) 152 | 153 | assert json_response(conn, 400) == "invalid_paramters" 154 | end 155 | 156 | test "returns an error with invalid download time", %{conn: conn} do 157 | conn = 158 | post( 159 | conn, 160 | Routes.download_app_path(conn, :add_download, %{ 161 | "latitude" => "30.5", 162 | "longitude" => "30.5", 163 | "app_id" => "testMeHard", 164 | "downloaded_at" => "notValidDate" 165 | }) 166 | ) 167 | 168 | assert json_response(conn, 400) == "invalid_paramters" 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 3 | "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, 4 | "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, 5 | "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, 6 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 7 | "ecto": {:hex, :ecto, "3.5.0", "9b45303af8e7eea81c0ad6fbcf2d442edb3f1c535a32ca42e3b1f31091a8995e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9413a21b0b1a8256724545550832605918ab26632010bd47ce9d71d24f4f4bd1"}, 8 | "ecto_sql": {:hex, :ecto_sql, "3.5.0", "760aa2935cc80b72da83fbd8cc97923623a2401915c308afea2cf2b0aabf4b2e", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3bab456e3ebb5680b327313f57ebb5356882a59fe04964a03232a83dc4c44aa2"}, 9 | "file_system": {:hex, :file_system, "0.2.9", "545b9c9d502e8bfa71a5315fac2a923bd060fd9acb797fe6595f54b0f975fd32", [:mix], [], "hexpm", "3cf87a377fe1d93043adeec4889feacf594957226b4f19d5897096d6f61345d8"}, 10 | "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, 11 | "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, 12 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 13 | "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, 14 | "mox": {:hex, :mox, "1.0.0", "4b3c7005173f47ff30641ba044eb0fe67287743eec9bd9545e37f3002b0a9f8b", [:mix], [], "hexpm", "201b0a20b7abdaaab083e9cf97884950f8a30a1350a1da403b3145e213c6f4df"}, 15 | "phoenix": {:hex, :phoenix, "1.5.5", "9a5a197edc1828c5f138a8ef10524dfecc43e36ab435c14578b1e9b4bd98858c", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b10eaf86ad026eafad2ee3dd336f0fb1c95a3711789855d913244e270bde463b"}, 16 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"}, 17 | "phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"}, 18 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.2.9", "ac43a73561a4010fd2a52289a2f570829e2be5d5ea408a5af99dbed8793439e7", [:mix], [{:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.14.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f7cd3265a53d2bcd2e541a7d3c55f5a22e07bf6070b1d4adabd81791885190f7"}, 19 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.4", "940c0344b1d66a2e46eef02af3a70e0c5bb45a4db0bf47917add271b76cd3914", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "38f9308357dea4cc77f247e216da99fcb0224e05ada1469167520bed4cb8cccd"}, 20 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.14.7", "e05ca2e57974bb99eb54fed88b04754a622e54cf7e832db3c868bd06e0b99ff2", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.3", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "899224a704221ab0019200da61019dea699763e12daa24d69edd79bc228fe5a5"}, 21 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 22 | "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"}, 23 | "plug_cowboy": {:hex, :plug_cowboy, "2.3.0", "149a50e05cb73c12aad6506a371cd75750c0b19a32f81866e1a323dda9e0e99d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bc595a1870cef13f9c1e03df56d96804db7f702175e4ccacdb8fc75c02a7b97e"}, 24 | "plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"}, 25 | "postgrex": {:hex, :postgrex, "0.15.6", "a464c72010a56e3214fe2b99c1a76faab4c2bb0255cabdef30dea763a3569aa2", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {: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]}], "hexpm", "f99268325ac8f66ffd6c4964faab9e70fbf721234ab2ad238c00f9530b8cdd55"}, 26 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 27 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 28 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.5.0", "1b796e74add83abf844e808564275dfb342bcc930b04c7577ab780e262b0d998", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31225e6ce7a37a421a0a96ec55244386aec1c190b22578bd245188a4a33298fd"}, 29 | "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, 30 | "tesla": {:hex, :tesla, "1.3.3", "26ae98627af5c406584aa6755ab5fc96315d70d69a24dd7f8369cfcb75094a45", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2648f1c276102f9250299e0b7b57f3071c67827349d9173f34c281756a1b124c"}, 31 | } 32 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.3.0 https://milligram.github.io 6 | * Copyright (c) 2017 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{-ms-grid-row-align:center;align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | pre{padding: 1em;} 19 | 20 | .container{ 21 | margin: 0 auto; 22 | max-width: 80.0rem; 23 | padding: 0 2.0rem; 24 | position: relative; 25 | width: 100% 26 | } 27 | select { 28 | width: auto; 29 | } 30 | 31 | /* Phoenix promo and logo */ 32 | .phx-hero { 33 | text-align: center; 34 | border-bottom: 1px solid #e3e3e3; 35 | background: #eee; 36 | border-radius: 6px; 37 | padding: 3em 3em 1em; 38 | margin-bottom: 3rem; 39 | font-weight: 200; 40 | font-size: 120%; 41 | } 42 | .phx-hero input { 43 | background: #ffffff; 44 | } 45 | .phx-logo { 46 | min-width: 300px; 47 | margin: 1rem; 48 | display: block; 49 | } 50 | .phx-logo img { 51 | width: auto; 52 | display: block; 53 | } 54 | 55 | /* Headers */ 56 | header { 57 | width: 100%; 58 | background: #fdfdfd; 59 | border-bottom: 1px solid #eaeaea; 60 | margin-bottom: 2rem; 61 | } 62 | header section { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: space-between; 67 | } 68 | header section :first-child { 69 | order: 2; 70 | } 71 | header section :last-child { 72 | order: 1; 73 | } 74 | header nav ul, 75 | header nav li { 76 | margin: 0; 77 | padding: 0; 78 | display: block; 79 | text-align: right; 80 | white-space: nowrap; 81 | } 82 | header nav ul { 83 | margin: 1rem; 84 | margin-top: 0; 85 | } 86 | header nav a { 87 | display: block; 88 | } 89 | 90 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 91 | header section { 92 | flex-direction: row; 93 | } 94 | header nav ul { 95 | margin: 1rem; 96 | } 97 | .phx-logo { 98 | flex-basis: 527px; 99 | margin: 2rem 1rem; 100 | } 101 | } 102 | --------------------------------------------------------------------------------