├── .tool-versions ├── Procfile ├── phoenix_static_buildpack.config ├── lib ├── cargo_elixir_web │ ├── templates │ │ ├── page │ │ │ └── index.html.eex │ │ └── layout │ │ │ └── app.html.eex │ ├── views │ │ ├── page_view.ex │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── channels │ │ ├── payload_channel.ex │ │ └── user_socket.ex │ ├── controllers │ │ ├── user_controller.ex │ │ ├── page_controller.ex │ │ └── payload_controller.ex │ ├── gettext.ex │ ├── router.ex │ └── endpoint.ex ├── cargo_elixir │ ├── repo.ex │ ├── users │ │ ├── users.ex │ │ └── user.ex │ ├── release.ex │ ├── application.ex │ └── payloads │ │ ├── payload.ex │ │ └── payloads.ex ├── mix │ └── tasks │ │ └── refresh_matviews.ex ├── cargo_elixir.ex └── cargo_elixir_web.ex ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20200303181332_update_payload_table.exs │ │ ├── 20191209210301_add_snr_and_fingerprint.exs │ │ ├── 20191211210339_create_user_table.exs │ │ └── 20191126185915_create_payloads.exs │ ├── seeds.exs │ └── materialized_view.exs └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── test ├── test_helper.exs ├── cargo_elixir_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ └── controllers │ │ └── page_controller_test.exs └── support │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex ├── assets ├── static │ ├── favicon.ico │ ├── images │ │ ├── phoenix.png │ │ ├── logocargo.svg │ │ └── logocargo_30.svg │ └── robots.txt ├── js │ ├── data │ │ ├── Rest.js │ │ └── chart.js │ ├── app.js │ ├── components │ │ ├── SearchBar.js │ │ ├── NavBarRow.js │ │ ├── SignUp.js │ │ ├── Timeline.js │ │ ├── NavBar.js │ │ └── Inspector.js │ ├── pages │ │ ├── StatsPage.js │ │ ├── mapStyle.js │ │ └── MapScreen.js │ └── socket.js ├── .babelrc ├── css │ └── app.css ├── webpack.config.js └── package.json ├── elixir_buildpack.config ├── .formatter.exs ├── entrypoint.sh ├── config ├── test.exs ├── releases.exs ├── config.exs ├── prod.secret.exs ├── dev.exs └── prod.exs ├── docker-compose.yaml ├── Dockerfile ├── CONTRIBUTING.md ├── .gitignore ├── README.md ├── mix.exs ├── LICENSE └── mix.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.9.0-otp-21 2 | erlang 21.1 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: MIX_ENV=prod POOL_SIZE=16 mix phx.server 2 | -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | node_version=16.20.0 2 | phoenix_ex=phx 3 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /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(CargoElixir.Repo, :manual) 3 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helium/cargo-elixir/HEAD/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helium/cargo-elixir/HEAD/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | # Erlang version 2 | erlang_version=24.3.4.14 3 | 4 | # Elixir version 5 | elixir_version=1.12 6 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.PageView do 2 | use CargoElixirWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.LayoutView do 2 | use CargoElixirWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /test/cargo_elixir_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.PageViewTest do 2 | use CargoElixirWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/cargo_elixir_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.LayoutViewTest do 2 | use CargoElixirWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /lib/cargo_elixir/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Repo do 2 | use Ecto.Repo, 3 | otp_app: :cargo_elixir, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /assets/js/data/Rest.js: -------------------------------------------------------------------------------- 1 | 2 | const DOMAIN = '/api/' 3 | // const DOMAIN = 'https://cargo.helium.com/api/' 4 | 5 | export const get = (url) => fetch(DOMAIN + url) 6 | -------------------------------------------------------------------------------- /lib/mix/tasks/refresh_matviews.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.RefreshMatviews do 2 | use Mix.Task 3 | 4 | def run(db) do 5 | Mix.shell.cmd("psql -a #{db} -c 'select refresh_all_matviews()'") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | subdirectories: ["priv/*/migrations"] 6 | ] 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/cargo_elixir_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.PageControllerTest do 2 | use CargoElixirWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Welcome to Phoenix!" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/channels/payload_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.PayloadChannel do 2 | use Phoenix.Channel 3 | 4 | def join("payload:new", _msg, socket) do 5 | {:ok, socket} 6 | end 7 | end 8 | 9 | # CargoElixirWeb.Endpoint.broadcast! "payload:new", "new_payload", %{data: 1} 10 | -------------------------------------------------------------------------------- /lib/cargo_elixir.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir do 2 | @moduledoc """ 3 | CargoElixir 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 | -------------------------------------------------------------------------------- /lib/cargo_elixir/users/users.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Users do 2 | import Ecto.Query, warn: false 3 | alias CargoElixir.Repo 4 | 5 | alias CargoElixir.Users.User 6 | 7 | def create_user(user_params) do 8 | %User{} 9 | |> User.changeset(user_params) 10 | |> Repo.insert() 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200303181332_update_payload_table.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Repo.Migrations.UpdatePayloadTablew do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table("payloads") do 6 | add :name, :string 7 | modify :device_id, :string, null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/controllers/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.UserController do 2 | use CargoElixirWeb, :controller 3 | 4 | alias CargoElixir.Users 5 | alias CargoElixir.Users.User 6 | 7 | def create(conn, params) do 8 | with {:ok, %User{}} <- Users.create_user(params) do 9 | conn |> send_resp(201, "") 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191209210301_add_snr_and_fingerprint.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Repo.Migrations.AddSnrAndFingerprint do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:payloads, primary_key: false) do 6 | add :fingerprint, :string, default: "none", null: false 7 | add :snr, :decimal, default: 0, null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /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 | # CargoElixir.Repo.insert!(%CargoElixir.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": [ 8 | ">0.25%", 9 | "not ie 11", 10 | "not op_mini all" 11 | ] 12 | } 13 | } 14 | ], 15 | "@babel/preset-react" 16 | ], 17 | "plugins": [ 18 | "@babel/plugin-transform-runtime" 19 | ] 20 | } -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | # File: my_app/entrypoint.sh 2 | #!/bin/bash 3 | # docker entrypoint script. 4 | 5 | # assign a default for the database_user 6 | DB_USER=${DATABASE_USER:-postgres} 7 | 8 | # wait until Postgres is ready 9 | while ! pg_isready -q -h $DATABASE_HOST -p 5432 -U $DB_USER 10 | do 11 | echo "$(date) - waiting for database to start" 12 | sleep 2 13 | done 14 | 15 | bin="_build/prod/rel/cargo_elixir/bin/cargo_elixir" 16 | eval "$bin eval \"CargoElixir.Release.migrate\"" 17 | exec "$bin" "start" 18 | -------------------------------------------------------------------------------- /test/cargo_elixir_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.ErrorViewTest do 2 | use CargoElixirWeb.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(CargoElixirWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(CargoElixirWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.PageController do 2 | use CargoElixirWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | 8 | def get_console_stats(conn, _params) do 9 | case HTTPoison.get("https://console.helium.com/api/stats", ["Authorization": Application.fetch_env!(:cargo_elixir, :console_stats_secret)]) do 10 | {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> 11 | conn |> json(Jason.decode!(body)) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :cargo_elixir, CargoElixir.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "cargo_elixir_test", 8 | hostname: "localhost", 9 | pool: Ecto.Adapters.SQL.Sandbox 10 | 11 | # We don't run a server during test. If one is required, 12 | # you can enable the server option below. 13 | config :cargo_elixir, CargoElixirWeb.Endpoint, 14 | http: [port: 4002], 15 | server: false 16 | 17 | # Print only warnings and errors during test 18 | config :logger, level: :warn 19 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | cargo: 4 | build: . 5 | image: helium/cargo:latest 6 | container_name: helium_cargo 7 | environment: 8 | - SECRET_KEY_BASE=${SECRET_KEY_BASE} 9 | - DATABASE_DB=cargo_elixir 10 | - DATABASE_HOST=postgres 11 | ports: 12 | - "4000:4000" 13 | depends_on: 14 | - postgres 15 | postgres: 16 | image: postgres 17 | container_name: helium_postgres 18 | environment: 19 | - POSTGRES_DB=cargo_elixir 20 | - POSTGRES_PASSWORD=postgres 21 | restart: always 22 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.ErrorView do 2 | use CargoElixirWeb, :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/cargo_elixir/release.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Release do 2 | @app :cargo_elixir 3 | 4 | def migrate do 5 | load_app() 6 | 7 | for repo <- repos() do 8 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 9 | end 10 | end 11 | 12 | def rollback(repo, version) do 13 | load_app() 14 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 15 | end 16 | 17 | defp repos do 18 | Application.fetch_env!(@app, :ecto_repos) 19 | end 20 | 21 | defp load_app do 22 | Application.load(@app) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import '../css/app.css' 4 | import { BrowserRouter as Router, Route, Link } from 'react-router-dom' 5 | import MapScreen from "./pages/MapScreen" 6 | import StatsPage from "./pages/StatsPage" 7 | 8 | class App extends React.Component { 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | } 18 | 19 | ReactDOM.render( 20 | , 21 | document.getElementById("react-app") 22 | ) 23 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191211210339_create_user_table.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Repo.Migrations.CreateUserTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :first_name, :string, null: false 8 | add :last_name, :string, null: false 9 | add :company_name, :string, null: false 10 | add :email, :string, null: false 11 | add :developer, :boolean, null: false, default: false 12 | 13 | timestamps() 14 | end 15 | 16 | create index(:users, [:email]) 17 | create index(:users, [:company_name]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Helium Cargo 8 | " rel='stylesheet' /> 9 | 10 | 11 | 12 | <%= @inner_content %> 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /lib/cargo_elixir/users/user.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Users.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key {:id, :binary_id, autogenerate: true} 6 | @foreign_key_type :binary_id 7 | schema "users" do 8 | field :first_name, :string 9 | field :last_name, :string 10 | field :company_name, :string 11 | field :email, :string 12 | field :developer, :boolean 13 | field :reported, :utc_datetime 14 | field :created_at, :utc_datetime 15 | 16 | timestamps() 17 | end 18 | 19 | def changeset(user, attrs) do 20 | changeset = 21 | user 22 | |> cast(attrs, [:first_name, :last_name, :company_name, :email, :developer]) 23 | |> validate_required([:first_name, :last_name, :company_name, :email, :developer]) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /assets/js/components/SearchBar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | const SearchBar = ({ onSearchChange }) => ( 4 |
e.preventDefault()}> 5 |
6 | 7 |
8 |
9 | ) 10 | 11 | const styles = { 12 | container: { 13 | display: 'flex', 14 | flexDirection: 'row', 15 | alignItems: 'center', 16 | justifyContent: 'flex-end', 17 | paddingRight: 8, 18 | }, 19 | input: { 20 | background: '#efefef', 21 | borderStyle: 'none', 22 | padding: '4px 10px', 23 | borderRadius: 4, 24 | marginLeft: 0, 25 | fontSize: 14, 26 | width: '100%', 27 | marginRight: 4, 28 | }, 29 | } 30 | 31 | export default SearchBar 32 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.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 CargoElixirWeb.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: :cargo_elixir 24 | end 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:1.9-alpine 2 | 3 | # install build dependencies 4 | RUN apk add --update git build-base nodejs npm yarn python bash openssl postgresql-client 5 | 6 | RUN mkdir /app 7 | WORKDIR /app 8 | 9 | # install Hex + Rebar 10 | RUN mix do local.hex --force, local.rebar --force 11 | 12 | # set build ENV 13 | ENV MIX_ENV=prod 14 | 15 | # install mix dependencies 16 | COPY mix.lock mix.lock 17 | COPY mix.exs mix.exs 18 | COPY config config 19 | RUN mix deps.get --only $MIX_ENV 20 | RUN mix deps.compile 21 | 22 | # build assets 23 | COPY assets assets 24 | RUN cd assets && yarn && yarn run deploy 25 | RUN mix phx.digest 26 | 27 | # build project 28 | COPY priv priv 29 | COPY lib lib 30 | RUN mix compile 31 | 32 | RUN mix release 33 | 34 | EXPOSE 4000 35 | COPY entrypoint.sh . 36 | RUN chown -R nobody: /app 37 | USER nobody 38 | 39 | CMD ["bash", "/app/entrypoint.sh"] 40 | -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | db_host = System.get_env("DATABASE_HOST") || 3 | raise """ 4 | environment variable DATABASE_HOST is missing. 5 | """ 6 | db_database = System.get_env("DATABASE_DB") || "cargo_elixir_dev" 7 | db_username = System.get_env("DATABASE_USER") || "postgres" 8 | db_password = System.get_env("DATABASE_PASSWORD") || "postgres" 9 | db_url = "ecto://#{db_username}:#{db_password}@#{db_host}/#{db_database}" 10 | config :cargo_elixir, CargoElixir.Repo, 11 | url: db_url, 12 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 13 | secret_key_base = System.get_env("SECRET_KEY_BASE") || 14 | raise """ 15 | environment variable SECRET_KEY_BASE is missing. 16 | You can generate one by calling: mix phx.gen.secret 17 | """ 18 | config :cargo_elixir, CargoElixirWeb.Endpoint, 19 | http: [:inet6, port: 4000], 20 | check_origin: false, 21 | secret_key_base: secret_key_base 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### How to Contribute to this repository 2 | 3 | We value contributions from the community and will do everything we 4 | can go get them reviewed in a timely fashion. If you have code to send 5 | our way or a bug to report: 6 | 7 | * **Contributing Code**: If you have new code or a bug fix, fork this 8 | repo, create a logically-named branch, and [submit a PR against this 9 | repo](https://github.com/helium/cargo-elixir). Include a 10 | write up of the PR with details on what it does. 11 | 12 | * **Reporting Bugs**: Open an issue [against this 13 | repo](https://github.com/helium/cargo-elixir/issues) with as 14 | much detail as you can. At the very least you'll include steps to 15 | reproduce the problem. 16 | 17 | This project is intended to be a safe, welcoming space for 18 | collaboration, and contributors are expected to adhere to the 19 | [Contributor Covenant Code of 20 | Conduct](http://contributor-covenant.org/). 21 | 22 | Above all, thank you for taking the time to be a part of the Helium 23 | community. 24 | -------------------------------------------------------------------------------- /.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 | cargo_elixir-*.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 | 36 | *.DS_Store 37 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191126185915_create_payloads.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Repo.Migrations.CreatePayloads do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:payloads, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :device_id, :integer, null: false 8 | add :hotspot_id, :string, null: false 9 | add :oui, :integer, null: false 10 | add :lat, :decimal, null: false 11 | add :lon, :decimal, null: false 12 | add :speed, :decimal, null: false 13 | add :rssi, :decimal, null: false 14 | add :elevation, :decimal, null: false 15 | add :battery, :decimal, null: false 16 | add :seq_num, :integer, null: false 17 | add :reported, :utc_datetime, null: false 18 | add :created_at, :utc_datetime, null: false 19 | end 20 | 21 | create index(:payloads, [:created_at]) 22 | create index(:payloads, [:device_id]) 23 | create index(:payloads, [:lat, :lon]) 24 | create index(:payloads, [:oui, :device_id, "created_at DESC"]) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /assets/js/data/chart.js: -------------------------------------------------------------------------------- 1 | export const packetsToChartData = (packets, type) => { 2 | return { 3 | datasets: [{ 4 | fill: false, 5 | lineTension: 0.1, 6 | backgroundColor: 'rgba(71, 144, 229, 1)', 7 | borderColor: 'rgba(71, 144, 229, 1)', 8 | borderCapStyle: 'butt', 9 | borderDash: [], 10 | borderDashOffset: 0.0, 11 | borderJoinStyle: 'miter', 12 | pointBorderColor: 'rgba(71, 144, 229, 1)', 13 | pointBackgroundColor: '#fff', 14 | pointBorderWidth: 1, 15 | pointHoverRadius: 6, 16 | pointHoverBackgroundColor: 'rgba(71, 144, 229, 1)', 17 | pointHoverBorderColor: 'rgba(245,245,245,1)', 18 | pointHoverBorderWidth: 2, 19 | pointRadius: 2, 20 | pointHitRadius: 10, 21 | data: packetsToData(packets, type) 22 | }] 23 | } 24 | } 25 | 26 | const packetsToData = (packets, type) => ( 27 | packets.map(packet => { 28 | return { 29 | t: new Date(packet.reported), 30 | y: type === 'sequence' ? packet.seq_num : packet[type], 31 | id: packet.id 32 | } 33 | }) 34 | ) 35 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.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 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint CargoElixirWeb.Endpoint 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(CargoElixir.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(CargoElixir.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.Router do 2 | use CargoElixirWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | end 11 | 12 | pipeline :api do 13 | plug CORSPlug, origin: "*" 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/api", CargoElixirWeb do 18 | pipe_through :api 19 | 20 | get "/oui/:oui", PayloadController, :get_devices 21 | get "/oui/:oui/payloads", PayloadController, :get_payloads 22 | get "/devices/:id", PayloadController, :get_payloads 23 | options "/oui/:oui", PayloadController, :options 24 | options "/devices/:id", PayloadController, :options 25 | get "/stats", PayloadController, :get_stats 26 | get "/console_stats", PageController, :get_console_stats 27 | post "/payloads", PayloadController, :create 28 | post "/signup", UserController, :create 29 | end 30 | 31 | scope "/", CargoElixirWeb do 32 | pipe_through :browser 33 | 34 | get "/*path", PageController, :index 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /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 :cargo_elixir, 11 | ecto_repos: [CargoElixir.Repo] 12 | 13 | # Configures the endpoint 14 | config :cargo_elixir, CargoElixirWeb.Endpoint, 15 | url: [host: "localhost"], # UPDATE TO YOUR CUSTOM HOST IF NOT RUNNING LOCALLY 16 | secret_key_base: "pnPHQ4sSmnHnN1DGsMoFaMALbUxIazKvmaKzYlCm5JUl2JmapF1hSnFlwKlFjZYQ", 17 | render_errors: [view: CargoElixirWeb.ErrorView, accepts: ~w(html json)], 18 | pubsub_server: CargoElixir.PubSub 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time $metadata[$level] $message\n", 23 | metadata: [:request_id] 24 | 25 | # Use Jason for JSON parsing in Phoenix 26 | config :phoenix, :json_library, Jason 27 | 28 | # Import environment specific config. This must remain at the bottom 29 | # of this file so it overrides the configuration defined above. 30 | import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /lib/cargo_elixir/application.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.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 | # List all child processes to be supervised 10 | children = [ 11 | # Start the Ecto repository 12 | CargoElixir.Repo, 13 | # Start the endpoint when the application starts 14 | CargoElixirWeb.Endpoint, 15 | # Starts a worker by calling: CargoElixir.Worker.start_link(arg) 16 | # {CargoElixir.Worker, arg}, 17 | # Start the PubSub system 18 | {Phoenix.PubSub, name: CargoElixir.PubSub} 19 | ] 20 | 21 | # See https://hexdocs.pm/elixir/Supervisor.html 22 | # for other strategies and supported options 23 | opts = [strategy: :one_for_one, name: CargoElixir.Supervisor] 24 | Supervisor.start_link(children, opts) 25 | end 26 | 27 | # Tell Phoenix to update the endpoint configuration 28 | # whenever the application is updated. 29 | def config_change(changed, _new, removed) do 30 | CargoElixirWeb.Endpoint.config_change(changed, removed) 31 | :ok 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | 2 | @import url("https://use.typekit.net/qfu3tzp.css"); 3 | 4 | body { 5 | font-family: soleil,-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji; 6 | } 7 | 8 | p { 9 | margin: 8px; 10 | } 11 | 12 | div.podHoverblue { 13 | background-color: #EBF3FC; 14 | } 15 | 16 | div.podHover:hover, div.podHoverblue:hover { 17 | background-color: #E1ECF8; 18 | } 19 | 20 | p.hotspotentry:hover { 21 | background-color: #8D66E8 !important; 22 | } 23 | 24 | 25 | 26 | @media only screen and (max-width: 500px) { 27 | 28 | div.timeline { 29 | width: 100% !important; 30 | height: 110px !important; 31 | } 32 | 33 | div.valueBox { 34 | min-width: 100px !important; 35 | max-width: 240px !important; 36 | } 37 | 38 | p.hotspotentry { 39 | display: inline-block; 40 | margin-left: 10px !important; 41 | width: auto; 42 | white-space: nowrap; 43 | margin-bottom: 10px !important; 44 | } 45 | 46 | div.nomargintop { 47 | margin-top: 0 !important; 48 | padding: 0 !important; 49 | } 50 | 51 | p.paddingLeft { 52 | padding-left: 10px !important; 53 | } 54 | 55 | div.hotspotwrapper { 56 | display: flex; 57 | flex-wrap: no-wrap; 58 | overflow-x: scroll !important; 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /lib/cargo_elixir_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | channel "payload:*", CargoElixirWeb.PayloadChannel 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 | def connect(_params, socket, _connect_info) do 19 | {:ok, socket} 20 | end 21 | 22 | # Socket id's are topics that allow you to identify all sockets for a given user: 23 | # 24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 25 | # 26 | # Would allow you to broadcast a "disconnect" event and terminate 27 | # all active sockets and channels for a given user: 28 | # 29 | # CargoElixirWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 30 | # 31 | # Returning `nil` makes this socket anonymous. 32 | def id(_socket), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.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 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | import Plug.Conn 22 | import Phoenix.ConnTest 23 | alias CargoElixirWeb.Router.Helpers, as: Routes 24 | 25 | # The default endpoint for testing 26 | @endpoint CargoElixirWeb.Endpoint 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(CargoElixir.Repo) 32 | 33 | unless tags[:async] do 34 | Ecto.Adapters.SQL.Sandbox.mode(CargoElixir.Repo, {:shared, self()}) 35 | end 36 | 37 | {:ok, conn: Phoenix.ConnTest.build_conn()} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/cargo_elixir/payloads/payload.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Payloads.Payload do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key {:id, :binary_id, autogenerate: true} 6 | @foreign_key_type :binary_id 7 | @derive {Jason.Encoder, only: [:name,:battery,:created_at,:device_id,:elevation,:hotspot_id,:id,:lat,:lon,:oui,:reported,:rssi,:seq_num,:speed,:snr,:fingerprint]} 8 | schema "payloads" do 9 | field :device_id, :string 10 | field :name, :string 11 | field :hotspot_id, :string 12 | field :oui, :integer 13 | field :lat, :decimal 14 | field :lon, :decimal 15 | field :speed, :decimal 16 | field :rssi, :decimal 17 | field :elevation, :decimal 18 | field :battery, :decimal 19 | field :seq_num, :integer 20 | field :fingerprint, :string 21 | field :snr, :decimal 22 | field :reported, :utc_datetime 23 | field :created_at, :utc_datetime 24 | end 25 | 26 | def changeset(payload, attrs) do 27 | changeset = 28 | payload 29 | |> cast(attrs, [:device_id, :name, :hotspot_id, :oui, :lat, :lon, :speed, :rssi, :elevation, :battery, :seq_num, :reported, :snr]) 30 | |> put_change(:created_at, DateTime.utc_now |> DateTime.truncate(:second)) 31 | |> validate_required([:name, :device_id, :hotspot_id, :oui, :lat, :lon, :speed, :rssi, :elevation, :battery, :seq_num, :reported, :created_at, :snr]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CargoElixir 2 | 3 | Screen Shot 2020-11-12 at 12 58 20 PM 4 | 5 | Cargo is a visualization tool for asset trackers. As part of our strategy we are making this application available as open source. 6 | 7 | Helium Console has a pre-built Console integration that makes it easy to quickly view a tracker’s physical location. 8 | 9 | ## Development Environment 10 | 11 | To start your Phoenix server: 12 | 13 | * Install dependencies with `mix deps.get` 14 | * Create and migrate your database with `mix ecto.setup` 15 | * Install Node.js dependencies with `cd assets && yarn` 16 | * Start Phoenix endpoint with `mix phx.server` 17 | 18 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 19 | 20 | ## Running with Docker 21 | 22 | * Clone the repo and `cd cargo-elixir` 23 | * Follow instructions at the bottom of `/config/prod.exs` 24 | * Follow instructions at the top of `/assets/js/pages/MapScreen.js` 25 | * Update host in `/config/releases.exs` 26 | * Build with `docker-compose build` 27 | * Generate secret key with `mix phx.gen.secret` 28 | * Set environment variable `export SECRET_KEY_BASE=` 29 | * Run with `docker-compose up` 30 | 31 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 32 | -------------------------------------------------------------------------------- /config/prod.secret.exs: -------------------------------------------------------------------------------- 1 | # In this file, we load production configuration and 2 | # secrets from environment variables. You can also 3 | # hardcode secrets, although such is generally not 4 | # recommended and you have to remember to add this 5 | # file to your .gitignore. 6 | use Mix.Config 7 | 8 | database_url = 9 | System.get_env("DATABASE_URL") || 10 | raise """ 11 | environment variable DATABASE_URL is missing. 12 | For example: ecto://USER:PASS@HOST/DATABASE 13 | """ 14 | 15 | config :cargo_elixir, CargoElixir.Repo, 16 | ssl: true, 17 | url: database_url, 18 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 19 | 20 | secret_key_base = 21 | System.get_env("SECRET_KEY_BASE") || 22 | raise """ 23 | environment variable SECRET_KEY_BASE is missing. 24 | You can generate one by calling: mix phx.gen.secret 25 | """ 26 | config :cargo_elixir, CargoElixirWeb.Endpoint, 27 | url: [scheme: "https", host: "cargo.helium.com", port: 443], 28 | http: [:inet6, port: String.to_integer(System.get_env("PORT") || "4000")], 29 | check_origin: false, 30 | # check_origin: [ 31 | # "https://cargo-elixir.herokuapp.com", 32 | # "https://cargo.helium.com", 33 | # ], 34 | force_ssl: [rewrite_on: [:x_forwarded_proto]], 35 | cache_static_manifest: "priv/static/cache_manifest.json", 36 | secret_key_base: secret_key_base 37 | 38 | 39 | config :cargo_elixir, console_stats_secret: System.get_env("CONSOLE_STATS_SECRET") 40 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = (env, options) => ({ 9 | optimization: { 10 | minimizer: [ 11 | new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }), 12 | new OptimizeCSSAssetsPlugin({}) 13 | ] 14 | }, 15 | entry: { 16 | app: "./js/app.js" 17 | }, 18 | output: { 19 | filename: 'app.js', 20 | path: path.resolve(__dirname, '../priv/static/js') 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.js$/, 26 | exclude: /node_modules/, 27 | use: { 28 | loader: 'babel-loader' 29 | } 30 | }, 31 | { 32 | test: /\.css$/, 33 | use: [MiniCssExtractPlugin.loader, 'css-loader'] 34 | }, 35 | { 36 | test: /\.svg$/, 37 | use: [ 38 | { 39 | loader: "babel-loader" 40 | }, 41 | { 42 | loader: "react-svg-loader", 43 | options: { 44 | jsx: true 45 | } 46 | } 47 | ] 48 | } 49 | ] 50 | }, 51 | plugins: [ 52 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 53 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) 54 | ] 55 | }); 56 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :cargo_elixir 3 | 4 | socket "/socket", CargoElixirWeb.UserSocket, 5 | websocket: [timeout: 45_000], 6 | longpoll: false, 7 | check_origin: false 8 | 9 | # Serve at "/" the static files from "priv/static" directory. 10 | # 11 | # You should set gzip to true if you are running phx.digest 12 | # when deploying your static files in production. 13 | plug Plug.Static, 14 | at: "/", 15 | from: :cargo_elixir, 16 | gzip: false, 17 | only: ~w(css fonts images js favicon.ico robots.txt) 18 | 19 | # Code reloading can be explicitly enabled under the 20 | # :code_reloader configuration of your endpoint. 21 | if code_reloading? do 22 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 23 | plug Phoenix.LiveReloader 24 | plug Phoenix.CodeReloader 25 | end 26 | 27 | plug Plug.RequestId 28 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 29 | 30 | plug Plug.Parsers, 31 | parsers: [:urlencoded, :multipart, :json], 32 | pass: ["*/*"], 33 | json_decoder: Phoenix.json_library() 34 | 35 | plug Plug.MethodOverride 36 | plug Plug.Head 37 | 38 | # The session will be stored in the cookie and signed, 39 | # this means its contents can be read but not tampered with. 40 | # Set :encryption_salt if you would also like to encrypt it. 41 | plug Plug.Session, 42 | store: :cookie, 43 | key: "_cargo_elixir_key", 44 | signing_salt: "h6awe/zo" 45 | 46 | plug CargoElixirWeb.Router 47 | end 48 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "webpack --mode production", 6 | "watch": "webpack --mode development --watch" 7 | }, 8 | "dependencies": { 9 | "@helium/http": "1.0.0", 10 | "chart.js": "^2.9.3", 11 | "geojson": "^0.5.0", 12 | "javascript-time-ago": "^2.0.4", 13 | "lodash": "^4.17.15", 14 | "mapbox-gl": "^2.15.0", 15 | "maplibre-gl": "^3.5.1", 16 | "phoenix": "file:../deps/phoenix", 17 | "phoenix_html": "file:../deps/phoenix_html", 18 | "pmtiles": "^2.11.0", 19 | "react": "^16.12.0", 20 | "react-chartjs-2": "^2.8.0", 21 | "react-dom": "^16.12.0", 22 | "react-map-gl": "^7.1.6", 23 | "react-media": "^1.10.0", 24 | "react-router-dom": "^5.1.2", 25 | "react-svg-loader": "^3.0.3", 26 | "react-switch": "^5.0.1", 27 | "react-typekit": "^1.1.4" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.0.0", 31 | "@babel/plugin-transform-regenerator": "7.10.4", 32 | "@babel/plugin-transform-runtime": "7.11.0", 33 | "@babel/polyfill": "7.10.4", 34 | "@babel/preset-env": "^7.0.0", 35 | "@babel/preset-react": "^7.7.0", 36 | "@babel/runtime": "7.11.2", 37 | "babel-loader": "^8.0.0", 38 | "copy-webpack-plugin": "^4.5.0", 39 | "css-loader": "^2.1.1", 40 | "mini-css-extract-plugin": "^0.4.0", 41 | "optimize-css-assets-webpack-plugin": "^4.0.0", 42 | "regenerator-runtime": "0.13.7", 43 | "uglifyjs-webpack-plugin": "^1.2.4", 44 | "webpack": "4.4.0", 45 | "webpack-cli": "^2.0.10" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.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 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias CargoElixir.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import CargoElixir.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(CargoElixir.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(CargoElixir.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transforms changeset errors into a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 49 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.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), class: "help-block") 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # When using gettext, we typically pass the strings we want 22 | # to translate as a static argument: 23 | # 24 | # # Translate "is invalid" in the "errors" domain 25 | # dgettext("errors", "is invalid") 26 | # 27 | # # Translate the number of files with plural rules 28 | # dngettext("errors", "1 file", "%{count} files", count) 29 | # 30 | # Because the error messages we show in our forms and APIs 31 | # are defined inside Ecto, we need to translate them dynamically. 32 | # This requires us to call the Gettext module passing our gettext 33 | # backend as first argument. 34 | # 35 | # Note we use the "errors" domain, which means translations 36 | # should be written to the errors.po file. The :count option is 37 | # set by Ecto and indicates we should also apply plural rules. 38 | if count = opts[:count] do 39 | Gettext.dngettext(CargoElixirWeb.Gettext, "errors", msg, msg, count, opts) 40 | else 41 | Gettext.dgettext(CargoElixirWeb.Gettext, "errors", msg, opts) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web/controllers/payload_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb.PayloadController do 2 | use CargoElixirWeb, :controller 3 | 4 | alias CargoElixir.Payloads 5 | alias CargoElixir.Payloads.Payload 6 | 7 | def create(conn, params) do 8 | with {:ok, %Payload{} = payload} <- Payloads.create_payload(params) do 9 | CargoElixirWeb.Endpoint.broadcast!("payload:new", "new_payload", payload) 10 | 11 | conn |> send_resp(201, "") 12 | end 13 | end 14 | 15 | def get_devices(conn, %{"oui" => oui, "device_id" => device_id}) do 16 | device = Payloads.get_device(oui, device_id) 17 | conn |> json(device) 18 | end 19 | 20 | def get_devices(conn, %{}) do 21 | devices = Payloads.get_devices() 22 | conn |> json(devices) 23 | end 24 | 25 | def get_payloads(conn, %{"id" => device_id, "last_at" => last_packet_time }) do 26 | payloads = Payloads.get_payloads(device_id, last_packet_time) 27 | conn |> json(payloads) 28 | end 29 | 30 | # return all payloads for an oui for the past hour 31 | def get_payloads(conn, %{"oui" => oui }) do 32 | payloads = Payloads.get_all_payloads(oui) 33 | conn |> json(payloads) 34 | end 35 | 36 | def get_stats(conn, %{ "time" => time }) do 37 | currently_transmitting = Payloads.get_currently_transmitting() |> List.first() 38 | devices_transmitted = Payloads.get_device_stats(time) |> List.first() 39 | hotspots_transmitted = Payloads.get_hotspot_stats(time) |> List.first() 40 | payloads_transmitted = Payloads.get_payload_stats(time) |> List.first() 41 | conn 42 | |> json(%{ 43 | currentlyTransmitting: currently_transmitting, 44 | devicesTransmitted: devices_transmitted, 45 | hotspotsTransmitted: hotspots_transmitted, 46 | payloadsTransmitted: payloads_transmitted, 47 | }) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/cargo_elixir_web.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixirWeb 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 CargoElixirWeb, :controller 9 | use CargoElixirWeb, :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: CargoElixirWeb 23 | 24 | import Plug.Conn 25 | import CargoElixirWeb.Gettext 26 | alias CargoElixirWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/cargo_elixir_web/templates", 34 | namespace: CargoElixirWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] 38 | 39 | # Use all HTML functionality (forms, tags, etc) 40 | use Phoenix.HTML 41 | 42 | import CargoElixirWeb.ErrorHelpers 43 | import CargoElixirWeb.Gettext 44 | alias CargoElixirWeb.Router.Helpers, as: Routes 45 | end 46 | end 47 | 48 | def router do 49 | quote do 50 | use Phoenix.Router 51 | import Plug.Conn 52 | import Phoenix.Controller 53 | end 54 | end 55 | 56 | def channel do 57 | quote do 58 | use Phoenix.Channel 59 | import CargoElixirWeb.Gettext 60 | end 61 | end 62 | 63 | @doc """ 64 | When used, dispatch to the appropriate controller/view/etc. 65 | """ 66 | defmacro __using__(which) when is_atom(which) do 67 | apply(__MODULE__, which, []) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :cargo_elixir, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {CargoElixir.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.7.0"}, 36 | {:phoenix_pubsub, "~> 2.0"}, 37 | {:phoenix_ecto, "~> 4.0"}, 38 | {:ecto_sql, "~> 3.0"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:phoenix_view, "~> 2.0"}, 41 | {:phoenix_html, "~> 3.0"}, 42 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 43 | {:gettext, "~> 0.11"}, 44 | {:jason, "~> 1.0"}, 45 | {:plug_cowboy, "~> 2.1"}, 46 | {:cowboy, "~> 2.7.0"}, 47 | {:httpoison, "~> 1.6"}, 48 | {:cors_plug, "~> 2.0"}, 49 | ] 50 | end 51 | 52 | # Aliases are shortcuts or tasks specific to the current project. 53 | # For example, to create, migrate and run the seeds file at once: 54 | # 55 | # $ mix ecto.setup 56 | # 57 | # See the documentation for `Mix` for more info on aliases. 58 | defp aliases do 59 | [ 60 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 61 | "ecto.reset": ["ecto.drop", "ecto.setup"], 62 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 63 | ] 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /assets/js/pages/StatsPage.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | const tabs = [ 4 | { title: "Last 24 hours", time: '24h' }, 5 | { title: "Last 7 days", time: '7d' }, 6 | { title: "Last 30 days", time: '30d' }, 7 | { title: "All time", time: 'all' }, 8 | ] 9 | 10 | class StatsPage extends React.Component { 11 | constructor(props) { 12 | super(props) 13 | this.state = { 14 | tabIndex: 0, 15 | stats: null, 16 | consoleStats: { users: "", organizations: "", devices: "", teams: "" }, 17 | } 18 | } 19 | 20 | componentDidMount() { 21 | fetch(`api/stats?time=24h`) 22 | .then(res => res.json()) 23 | .then(stats => this.setState({ stats })) 24 | 25 | fetch('api/console_stats') 26 | .then(res => res.json()) 27 | .then(consoleStats => this.setState({consoleStats})) 28 | } 29 | 30 | changeTab(tabIndex) { 31 | this.setState({ tabIndex, stats: null }) 32 | fetch(`api/stats?time=${tabs[tabIndex].time}`) 33 | .then(res => res.json()) 34 | .then(stats => this.setState({ stats })) 35 | } 36 | 37 | render() { 38 | const { tabIndex, stats, consoleStats } = this.state 39 | return ( 40 |
41 |

Cargo Statistics

42 |

Currently transmitting devices: {!stats ? "loading" : stats.currentlyTransmitting}

43 |

Devices that transmitted ({tabs[tabIndex].title}): {!stats ? "loading" : stats.devicesTransmitted}

44 |

Hotspots that transmitted ({tabs[tabIndex].title}): {!stats ? "loading" : stats.hotspotsTransmitted}

45 |

Packets transmitted ({tabs[tabIndex].title}): {!stats ? "loading" : stats.payloadsTransmitted}

46 | { 47 | tabs.map((t, i) => ( 48 | 49 | )) 50 | } 51 |

Console Statistics

52 |

Number of Users: {consoleStats.users}

53 |

Number of Organizations: {consoleStats.organizations}

54 |

Number of Teams: {consoleStats.teams}

55 |

Number of Devices: {consoleStats.devices}

56 |
57 | ) 58 | } 59 | } 60 | 61 | export default StatsPage 62 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket, 5 | // and connect at the socket path in "lib/web/endpoint.ex". 6 | // 7 | // Pass the token on params as below. Or remove it 8 | // from the params if you are not using authentication. 9 | import {Socket} from "phoenix" 10 | 11 | const SOCKET_URL = '/socket' 12 | // const SOCKET_URL = 'wss://cargo.helium.com/socket' 13 | 14 | let socket = new Socket(SOCKET_URL) 15 | 16 | // When you connect, you'll often need to authenticate the client. 17 | // For example, imagine you have an authentication plug, `MyAuth`, 18 | // which authenticates the session and assigns a `:current_user`. 19 | // If the current user exists you can assign the user's token in 20 | // the connection for use in the layout. 21 | // 22 | // In your "lib/web/router.ex": 23 | // 24 | // pipeline :browser do 25 | // ... 26 | // plug MyAuth 27 | // plug :put_user_token 28 | // end 29 | // 30 | // defp put_user_token(conn, _) do 31 | // if current_user = conn.assigns[:current_user] do 32 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 33 | // assign(conn, :user_token, token) 34 | // else 35 | // conn 36 | // end 37 | // end 38 | // 39 | // Now you need to pass this token to JavaScript. You can do so 40 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 41 | // 42 | // 43 | // 44 | // You will need to verify the user token in the "connect/3" function 45 | // in "lib/web/channels/user_socket.ex": 46 | // 47 | // def connect(%{"token" => token}, socket, _connect_info) do 48 | // # max_age: 1209600 is equivalent to two weeks in seconds 49 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 50 | // {:ok, user_id} -> 51 | // {:ok, assign(socket, :user, user_id)} 52 | // {:error, reason} -> 53 | // :error 54 | // end 55 | // end 56 | // 57 | // Finally, connect to the socket: 58 | socket.connect() 59 | 60 | export default socket 61 | -------------------------------------------------------------------------------- /priv/repo/materialized_view.exs: -------------------------------------------------------------------------------- 1 | # CREATE MATERIALIZED VIEW distinct_devices_all_time AS SELECT DISTINCT payloads.device_id FROM payloads WHERE (payloads.created_at <= CURRENT_DATE); 2 | # CREATE MATERIALIZED VIEW distinct_devices_last_7 AS SELECT DISTINCT payloads.device_id FROM payloads WHERE ((payloads.created_at <= CURRENT_DATE) AND (payloads.created_at > (CURRENT_DATE - '7 days'::interval))); 3 | # CREATE MATERIALIZED VIEW distinct_devices_last_30 AS SELECT DISTINCT payloads.device_id FROM payloads WHERE ((payloads.created_at <= CURRENT_DATE) AND (payloads.created_at > (CURRENT_DATE - '30 days'::interval))); 4 | # CREATE MATERIALIZED VIEW total_payloads_all_time AS SELECT count(*) AS count FROM payloads WHERE (payloads.created_at <= CURRENT_DATE); 5 | # CREATE MATERIALIZED VIEW total_payloads_last_30 AS SELECT count(*) AS count FROM payloads WHERE ((payloads.created_at <= CURRENT_DATE) AND (payloads.created_at > (CURRENT_DATE - '30 days'::interval))); 6 | # CREATE MATERIALIZED VIEW distinct_hotspots_all_time AS SELECT DISTINCT payloads.hotspot_id FROM payloads WHERE (payloads.created_at <= CURRENT_DATE); 7 | # CREATE MATERIALIZED VIEW total_payloads_last_7 AS SELECT count(*) AS count FROM payloads WHERE ((payloads.created_at <= CURRENT_DATE) AND (payloads.created_at > (CURRENT_DATE - '7 days'::interval))); 8 | # CREATE MATERIALIZED VIEW distinct_hotspots_last_30 AS SELECT DISTINCT payloads.hotspot_id FROM payloads WHERE ((payloads.created_at <= CURRENT_DATE) AND (payloads.created_at > (CURRENT_DATE - '30 days'::interval))); 9 | # CREATE MATERIALIZED VIEW distinct_hotspots_last_7 AS SELECT DISTINCT payloads.hotspot_id FROM payloads WHERE ((payloads.created_at <= CURRENT_DATE) AND (payloads.created_at > (CURRENT_DATE - '7 days'::interval))); 10 | 11 | # CREATE OR REPLACE FUNCTION refresh_all_matviews() RETURNS void AS $$ 12 | # DECLARE 13 | # rec RECORD; 14 | # BEGIN 15 | # FOR rec IN 16 | # SELECT matviewname from pg_matviews 17 | # LOOP 18 | # EXECUTE format('REFRESH MATERIALIZED VIEW %I;', rec.matviewname); 19 | # RAISE NOTICE 'Refreshed: %', rec.matviewname; 20 | # END LOOP; 21 | # END; 22 | # $$ LANGUAGE plpgsql; 23 | # select * from total_payloads_last_7 2872445 24 | # select * from total_payloads_last_30 13623127 25 | # select * from total_payloads_all_time 20387278 26 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :cargo_elixir, CargoElixir.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "cargo_elixir_dev", 8 | hostname: "localhost", 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 :cargo_elixir, CargoElixirWeb.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 :cargo_elixir, CargoElixirWeb.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/cargo_elixir_web/{live,views}/.*(ex)$", 64 | ~r"lib/cargo_elixir_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 | -------------------------------------------------------------------------------- /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 | 13 | # Do not print debug messages in production 14 | config :logger, level: :info 15 | 16 | # ## SSL Support 17 | # 18 | # To get SSL working, you will need to add the `https` key 19 | # to the previous section and set your `:url` port to 443: 20 | # 21 | # config :cargo_elixir, CargoElixirWeb.Endpoint, 22 | # ... 23 | # url: [host: "example.com", port: 443], 24 | # https: [ 25 | # :inet6, 26 | # port: 443, 27 | # cipher_suite: :strong, 28 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 29 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 30 | # ] 31 | # 32 | # The `cipher_suite` is set to `:strong` to support only the 33 | # latest and more secure SSL ciphers. This means old browsers 34 | # and clients may not be supported. You can set it to 35 | # `:compatible` for wider support. 36 | # 37 | # `:keyfile` and `:certfile` expect an absolute path to the key 38 | # and cert in disk or a relative path inside priv, for example 39 | # "priv/ssl/server.key". For all supported SSL configuration 40 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 41 | # 42 | # We also recommend setting `force_ssl` in your endpoint, ensuring 43 | # no data is ever sent via http, always redirecting to https: 44 | # 45 | # config :cargo_elixir, CargoElixirWeb.Endpoint, 46 | # force_ssl: [hsts: true] 47 | # 48 | # Check `Plug.SSL` for all available options in `force_ssl`. 49 | 50 | # ## Using releases (Elixir v1.9+) 51 | # 52 | # If you are doing OTP releases, you need to instruct Phoenix 53 | # to start each relevant endpoint: 54 | # 55 | # config :cargo_elixir, CargoElixirWeb.Endpoint, server: true 56 | # 57 | # Then you can assemble a release by calling `mix release`. 58 | # See `mix help release` for more information. 59 | 60 | # Finally import the config/prod.secret.exs which loads secrets 61 | # and configuration from environment variables. 62 | 63 | ##COMMENT OUT THIS LINE TO USE WITH DOCKER COMPOSE 64 | import_config "prod.secret.exs" 65 | 66 | ##UNCOMMENT BELOW TO USE WITH DOCKER COMPOSE 67 | # config :cargo_elixir, CargoElixirWeb.Endpoint, 68 | # url: [host: "example.com", port: 80], 69 | # cache_static_manifest: "priv/static/cache_manifest.json", 70 | # server: true 71 | -------------------------------------------------------------------------------- /assets/js/components/NavBarRow.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import TimeAgo from 'javascript-time-ago' 3 | import en from 'javascript-time-ago/locale/en' 4 | TimeAgo.addLocale(en) 5 | const timeAgo = new TimeAgo('en-US'); 6 | 7 | const styles = { 8 | container: { 9 | display: 'flex', 10 | flexDirection: 'row', 11 | justifyContent: 'space-between', 12 | alignItems: 'center', 13 | paddingLeft: 16, 14 | paddingRight: 16, 15 | cursor: 'pointer', 16 | minWidth: 300, 17 | '&:hover': { 18 | backgroundColor: '#0D47A1 !important', 19 | } 20 | }, 21 | title: { 22 | fontSize: 16, 23 | fontWeight: 500, 24 | color: '#1B8DFF', 25 | background: 'white', 26 | padding: '3px 10px 4px', 27 | borderRadius: 6, 28 | marginLeft: 0, 29 | maxWidth: 180, 30 | whiteSpace: 'nowrap', 31 | overflow: 'hidden', 32 | textOverflow: 'ellipsis', 33 | }, 34 | tag: { 35 | fontSize: 11, 36 | paddingTop: 5, 37 | paddingBottom: 3, 38 | lineHeight: 1 39 | }, 40 | pillRed: { 41 | display: 'inline-block', 42 | fontSize: 10, 43 | backgroundColor: 'red', 44 | fontWeight: 'bold', 45 | color: '#ffffff', 46 | paddingLeft: 10, 47 | paddingRight: 10, 48 | paddingTop: 4, 49 | paddingBottom: 4, 50 | borderRadius: 15 51 | }, 52 | pillGrey: { 53 | display: 'inline-block', 54 | fontSize: 10, 55 | backgroundColor: 'grey', 56 | fontWeight: 'bold', 57 | color: '#ffffff', 58 | paddingLeft: 10, 59 | paddingRight: 10, 60 | paddingTop: 4, 61 | paddingBottom: 4, 62 | borderRadius: 15 63 | } 64 | } 65 | 66 | class NavBarRow extends Component { 67 | render() { 68 | const { device, name, selectDevice, selectedDevice } = this.props 69 | const selected = selectedDevice && selectedDevice.device_id === device.device_id 70 | const latest = new Date(device.created_at) 71 | const withinLast2Min = ((Date.now() - latest) / 1000) < 120 72 | 73 | return ( 74 |
selectDevice(device)} 81 | 82 | className="podHover" 83 | > 84 |

{device.name}

85 |
86 | { 87 | withinLast2Min ? ( 88 | device.lat > 0 ? ( 89 |

{timeAgo.format(latest, {flavour: "small"})}

90 | ) : ( 91 |

No GPS Fix

92 | ) 93 | ) : ( 94 |

{timeAgo.format(latest, {flavour: "small"})}

95 | ) 96 | } 97 |

{name}

98 |
99 |
100 | ) 101 | } 102 | } 103 | 104 | export default NavBarRow 105 | -------------------------------------------------------------------------------- /assets/js/components/SignUp.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Logo from "../../static/images/logocargo.svg"; 3 | 4 | const styles = { 5 | signUp: { 6 | position: 'absolute', 7 | top: 0, 8 | left: 0, 9 | height: '100%', 10 | width: '100%', 11 | zIndex: 20, 12 | backgroundColor: 'rgba(0,0,0,0.5)', 13 | display: 'flex', 14 | flexDirection: 'row', 15 | justifyContent: 'space-between', 16 | }, 17 | card: { 18 | backgroundColor: 'white', 19 | width: '100%', 20 | maxWidth: 400, 21 | padding: 40, 22 | borderRadius: 10, 23 | position: 'absolute', 24 | left: '50%', 25 | top: '50%', 26 | transform: 'translate(-50% , -50%)', 27 | textAlign: 'center', 28 | boxSizing: 'border-box', 29 | }, 30 | formRow: { 31 | display: 'flex', 32 | flexDirection: 'row', 33 | alignItems: 'center', 34 | marginBottom: 10, 35 | boxSizing: 'border-box', 36 | 37 | }, 38 | 39 | forminput: { 40 | height: '20px', 41 | width: '100%', 42 | fontSize: 15, 43 | padding: 8, 44 | border: 'none', 45 | background: '#f1f1f1', 46 | borderRadius: 3, 47 | 48 | }, 49 | 50 | button: { 51 | padding: '6px 12px', 52 | color: 'white', 53 | background: '#38A2FF', 54 | borderRadius: 999, 55 | margin: 10, 56 | fontSize: 16, 57 | fontWeight: 500, 58 | width: '45%', 59 | border: 'none' 60 | }, 61 | 62 | 63 | 64 | buttonsecondary: { 65 | padding: '6px 12px', 66 | color: '#38A2FF', 67 | background: 'white', 68 | borderRadius: 999, 69 | margin: 10, 70 | fontSize: 16, 71 | fontWeight: 500, 72 | width: '45%', 73 | border: '1px solid #38A2FF', 74 | } 75 | 76 | 77 | 78 | } 79 | 80 | class SignUp extends React.Component { 81 | constructor(props) { 82 | super(props) 83 | 84 | this.state = { 85 | firstName: "", 86 | lastName: "", 87 | companyName: "", 88 | email: "", 89 | developer: false, 90 | } 91 | this.onChange = this.onChange.bind(this) 92 | this.onSubmit = this.onSubmit.bind(this) 93 | this.onCancel = this.onCancel.bind(this) 94 | } 95 | 96 | onChange(e) { 97 | this.setState({ [e.target.name]: e.target.value }) 98 | } 99 | 100 | onSubmit(e) { 101 | e.preventDefault() 102 | 103 | fetch("api/signup", { 104 | method: 'POST', 105 | headers: { 'Content-Type': 'application/json' }, 106 | body: JSON.stringify({ 107 | first_name: this.state.firstName, 108 | last_name: this.state.lastName, 109 | company_name: this.state.companyName, 110 | email: this.state.email, 111 | developer: this.state.developer, 112 | }) 113 | }) 114 | .then(() => { 115 | console.log("Email submitted") 116 | }) 117 | .catch(err => { 118 | console.log("Email submission error") 119 | }) 120 | 121 | this.onCancel(e) 122 | } 123 | 124 | onCancel(e) { 125 | e.preventDefault() 126 | if (window.localStorage) { 127 | window.localStorage.setItem("seenSignUp", true) 128 | } 129 | this.props.hideSignUp() 130 | } 131 | 132 | render() { 133 | const { firstName, lastName, companyName, email, developer } = this.state 134 | return ( 135 |
136 |
137 | 138 |

Enter your details to discuss IoT asset tracking use cases with Helium.

139 |
140 |
141 | 142 | 143 | 144 |
145 | 146 |
147 | 148 |
149 |
150 | 151 |
152 |
153 | this.setState({ developer: !developer })} checked={developer} /> 154 | 155 |
156 |
157 | 158 | 159 |
160 |
161 |
162 |
163 | ) 164 | } 165 | } 166 | 167 | export default SignUp 168 | -------------------------------------------------------------------------------- /assets/js/components/Timeline.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Line } from "react-chartjs-2"; 3 | import upperCase from 'lodash/upperCase' 4 | 5 | const styles = { 6 | timeline: { 7 | position: "absolute", 8 | bottom: "0px", 9 | background: "white", 10 | right: "0px", 11 | zIndex: 10, 12 | height: 150, 13 | overflow: "hidden", 14 | display: "flex", 15 | width: "calc(100vw - 230px)", 16 | fontFamily: '"Soleil", "Helvetica Neue", Helvetica, Arial, sans-serif', 17 | }, 18 | valueBox: { 19 | minWidth: 230, 20 | background: "#1B8DFF", 21 | color: "white", 22 | display: "flex", 23 | flexDirection: 'column', 24 | alignItems: "center", 25 | justifyContent: "center", 26 | }, 27 | value: { 28 | fontSize: 42, 29 | }, 30 | chart: { 31 | position: "relative", 32 | width: "100%", 33 | }, 34 | close: { 35 | position: "absolute", 36 | top: 10, 37 | right: 15, 38 | fontSize: "24px", 39 | fontWeight: 300, 40 | color: "#D5DCE2", 41 | cursor: "pointer", 42 | zIndex: 10, 43 | }, 44 | title: { 45 | position: "absolute", 46 | top: 10, 47 | left: 15, 48 | fontSize: "14px", 49 | fontWeight: 300, 50 | color: "#D5DCE2", 51 | marginTop: 5, 52 | } 53 | } 54 | 55 | class Timeline extends Component { 56 | constructor(props) { 57 | super(props) 58 | 59 | this.state = { 60 | timelineValue: "0" 61 | } 62 | 63 | this.onHover = this.onHover.bind(this) 64 | } 65 | 66 | onHover(e, a) { 67 | const { packets, type, setHotspots } = this.props 68 | if (a[0]) { 69 | if (type === 'sequence') { 70 | this.setState({ timelineValue: packets.seq[a[0]._index] }) 71 | } else { 72 | this.setState({ timelineValue: packets.data[packets.seq[a[0]._index]][type] }) 73 | } 74 | setHotspots(packets.geoJson.features[a[0]._index]) 75 | } else { 76 | this.setState({ timelineValue: "0" }) 77 | } 78 | } 79 | 80 | render() { 81 | const { type, setChartType, chartData } = this.props 82 | return ( 83 |
84 | 88 |
89 | 90 | {upperCase(type)} 91 | 92 | setChartType(null)}> 93 | × 94 | 95 |
102 | 153 |
154 |
155 |
156 | ); 157 | } 158 | } 159 | 160 | const TimelineValue = props => { 161 | const { value, type } = props; 162 | 163 | if (type === "sequence") { 164 | return ( 165 |
166 |

167 | {value.split("-")[0]} 168 |

169 |

seq #

170 |
171 | ); 172 | } 173 | 174 | if (type === "speed") { 175 | return ( 176 |
177 |

178 | {value} 179 |

180 |

mph

181 |
182 | ); 183 | } 184 | 185 | if (type === "elevation") { 186 | return ( 187 |
188 |

189 | {value} 190 |

191 |

meters

192 |
193 | ); 194 | } 195 | 196 | if (type === "rssi") { 197 | return ( 198 |
199 |

200 | {value} 201 |

202 |

dBm

203 |
204 | ); 205 | } 206 | 207 | if (type === "battery") { 208 | return ( 209 |
210 |

211 | {value} 212 |

213 |

volts

214 |
215 | ); 216 | } 217 | 218 | if (type === "snr") { 219 | return ( 220 |
221 |

222 | {value} 223 |

224 |

SNR

225 |
226 | ); 227 | } 228 | return null; 229 | } 230 | 231 | export default Timeline 232 | -------------------------------------------------------------------------------- /assets/js/components/NavBar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Logo from "../../static/images/logocargo.svg"; 3 | import LogoSm from "../../static/images/logocargo_30.svg"; 4 | import NavBarRow from './NavBarRow' 5 | import SearchBar from './SearchBar' 6 | import Media from 'react-media'; 7 | import Switch from "react-switch"; 8 | 9 | const styles = { 10 | container: { 11 | position: 'absolute', 12 | top: 0, 13 | left: 0, 14 | backgroundColor: '#ffffff', 15 | width: 375, 16 | zIndex: 10, 17 | height: '100vh', 18 | }, 19 | title: { 20 | marginBottom: 0, 21 | paddingBottom: 16, 22 | marginLeft: 0, 23 | marginRight: 0, 24 | borderBottom: '1px solid #D3D3D3', 25 | }, 26 | tip: { 27 | marginBottom: 0, 28 | marginLeft: 0, 29 | marginRight: 0, 30 | borderBottom: '1px solid #D3D3D3', 31 | paddingBottom: 8, 32 | fontSize: 12, 33 | color: 'red' 34 | }, 35 | tipSmallContainer: { 36 | fontSize: 12, 37 | color: 'red', 38 | width: 200, 39 | marginTop: 12, 40 | height: 30, 41 | }, 42 | paddingBox: { 43 | paddingLeft: 16, 44 | paddingRight: 16, 45 | }, 46 | smallContainer: { 47 | position: 'absolute', 48 | top: 0, 49 | left: 0, 50 | backgroundColor: '#ffffff', 51 | width: '100%', 52 | zIndex: 10, 53 | height: 105, 54 | overflow: 'hidden', 55 | }, 56 | arrowUp: { 57 | width: 0, 58 | height: 0, 59 | borderLeft: '5px solid transparent', 60 | borderRight: '5px solid transparent', 61 | borderBottom: '5px solid #1B8DFF', 62 | marginRight: 16, 63 | position: "absolute", 64 | top: 9, 65 | right: 12, 66 | }, 67 | arrowDown: { 68 | width: 0, 69 | height: 0, 70 | borderLeft: '5px solid transparent', 71 | borderRight: '5px solid transparent', 72 | borderTop: '5px solid #1B8DFF', 73 | marginRight: 16, 74 | position: "absolute", 75 | top: 9, 76 | right: 12, 77 | }, 78 | } 79 | 80 | class NavBar extends Component { 81 | constructor(props) { 82 | super(props) 83 | 84 | this.state = { 85 | show: true, 86 | toggleChecked: false 87 | } 88 | this.toggle = this.toggle.bind(this) 89 | this.toggleMappers = this.toggleMappers.bind(this) 90 | } 91 | 92 | toggle() { 93 | this.setState({ show: !this.state.show}) 94 | } 95 | 96 | toggleMappers(toggleChecked) { 97 | this.setState({ toggleChecked }); 98 | this.props.toggleMappers() 99 | } 100 | 101 | render() { 102 | const { devices, names, selectDevice, selectedDevice, findDevice, loading, onSearchChange } = this.props 103 | const { show } = this.state 104 | 105 | return ( 106 | 110 | {matches => ( 111 | 112 | {matches.small && ( 113 |
114 |
115 | 116 |
117 | 121 |
122 |
123 | 124 |
125 |
126 |
127 | 128 |
129 |
130 | 131 | {devices.map((d, i) => 132 |
133 | 134 |
135 | )} 136 |
137 |
138 | )} 139 | {matches.large && ( 140 |
141 |
142 |
143 | 144 | 148 |
149 |
150 |

Devices

151 |
152 | 153 |
154 | 155 |
156 |
157 | 158 |
159 | { show && devices.map((d, i) => 160 | 161 | )} 162 |
163 |
164 | )} 165 |
166 | )} 167 |
168 | ) 169 | } 170 | } 171 | 172 | export default NavBar 173 | -------------------------------------------------------------------------------- /assets/static/images/logocargo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 15 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/static/images/logocargo_30.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | logocargo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2018, Helium Systems Inc. 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, 3 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 4 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 5 | "cors_plug": {:hex, :cors_plug, "2.0.3", "316f806d10316e6d10f09473f19052d20ba0a0ce2a1d910ddf57d663dac402ae", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ee4ae1418e6ce117fc42c2ba3e6cbdca4e95ecd2fe59a05ec6884ca16d469aea"}, 6 | "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, 7 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 8 | "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, 9 | "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, 10 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 11 | "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [: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 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, 12 | "ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"}, 13 | "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, 14 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 15 | "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, 16 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 17 | "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, 18 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 19 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 20 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 21 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 22 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 23 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 24 | "phoenix": {:hex, :phoenix, "1.7.9", "9a2b873e2cb3955efdd18ad050f1818af097fa3f5fc3a6aaba666da36bdd3f02", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83e32da028272b4bfd076c61a964e6d2b9d988378df2f1276a0ed21b13b5e997"}, 25 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, 26 | "phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"}, 27 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [: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", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, 28 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 29 | "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, 30 | "phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"}, 31 | "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, 32 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, 33 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 34 | "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, 35 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 36 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 37 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 38 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 39 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 40 | "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, 41 | } 42 | -------------------------------------------------------------------------------- /assets/js/components/Inspector.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import startCase from 'lodash/startCase' 3 | import Media from 'react-media'; 4 | 5 | 6 | const styles = { 7 | container: { 8 | position: 'absolute', 9 | top: 0, 10 | right: 0, 11 | backgroundColor: '#fff', 12 | width: 350, 13 | zIndex: 10, 14 | overflow: 'hidden', 15 | }, 16 | smallContainer: { 17 | position: 'absolute', 18 | top: 105, 19 | left: 0, 20 | backgroundColor: '#fff', 21 | width: '100%', 22 | zIndex: 10, 23 | overflow: 'hidden' 24 | }, 25 | top: { 26 | padding: 10, 27 | paddingBottom:12, 28 | }, 29 | row: { 30 | display: 'flex', 31 | flexDirection: 'row', 32 | justifyContent: 'space-between', 33 | flexWrap: 'wrap', 34 | padding: 10, 35 | paddingBottom: 0, 36 | }, 37 | rowsm: { 38 | display: 'flex', 39 | flexDirection: 'row', 40 | justifyContent: 'space-between', 41 | flexWrap: 'no-wrap', 42 | padding: 10, 43 | paddingBottom: 0, 44 | }, 45 | pod: { 46 | width: 'calc(50% - 5px)', 47 | padding: 10, 48 | cursor: 'pointer', 49 | borderRadius: 4, 50 | minWidth: 80, 51 | paddingBottom:14, 52 | backgroundColor: '#EAF3FC', 53 | boxSizing: 'border-box', 54 | marginBottom: 10, 55 | 56 | 57 | }, 58 | podsm: { 59 | width: 100, 60 | padding: 10, 61 | cursor: 'pointer', 62 | borderRadius: 4, 63 | minWidth: 100, 64 | marginRight: 10, 65 | paddingBottom:14, 66 | backgroundColor: '#EAF3FC', 67 | boxSizing: 'border-box', 68 | marginBottom: 0, 69 | 70 | 71 | }, 72 | 73 | header: { 74 | fontSize: 10, 75 | margin: 0, 76 | marginBottom: 4, 77 | fontWeight: 300, 78 | textTransform: 'uppercase', 79 | letterSpacing: 1, 80 | }, 81 | value: { 82 | fontSize: 22, 83 | color: '#38A2FF', 84 | margin: 0, 85 | fontWeight: 300, 86 | lineHeight: 0.7, 87 | letterSpacing: '-0.5px', 88 | }, 89 | bar: { 90 | backgroundColor: '#1F8FFF', 91 | height: 40, 92 | display: 'flex', 93 | alignItems: 'center', 94 | justifyContent: 'space-between', 95 | cursor: 'pointer', 96 | }, 97 | arrowUp: { 98 | width: 0, 99 | height: 0, 100 | borderLeft: '5px solid transparent', 101 | borderRight: '5px solid transparent', 102 | borderBottom: '5px solid white', 103 | marginRight: 16, 104 | }, 105 | arrowDown: { 106 | width: 0, 107 | height: 0, 108 | borderLeft: '5px solid transparent', 109 | borderRight: '5px solid transparent', 110 | borderTop: '5px solid white', 111 | marginRight: 16, 112 | }, 113 | 114 | hotspotText: { 115 | fontSize: 13, 116 | fontWeight: 500, 117 | color: '#ffffff', 118 | cursor: 'pointer', 119 | backgroundColor: '#A984FF', 120 | borderRadius: 4, 121 | margin: 0, 122 | marginBottom: 6, 123 | padding: '6px 10px', 124 | boxSizing: 'border-box', 125 | }, 126 | } 127 | 128 | class Inspector extends Component { 129 | constructor(props) { 130 | super(props) 131 | 132 | this.state = { 133 | show: true, 134 | showHS: true, 135 | } 136 | this.toggle = this.toggle.bind(this) 137 | this.toggleHS = this.toggleHS.bind(this) 138 | } 139 | 140 | toggle() { 141 | this.setState({ show: !this.state.show}) 142 | } 143 | 144 | toggleHS() { 145 | this.setState({ showHS: !this.state.showHS }) 146 | } 147 | 148 | renderHotspotsList() { 149 | const { hotspots, highlightHotspot, toggleHotspots, showHotspots, highlightedHotspot } = this.props 150 | const { showHS } = this.state 151 | if (hotspots.data.length > 0 && showHS) return ( 152 |
153 |
154 | 155 |

Show Hotspot Paths

156 |
157 |

{hotspots.data.length} Hotspots Witnessed

158 |
159 | { 160 | hotspots.data.map(h => { 161 | if (highlightedHotspot && highlightedHotspot.address === h.address) { 162 | return ( 163 |

highlightHotspot(h)}>{h.name}

164 | ) 165 | } 166 | return ( 167 |

highlightHotspot(h)}>{h.name}

168 | ) 169 | }) 170 | } 171 |
172 |
173 | ) 174 | } 175 | 176 | renderHotspotsToggleBar() { 177 | const { showHS } = this.state 178 | const { hotspots } = this.props 179 | if (hotspots.data.length > 0) { 180 | return ( 181 |
182 |

Hotspots List

183 | { 184 | showHS ?
:
185 | } 186 |
187 | ) 188 | } else { 189 | return
190 | } 191 | } 192 | 193 | render() { 194 | const { lastPacket, selectedDevice, setChartType, chartType, hotspots, toggleHotspots } = this.props 195 | const { show } = this.state 196 | 197 | return ( 198 | 202 | {matches => ( 203 | 204 | {matches.small && ( 205 |
206 |
207 |

Last Packet Stats

208 | { 209 | show ?
:
210 | } 211 |
212 | { 213 | show && ( 214 | 215 |
216 |
setChartType("sequence")}> 217 |

Seq. No.

218 |

{lastPacket.seq_id.split("-")[0]}

219 |
220 |
setChartType("speed")}> 221 |

Avg Speed:

222 |

{lastPacket.speed}mph

223 |
224 | 225 |
setChartType("elevation")}> 226 |

Elevation

227 |

{lastPacket.elevation.toFixed(0)}m

228 |
229 |
setChartType("battery")}> 230 |

Voltage

231 |

{lastPacket.battery.toFixed(2)}v

232 |
233 | 234 |
setChartType("rssi")}> 235 |

RSSI

236 |

{lastPacket.rssi}dBm

237 |
238 |
setChartType("snr")}> 239 |

SNR

240 |

n/a

241 |
242 |
243 | {this.renderHotspotsToggleBar()} 244 | {this.renderHotspotsList()} 245 |
246 | ) 247 | } 248 |
249 | )} 250 | {matches.large && ( 251 |
252 |
253 |

Last Packet Stats

254 | { 255 | show ?
:
256 | } 257 |
258 | { 259 | show && ( 260 | 261 |
262 |

Name:

263 |

{selectedDevice.name}

264 |

Hotspot:

265 |

{startCase(lastPacket.hotspots[lastPacket.hotspots.length - 1])}

266 |
267 |
268 |
setChartType("sequence")}> 269 |

Sequence No.

270 |

{lastPacket.seq_id.split("-")[0]}

271 |
272 |
setChartType("speed")}> 273 |

Avg Speed

274 |

{lastPacket.speed}mph

275 |
276 | 277 |
setChartType("elevation")}> 278 |

Elevation

279 |

{lastPacket.elevation.toFixed(0)}m

280 |
281 |
setChartType("battery")}> 282 |

Voltage

283 |

{lastPacket.battery.toFixed(2)}v

284 |
285 | 286 |
setChartType("rssi")}> 287 |

RSSI

288 |

{lastPacket.rssi}dBm

289 |
290 |
setChartType("snr")}> 291 |

SNR

292 |

{(Math.round(lastPacket.snr * 100) / 100).toFixed(2)}

293 |
294 |
295 | {this.renderHotspotsToggleBar()} 296 | {this.renderHotspotsList()} 297 |
298 | ) 299 | } 300 |
301 | )} 302 |
303 | )} 304 |
305 | ) 306 | } 307 | } 308 | 309 | export default Inspector 310 | -------------------------------------------------------------------------------- /lib/cargo_elixir/payloads/payloads.ex: -------------------------------------------------------------------------------- 1 | defmodule CargoElixir.Payloads do 2 | import Ecto.Query, warn: false 3 | alias CargoElixir.Repo 4 | 5 | alias CargoElixir.Payloads.Payload 6 | def find_key(decoded, key, acc) do 7 | Enum.reduce(decoded, acc, fn{k, v}, acc -> 8 | cond do 9 | String.equivalent?(k, key) -> [v | acc] 10 | is_map(v) -> find_key(v, key, acc) 11 | true -> acc 12 | end 13 | end) 14 | end 15 | 16 | def parse_decoded(attrs, decoded) do 17 | 18 | # required fields 19 | lat = find_key(decoded, "latitude", []) 20 | attrs = if Enum.empty?(lat) do 21 | throw RuntimeError 22 | else 23 | if is_float(Enum.at(lat, 0)) or is_integer(Enum.at(lat, 0)) do 24 | Map.put(attrs, :lat, Enum.at(lat, 0)) 25 | else 26 | Map.put(attrs, :lat, Kernel.elem(Float.parse(Enum.at(lat, 0)), 0)) 27 | end 28 | end 29 | 30 | lon = find_key(decoded, "longitude", []) 31 | attrs = if Enum.empty?(lon) do 32 | throw RuntimeError 33 | else 34 | if is_float(Enum.at(lon, 0)) or is_integer(Enum.at(lon, 0)) do 35 | Map.put(attrs, :lon, Enum.at(lon, 0)) 36 | else 37 | Map.put(attrs, :lon, Kernel.elem(Float.parse(Enum.at(lon, 0)), 0)) 38 | end 39 | end 40 | 41 | elevation = find_key(decoded, "altitude", []) 42 | attrs = if Enum.empty?(elevation) do 43 | throw RuntimeError 44 | else 45 | if is_float(Enum.at(elevation, 0)) or is_integer(Enum.at(elevation, 0)) do 46 | Map.put(attrs, :elevation, Enum.at(elevation, 0)) 47 | else 48 | Map.put(attrs, :elevation, Kernel.elem(Float.parse(Enum.at(elevation, 0)), 0)) 49 | end 50 | end 51 | 52 | # optional fields 53 | battery = find_key(decoded, "battery", []) 54 | attrs = if Enum.empty?(battery) do 55 | Map.put(attrs, :battery, 0) 56 | else 57 | if is_float(Enum.at(battery, 0)) or is_integer(Enum.at(battery, 0)) do 58 | Map.put(attrs, :battery, Enum.at(battery, 0)) 59 | else 60 | Map.put(attrs, :battery, Kernel.elem(Float.parse(Enum.at(battery, 0)), 0)) 61 | end 62 | end 63 | 64 | speed = find_key(decoded, "speed", []) 65 | attrs = if Enum.empty?(speed) do 66 | Map.put(attrs, :speed, 0) 67 | else 68 | if is_float(Enum.at(speed, 0)) or is_integer(Enum.at(speed, 0)) do 69 | Map.put(attrs, :speed, Enum.at(speed, 0)) 70 | else 71 | Map.put(attrs, :speed, Kernel.elem(Float.parse(Enum.at(speed, 0)), 0)) 72 | end 73 | end 74 | 75 | # lat validation 76 | if attrs.lat > 90 or attrs.lat < -90 do 77 | attrs |> Map.put(:lat, 0) 78 | else 79 | attrs 80 | end 81 | end 82 | 83 | def create_payload(packet = %{ "id" => device_id, "dev_eui" => dev_eui, "name" => name, "hotspots" => hotspots, "payload" => payload, "fcnt" => fcnt, "reported_at" => reported }) do 84 | first_hotspot = List.first(hotspots) 85 | 86 | attrs = %{} 87 | |> Map.put(:device_id, device_id) 88 | |> Map.put(:name, name) 89 | |> Map.put(:hotspot_id, Map.fetch!(first_hotspot, "name")) 90 | |> Map.put(:oui, 1) 91 | |> Map.put(:rssi, Map.fetch!(first_hotspot, "rssi")) 92 | |> Map.put(:seq_num, fcnt) 93 | |> Map.put(:reported, round(reported / 1000) |> DateTime.from_unix!()) 94 | |> Map.put(:snr, Map.fetch!(first_hotspot, "snr")) 95 | 96 | binary = payload |> :base64.decode() 97 | 98 | attrs = if Map.has_key?(packet, "decoded") do 99 | try do 100 | parse_decoded(attrs, Map.get(packet, "decoded")) 101 | catch 102 | RuntimeError -> decode_payload(binary, attrs, dev_eui) 103 | end 104 | else 105 | decode_payload(binary, attrs, dev_eui) 106 | end 107 | 108 | %Payload{} 109 | |> Payload.changeset(attrs) 110 | |> Repo.insert() 111 | end 112 | 113 | def create_payload(packet = %{ "device_id" => device_id, "gateway" => hotspot_id, "oui" => oui, "lat" => lat, "lon" => lon, "speed" => speed, "elevation" => elevation, 114 | "battery" => battery, "rssi" => rssi, "snr" => snr, "sequence" => seq_num, "timestamp" => reported}) do 115 | attrs = %{} 116 | |> Map.put(:device_id, device_id) 117 | |> Map.put(:name, device_id) 118 | |> Map.put(:hotspot_id, hotspot_id) 119 | |> Map.put(:oui, oui) 120 | |> Map.put(:lat, lat) 121 | |> Map.put(:lon, lon) 122 | |> Map.put(:speed, speed) 123 | |> Map.put(:rssi, rssi) 124 | |> Map.put(:elevation, elevation) 125 | |> Map.put(:battery, battery) 126 | |> Map.put(:seq_num, seq_num) 127 | |> Map.put(:reported, round(reported / 1000) |> DateTime.from_unix!()) 128 | |> Map.put(:snr, snr) 129 | 130 | %Payload{} 131 | |> Payload.changeset(attrs) 132 | |> Repo.insert() 133 | end 134 | 135 | def decode_payload(binary, attrs, dev_eui) do 136 | attrs = case binary do 137 | # RAK7200 138 | <<0x01, 0x88, lat :: integer-signed-big-24, lon :: integer-signed-big-24, alt :: integer-signed-big-24, 139 | 0x08, 0x02, batt :: integer-signed-big-16, 140 | 0x03, 0x71, _accx :: integer-signed-big-16, _accy :: integer-signed-big-16, _accz :: integer-signed-big-16, 141 | 0x05, 0x86, _gyrox :: integer-signed-big-16, _gyroy :: integer-signed-big-16, _gyroz :: integer-signed-big-16, 142 | 0x09, 0x02, _magx :: integer-signed-big-16, 143 | 0x0a, 0x02, _magy :: integer-signed-big-16, 144 | 0x0b, 0x02, _magz :: integer-signed-big-16>> -> 145 | attrs 146 | |> Map.put(:lat, lat * 0.0001) 147 | |> Map.put(:lon, lon * 0.0001) 148 | |> Map.put(:elevation, alt * 0.001) 149 | |> Map.put(:speed, 0) 150 | |> Map.put(:battery, batt * 0.01) 151 | # Dragino LGT-92 152 | <> -> 154 | attrs 155 | |> Map.put(:lat, lat / 1000000) 156 | |> Map.put(:lon, lon / 1000000) 157 | |> Map.put(:elevation, 0) 158 | |> Map.put(:speed, 0) 159 | |> Map.put(:battery, battery / 1000) 160 | # Browan Object Locator 161 | << 0 :: integer-1, 0 :: integer-1, 0 :: integer-1, _gnsserror :: integer-1, _gnssfix :: integer-1, _ :: integer-1, _moving :: integer-1, _button :: integer-1, 162 | _ :: integer-4, batt :: integer-unsigned-4, 163 | _ :: integer-1, _temp :: integer-7, 164 | lat :: integer-signed-little-32, 165 | temp_lon :: integer-signed-little-24, _accuracy :: integer-3, i :: integer-unsigned-5>> -> 166 | <> = <> 167 | attrs 168 | |> Map.put(:lat, lat / 1000000) 169 | |> Map.put(:lon, lon / 1000000) 170 | |> Map.put(:elevation, 0) 171 | |> Map.put(:speed, 0) 172 | |> Map.put(:battery, (batt + 25) / 10) 173 | # Helium/Arduino without battery 174 | <> -> 175 | attrs 176 | |> Map.put(:lat, lat / 10000000) 177 | |> Map.put(:lon, lon / 10000000) 178 | |> Map.put(:elevation, elevation) 179 | |> Map.put(:speed, speed) 180 | |> Map.put(:battery, 0) 181 | # Helium/Arduino with battery 182 | <> -> 183 | attrs 184 | |> Map.put(:lat, lat / 10000000) 185 | |> Map.put(:lon, lon / 10000000) 186 | |> Map.put(:elevation, elevation) 187 | |> Map.put(:speed, speed) 188 | |> Map.put(:battery, battery) 189 | # DigitalMatter Oyster/Yabby 190 | <> -> 191 | attrs 192 | |> Map.put(:lat, lat * 0.0000001) 193 | |> Map.put(:lon, lon * 0.0000001) 194 | |> Map.put(:elevation, 0) 195 | |> Map.put(:speed, speed) 196 | |> Map.put(:battery, (battery * 25) / 1000) 197 | # Keyco Tracker 198 | <<_company :: integer-16, _product :: integer-24, _version :: integer-8, _major :: integer-16, _minor :: integer-16, _deveui :: integer-32, _timestamp :: integer-32, 199 | lat :: float-32, lon :: float-32, elevation :: integer-16, speed :: integer-16, _hdop :: integer-24, _gpsnum :: integer-8, _ :: integer-32, battery :: integer-8, _ :: integer-80>> -> 200 | # send to Keyco app server 201 | HTTPoison.post "https://keycoiot-eu.solu-m.com/keyco/iotrestapi/solum/helium", "{\"latitude\": #{lat}, \"longitude\": #{lon}, \"deveui\": \"#{dev_eui}\", \"encryption\": 1, \"payload\": \"#{:base64.encode(binary)}\"}", [{"Content-Type", "application/json"}] 202 | attrs 203 | |> Map.put(:lat, lat) 204 | |> Map.put(:lon, lon) 205 | |> Map.put(:elevation, round(elevation * 0.1)) 206 | |> Map.put(:speed, round((speed * 0.1) * 0.6214)) 207 | |> Map.put(:battery, (4/100) * battery) 208 | _ -> 209 | attrs 210 | |> Map.put(:lat, 0) 211 | |> Map.put(:lon, 0) 212 | |> Map.put(:elevation, 0) 213 | |> Map.put(:speed, 0) 214 | |> Map.put(:battery, 0) 215 | end 216 | attrs = if attrs.lat > 90 or attrs.lat < -90 do 217 | attrs |> Map.put(:lat, 0) 218 | else 219 | attrs 220 | end 221 | attrs 222 | end 223 | 224 | def get_devices() do 225 | current_unix = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_unix() 226 | date_threshold = DateTime.from_unix!(current_unix - 259200) 227 | 228 | query = from p in Payload, 229 | where: p.created_at > ^date_threshold, 230 | order_by: [desc: p.created_at], 231 | distinct: p.device_id, 232 | select: %{name: p.name, device_id: p.device_id, created_at: p.created_at, hotspot: p.hotspot_id, lat: p.lat, lon: p.lon} 233 | Repo.all(query) 234 | end 235 | 236 | def get_device(oui, device_id) do 237 | query = from p in Payload, 238 | where: p.oui == ^oui and p.device_id == ^device_id, 239 | select: %{ device_id: p.device_id, created_at: p.created_at, hotspot: p.hotspot_id }, 240 | order_by: [desc: p.created_at], 241 | limit: 1 242 | Repo.all(query) 243 | end 244 | 245 | def get_payloads(device_id, last_packet_time) do 246 | {:ok, datetime, 0} = DateTime.from_iso8601(last_packet_time) 247 | packets_start_time = DateTime.from_unix!(DateTime.to_unix(datetime) - 10800) 248 | 249 | query = from p in Payload, 250 | where: (p.device_id == ^device_id and p.created_at > ^packets_start_time), 251 | order_by: [asc: p.created_at], 252 | select: p 253 | Repo.all(query) 254 | end 255 | 256 | def get_all_payloads(oui) do 257 | time_limit = DateTime.utc_now() |> DateTime.add(-3600, :second) 258 | query = from p in Payload, 259 | where: (p.oui == ^oui and p.created_at > ^time_limit), 260 | select: %{ device_id: p.device_id, name: p.name, lat: p.lat, lon: p.lon, created_at: p.created_at } 261 | Repo.all(query) 262 | end 263 | 264 | def get_currently_transmitting() do 265 | current_unix = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_unix() 266 | date_threshold = DateTime.from_unix!(current_unix - 120) 267 | 268 | query = from p in Payload, 269 | where: p.created_at > ^date_threshold, 270 | select: count(p.device_id, :distinct) 271 | Repo.all(query) 272 | end 273 | 274 | def get_device_stats(time) do 275 | current_unix = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_unix() 276 | query = 277 | case time do 278 | "24h" -> 279 | "SELECT count(distinct device_id) FROM payloads WHERE created_at > NOW() - interval '24 hour'" 280 | "7d" -> 281 | "SELECT count(*) FROM (SELECT * FROM distinct_devices_last_7 UNION SELECT distinct device_id FROM payloads WHERE created_at > CURRENT_DATE) AS count;" 282 | "30d" -> 283 | "SELECT count(*) FROM (SELECT * FROM distinct_devices_last_30 UNION SELECT distinct device_id FROM payloads WHERE created_at > CURRENT_DATE) AS count;" 284 | "all" -> 285 | "SELECT count(*) FROM (SELECT * FROM distinct_devices_all_time UNION SELECT distinct device_id FROM payloads WHERE created_at > CURRENT_DATE) AS count;" 286 | end 287 | run_query(query) 288 | end 289 | 290 | def get_hotspot_stats(time) do 291 | current_unix = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_unix() 292 | query = 293 | case time do 294 | "24h" -> 295 | "SELECT count(distinct hotspot_id) FROM payloads WHERE created_at > NOW() - interval '24 hour'" 296 | "7d" -> 297 | "SELECT count(*) FROM (SELECT * FROM distinct_hotspots_last_7 UNION SELECT distinct hotspot_id FROM payloads WHERE created_at > CURRENT_DATE) AS count;" 298 | "30d" -> 299 | "SELECT count(*) FROM (SELECT * FROM distinct_hotspots_last_30 UNION SELECT distinct hotspot_id FROM payloads WHERE created_at > CURRENT_DATE) AS count;" 300 | "all" -> 301 | "SELECT count(*) FROM (SELECT * FROM distinct_hotspots_all_time UNION SELECT distinct hotspot_id FROM payloads WHERE created_at > CURRENT_DATE) AS count;" 302 | end 303 | run_query(query) 304 | end 305 | 306 | def get_payload_stats(time) do 307 | current_unix = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_unix() 308 | query = 309 | case time do 310 | "24h" -> 311 | "SELECT count(*) FROM payloads WHERE created_at > NOW() - interval '24 hour'" 312 | "7d" -> 313 | "SELECT SUM(count) FROM (SELECT * FROM total_payloads_last_7 UNION SELECT count(*) FROM payloads WHERE created_at > CURRENT_DATE) AS sum;" 314 | "30d" -> 315 | "SELECT SUM(count) FROM (SELECT * FROM total_payloads_last_30 UNION SELECT count(*) FROM payloads WHERE created_at > CURRENT_DATE) AS sum;" 316 | "all" -> 317 | "SELECT SUM(count) FROM (SELECT * FROM total_payloads_all_time UNION SELECT count(*) FROM payloads WHERE created_at > CURRENT_DATE) AS sum;" 318 | end 319 | run_query(query) 320 | end 321 | 322 | defp run_query(query) do 323 | result = Ecto.Adapters.SQL.query!(Repo, query) 324 | result.rows |> List.first() 325 | end 326 | end 327 | -------------------------------------------------------------------------------- /assets/js/pages/mapStyle.js: -------------------------------------------------------------------------------- 1 | export const mapLayersDark = [ 2 | { 3 | id: "background", 4 | type: "background", 5 | paint: { 6 | "background-color": "#2A2A2A", 7 | }, 8 | }, 9 | { 10 | id: "earth", 11 | type: "fill", 12 | source: "protomaps", 13 | "source-layer": "earth", 14 | paint: { 15 | "fill-color": "#2A2A2A", 16 | }, 17 | }, 18 | { 19 | id: "landuse_park", 20 | type: "fill", 21 | source: "protomaps", 22 | "source-layer": "landuse", 23 | filter: ["any", ["==", "pmap:kind", "park"], ["==", "landuse", "cemetery"]], 24 | paint: { 25 | "fill-color": "#2A2A2A", 26 | }, 27 | }, 28 | { 29 | id: "landuse_hospital", 30 | type: "fill", 31 | source: "protomaps", 32 | "source-layer": "landuse", 33 | filter: ["any", ["==", "pmap:kind", "hospital"]], 34 | paint: { 35 | "fill-color": "#2A2A2A", 36 | }, 37 | }, 38 | { 39 | id: "landuse_industrial", 40 | type: "fill", 41 | source: "protomaps", 42 | "source-layer": "landuse", 43 | filter: ["any", ["==", "pmap:kind", "industrial"]], 44 | paint: { 45 | "fill-color": "#2A2A2A", 46 | }, 47 | }, 48 | { 49 | id: "landuse_school", 50 | type: "fill", 51 | source: "protomaps", 52 | "source-layer": "landuse", 53 | filter: ["any", ["==", "pmap:kind", "school"]], 54 | paint: { 55 | "fill-color": "#2A2A2A", 56 | }, 57 | }, 58 | { 59 | id: "natural_wood", 60 | type: "fill", 61 | source: "protomaps", 62 | "source-layer": "natural", 63 | filter: [ 64 | "any", 65 | ["==", "natural", "wood"], 66 | ["==", "leisure", "nature_reserve"], 67 | ["==", "landuse", "forest"], 68 | ], 69 | paint: { 70 | "fill-color": "#2A2A2A", 71 | }, 72 | }, 73 | { 74 | id: "landuse_pedestrian", 75 | type: "fill", 76 | source: "protomaps", 77 | "source-layer": "landuse", 78 | filter: ["any", ["==", "highway", "footway"]], 79 | paint: { 80 | "fill-color": "#2A2A2A", 81 | }, 82 | }, 83 | { 84 | id: "natural_glacier", 85 | type: "fill", 86 | source: "protomaps", 87 | "source-layer": "natural", 88 | filter: ["==", "natural", "glacier"], 89 | paint: { 90 | "fill-color": "#2A2A2A", 91 | }, 92 | }, 93 | { 94 | id: "natural_sand", 95 | type: "fill", 96 | source: "protomaps", 97 | "source-layer": "natural", 98 | filter: ["==", "natural", "sand"], 99 | paint: { 100 | "fill-color": "#2A2A2A", 101 | }, 102 | }, 103 | { 104 | id: "landuse_aerodrome", 105 | type: "fill", 106 | source: "protomaps", 107 | "source-layer": "landuse", 108 | filter: ["==", "aeroway", "aerodrome"], 109 | paint: { 110 | "fill-color": "#2A2A2A", 111 | }, 112 | }, 113 | { 114 | id: "transit_runway", 115 | type: "line", 116 | source: "protomaps", 117 | "source-layer": "transit", 118 | filter: ["has", "aeroway"], 119 | paint: { 120 | "line-color": "#2A2A2A", 121 | "line-width": 6, 122 | }, 123 | }, 124 | { 125 | id: "landuse_runway", 126 | type: "fill", 127 | source: "protomaps", 128 | "source-layer": "landuse", 129 | filter: [ 130 | "any", 131 | ["==", "aeroway", "runway"], 132 | ["==", "area:aeroway", "runway"], 133 | ["==", "area:aeroway", "taxiway"], 134 | ], 135 | paint: { 136 | "fill-color": "#2A2A2A", 137 | }, 138 | }, 139 | { 140 | id: "water", 141 | type: "fill", 142 | source: "protomaps", 143 | "source-layer": "water", 144 | paint: { 145 | "fill-color": "#202020", 146 | }, 147 | }, 148 | { 149 | id: "roads_tunnels_other", 150 | type: "line", 151 | source: "protomaps", 152 | "source-layer": "roads", 153 | filter: ["all", ["<", "pmap:level", 0], ["==", "pmap:kind", "other"]], 154 | paint: { 155 | "line-color": "#3D3D3D", 156 | "line-dasharray": [1, 1], 157 | "line-width": [ 158 | "interpolate", 159 | ["exponential", 1.6], 160 | ["zoom"], 161 | 14, 162 | 0, 163 | 14.5, 164 | 0.5, 165 | 20, 166 | 12, 167 | ], 168 | }, 169 | }, 170 | { 171 | id: "roads_tunnels_minor", 172 | type: "line", 173 | source: "protomaps", 174 | "source-layer": "roads", 175 | filter: ["all", ["<", "pmap:level", 0], ["==", "pmap:kind", "minor_road"]], 176 | paint: { 177 | "line-color": "#3D3D3D", 178 | "line-width": [ 179 | "interpolate", 180 | ["exponential", 1.6], 181 | ["zoom"], 182 | 12, 183 | 0, 184 | 12.5, 185 | 0.5, 186 | 20, 187 | 32, 188 | ], 189 | }, 190 | }, 191 | { 192 | id: "roads_tunnels_medium", 193 | type: "line", 194 | source: "protomaps", 195 | "source-layer": "roads", 196 | filter: ["all", ["<", "pmap:level", 0], ["==", "pmap:kind", "medium_road"]], 197 | paint: { 198 | "line-color": "#3D3D3D", 199 | "line-width": [ 200 | "interpolate", 201 | ["exponential", 1.6], 202 | ["zoom"], 203 | 7, 204 | 0, 205 | 7.5, 206 | 0.5, 207 | 20, 208 | 32, 209 | ], 210 | }, 211 | }, 212 | { 213 | id: "roads_tunnels_major", 214 | type: "line", 215 | source: "protomaps", 216 | "source-layer": "roads", 217 | filter: ["all", ["<", "pmap:level", 0], ["==", "pmap:kind", "major_road"]], 218 | paint: { 219 | "line-color": "#3D3D3D", 220 | "line-width": [ 221 | "interpolate", 222 | ["exponential", 1.6], 223 | ["zoom"], 224 | 7, 225 | 0, 226 | 7.5, 227 | 0.5, 228 | 19, 229 | 32, 230 | ], 231 | }, 232 | }, 233 | { 234 | id: "roads_tunnels_highway", 235 | type: "line", 236 | source: "protomaps", 237 | "source-layer": "roads", 238 | filter: ["all", ["<", "pmap:level", 0], ["==", "pmap:kind", "highway"]], 239 | paint: { 240 | "line-color": "#3D3D3D", 241 | "line-width": [ 242 | "interpolate", 243 | ["exponential", 1.6], 244 | ["zoom"], 245 | 3, 246 | 0, 247 | 3.5, 248 | 0.5, 249 | 18, 250 | 32, 251 | ], 252 | }, 253 | }, 254 | { 255 | id: "physical_line_waterway", 256 | type: "line", 257 | source: "protomaps", 258 | "source-layer": "physical_line", 259 | filter: ["==", ["get", "pmap:kind"], "waterway"], 260 | paint: { 261 | "line-color": "#202020", 262 | "line-width": 0.5, 263 | }, 264 | }, 265 | { 266 | id: "roads_other", 267 | type: "line", 268 | source: "protomaps", 269 | "source-layer": "roads", 270 | filter: ["all", ["==", "pmap:level", 0], ["==", "pmap:kind", "other"]], 271 | paint: { 272 | "line-color": "#3D3D3D", 273 | "line-dasharray": [2, 1], 274 | "line-width": [ 275 | "interpolate", 276 | ["exponential", 1.6], 277 | ["zoom"], 278 | 14, 279 | 0, 280 | 14.5, 281 | 0.5, 282 | 20, 283 | 12, 284 | ], 285 | }, 286 | }, 287 | { 288 | id: "roads_minor", 289 | type: "line", 290 | source: "protomaps", 291 | "source-layer": "roads", 292 | filter: ["all", ["==", "pmap:level", 0], ["==", "pmap:kind", "minor_road"]], 293 | paint: { 294 | "line-color": "#3D3D3D", 295 | "line-width": [ 296 | "interpolate", 297 | ["exponential", 1.6], 298 | ["zoom"], 299 | 12, 300 | 0, 301 | 12.5, 302 | 0.5, 303 | 20, 304 | 32, 305 | ], 306 | }, 307 | }, 308 | { 309 | id: "roads_medium", 310 | type: "line", 311 | source: "protomaps", 312 | "source-layer": "roads", 313 | filter: [ 314 | "all", 315 | ["==", "pmap:level", 0], 316 | ["==", "pmap:kind", "medium_road"], 317 | ], 318 | paint: { 319 | "line-color": "#3D3D3D", 320 | "line-width": [ 321 | "interpolate", 322 | ["exponential", 1.6], 323 | ["zoom"], 324 | 7, 325 | 0, 326 | 7.5, 327 | 0.5, 328 | 20, 329 | 32, 330 | ], 331 | }, 332 | }, 333 | { 334 | id: "roads_major", 335 | type: "line", 336 | source: "protomaps", 337 | "source-layer": "roads", 338 | filter: ["all", ["==", "pmap:level", 0], ["==", "pmap:kind", "major_road"]], 339 | paint: { 340 | "line-color": "#3D3D3D", 341 | "line-width": [ 342 | "interpolate", 343 | ["exponential", 1.6], 344 | ["zoom"], 345 | 7, 346 | 0, 347 | 7.5, 348 | 0.5, 349 | 19, 350 | 32, 351 | ], 352 | }, 353 | }, 354 | { 355 | id: "roads_highway", 356 | type: "line", 357 | source: "protomaps", 358 | "source-layer": "roads", 359 | filter: ["all", ["==", "pmap:level", 0], ["==", "pmap:kind", "highway"]], 360 | paint: { 361 | "line-color": "#3D3D3D", 362 | "line-width": [ 363 | "interpolate", 364 | ["exponential", 1.6], 365 | ["zoom"], 366 | 3, 367 | 0, 368 | 3.5, 369 | 0.5, 370 | 18, 371 | 32, 372 | ], 373 | }, 374 | }, 375 | { 376 | id: "transit_railway", 377 | type: "line", 378 | source: "protomaps", 379 | "source-layer": "transit", 380 | filter: ["all", ["==", "pmap:kind", "railway"]], 381 | paint: { 382 | "line-color": "#3D3D3D", 383 | "line-width": 2, 384 | }, 385 | }, 386 | { 387 | id: "transit_railway_tracks", 388 | type: "line", 389 | source: "protomaps", 390 | "source-layer": "transit", 391 | filter: ["all", ["==", "pmap:kind", "railway"]], 392 | paint: { 393 | "line-color": "#3D3D3D", 394 | "line-width": 0.8, 395 | "line-dasharray": [6, 10], 396 | }, 397 | }, 398 | { 399 | id: "boundaries_country", 400 | type: "line", 401 | source: "protomaps", 402 | "source-layer": "boundaries", 403 | filter: ["<=", "pmap:min_admin_level", 2], 404 | paint: { 405 | "line-color": "#696969", 406 | "line-width": 1.5, 407 | }, 408 | }, 409 | { 410 | id: "boundaries", 411 | type: "line", 412 | source: "protomaps", 413 | "source-layer": "boundaries", 414 | filter: [">", "pmap:min_admin_level", 2], 415 | paint: { 416 | "line-color": "#696969", 417 | "line-width": 1, 418 | "line-dasharray": [1, 2], 419 | }, 420 | }, 421 | { 422 | id: "roads_bridges_other", 423 | type: "line", 424 | source: "protomaps", 425 | "source-layer": "roads", 426 | filter: ["all", [">", "pmap:level", 0], ["==", "pmap:kind", "other"]], 427 | paint: { 428 | "line-color": "#3D3D3D", 429 | "line-dasharray": [2, 1], 430 | "line-width": [ 431 | "interpolate", 432 | ["exponential", 1.6], 433 | ["zoom"], 434 | 14, 435 | 0, 436 | 14.5, 437 | 0.5, 438 | 20, 439 | 12, 440 | ], 441 | }, 442 | }, 443 | { 444 | id: "roads_bridges_minor", 445 | type: "line", 446 | source: "protomaps", 447 | "source-layer": "roads", 448 | filter: ["all", [">", "pmap:level", 0], ["==", "pmap:kind", "minor_road"]], 449 | paint: { 450 | "line-color": "#3D3D3D", 451 | "line-width": [ 452 | "interpolate", 453 | ["exponential", 1.6], 454 | ["zoom"], 455 | 12, 456 | 0, 457 | 12.5, 458 | 0.5, 459 | 20, 460 | 32, 461 | ], 462 | }, 463 | }, 464 | { 465 | id: "roads_bridges_medium", 466 | type: "line", 467 | source: "protomaps", 468 | "source-layer": "roads", 469 | filter: ["all", [">", "pmap:level", 0], ["==", "pmap:kind", "medium_road"]], 470 | paint: { 471 | "line-color": "#3D3D3D", 472 | "line-width": [ 473 | "interpolate", 474 | ["exponential", 1.6], 475 | ["zoom"], 476 | 7, 477 | 0, 478 | 7.5, 479 | 0.5, 480 | 20, 481 | 32, 482 | ], 483 | }, 484 | }, 485 | { 486 | id: "roads_bridges_major", 487 | type: "line", 488 | source: "protomaps", 489 | "source-layer": "roads", 490 | filter: ["all", [">", "pmap:level", 0], ["==", "pmap:kind", "major_road"]], 491 | paint: { 492 | "line-color": "#3D3D3D", 493 | "line-width": [ 494 | "interpolate", 495 | ["exponential", 1.6], 496 | ["zoom"], 497 | 7, 498 | 0, 499 | 7.5, 500 | 0.5, 501 | 19, 502 | 32, 503 | ], 504 | }, 505 | }, 506 | { 507 | id: "roads_bridges_highway", 508 | type: "line", 509 | source: "protomaps", 510 | "source-layer": "roads", 511 | filter: ["all", [">", "pmap:level", 0], ["==", "pmap:kind", "highway"]], 512 | paint: { 513 | "line-color": "#3D3D3D", 514 | "line-width": [ 515 | "interpolate", 516 | ["exponential", 1.6], 517 | ["zoom"], 518 | 3, 519 | 0, 520 | 3.5, 521 | 0.5, 522 | 18, 523 | 32, 524 | ], 525 | }, 526 | }, 527 | { 528 | id: "physical_line_waterway_label", 529 | type: "symbol", 530 | source: "protomaps", 531 | "source-layer": "physical_line", 532 | minzoom: 14, 533 | layout: { 534 | "symbol-placement": "line", 535 | "text-font": ["NotoSans-Regular"], 536 | "text-field": ["get", "name"], 537 | "text-size": 10, 538 | "text-letter-spacing": 0.3, 539 | }, 540 | paint: { 541 | "text-color": "#6D6D6D", 542 | "text-halo-color": "#151515", 543 | "text-halo-width": 1, 544 | }, 545 | }, 546 | { 547 | id: "roads_labels", 548 | type: "symbol", 549 | source: "protomaps", 550 | "source-layer": "roads", 551 | layout: { 552 | "symbol-placement": "line", 553 | "text-font": ["NotoSans-Regular"], 554 | "text-field": ["get", "name"], 555 | "text-size": 12, 556 | }, 557 | paint: { 558 | "text-color": "#6D6D6D", 559 | "text-halo-color": "#151515", 560 | "text-halo-width": 2, 561 | }, 562 | }, 563 | { 564 | id: "mask", 565 | type: "fill", 566 | source: "protomaps", 567 | "source-layer": "mask", 568 | paint: { 569 | "fill-color": "#F3F3F1", 570 | }, 571 | }, 572 | { 573 | id: "physical_point_ocean", 574 | type: "symbol", 575 | source: "protomaps", 576 | "source-layer": "physical_point", 577 | filter: ["any", ["==", "place", "sea"], ["==", "place", "ocean"]], 578 | layout: { 579 | "text-font": ["NotoSans-Regular"], 580 | "text-field": ["get", "name"], 581 | "text-size": 13, 582 | "text-letter-spacing": 0.2, 583 | }, 584 | paint: { 585 | "text-color": "#6D6D6D", 586 | }, 587 | }, 588 | { 589 | id: "places_subplace", 590 | type: "symbol", 591 | source: "protomaps", 592 | "source-layer": "places", 593 | filter: ["==", "pmap:kind", "neighbourhood"], 594 | layout: { 595 | "text-field": "{name:en}", 596 | "text-font": ["NotoSans-Regular"], 597 | "text-size": 10, 598 | "text-transform": "uppercase", 599 | }, 600 | paint: { 601 | "text-color": "#6D6D6D", 602 | "text-halo-color": "#151515", 603 | "text-halo-width": 0.5, 604 | }, 605 | }, 606 | { 607 | id: "places_city", 608 | type: "symbol", 609 | source: "protomaps", 610 | "source-layer": "places", 611 | filter: ["==", "pmap:kind", "city"], 612 | layout: { 613 | "text-field": "{name:en}", 614 | "text-font": ["NotoSans-Regular"], 615 | "text-size": ["step", ["get", "pmap:rank"], 0, 1, 12, 2, 10], 616 | "text-variable-anchor": ["bottom-left"], 617 | "text-radial-offset": 0.2, 618 | }, 619 | paint: { 620 | "text-color": "#6D6D6D", 621 | "text-halo-color": "#151515", 622 | "text-halo-width": 1, 623 | }, 624 | }, 625 | { 626 | id: "places_state", 627 | type: "symbol", 628 | source: "protomaps", 629 | "source-layer": "places", 630 | filter: ["==", "pmap:kind", "state"], 631 | layout: { 632 | "text-field": "{name:en}", 633 | "text-font": ["NotoSans-Regular"], 634 | "text-size": 14, 635 | "text-radial-offset": 0.2, 636 | "text-anchor": "center", 637 | "text-transform": "uppercase", 638 | "text-letter-spacing": 0.1, 639 | }, 640 | paint: { 641 | "text-color": "#6D6D6D", 642 | "text-halo-color": "#151515", 643 | "text-halo-width": 1, 644 | }, 645 | }, 646 | { 647 | id: "places_country", 648 | type: "symbol", 649 | source: "protomaps", 650 | "source-layer": "places", 651 | filter: ["==", "place", "country"], 652 | layout: { 653 | "text-field": "{name:en}", 654 | "text-font": ["NotoSans-Regular"], 655 | "text-size": 10, 656 | }, 657 | paint: { 658 | "text-color": "#6D6D6D", 659 | "text-halo-color": "#151515", 660 | "text-halo-width": 1, 661 | }, 662 | }, 663 | ]; 664 | -------------------------------------------------------------------------------- /assets/js/pages/MapScreen.js: -------------------------------------------------------------------------------- 1 | import geoJSON from "geojson"; 2 | import findIndex from "lodash/findIndex"; 3 | import React from "react"; 4 | import Inspector from "../components/Inspector"; 5 | import NavBar from "../components/NavBar"; 6 | import SignUp from "../components/SignUp"; 7 | import Timeline from "../components/Timeline"; 8 | import { get } from "../data/Rest"; 9 | import { packetsToChartData } from "../data/chart"; 10 | import socket from "../socket"; 11 | 12 | import maplibregl from "maplibre-gl"; 13 | import "maplibre-gl/dist/maplibre-gl.css"; 14 | import { Protocol } from "pmtiles"; 15 | import Map, { Layer, Marker, Source } from "react-map-gl"; 16 | import { mapLayersDark } from "./mapStyle"; 17 | 18 | const CURRENT_OUI = 1; 19 | 20 | const styles = { 21 | selectedMarker: { 22 | width: 14, 23 | height: 14, 24 | borderRadius: "50%", 25 | backgroundColor: "#1B8DFF", 26 | display: "flex", 27 | justifyContent: "center", 28 | alignItems: "center", 29 | border: "4px solid #fff", 30 | zIndex: 2, 31 | }, 32 | packetCircle: { 33 | width: 8, 34 | height: 8, 35 | borderRadius: "50%", 36 | backgroundColor: "#4790E5", 37 | display: "flex", 38 | justifyContent: "center", 39 | alignItems: "center", 40 | }, 41 | transmittingMarker: { 42 | width: 14, 43 | height: 14, 44 | borderRadius: "50%", 45 | backgroundColor: "black", 46 | display: "flex", 47 | justifyContent: "center", 48 | alignItems: "center", 49 | border: "4px solid #fff", 50 | }, 51 | gatewayMarker: { 52 | width: 14, 53 | height: 14, 54 | borderRadius: "50%", 55 | backgroundColor: "#A984FF", 56 | display: "flex", 57 | justifyContent: "center", 58 | alignItems: "center", 59 | border: "3px solid #8B62EA", 60 | boxShadow: "0px 2px 4px 0px rgba(0,0,0,0.5)", 61 | cursor: "pointer", 62 | }, 63 | mapStyle: { 64 | version: 8, 65 | sources: { 66 | protomaps: { 67 | type: "vector", 68 | tiles: [`https://pmtiles.heliumfoundation.wtf/world/{z}/{x}/{y}.mvt`], 69 | }, 70 | }, 71 | glyphs: "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf", 72 | layers: mapLayersDark, 73 | }, 74 | }; 75 | 76 | const VECTOR_SOURCE_OPTIONS = { 77 | type: "vector", 78 | url: "https://mappers-tileserver.helium.wtf/public.h3_res9.json", 79 | }; 80 | 81 | class MapScreen extends React.Component { 82 | constructor(props) { 83 | super(props); 84 | 85 | this.mapRef = React.createRef(); 86 | this.state = { 87 | showSignUp: window.localStorage 88 | ? !window.localStorage.getItem("seenSignUp") 89 | : true, 90 | devices: [], 91 | allDevices: [], 92 | selectedDevice: null, 93 | packets: {}, 94 | lastPacket: null, 95 | mapCenter: [-122.41919, 37.77115], 96 | hotspots: { data: [] }, 97 | hexdata: null, 98 | showMappers: false, 99 | chartType: null, 100 | /* 101 | setting to false as hotspots fetch is currently down 102 | when re-enabled we'll need to update the relevant Layers + Features 103 | */ 104 | showHotspots: false, 105 | highlightHotspoted: null, 106 | transmittingDevices: {}, 107 | loading: false, 108 | hotspotsData: {}, 109 | }; 110 | 111 | this.selectDevice = this.selectDevice.bind(this); 112 | this.findDevice = this.findDevice.bind(this); 113 | this.setChartType = this.setChartType.bind(this); 114 | this.setHotspots = this.setHotspots.bind(this); 115 | this.toggleHotspots = this.toggleHotspots.bind(this); 116 | this.highlightHotspot = this.highlightHotspot.bind(this); 117 | this.parsePackets = this.parsePackets.bind(this); 118 | this.hideSignUp = this.hideSignUp.bind(this); 119 | this.onSearchChange = this.onSearchChange.bind(this); 120 | this.toggleMappers = this.toggleMappers.bind(this); 121 | } 122 | 123 | componentWillUnmount() { 124 | maplibregl.removeProtocol("pmtiles"); 125 | } 126 | 127 | componentDidMount() { 128 | let protocol = new Protocol(); 129 | maplibregl.addProtocol("pmtiles", protocol.tile); 130 | 131 | if (navigator.geolocation) { 132 | navigator.geolocation.getCurrentPosition(({ coords }) => { 133 | const { mapCenter } = this.state; 134 | if (mapCenter[0] == -122.41919 && mapCenter[1] == 37.77115) { 135 | console.log("Using browser location"); 136 | this.setState({ mapCenter: [coords.longitude, coords.latitude] }); 137 | } 138 | }); 139 | } 140 | 141 | this.loadHotspots(); 142 | 143 | this.loadDevices(); 144 | 145 | let channel = socket.channel("payload:new", {}); 146 | channel 147 | .join() 148 | .receive("ok", (resp) => { 149 | console.log("Joined successfully", resp); 150 | }) 151 | .receive("error", (resp) => { 152 | console.log("Unable to join", resp); 153 | }); 154 | 155 | channel.on("new_payload", (d) => { 156 | if (d.oui !== CURRENT_OUI) return; 157 | 158 | if ( 159 | this.state.selectedDevice && 160 | this.state.selectedDevice.device_id === d.device_id 161 | ) { 162 | const packets = this.parsePackets(this.state.packets, d); 163 | const packetsArray = packets.seq.map((s) => packets.data[s]); 164 | packets.geoJson = geoJSON.parse(packetsArray, { 165 | Point: ["coordinates.lat", "coordinates.lon"], 166 | }); 167 | const lastPacket = packetsArray[packetsArray.length - 1]; 168 | 169 | this.setState({ 170 | packets, 171 | lastPacket, 172 | }); 173 | } 174 | 175 | const allDevices = this.state.allDevices; 176 | const index = findIndex(this.state.allDevices, { 177 | device_id: d.device_id, 178 | }); 179 | if (allDevices[index]) { 180 | allDevices[index].created_at = d.created_at; 181 | allDevices.sort(function(a, b) { 182 | return new Date(b.created_at) - new Date(a.created_at); 183 | }); 184 | this.setState({ allDevices }); 185 | 186 | const { transmittingDevices } = this.state; 187 | //transmittingDevices[d.device_id] = [Number(d.lon), Number(d.lat)] 188 | transmittingDevices[d.device_id] = d; 189 | this.setState({ transmittingDevices }); 190 | } else { 191 | this.loadDevices(); 192 | } 193 | }); 194 | } 195 | 196 | // endpoint is down so not triggering fetch 197 | async loadHotspots() { 198 | // const { hotspotsData } = this.state; 199 | // this.client = new Client(); 200 | // const list = await this.client.hotspots.list(); 201 | // const spots = await list.take(1000000); 202 | // spots.forEach((d) => { 203 | // hotspotsData[d.name.toLowerCase()] = d; 204 | // }); 205 | // this.setState({ hotspotsData }); 206 | } 207 | 208 | loadDevices() { 209 | get("oui/" + CURRENT_OUI) 210 | .then((res) => res.json()) 211 | .then((devices) => { 212 | devices.sort(function(a, b) { 213 | return new Date(b.created_at) - new Date(a.created_at); 214 | }); 215 | const allDevices = devices; 216 | this.setState({ devices, allDevices }); 217 | }); 218 | } 219 | 220 | onSearchChange(e) { 221 | const { devices, allDevices } = this.state; 222 | var results = allDevices.filter((obj) => { 223 | return obj.name.toLowerCase().includes(e.target.value.toLowerCase()); 224 | }); 225 | this.setState({ devices: results }); 226 | } 227 | 228 | hideSignUp() { 229 | this.setState({ showSignUp: false }); 230 | } 231 | 232 | toggleMappers() { 233 | this.setState({ showMappers: !this.state.showMappers }); 234 | } 235 | 236 | selectDevice(d) { 237 | if (this.state.loading) return; 238 | 239 | this.setState({ loading: true }, () => { 240 | get("devices/" + d.device_id + "?last_at=" + d.created_at) 241 | .then((res) => res.json()) 242 | .then((data) => { 243 | console.log("Received " + data.length + " Packets"); 244 | let packets = { 245 | data: {}, 246 | geoJson: null, 247 | seq: [], 248 | }; 249 | 250 | data.forEach((d) => { 251 | packets = this.parsePackets(packets, d); 252 | }); 253 | const packetsArray = packets.seq.map((s) => packets.data[s]); 254 | packets.geoJson = geoJSON.parse(packetsArray, { 255 | Point: ["coordinates.lat", "coordinates.lon"], 256 | }); 257 | const lastPacket = packetsArray[packetsArray.length - 1]; 258 | 259 | this.setState({ 260 | selectedDevice: d, 261 | packets, 262 | lastPacket, 263 | hotspots: { data: [] }, 264 | loading: false, 265 | }); 266 | const map = this.mapRef.current.getMap(); 267 | map.flyTo({ 268 | center: [lastPacket.coordinates.lon, lastPacket.coordinates.lat], 269 | }); 270 | }) 271 | .catch((err) => { 272 | this.setState({ loading: false }); 273 | }); 274 | }); 275 | } 276 | 277 | findDevice(deviceId) { 278 | get("oui/" + CURRENT_OUI + "?device_id=" + deviceId) 279 | .then((res) => res.json()) 280 | .then((device) => { 281 | if (device.length == 0) { 282 | alert("Device " + deviceId + " does not exist"); 283 | return; 284 | } 285 | this.selectDevice(device[0]); 286 | }) 287 | .catch((err) => { 288 | alert("Server error: Please try again"); 289 | }); 290 | } 291 | 292 | setHotspots({ properties }) { 293 | const { hotspotsData } = this.state; 294 | this.setState({ hotspots: { data: [] } }, () => { 295 | const hotspots = { 296 | data: [], 297 | center: geoToMarkerCoords(properties.coordinates), 298 | }; 299 | properties.hotspots.forEach((h) => { 300 | const hotspotName = h 301 | .trim() 302 | .split(" ") 303 | .join("-"); 304 | if (hotspotsData[hotspotName]) 305 | hotspots.data.push(hotspotsData[hotspotName]); 306 | else console.log("Found undefined hotspot name not shown on map", h); 307 | }); 308 | this.setState({ hotspots }); 309 | }); 310 | } 311 | 312 | toggleHotspots() { 313 | this.setState({ showHotspots: !this.state.showHotspots }); 314 | } 315 | 316 | highlightHotspot(h) { 317 | this.setState({ highlightedHotspot: h }, () => { 318 | setTimeout(() => { 319 | this.setState({ highlightedHotspot: null }); 320 | }, 1000); 321 | }); 322 | } 323 | 324 | setChartType(chartType) { 325 | this.setState({ chartType }); 326 | } 327 | 328 | parsePackets(packets, packet) { 329 | packet.battery = Number(packet.battery); 330 | packet.elevation = Number(packet.elevation); 331 | packet.lat = Number(packet.lat); 332 | packet.lon = Number(packet.lon); 333 | packet.rssi = Number(packet.rssi); 334 | packet.speed = Number(packet.speed); 335 | packet.snr = Number(packet.snr); 336 | if (packet.lat == 0 || packet.lon == 0) return packets; //filter out africa packets 337 | if (packet.lat > 90 || packet.lat < -90) { 338 | console.log("Packet dropped, latitude value out of range"); 339 | return packets; 340 | } 341 | if (packet.lat > 180 || packet.lat < -180) { 342 | console.log("Packet dropped, longitude value out of range"); 343 | return packets; 344 | } 345 | const seq_id = packet.seq_num + "-" + packet.reported; 346 | 347 | if (packets.data[seq_id]) { 348 | if (packets.data[seq_id].rssi < packet.rssi) 349 | packets.data[seq_id].rssi = packet.rssi; 350 | if (packets.data[seq_id].battery > packet.battery) 351 | packets.data[seq_id].battery = packet.battery; 352 | packets.data[seq_id].hotspots.push( 353 | packet.hotspot_id.replace("rapping", "dandy") 354 | ); 355 | } else { 356 | packets.data[seq_id] = { 357 | id: packet.id, 358 | key: packet.lat + packet.lon, 359 | coordinates: { lat: packet.lat, lon: packet.lon }, 360 | speed: packet.speed, 361 | rssi: packet.rssi, 362 | battery: packet.battery, 363 | elevation: packet.elevation, 364 | seq_num: packet.seq_num, 365 | reported: packet.created_at, 366 | snr: packet.snr, 367 | seq_id, 368 | }; 369 | packets.data[seq_id].hotspots = [ 370 | packet.hotspot_id.replace("rapping", "dandy"), 371 | ]; 372 | packets.seq.push(seq_id); 373 | } 374 | return packets; 375 | } 376 | 377 | render() { 378 | const { 379 | devices, 380 | mapCenter, 381 | selectedDevice, 382 | packets, 383 | lastPacket, 384 | hotspots, 385 | hexdata, 386 | showMappers, 387 | hotspotsData, 388 | chartType, 389 | showHotspots, 390 | highlightedHotspot, 391 | transmittingDevices, 392 | showSignUp, 393 | } = this.state; 394 | 395 | return ( 396 |
397 | 415 | 416 | 417 | {showMappers && ( 418 | 439 | )} 440 | 441 | {selectedDevice && 442 | packets.geoJson.features.map((packet, i) => ( 443 | 449 |
450 | 451 | ))} 452 | 453 | {Object.keys(transmittingDevices).length > 0 && 454 | Object.keys(transmittingDevices).map((id) => { 455 | return ( 456 | this.selectDevice(transmittingDevices[id])} 462 | > 463 |
464 | 465 | ); 466 | })} 467 | 468 | {lastPacket && ( 469 | 474 |
475 | 476 | )} 477 | 478 | {/* {showHotspots && 479 | hotspots.data.map((h, i) => { 480 | if ( 481 | h.lng && 482 | calculateDistance( 483 | h.lat, 484 | hotspots.center[1], 485 | h.lng, 486 | hotspots.center[0] 487 | ) < 0.4 488 | ) { 489 | //filter out africa packets 490 | return ( 491 | this.highlightHotspot(h)} 497 | > 498 |
499 | 500 | ); 501 | } 502 | })} 503 | 504 | {showHotspots && 505 | hotspots.data.map((h, i) => { 506 | if ( 507 | h.lng && 508 | calculateDistance( 509 | h.lat, 510 | hotspots.center[1], 511 | h.lng, 512 | hotspots.center[0] 513 | ) < 0.4 514 | ) { 515 | //filter out africa packets 516 | return ( 517 | 523 | 524 | 525 | ); 526 | } 527 | })} */} 528 | 529 | {/* {highlightedHotspot && showHotspots && ( 530 | 536 | 542 | 543 | )} 544 | 545 | {showHotspots && highlightedHotspot && ( 546 | 552 | )} */} 553 | 554 | 555 | { 559 | const hotspotName = d.hotspot.replace(/-/g, "-"); 560 | const hotspot = hotspotsData[hotspotName]; 561 | if (hotspot) 562 | return ( 563 | hotspot.geocode.longCity + ", " + hotspot.geocode.shortState 564 | ); 565 | return "Unknown"; 566 | })} 567 | selectDevice={this.selectDevice} 568 | findDevice={this.findDevice} 569 | selectedDevice={selectedDevice} 570 | onSearchChange={this.onSearchChange} 571 | toggleMappers={this.toggleMappers} 572 | /> 573 | 574 | {lastPacket && ( 575 | 586 | )} 587 | 588 | {chartType && ( 589 | packets.data[s]), 596 | chartType 597 | )} 598 | /> 599 | )} 600 | 601 | {showSignUp && } 602 |
603 | ); 604 | } 605 | } 606 | 607 | const geoToMarkerCoords = (geo) => [geo.lon, geo.lat]; 608 | 609 | const calculateDistance = (lat1, lat2, lng1, lng2) => 610 | Math.sqrt(Math.pow(lat1 - lat2, 2) + Math.pow(lng1 - lng2, 2)); 611 | 612 | export default MapScreen; 613 | --------------------------------------------------------------------------------