├── .dir-locals.el ├── lib ├── luminous.ex └── luminous │ ├── utils.ex │ ├── query.ex │ ├── time_range_selector.ex │ ├── attributes.ex │ ├── panel │ ├── stat.ex │ ├── table.ex │ └── chart.ex │ ├── dashboard.ex │ ├── panel.ex │ ├── live.ex │ ├── variable.ex │ ├── time_range.ex │ └── components.ex ├── .tool-versions ├── test ├── test_helper.exs ├── support │ └── conn_case.ex └── luminous │ ├── dashboard_test.exs │ ├── variable_test.exs │ ├── time_range_test.exs │ ├── panel_test.exs │ └── live_test.exs ├── .formatter.exs ├── assets ├── js │ ├── luminous.js │ ├── components │ │ ├── utils.js │ │ ├── time_range_hook.js │ │ ├── table_hook.js │ │ ├── multi_select_variable_hook.js │ │ └── chartjs_hook.js │ └── app.js ├── tailwind.config.js ├── package.json └── css │ └── app.css ├── config ├── test.exs ├── dev.exs └── config.exs ├── env ├── run.exs ├── test │ ├── dashboard.ex │ └── phoenix.ex ├── generator.ex └── dev │ ├── phoenix.ex │ └── dashboard.ex ├── package.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── Orkfile.yml ├── mix.exs ├── docs └── Applying custom CSS.md ├── README.md └── mix.lock /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((nil . ((projectile-project-type . elixir)))) 2 | -------------------------------------------------------------------------------- /lib/luminous.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous do 2 | @external_resource "README.md" 3 | @moduledoc File.read!(@external_resource) 4 | end 5 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | # export KERL_CONFIGURE_OPTIONS="--disable-debug --without-javac" 2 | erlang 25.3 3 | elixir 1.14.3-otp-25 4 | nodejs 18.14.2 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Supervisor.start_link( 2 | [ 3 | Luminous.Test.Endpoint 4 | ], 5 | strategy: :one_for_one 6 | ) 7 | 8 | ExUnit.start() 9 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:phoenix], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["{mix,.formatter}.exs", "{config,lib,test,env}/**/*.{ex,exs}"] 6 | ] 7 | -------------------------------------------------------------------------------- /lib/luminous/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Utils do 2 | @moduledoc """ 3 | Various utility functions. 4 | """ 5 | 6 | def dom_id(%{id: id}), do: "panel-#{id}" 7 | 8 | def print_number(%Decimal{} = n), do: Decimal.to_string(n) 9 | def print_number(nil), do: "-" 10 | def print_number(n), do: n 11 | end 12 | -------------------------------------------------------------------------------- /assets/js/luminous.js: -------------------------------------------------------------------------------- 1 | import ChartJSHook from "./components/chartjs_hook" 2 | import TableHook from "./components/table_hook" 3 | import TimeRangeHook from "./components/time_range_hook" 4 | import MultiSelectVariableHook from "./components/multi_select_variable_hook" 5 | 6 | export { ChartJSHook, TableHook, TimeRangeHook, MultiSelectVariableHook } 7 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // https://tailwindcss.com/docs/configuration 2 | module.exports = { 3 | mode: "jit", 4 | content: ["../lib/luminous/*", "../lib/luminous/panel/*"], 5 | darkMode: 'media', // or 'media' or 'class' 6 | corePlugins: { 7 | textOpacity: false, 8 | backgroundOpacity: false, 9 | borderOpacity: false 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :luminous, Luminous.Test.Endpoint, 4 | url: [host: "localhost", port: 4000], 5 | secret_key_base: "Hu4qQN3iKzTV4fJxhorPQlA/osH9fAMtbtjVS58PFgfw3ja5Z18Q/WSNR9wP4OfW", 6 | live_view: [signing_salt: "hMegieSe"], 7 | render_errors: [view: Luminous.Test.ErrorView], 8 | check_origin: false 9 | 10 | config :logger, :console, level: :error 11 | -------------------------------------------------------------------------------- /assets/js/components/utils.js: -------------------------------------------------------------------------------- 1 | export function sendFileToClient(url, filename) { 2 | var link = document.createElement("a"); 3 | link.setAttribute("href", url); 4 | link.setAttribute("download", filename); 5 | link.style.visibility = 'hidden'; 6 | link.setAttribute("target", "_blank") 7 | document.body.appendChild(link); 8 | link.click(); 9 | document.body.removeChild(link); 10 | } 11 | -------------------------------------------------------------------------------- /env/run.exs: -------------------------------------------------------------------------------- 1 | ########################################### 2 | # Development Server for testing dashboards 3 | ########################################### 4 | 5 | Task.async(fn -> 6 | children = [ 7 | Luminous.Dev.Endpoint, 8 | {Phoenix.PubSub, name: Luminous.PubSub} 9 | ] 10 | 11 | {:ok, _} = Supervisor.start_link(children, strategy: :one_for_one) 12 | Process.sleep(:infinity) 13 | end) 14 | |> Task.await(:infinity) 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luminous", 3 | "version": "2.6.1", 4 | "description": "The JavaScript client for the Luminous framework.", 5 | "license": "MIT", 6 | "module": "./dist/luminous.js", 7 | "main": "./dist/luminous.js", 8 | "exports": { 9 | "import": "./dist/luminous.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/elinverd/luminous.git" 14 | }, 15 | "files": [ 16 | "README.md", 17 | "package.json", 18 | "dist/*", 19 | "assets/js/*" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luminous", 3 | "version": "2.6.1", 4 | "description": "The JavaScript client for the Luminous framework.", 5 | "license": "MIT", 6 | "main": "./assets/js/app.js", 7 | "module": "./assets/js/app.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com/elinverd/luminous.git" 11 | }, 12 | "exports": { 13 | ".": "./js/app.js" 14 | }, 15 | "devDependencies": { 16 | "chart.js": "^3.9.1", 17 | "chartjs-adapter-luxon": "^1.3.1", 18 | "chartjs-plugin-zoom": "^2.0.1", 19 | "flatpickr": "^4.6.13", 20 | "luxon": "^3.4.4", 21 | "tabulator-tables": "^5.5.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.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 | 7 | use ExUnit.CaseTemplate 8 | 9 | using do 10 | quote do 11 | # Import conveniences for testing with connections 12 | import Plug.Conn 13 | import Phoenix.ConnTest 14 | import Luminous.ConnCase 15 | import Phoenix.LiveViewTest 16 | 17 | alias Luminous.Test.Router.Helpers, as: Routes 18 | 19 | # The default endpoint for testing 20 | @endpoint Luminous.Test.Endpoint 21 | end 22 | end 23 | 24 | setup do 25 | {:ok, conn: Phoenix.ConnTest.build_conn()} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :luminous, Luminous.Dev.Endpoint, 4 | url: [host: "localhost"], 5 | secret_key_base: "Hu4qQN3iKzTV4fJxhorPQlA/osH9fAMtbtjVS58PFgfw3ja5Z18Q/WSNR9wP4OfW", 6 | live_view: [signing_salt: "hMegieSe"], 7 | http: [port: System.get_env("PORT") || 5000], 8 | render_errors: [view: Luminous.Dev.ErrorView], 9 | code_reloader: true, 10 | debug_errors: true, 11 | check_origin: false, 12 | pubsub_server: Luminous.PubSub, 13 | live_reload: [ 14 | patterns: [ 15 | ~r"priv/static/assets/.*(js|css|png|jpeg|jpg|gif|svg)$", 16 | ~r"lib/luminous/.*(ex)$", 17 | ~r"dev/.*(ex)$" 18 | ] 19 | ], 20 | watchers: [ 21 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 22 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 23 | ] 24 | 25 | config :phoenix, serve_endpoints: true 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: push 4 | 5 | jobs: 6 | mix_test: 7 | runs-on: ubuntu-latest 8 | env: 9 | MIX_ENV: test 10 | strategy: 11 | matrix: 12 | include: 13 | - pair: 14 | elixir: 1.14.0 15 | otp: 24.3 16 | lint: lint 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - uses: erlef/setup-beam@v1 21 | with: 22 | otp-version: ${{matrix.pair.otp}} 23 | elixir-version: ${{matrix.pair.elixir}} 24 | 25 | - name: Fetch Dependencies 26 | run: mix deps.get --only test 27 | 28 | - name: Compile project 29 | run: mix compile --warnings-as-errors 30 | if: ${{ matrix.lint }} 31 | 32 | - name: Check formatting 33 | run: mix format --check-formatted 34 | if: ${{ matrix.lint }} 35 | 36 | - name: Run test suite 37 | run: mix test || if [[ $? = 2 ]]; then mix test --failed; else false; fi 38 | -------------------------------------------------------------------------------- /.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 third-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 | # exclude lsp files 20 | .elixir_ls 21 | 22 | # Also ignore archive artifacts (built via "mix archive.build"). 23 | *.ez 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | luminous-*.tar 27 | 28 | # Temporary files, for example, from tests. 29 | /tmp/ 30 | 31 | # The directory NPM downloads your dependencies sources to. 32 | /assets/node_modules/ 33 | 34 | # do not store compiled assets 35 | /priv/static/assets 36 | 37 | # include dist files 38 | /dist/* 39 | !/dist/luminous.js 40 | !/dist/luminous.css 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Elin Verd SA 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import "phoenix_html" 2 | 3 | import { Socket } from "phoenix" 4 | import { LiveSocket } from "phoenix_live_view" 5 | 6 | import ChartJSHook from "./components/chartjs_hook" 7 | import TimeRangeHook from "./components/time_range_hook" 8 | import TableHook from "./components/table_hook" 9 | import MultiSelectVariableHook from "./components/multi_select_variable_hook" 10 | 11 | let Hooks = { 12 | ChartJSHook: new ChartJSHook(), 13 | TimeRangeHook: new TimeRangeHook(), 14 | TableHook: new TableHook(), 15 | MultiSelectVariableHook: new MultiSelectVariableHook() 16 | } 17 | 18 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 19 | let liveSocket = new LiveSocket("/live", Socket, { 20 | params: { _csrf_token: csrfToken }, 21 | hooks: Hooks 22 | }) 23 | 24 | // connect if there are any LiveViews on the page 25 | liveSocket.connect() 26 | 27 | // expose liveSocket on window for web console debug logs and latency simulation: 28 | // >> liveSocket.enableDebug() 29 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 30 | // >> liveSocket.disableLatencySim() 31 | window.liveSocket = liveSocket 32 | -------------------------------------------------------------------------------- /env/test/dashboard.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Test.DashboardLive do 2 | @moduledoc false 3 | 4 | alias Luminous.Test.Router.Helpers, as: Routes 5 | alias Luminous.{Dashboard, Components} 6 | 7 | use Luminous.Live, 8 | title: "This will be overriden by the tests", 9 | time_zone: "Europe/Athens" 10 | 11 | @impl true 12 | # this function will be called from the tests in order 13 | # to set up the entire dashboard 14 | def handle_info({_task_ref, {:dashboard, schema}}, socket) do 15 | dashboard = 16 | schema 17 | |> Dashboard.define!() 18 | |> Dashboard.populate(socket.assigns) 19 | 20 | dashboard = 21 | Dashboard.update_current_time_range(dashboard, lmn_get_default_time_range(dashboard)) 22 | 23 | socket = 24 | socket 25 | |> assign(dashboard: dashboard) 26 | |> push_patch(to: dashboard_path(socket, [])) 27 | 28 | {:noreply, socket} 29 | end 30 | 31 | @impl Dashboard 32 | def dashboard_path(socket, url_params), do: Routes.dashboard_path(socket, :index, url_params) 33 | 34 | def render(assigns) do 35 | ~H""" 36 | 37 | """ 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /Orkfile.yml: -------------------------------------------------------------------------------- 1 | default: test 2 | 3 | tasks: 4 | - name: setup 5 | description: fetch the basic dependencies 6 | actions: 7 | - mix install 8 | 9 | - name: deps 10 | description: fetch the project dependencies 11 | actions: 12 | - mix deps.get 13 | - npm install --prefix ./assets 14 | 15 | - name: check.format 16 | description: check the formatting 17 | actions: 18 | - mix format --check-formatted 19 | 20 | - name: test 21 | description: run all the tests 22 | depends_on: 23 | - check.format 24 | expand_env: false 25 | actions: 26 | - bash -c "mix test --color || if [[ $? = 2 ]]; then mix test --color --failed; fi" 27 | 28 | - name: verify 29 | description: run dialyzer 30 | actions: 31 | - mix dialyzer 32 | 33 | - name: run 34 | description: run the dev server 35 | actions: 36 | - mix run 37 | 38 | - name: docs 39 | description: generate the docs 40 | actions: 41 | - mix docs 42 | 43 | - name: build 44 | description: build the project and the assets 45 | actions: 46 | - mix compile 47 | - mix assets.build 48 | 49 | - name: publish 50 | description: publish the package to hex 51 | depends_on: 52 | - test 53 | actions: 54 | - mix hex.publish 55 | -------------------------------------------------------------------------------- /env/generator.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Generator do 2 | @moduledoc false 3 | 4 | @spec generate(Luminous.TimeRange.t(), non_neg_integer(), :hour | :day, binary() | [binary()]) :: 5 | any() 6 | def generate(time_range, multiplier, interval, variables) when is_list(variables) do 7 | time_range 8 | |> generate_time_points(interval) 9 | |> Enum.map(fn t -> 10 | row = 11 | Enum.reduce(variables, [], fn variable, acc -> 12 | value = 13 | :rand.uniform() 14 | |> Decimal.from_float() 15 | |> Decimal.mult(multiplier) 16 | |> Decimal.round(2) 17 | 18 | [{variable, value} | acc] 19 | end) 20 | 21 | [{:time, t} | row] 22 | end) 23 | end 24 | 25 | def generate(time_range, multiplier, interval, variable), 26 | do: generate(time_range, multiplier, interval, [variable]) 27 | 28 | defp(generate_time_points(%{from: from, to: to}, interval)) do 29 | seconds_in_interval = 30 | case interval do 31 | :hour -> 60 * 60 32 | :day -> 60 * 60 * 24 33 | end 34 | 35 | number_of_intervals = 36 | to 37 | |> DateTime.diff(from, :second) 38 | |> div(seconds_in_interval) 39 | 40 | # assemble the timestamps in a list 41 | 0..(number_of_intervals - 1) 42 | |> Enum.map(fn n -> 43 | DateTime.add(from, n * seconds_in_interval, :second) 44 | end) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/luminous/query.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Query do 2 | @moduledoc """ 3 | A query is embedded in a panel and contains a function 4 | which will be executed upon panel refresh to fetch the query's data. 5 | """ 6 | 7 | alias Luminous.{TimeRange, Variable} 8 | 9 | @type result :: any() 10 | 11 | @doc """ 12 | A module must implement this behaviour to be passed as an argument to `define/2`. 13 | A query must return a list of 2-tuples: 14 | - the 2-tuple's first element is the time series' label 15 | - the 2-tuple's second element is the label's value 16 | the list must contain a 2-tuple with the label `:time` and a `DateTime` value. 17 | """ 18 | @callback query(atom(), TimeRange.t(), [Variable.t()]) :: result() 19 | 20 | @type t :: %__MODULE__{ 21 | id: atom(), 22 | mod: module() 23 | } 24 | 25 | @enforce_keys [:id, :mod] 26 | defstruct [:id, :mod] 27 | 28 | @doc """ 29 | Initialize a query at compile time. The module must implement the `Luminous.Query` behaviour. 30 | """ 31 | @spec define(atom(), module()) :: t() 32 | def define(id, mod), do: %__MODULE__{id: id, mod: mod} 33 | 34 | @doc """ 35 | Execute the query and return the data as multiple TimeSeries structs. 36 | """ 37 | @spec execute(t(), TimeRange.t(), [Variable.t()]) :: result() 38 | def execute(query, time_range, variables) do 39 | apply(query.mod, :query, [query.id, time_range, variables]) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix, :json_library, Jason 4 | 5 | # use time zones 6 | # see: https://hexdocs.pm/elixir/DateTime.html#module-time-zone-database 7 | # also: https://elixirschool.com/en/lessons/basics/date-time/#working-with-timezones 8 | config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase 9 | 10 | # === ASSET PIPELINE === 11 | # targets: 12 | # - default: will generate the assets under /priv/static/assets (for dev) 13 | # - dist: will generate the assets under dist/ (for package) 14 | 15 | # CSS 16 | config :tailwind, 17 | version: "3.2.7", 18 | default: [ 19 | args: ~w( 20 | --config=tailwind.config.js 21 | --input=css/app.css 22 | --output=../priv/static/assets/app.css 23 | ), 24 | cd: Path.expand("../assets", __DIR__) 25 | ], 26 | dist: [ 27 | args: ~w( 28 | --config=tailwind.config.js 29 | --input=css/app.css 30 | --output=../dist/luminous.css 31 | ), 32 | cd: Path.expand("../assets", __DIR__) 33 | ] 34 | 35 | # JS 36 | config :esbuild, 37 | version: "0.17.11", 38 | default: [ 39 | args: ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets), 40 | cd: Path.expand("../assets", __DIR__), 41 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 42 | ], 43 | dist: [ 44 | args: ~w(js/luminous.js --bundle --target=es2016 --format=cjs --outdir=../dist), 45 | cd: Path.expand("../assets", __DIR__), 46 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 47 | ] 48 | 49 | # Import environment specific config. This must remain at the bottom 50 | # of this file so it overrides the configuration defined above. 51 | import_config "#{Mix.env()}.exs" 52 | -------------------------------------------------------------------------------- /assets/js/components/time_range_hook.js: -------------------------------------------------------------------------------- 1 | import flatpickr from "flatpickr" 2 | import { DateTime } from 'luxon' 3 | 4 | function TimeRangeHook() { 5 | this.mounted = function () { 6 | this.setupFlatpickr() 7 | // we subscribe to messages coming from the dashboard LV 8 | // and from any chart in the page (zoom functionality) 9 | // we reject all messages from other components 10 | // we expect a `time_range` object to be part of the payload 11 | // or two dates (from, to) 12 | this.handleEvent(this.el.id + "::refresh-data", (payload) => { 13 | let time_range = [payload.time_range.from, payload.time_range.to] 14 | this.flatpickr.setDate(time_range, false, null) 15 | }) 16 | 17 | document.getElementById(this.el.id).addEventListener('zoomCompleted', (e) => { 18 | this.sendNotification({ from: e.detail.from, to: e.detail.to }) 19 | }) 20 | } 21 | 22 | this.reconnected = function () { 23 | // initialize the flatpckr calendar 24 | this.setupFlatpickr() 25 | } 26 | 27 | // send a notification to the live view that the state has changed 28 | this.sendNotification = function (payload) { 29 | this.pushEventTo("#" + this.el.id, "lmn_time_range_change", payload) 30 | } 31 | 32 | this.setupFlatpickr = function () { 33 | this.flatpickr = flatpickr("#" + this.el.id, { 34 | mode: "range", 35 | dateFormat: "Y-m-d", 36 | monthSelectorType: "static", 37 | locale: { 38 | firstDayOfWeek: 1 39 | }, 40 | onChange: (selectedDates, dateStr, instance) => { 41 | // do not fire when the user selects the first date 42 | if (2 == selectedDates.length) { 43 | // Since the calendar contains dates, we want the end date to be inclusive, 44 | // that's why we're rounding to up to the nearest second to the next day 45 | let to = DateTime.fromJSDate(selectedDates[1]) 46 | .plus({ days: 1 }).plus({ seconds: -1 }) 47 | .toJSDate() 48 | this.sendNotification({ from: selectedDates[0], to: to }) 49 | } 50 | } 51 | }) 52 | } 53 | 54 | } 55 | export default TimeRangeHook; 56 | -------------------------------------------------------------------------------- /lib/luminous/time_range_selector.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.TimeRangeSelector do 2 | @moduledoc """ 3 | A selector supports the widget in the dashboard that allows 4 | for selecting a time range/period. 5 | It can also be updated with a new value. 6 | """ 7 | alias Luminous.TimeRange 8 | 9 | @type preset :: binary() 10 | 11 | @type t :: %__MODULE__{current_time_range: nil | TimeRange.t()} 12 | 13 | defstruct [:id, :current_time_range] 14 | 15 | @presets [ 16 | {"Today", &TimeRange.today/1, []}, 17 | {"Yesterday", &TimeRange.yesterday/1, []}, 18 | {"Last 7 days", &TimeRange.last_n_days/2, [7]}, 19 | {"This week", &TimeRange.this_week/1, []}, 20 | {"Previous week", &TimeRange.last_week/1, []}, 21 | {"This month", &TimeRange.this_month/1, []}, 22 | {"Previous month", &TimeRange.last_month/1, []} 23 | ] 24 | 25 | def id(), do: "time-range-selector" 26 | 27 | def hook(), do: "TimeRangeHook" 28 | 29 | @doc """ 30 | Create and return a new selector 31 | """ 32 | @spec new(t()) :: t() 33 | def new(selector), do: Map.put(selector, :id, id()) 34 | 35 | @doc """ 36 | Updates the current time range of the selector. 37 | """ 38 | @spec update_current(t(), TimeRange.t()) :: t() 39 | def update_current(selector, time_range) do 40 | Map.put(selector, :current_time_range, time_range) 41 | end 42 | 43 | @doc """ 44 | Get the selector's current time range value 45 | """ 46 | @spec get_current(t()) :: TimeRange.t() | nil 47 | def get_current(selector), do: selector.current_time_range 48 | 49 | @doc """ 50 | Returns a list with the available time range presets. 51 | """ 52 | @spec presets() :: [preset()] 53 | def presets(), do: ["Default" | Enum.map(@presets, fn {label, _, _} -> label end)] 54 | 55 | @doc """ 56 | Calculates and returns the time range for the given preset in the given 57 | time zone. 58 | """ 59 | @spec get_time_range_for(preset(), TimeRange.time_zone()) :: TimeRange.t() | nil 60 | def get_time_range_for(preset, time_zone) do 61 | case Enum.find(@presets, fn {label, _, _} -> label == preset end) do 62 | {_, function, args} -> apply(function, List.insert_at(args, -1, time_zone)) 63 | _ -> nil 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/luminous/attributes.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Attributes do 2 | @moduledoc """ 3 | Attributes map variable values (user-defined) to attribute keyword lists. 4 | They are created by parsing and validating a `NimbleOptions` schema (see `parse/2`). 5 | """ 6 | @type t :: map() 7 | 8 | defmodule Schema do 9 | @moduledoc """ 10 | Attribute schemas that are common across all instances of their type. 11 | """ 12 | 13 | alias Luminous.Query 14 | 15 | @type t :: NimbleOptions.schema() 16 | 17 | @doc """ 18 | Schema for the attributes that apply to all panels regardless of their type 19 | """ 20 | @spec panel() :: t() 21 | def panel(), 22 | do: [ 23 | type: [type: :atom, required: true], 24 | id: [type: :atom, required: true], 25 | title: [type: :string, default: ""], 26 | hook: [type: {:or, [:string, nil]}, default: nil], 27 | queries: [type: {:list, {:struct, Query}}], 28 | description: [type: {:or, [:string, nil]}, default: nil], 29 | data_attributes: [type: {:map, {:or, [:atom, :string]}, :keyword_list}, default: %{}] 30 | ] 31 | 32 | @doc """ 33 | Schema for the attributes that apply to all data attributes 34 | regardless of the Panel in which they are defined 35 | """ 36 | @spec data() :: t() 37 | def data(), 38 | do: [ 39 | title: [type: :string, default: ""], 40 | unit: [type: :string, default: ""], 41 | order: [type: :integer, default: 0] 42 | ] 43 | end 44 | 45 | @doc """ 46 | Parse the supplied keyword list using the specified schema (performs validations as well) 47 | """ 48 | @spec parse(keyword(), Schema.t()) :: {:ok, t()} | {:error, binary()} 49 | def parse(opts, schema) do 50 | case NimbleOptions.validate(opts, schema) do 51 | {:ok, attrs} -> {:ok, Map.new(attrs)} 52 | {:error, %{message: message}} -> {:error, message} 53 | end 54 | end 55 | 56 | @doc """ 57 | Parse the supplied keyword list using the specified schema (performs validations as well). Raises on error. 58 | """ 59 | @spec parse!(keyword(), Schema.t()) :: map() 60 | def parse!(opts, schema) do 61 | case parse(opts, schema) do 62 | {:ok, attrs} -> attrs 63 | {:error, message} -> raise message 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /assets/js/components/table_hook.js: -------------------------------------------------------------------------------- 1 | import {TabulatorFull as Tabulator} from 'tabulator-tables'; 2 | import {sendFileToClient} from './utils' 3 | 4 | function TableHook() { 5 | this.mounted = function() { 6 | this.id = document.getElementById(this.el.id) 7 | // we can not initialize the table with no data 8 | // because when we use table.replaceData no data is shown 9 | this.table = null; 10 | // setup the data refresh event handler (LV) 11 | this.handleEvent(this.el.id + "::refresh-data", this.handler()) 12 | // setup the download CSV event handler 13 | this.el.addEventListener("panel:" + this.el.id + ":download:csv", this.downloadCSV()) 14 | } 15 | 16 | this.handler = function() { 17 | return (payload) => { 18 | this.createOrUpdateTable(payload.rows, payload.columns, payload.attributes); 19 | } 20 | } 21 | 22 | this.createOrUpdateTable = function(rows, columns, attributes) { 23 | if (this.table === null) { 24 | this.table = new Tabulator(this.id, { 25 | placeholder: "No data available", 26 | minHeight: rows.length == 0 ? 50 : false, 27 | pagination: true, 28 | paginationSize: attributes.page_size, 29 | data: rows, 30 | columns: columns, 31 | layout: "fitColumns" 32 | }); 33 | } else { 34 | this.table.setColumns(columns); 35 | this.table.replaceData(rows); 36 | } 37 | } 38 | 39 | // download the table's data as CSV 40 | this.downloadCSV = function () { 41 | return (event) => { 42 | // determine column labels 43 | let fields = this.table.getColumnDefinitions().map((coldef) => coldef.field); 44 | let titles = this.table.getColumnDefinitions().map((coldef) => coldef.title); 45 | // form CSV header 46 | let csvRows = ['sep=,', titles.map((title) => '\"' + title + '\"').join(',')] 47 | 48 | // let's create all the csv rows 49 | let data = this.table.getData(); 50 | for (let i=0; i data[i][field]).join(',')); 52 | }; 53 | // create and send file 54 | let csv = csvRows.join('\r\n'); 55 | var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); 56 | var url = URL.createObjectURL(blob); 57 | sendFileToClient(url, this.el.id + ".csv") 58 | } 59 | } 60 | } 61 | 62 | export default TableHook 63 | -------------------------------------------------------------------------------- /assets/js/components/multi_select_variable_hook.js: -------------------------------------------------------------------------------- 1 | function MultiSelectVariableHook() { 2 | this.mounted = function() { 3 | this.state = {open: false, values: []} 4 | 5 | document.getElementById(this.el.id).addEventListener('dropdownOpen', (e) => { 6 | this.state.open = true 7 | this.state.values = e.detail.values 8 | }) 9 | 10 | // if the clicked item exists in the state, it is removed 11 | // otherwise it is added to the state 12 | document.getElementById(this.el.id).addEventListener('valueClicked', (e) => { 13 | const index = this.state.values.indexOf(e.detail.value) 14 | 15 | if (index > -1) { 16 | this.state.values.splice(index, 1) 17 | } else { 18 | this.state.values.push(e.detail.value) 19 | } 20 | }) 21 | 22 | document.getElementById(this.el.id).addEventListener('clickAway', (e) => { 23 | if (this.state.open) { 24 | this.state.open = false 25 | this.pushEventTo("#" + this.el.id, "lmn_variable_updated", {variable: e.detail.var_id, value: this.state.values}) 26 | } 27 | }) 28 | 29 | document.getElementById(this.el.id).addEventListener('itemSearch', (e) => { 30 | const text_to_search = document.getElementById(e.detail.input_id).value.toLowerCase() 31 | const list = document.getElementById(e.detail.list_id) 32 | 33 | for (const list_item of list.children) { 34 | if (list_item.textContent.toLowerCase().includes(text_to_search)) { 35 | list_item.style.display = 'list-item' 36 | } else { 37 | list_item.style.display = 'none' 38 | } 39 | } 40 | }) 41 | 42 | document.getElementById(this.el.id).addEventListener('clearSelection', (e) => { 43 | const list = document.getElementById(e.detail.list_id) 44 | 45 | for (const input of list.getElementsByTagName("input")) { 46 | if (input.getAttribute("type") === "checkbox" && input.checked === true) { 47 | input.click() 48 | } 49 | } 50 | }) 51 | 52 | document.getElementById(this.el.id).addEventListener('selectAll', (e) => { 53 | const list = document.getElementById(e.detail.list_id) 54 | 55 | for (const input of list.getElementsByTagName("input")) { 56 | if (input.getAttribute("type") === "checkbox" && input.checked === false) { 57 | input.click() 58 | } 59 | } 60 | }) 61 | } 62 | } 63 | 64 | export default MultiSelectVariableHook; 65 | -------------------------------------------------------------------------------- /env/test/phoenix.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Test.Router do 2 | @moduledoc false 3 | 4 | use Phoenix.Router 5 | import Phoenix.LiveView.Router 6 | 7 | pipeline :browser do 8 | plug :put_root_layout, html: {Luminous.Test.LayoutView, :root} 9 | plug :fetch_session 10 | end 11 | 12 | scope "/", Luminous do 13 | pipe_through :browser 14 | 15 | live "/test", Test.DashboardLive, :index 16 | end 17 | end 18 | 19 | defmodule Luminous.Test.Endpoint do 20 | @moduledoc false 21 | 22 | use Phoenix.Endpoint, otp_app: :luminous 23 | 24 | socket("/live", Phoenix.LiveView.Socket) 25 | 26 | if code_reloading? do 27 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 28 | plug(Phoenix.LiveReloader) 29 | plug(Phoenix.CodeReloader) 30 | end 31 | 32 | plug(Plug.Session, 33 | store: :cookie, 34 | key: "_live_view_key", 35 | signing_salt: "/VEDsdfsffMnp5" 36 | ) 37 | 38 | plug(Plug.Static, 39 | at: "/", 40 | from: :luminous, 41 | gzip: false, 42 | only: ~w(assets) 43 | ) 44 | 45 | plug(Luminous.Test.Router) 46 | end 47 | 48 | defmodule Luminous.Test.ErrorView do 49 | @moduledoc false 50 | 51 | use Phoenix.View, root: "test/templates" 52 | 53 | def template_not_found(template, _assigns) do 54 | Phoenix.Controller.status_message_from_template(template) 55 | end 56 | end 57 | 58 | defmodule Luminous.Test.LayoutView do 59 | @moduledoc false 60 | 61 | use Phoenix.View, root: "dev" 62 | use Phoenix.Component 63 | 64 | alias Luminous.Test.Router.Helpers, as: Routes 65 | 66 | def render("root.html", assigns) do 67 | ~H""" 68 | 69 | 70 | 71 | 72 | 73 | 77 | 78 | Luminous 79 | 80 | 87 | 88 | 89 | <%= @inner_content %> 90 | 91 | 92 | """ 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/luminous/panel/stat.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Panel.Stat do 2 | require Decimal 3 | 4 | alias Luminous.{Dashboard, Utils} 5 | 6 | use Luminous.Panel 7 | 8 | @impl true 9 | # do we have a single number? 10 | def transform(n, _panel) when is_number(n) or Decimal.is_decimal(n) do 11 | [%{title: nil, value: n, unit: nil}] 12 | end 13 | 14 | # we have a map of values and the relevant attributes potentially 15 | def transform(data, panel) when is_map(data) or is_list(data) do 16 | data 17 | |> Enum.sort_by(fn {label, _} -> if(attr = panel.data_attributes[label], do: attr.order) end) 18 | |> Enum.map(fn {label, value} -> 19 | %{ 20 | title: if(attr = panel.data_attributes[label], do: attr.title), 21 | value: value, 22 | unit: if(attr = panel.data_attributes[label], do: attr.unit) 23 | } 24 | end) 25 | end 26 | 27 | # fallback 28 | def transform(_), do: [] 29 | 30 | @impl true 31 | def reduce(datasets, _panel, _dashboard), do: %{stats: datasets} 32 | 33 | @impl true 34 | def render(assigns) do 35 | ~H""" 36 | <%= case Dashboard.get_data(@dashboard, @panel.id) do %> 37 | <% %{stats: [_ | _] = stats} -> %> 38 |
39 | <%= for {column, index} <- Enum.with_index(stats) do %> 40 |
41 |
42 |

43 | <%= column.title %> 44 |

45 |
46 |
47 | 48 | <%= Utils.print_number(column.value) %> 49 | 50 | 51 | <%= column.unit %> 52 | 53 |
54 |
55 | <% end %> 56 |
57 | <% _ -> %> 58 |
59 |
-
60 |
61 | <% end %> 62 | """ 63 | end 64 | 65 | defp stats_grid_structure(1), do: "grid grid-cols-1 w-full" 66 | defp stats_grid_structure(2), do: "grid grid-cols-2 w-full" 67 | defp stats_grid_structure(3), do: "grid grid-cols-3 w-full" 68 | defp stats_grid_structure(_), do: "grid grid-cols-2 gap-2 w-full" 69 | end 70 | -------------------------------------------------------------------------------- /env/dev/phoenix.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Dev.Router do 2 | @moduledoc false 3 | 4 | use Phoenix.Router 5 | import Phoenix.LiveView.Router 6 | 7 | pipeline :browser do 8 | plug :put_root_layout, html: {Luminous.Dev.LayoutView, :root} 9 | plug :fetch_session 10 | end 11 | 12 | scope "/", Luminous do 13 | pipe_through :browser 14 | 15 | live "/", Dev.DashboardLive, :index 16 | end 17 | end 18 | 19 | defmodule Luminous.Dev.Socket do 20 | @moduledoc false 21 | 22 | use Phoenix.Socket 23 | 24 | @impl true 25 | def connect(_params, socket, _connect_info) do 26 | {:ok, socket} 27 | end 28 | 29 | @impl true 30 | def id(_socket), do: nil 31 | end 32 | 33 | defmodule Luminous.Dev.Endpoint do 34 | @moduledoc false 35 | 36 | use Phoenix.Endpoint, otp_app: :luminous 37 | 38 | socket("/live", Phoenix.LiveView.Socket) 39 | 40 | if code_reloading? do 41 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 42 | plug(Phoenix.LiveReloader) 43 | plug(Phoenix.CodeReloader) 44 | end 45 | 46 | plug(Plug.Session, 47 | store: :cookie, 48 | key: "_live_view_key", 49 | signing_salt: "/VEDsdfsffMnp5" 50 | ) 51 | 52 | plug(Plug.Static, 53 | at: "/", 54 | from: :luminous, 55 | gzip: false, 56 | only: ~w(assets) 57 | ) 58 | 59 | plug(Luminous.Dev.Router) 60 | end 61 | 62 | defmodule Luminous.Dev.ErrorView do 63 | @moduledoc false 64 | 65 | use Phoenix.View, root: "test/templates" 66 | 67 | def template_not_found(template, _assigns) do 68 | Phoenix.Controller.status_message_from_template(template) 69 | end 70 | end 71 | 72 | defmodule Luminous.Dev.LayoutView do 73 | @moduledoc false 74 | 75 | use Phoenix.View, root: "dev" 76 | use Phoenix.Component 77 | 78 | alias Luminous.Dev.Router.Helpers, as: Routes 79 | 80 | def render("root.html", assigns) do 81 | ~H""" 82 | 83 | 84 | 85 | 86 | 87 | 91 | 92 | Luminous 93 | 94 | 101 | 102 | 103 | <%= @inner_content %> 104 | 105 | 106 | """ 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/luminous/dashboard_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Luminous.DashboardTest do 2 | use Luminous.ConnCase, async: true 3 | 4 | alias Luminous.{Dashboard, TimeRange, Variable} 5 | 6 | defmodule Variables do 7 | @behaviour Variable 8 | @impl true 9 | def variable(:foo, _), do: ["a"] 10 | end 11 | 12 | describe "url_params/2" do 13 | test "the url params should include the non-hidden variables" do 14 | dashboard = 15 | Dashboard.define!( 16 | title: "Test", 17 | variables: [ 18 | Variable.define!(hidden: false, id: :foo, label: "Foo", module: Variables) 19 | ] 20 | ) 21 | |> Dashboard.populate(%{}) 22 | 23 | assert [foo: "a"] = Dashboard.url_params(dashboard) 24 | end 25 | 26 | test "the path should exclude the hidden variables" do 27 | dashboard = 28 | Dashboard.define!( 29 | title: "Test", 30 | variables: [ 31 | Variable.define!(hidden: true, id: :foo, label: "Foo", module: Variables) 32 | ] 33 | ) 34 | |> Dashboard.populate(%{}) 35 | 36 | assert Enum.empty?(Dashboard.url_params(dashboard)) 37 | end 38 | 39 | test "when the current time range is nil and no time range is passed in params" do 40 | dashboard = Dashboard.define!(title: "Test") 41 | 42 | assert dashboard |> Dashboard.get_current_time_range() |> is_nil() 43 | 44 | assert Enum.empty?(Dashboard.url_params(dashboard)) 45 | end 46 | 47 | test "the current time range should be preserved if not overriden in params" do 48 | dashboard = 49 | Dashboard.define!( 50 | title: "Test", 51 | variables: [ 52 | Variable.define!(id: :foo, label: "Foo", module: Variables) 53 | ] 54 | ) 55 | |> Dashboard.populate(%{}) 56 | 57 | current = TimeRange.last_n_days(7, dashboard.time_zone) 58 | dashboard = Dashboard.update_current_time_range(dashboard, current) 59 | assert Dashboard.get_current_time_range(dashboard) == current 60 | 61 | %{from: from, to: to} = TimeRange.to_unix(current) 62 | 63 | assert [foo: "bar", from: ^from, to: ^to] = Dashboard.url_params(dashboard, foo: "bar") 64 | end 65 | 66 | test "the current time range should be updated if overriden in params" do 67 | dashboard = Dashboard.define!(title: "Test") 68 | 69 | # let's set a value first 70 | current = TimeRange.last_n_days(7, dashboard.time_zone) 71 | dashboard = Dashboard.update_current_time_range(dashboard, current) 72 | assert Dashboard.get_current_time_range(dashboard) == current 73 | 74 | # let's update the "current" 75 | %{from: nc_from, to: nc_to} = nc = TimeRange.last_month(dashboard.time_zone) 76 | %{from: ncu_from, to: ncu_to} = TimeRange.to_unix(nc) 77 | 78 | assert [from: ^ncu_from, to: ^ncu_to] = 79 | Dashboard.url_params(dashboard, from: nc_from, to: nc_to) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Luminous.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :luminous, 7 | version: "2.6.1", 8 | elixir: ">= 1.12.0", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | aliases: aliases(), 13 | package: package(), 14 | description: description(), 15 | # Docs 16 | name: "luminous", 17 | source_url: "https://github.com/elinverd/luminous", 18 | homepage_url: "https://github.com/elinverd/luminous", 19 | docs: [ 20 | # The main page in the docs 21 | main: "Luminous", 22 | extras: ["README.md", "docs/Applying custom CSS.md"], 23 | # `Elixir.Luminous.Router.Helpers` is an auto-generated module and we cannot 24 | # exclude it with `@moduledoc false`. Therefore, we use the following option. 25 | # https://hexdocs.pm/ex_doc/Mix.Tasks.Docs.html#module-configuration 26 | filter_modules: &filter_modules_for_ex_doc/2 27 | ] 28 | ] 29 | end 30 | 31 | # Run "mix help compile.app" to learn about applications. 32 | def application do 33 | [ 34 | extra_applications: [:logger] 35 | ] 36 | end 37 | 38 | # Specifies which paths to compile per environment. 39 | defp elixirc_paths(:prod), do: ["lib"] 40 | defp elixirc_paths(:dev), do: ["lib", "env"] 41 | defp elixirc_paths(:test), do: ["lib", "env", "test/support"] 42 | 43 | # Run "mix help deps" to learn about dependencies. 44 | defp deps do 45 | [ 46 | # production dependencies 47 | {:decimal, "~> 2.0"}, 48 | {:jason, "~> 1.2"}, 49 | {:nimble_options, "~> 1.0"}, 50 | {:phoenix_live_view, ">= 0.20.2"}, 51 | {:phoenix_view, "~> 2.0"}, 52 | {:tzdata, "~> 1.1"}, 53 | 54 | # dev & test dependencies 55 | {:tailwind, "~> 0.2", only: [:dev, :test]}, 56 | {:esbuild, "~> 0.8", only: [:dev, :test]}, 57 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 58 | {:plug_cowboy, "~> 2.0", only: :dev}, 59 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 60 | {:floki, ">= 0.30.0", only: :test}, 61 | {:ex_doc, "~> 0.27", only: :dev, runtime: false}, 62 | {:assert_html, ">= 0.1.2", only: :test}, 63 | {:assert_eventually, "~> 1.0", only: :test} 64 | ] 65 | end 66 | 67 | defp aliases do 68 | [ 69 | setup: ["deps.get", "cmd --cd assets npm install"], 70 | run: "run --no-halt env/run.exs", 71 | install: ["tailwind.install", "esbuild.install"], 72 | "assets.build": ["tailwind dist", "esbuild dist"] 73 | ] 74 | end 75 | 76 | defp package do 77 | [ 78 | maintainers: ["Kyriakos Kentzoglanakis", "Thanasis Karetsos"], 79 | licenses: ["MIT"], 80 | links: %{github: "https://github.com/elinverd/luminous"}, 81 | files: ~w(dist lib mix.exs package.json README.md) 82 | ] 83 | end 84 | 85 | defp description do 86 | "A dashboard framework for Phoenix Live View" 87 | end 88 | 89 | defp filter_modules_for_ex_doc(module, _metadata) do 90 | module != Luminous.Router.Helpers 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/luminous/panel/table.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Panel.Table do 2 | alias Luminous.Attributes 3 | 4 | use Luminous.Panel 5 | 6 | @impl true 7 | def data_attributes(), 8 | do: [ 9 | title: [type: :string, default: ""], 10 | halign: [type: {:in, [:left, :right, :center]}, default: :left], 11 | table_totals: [type: {:in, [:avg, :sum, nil]}, default: nil], 12 | number_formatting: [ 13 | type: :keyword_list, 14 | keys: [ 15 | thousand_separator: [type: {:or, [:string, :boolean]}, default: false], 16 | decimal_separator: [type: {:or, [:string, :boolean]}, default: false], 17 | precision: [type: {:or, [:non_neg_integer, :boolean]}, default: false] 18 | ] 19 | ] 20 | ] 21 | 22 | @impl true 23 | def panel_attributes(), 24 | do: [ 25 | hook: [type: :string, default: "TableHook"], 26 | page_size: [type: :pos_integer, default: 10] 27 | ] 28 | 29 | @impl true 30 | def transform(rows, panel) do 31 | col_defs = 32 | rows 33 | |> extract_labels() 34 | |> Enum.map(fn label -> 35 | attrs = 36 | Map.get(panel.data_attributes, label) || 37 | Map.get(panel.data_attributes, to_string(label)) || 38 | Attributes.parse!( 39 | [title: to_string(label)], 40 | data_attributes() ++ Attributes.Schema.data() 41 | ) 42 | 43 | {label, attrs} 44 | end) 45 | |> Enum.sort_by(fn {_, attrs} -> attrs.order end) 46 | |> Enum.map(fn {label, attrs} -> 47 | %{ 48 | field: label, 49 | title: attrs.title, 50 | hozAlign: attrs.halign, 51 | headerHozAlign: attrs.halign, 52 | formatter: "textarea" 53 | } 54 | |> add_table_totals_option(attrs) 55 | |> add_number_formatting_option(attrs) 56 | end) 57 | 58 | %{rows: rows, columns: col_defs} 59 | end 60 | 61 | @impl true 62 | def reduce(datasets, panel, _dashboard) do 63 | columns = Enum.flat_map(datasets, &Map.get(&1, :columns)) 64 | 65 | datasets = 66 | datasets 67 | |> Enum.map(&Map.get(&1, :rows)) 68 | |> Enum.zip() 69 | |> Enum.map(&Tuple.to_list/1) 70 | |> Enum.map(fn 71 | [m | _] = maps when is_map(m) -> Enum.reduce(maps, %{}, &Map.merge(&2, &1)) 72 | [l | _] = lists when is_list(l) -> lists |> Enum.concat() |> Map.new() 73 | end) 74 | 75 | %{rows: datasets, columns: columns, attributes: %{page_size: panel.page_size}} 76 | end 77 | 78 | @impl true 79 | def actions(), do: [%{label: "Download CSV", event: "download:csv"}] 80 | 81 | @impl true 82 | def render(assigns) do 83 | ~H""" 84 |
85 |
86 |
87 | """ 88 | end 89 | 90 | defp extract_labels(rows) when is_list(rows) do 91 | rows 92 | |> Enum.flat_map(fn 93 | m when is_map(m) -> Map.keys(m) 94 | l when is_list(l) -> Enum.map(l, &elem(&1, 0)) 95 | end) 96 | |> Enum.uniq() 97 | end 98 | 99 | defp add_table_totals_option(col_params, attr) do 100 | if is_nil(attr.table_totals), 101 | do: col_params, 102 | else: Map.put(col_params, :bottomCalc, attr.table_totals) 103 | end 104 | 105 | defp add_number_formatting_option(col_params, %{number_formatting: nf}) do 106 | formatterParams = %{ 107 | thousand: Keyword.get(nf, :thousand_separator), 108 | decimal: Keyword.get(nf, :decimal_separator), 109 | precision: Keyword.get(nf, :precision) 110 | } 111 | 112 | col_params 113 | |> Map.put(:formatter, "money") 114 | |> Map.put(:formatterParams, formatterParams) 115 | |> Map.put(:bottomCalcFormatter, "money") 116 | |> Map.put(:bottomCalcFormatterParams, formatterParams) 117 | end 118 | 119 | defp add_number_formatting_option(col_params, _), do: col_params 120 | end 121 | -------------------------------------------------------------------------------- /lib/luminous/dashboard.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Dashboard do 2 | @moduledoc """ 3 | A dashboard is the highest-level luminous component and contains all 4 | the necessary dashboard attributes such as the panels, variables and 5 | the time range selector. It also stores the state of the panels 6 | (query results). The dashboard is initialized in `Luminous.Live` 7 | and populated at runtime using `populate/2`. 8 | """ 9 | 10 | alias Luminous.{Attributes, TimeRange, TimeRangeSelector, Variable} 11 | 12 | @type t :: map() 13 | @type url_params :: keyword() 14 | 15 | defmacro __using__(_opts) do 16 | quote do 17 | @behaviour Luminous.Dashboard 18 | end 19 | end 20 | 21 | @callback dashboard_path(Phoenix.LiveView.Socket.t(), url_params()) :: binary() 22 | 23 | @attributes [ 24 | title: [type: :string, required: true], 25 | panels: [type: {:list, :map}, default: []], 26 | variables: [type: {:list, :map}, default: []], 27 | time_range_selector: [type: {:struct, TimeRangeSelector}, default: %TimeRangeSelector{}], 28 | time_zone: [type: :string, default: TimeRange.default_time_zone()] 29 | ] 30 | 31 | @doc """ 32 | Parse the supplied parameters and return the dashboard map structure. 33 | The following options are supported: 34 | #{NimbleOptions.docs(@attributes)} 35 | """ 36 | @spec define!(keyword()) :: t() 37 | def define!(opts), do: Attributes.parse!(opts, @attributes) 38 | 39 | @doc """ 40 | Populate the dashboard's dynamic properties (e.g. variable values, time range etc.) at runtime. 41 | """ 42 | @spec populate(t(), map()) :: t() 43 | def populate(dashboard, socket_assigns) do 44 | dashboard 45 | |> Map.put(:data, %{}) 46 | |> Map.put(:variables, Enum.map(dashboard.variables, &Variable.populate(&1, socket_assigns))) 47 | |> Map.put( 48 | :time_range_selector, 49 | TimeRangeSelector.new(dashboard.time_range_selector) 50 | ) 51 | end 52 | 53 | @doc """ 54 | Returns the dashboard's URL parameters as a keyword list based on the supplied params 55 | and the dashboard's current state 56 | """ 57 | @spec url_params(t(), Keyword.t()) :: url_params() 58 | def url_params(dashboard, params \\ []) do 59 | var_params = 60 | dashboard.variables 61 | |> Enum.reject(& &1.hidden) 62 | |> Enum.map(fn var -> 63 | {var.id, Keyword.get(params, var.id, Variable.extract_value(var.current))} 64 | end) 65 | 66 | time_range_params = 67 | params 68 | |> Keyword.get(:from) 69 | |> TimeRange.new(Keyword.get(params, :to)) 70 | |> TimeRange.to_unix(get_current_time_range(dashboard)) 71 | |> Keyword.new() 72 | 73 | Keyword.merge(var_params, time_range_params) 74 | end 75 | 76 | @doc """ 77 | Update the dashboard's variables 78 | """ 79 | @spec update_variables(t(), [Variable.t()]) :: t() 80 | def update_variables(dashboard, new_variables) do 81 | %{dashboard | variables: new_variables} 82 | end 83 | 84 | @doc """ 85 | Update the dashboard's panel data 86 | """ 87 | @spec update_data(t(), atom(), any()) :: t() 88 | def update_data(dashboard, panel_id, data), do: put_in(dashboard, [:data, panel_id], data) 89 | 90 | @doc """ 91 | return the panel data for the specified panel 92 | """ 93 | @spec get_data(t(), atom()) :: any() 94 | def get_data(dashboard, panel_id), do: get_in(dashboard, [:data, panel_id]) 95 | 96 | @doc """ 97 | Update the dashboard's current time range 98 | """ 99 | @spec update_current_time_range(t(), TimeRange.t()) :: t() 100 | def update_current_time_range(dashboard, time_range) do 101 | %{ 102 | dashboard 103 | | time_range_selector: 104 | TimeRangeSelector.update_current(dashboard.time_range_selector, time_range) 105 | } 106 | end 107 | 108 | @doc """ 109 | Get the dashboard's current time range 110 | """ 111 | @spec get_current_time_range(t()) :: TimeRange.t() | nil 112 | def get_current_time_range(dashboard), 113 | do: TimeRangeSelector.get_current(dashboard.time_range_selector) 114 | end 115 | -------------------------------------------------------------------------------- /docs/Applying custom CSS.md: -------------------------------------------------------------------------------- 1 | `luminous` provides CSS classes that can be overriden, so that the components match the look and feel of the consumer application. Those classes belong to three `luminous` components: 2 | - Variables 3 | - Time range selector 4 | - Panel dropdown 5 | 6 | ### Variables 7 | 8 | #### `lmn-variable-button` 9 | Define the size, shape, background color, on hover behavior, etc. of the variable buttons. 10 | 11 | #### `lmn-variable-button-label` 12 | Define the font, text size, weight, alignment of the variable button's label. 13 | 14 | #### `lmn-variable-button-label-prefix` 15 | Define the font, text size, weight, alignment of the variable button's label prefix. 16 | 17 | #### `lmn-variable-button-icon` 18 | Define the size and alignment of the variable button's chevron icon. 19 | 20 | #### `lmn-variable-dropdown` 21 | Define the size, background color, rouding, shadows, etc. of the variable dropdown menu. 22 | 23 | #### `lmn-variable-dropdown-item-container` 24 | Define the text alignment, rounding, on hover behaviour, etc. of each item in the variable dropdown menu. 25 | 26 | #### `lmn-variable-dropdown-item-content` 27 | Define the size, padding, etc. of the content of each item in the variable dropdown menu. 28 | 29 | ### Time range picker 30 | 31 | #### `lmn-time-range-compound` 32 | Define the structure of the time range component. This includes the time range picker button, the pressets button and the time zone component. 33 | 34 | #### `lmn-time-range-selector` 35 | Define the structure, the size and the shape of the time range selector component. This includes the button that opens the custom time range selector and the button that opens the preset menu dropdown. 36 | 37 | #### `lmn-custom-time-range-input` 38 | Define the size, background color, text size, on hover behavior, etc. of the button that opens the custom date range picker dropdown. 39 | 40 | #### `lmn-time-range-presets-button` 41 | Define the size, background color, on hover behavior, etc. of the button that opens the time range presets dropdown. 42 | 43 | #### `lmn-time-range-presets-button-icon` 44 | Define the size and spacing of the icon in the button that opens the time range presets dropdown. 45 | 46 | #### `lmn-time-range-presets-dropdown` 47 | Define the size, background color, rouding, shadows, etc. of the time range presets dropdown menu. 48 | 49 | #### `lmn-time-range-presets-dropdown-item-container` 50 | Define the text alignment, rounding, on hover behaviour, etc. of each item in the time range presets dropdown menu. 51 | 52 | #### `lmn-time-range-presets-dropdown-item-content` 53 | Define the size, padding, etc. of the content of each item in the time range presets dropdown menu. 54 | 55 | #### `lmn-time-zone` 56 | Define the background color, rounding, text size, etc. of the time zone label 57 | 58 | ### Panel dropdown 59 | 60 | #### `lmn-panel-actions-dropdown` 61 | Define the size, background color, rouding, shadows, etc. of the panel actions dropdown menu. 62 | 63 | #### `lmn-panel-actions-dropdown-item-container` 64 | Define the text alignment, rounding, on hover behaviour, etc. of each item in the panel actions dropdown menu. 65 | 66 | #### `lmn-panel-actions-dropdown-item-content` 67 | Define the size, padding, etc. of the content of each item in the panel actions dropdown menu. 68 | 69 | ### Dropdown transition 70 | 71 | #### `lmn-dropdown-transition-enter` 72 | Define the animation of all dropdowns when they open up. 73 | 74 | #### `lmn-dropdown-transition-start` 75 | Define the initial state of the animation of all dropdowns when they open up. 76 | 77 | #### `lmn-dropdown-transition-end` 78 | Define the final state of the animation of all dropdowns when they open up. 79 | 80 | ### Calendar dropdown 81 | For the calendar dropdown, by default we use the [`airbnb`](https://flatpickr.js.org/themes/) theme provided by `flatpickr`. 82 | To change this theme, you have to import the desired theme **after** importing `luminous` to your `app.css` file like so: 83 | 84 | ```CSS 85 | @import 'luminous/dist/luminous'; 86 | @import '../node_modules/flatpickr/dist/themes/material_blue.css'; 87 | ``` 88 | 89 | The path that the `flatpickr` theme resides, depends on your project's directory structure. -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | @import '../node_modules/flatpickr/dist/themes/airbnb.css'; 3 | 4 | @import "tailwindcss/base"; 5 | @import "tailwindcss/components"; 6 | @import "tailwindcss/utilities"; 7 | 8 | @import "../node_modules/tabulator-tables/dist/css/tabulator_modern.min.css"; 9 | 10 | @layer components { 11 | 12 | /* Time range classes */ 13 | .lmn-time-range-compound { 14 | @apply flex items-center cursor-default space-x-4 15 | } 16 | 17 | .lmn-time-zone { 18 | @apply px-2 rounded-full cursor-default text-sm font-bold text-white bg-slate-800 19 | } 20 | 21 | .lmn-time-range-selector { 22 | @apply flex items-center rounded-lg border border-slate-800 h-8 cursor-pointer 23 | } 24 | 25 | .lmn-custom-time-range-input { 26 | @apply h-8 w-52 rounded-l-lg rounded-r-none cursor-pointer bg-transparent text-sm text-center capitalize font-bold hover:text-white hover:bg-slate-800 focus:outline-none active:bg-slate-900 27 | } 28 | 29 | .lmn-time-range-presets-button { 30 | @apply h-8 rounded-l-none rounded-r-lg cursor-pointer bg-transparent hover:bg-slate-800 hover:text-white focus:outline-none active:bg-slate-900 31 | } 32 | 33 | .lmn-time-range-presets-button-icon { 34 | @apply mx-1 h-5 w-5 35 | } 36 | 37 | .lmn-time-range-presets-dropdown { 38 | @apply flex flex-col p-2 min-w-max z-50 bg-white rounded-lg shadow-lg 39 | } 40 | 41 | .lmn-time-range-presets-dropdown-item-container { 42 | @apply text-left rounded-lg cursor-pointer hover:bg-slate-200 43 | } 44 | 45 | .lmn-time-range-presets-dropdown-item-content { 46 | @apply px-4 py-3 47 | } 48 | 49 | /* Variable classes */ 50 | .lmn-variable-button { 51 | @apply h-8 px-3 py-2.5 flex items-center gap-2 rounded-lg border border-slate-800 hover:text-white hover:bg-slate-800 focus:outline-none transition duration-200 active:bg-slate-900 active:scale-95 52 | } 53 | 54 | .lmn-variable-button-label { 55 | @apply text-sm font-bold capitalize text-left text-ellipsis whitespace-nowrap overflow-hidden max-w-xs 56 | } 57 | 58 | .lmn-variable-button-label-prefix { 59 | @apply text-xs 60 | } 61 | 62 | .lmn-variable-button-icon { 63 | @apply h-5 w-5 64 | } 65 | 66 | .lmn-variable-dropdown { 67 | @apply flex flex-col p-2 z-50 max-h-96 min-w-max overflow-auto bg-white rounded-lg shadow-lg 68 | } 69 | 70 | .lmn-variable-dropdown-item-container { 71 | @apply text-left rounded-lg cursor-pointer hover:bg-slate-200 72 | } 73 | 74 | .lmn-multi-variable-dropdown-searchbox { 75 | @apply flex items-center h-10 rounded-lg border border-gray-300 focus-within:ring-1 focus-within:ring-blue-600 focus-within:border-transparent 76 | } 77 | 78 | .lmn-multi-variable-dropdown-search-input { 79 | @apply h-8 px-2 outline-0 grow 80 | } 81 | 82 | .lmn-multi-variable-dropdown-search-icon { 83 | @apply h-5 w-5 mr-1 84 | } 85 | 86 | 87 | .lmn-multi-variable-dropdown-container { 88 | @apply absolute hidden p-2 z-50 bg-white rounded-lg shadow-lg 89 | } 90 | 91 | .lmn-multi-variable-dropdown-item-container { 92 | @apply text-left rounded-lg cursor-pointer hover:bg-slate-200 px-4 py-3 flex items-center gap-2 93 | } 94 | 95 | .lmn-multi-variable-dropdown-checkbox { 96 | @apply cursor-pointer text-slate-800 rounded focus:ring-0 focus:ring-offset-0 97 | } 98 | 99 | .lmn-variable-dropdown-item-content { 100 | @apply px-4 py-3 101 | } 102 | 103 | /* Panel actions (download image/csv) */ 104 | .lmn-panel-actions-dropdown { 105 | @apply flex flex-col p-2 min-w-max z-50 bg-white rounded-lg shadow-lg text-sm 106 | } 107 | 108 | .lmn-panel-actions-dropdown-item-container { 109 | @apply text-left rounded-lg cursor-pointer hover:bg-slate-200 110 | } 111 | 112 | .lmn-panel-actions-dropdown-item-content { 113 | @apply px-4 py-3 114 | } 115 | 116 | /* dropdown transition classes */ 117 | .lmn-dropdown-transition-enter { 118 | @apply transition ease-out duration-100 119 | } 120 | 121 | .lmn-dropdown-transition-start { 122 | @apply opacity-0 scale-90 123 | } 124 | 125 | .lmn-dropdown-transition-end { 126 | @apply opacity-100 scale-100 127 | } 128 | 129 | .lmn-tooltip { 130 | @apply absolute invisible md:w-64 rounded-md p-2 text-sm bg-gray-800 text-gray-100 opacity-0 transition-opacity; 131 | } 132 | 133 | .lmn-has-tooltip:hover .lmn-tooltip { 134 | @apply visible z-50 opacity-100 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /test/luminous/variable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Luminous.VariableTest do 2 | use ExUnit.Case 3 | 4 | alias Luminous.Variable 5 | 6 | defmodule Variables do 7 | @behaviour Variable 8 | def variable(:foo, _), do: ["a", "b"] 9 | end 10 | 11 | describe "populate/2" do 12 | test "the default value of a single variable is the first list element" do 13 | assert [id: :foo, label: "Foo", module: Variables, type: :single] 14 | |> Variable.define!() 15 | |> Variable.populate(%{}) 16 | |> Variable.get_current() 17 | |> Variable.extract_value() == "a" 18 | end 19 | 20 | test "the default value of a multi variable is the selection of all list elements by default" do 21 | assert [id: :foo, label: "Foo", module: Variables, type: :multi] 22 | |> Variable.define!() 23 | |> Variable.populate(%{}) 24 | |> Variable.get_current() 25 | |> Variable.extract_value() == ["a", "b"] 26 | end 27 | 28 | test "the default value of a multi variable is an empty list when specified as such" do 29 | assert [id: :foo, label: "Foo", module: Variables, type: :multi, multi_default: :none] 30 | |> Variable.define!() 31 | |> Variable.populate(%{}) 32 | |> Variable.get_current() 33 | |> Variable.extract_value() 34 | |> Enum.empty?() 35 | end 36 | end 37 | 38 | describe "update_current" do 39 | test "should update the current value of a single variable" do 40 | assert [id: :foo, label: "Foo", module: Variables, type: :single] 41 | |> Variable.define!() 42 | |> Variable.populate(%{}) 43 | |> Variable.update_current("b", %{}) 44 | |> Variable.get_current() 45 | |> Variable.extract_value() == "b" 46 | end 47 | 48 | test "should not update the current value of a single variable if the new value is not legit" do 49 | assert [id: :foo, label: "Foo", module: Variables, type: :single] 50 | |> Variable.define!() 51 | |> Variable.populate(%{}) 52 | |> Variable.update_current("invalid value", %{}) 53 | |> Variable.get_current() 54 | |> Variable.extract_value() == "a" 55 | end 56 | 57 | test "should not update the current value of a multi variable if the new value is not legit" do 58 | assert [id: :foo, label: "Foo", module: Variables, type: :multi] 59 | |> Variable.define!() 60 | |> Variable.populate(%{}) 61 | |> Variable.update_current(["invalid value 1", "invalid value 2"], %{}) 62 | |> Variable.get_current() 63 | |> Variable.extract_value() == ["a", "b"] 64 | end 65 | 66 | test "should update the current value of a hidden single variable regardless of the new value" do 67 | assert [id: :foo, label: "Foo", module: Variables, type: :single, hidden: true] 68 | |> Variable.define!() 69 | |> Variable.populate(%{}) 70 | |> Variable.update_current("arbitrary value 1", %{}) 71 | |> Variable.get_current() 72 | |> Variable.extract_value() == "arbitrary value 1" 73 | end 74 | 75 | test "should update the current value of a hidden multi variable regardless of the new value" do 76 | assert [id: :foo, label: "Foo", module: Variables, type: :multi, hidden: true] 77 | |> Variable.define!() 78 | |> Variable.populate(%{}) 79 | |> Variable.update_current(["arbitrary value 1", "arbitrary value 2"], %{}) 80 | |> Variable.get_current() 81 | |> Variable.extract_value() == ["arbitrary value 1", "arbitrary value 2"] 82 | end 83 | 84 | test "should handle the special 'none' value in the case of a multi variable" do 85 | assert [id: :foo, label: "Foo", module: Variables, type: :multi] 86 | |> Variable.define!() 87 | |> Variable.populate(%{}) 88 | |> Variable.update_current("none", %{}) 89 | |> Variable.get_current() 90 | |> Variable.extract_value() == [] 91 | end 92 | 93 | test "should return the default value when the new value is nil" do 94 | var = 95 | [id: :foo, label: "Foo", module: Variables, type: :single] 96 | |> Variable.define!() 97 | |> Variable.populate(%{}) 98 | |> Variable.update_current("b", %{}) 99 | 100 | assert var 101 | |> Variable.update_current(nil, %{}) 102 | |> Variable.get_current() 103 | |> Variable.extract_value() == "a" 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/luminous/panel.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Panel do 2 | @moduledoc """ 3 | A panel represents a single visual element (chart) in a dashboard 4 | that can contain many queries. A panel is "refreshed" when the live 5 | view first loads, as well as when a variable or the time range are 6 | updated. The panel's data (as returned by the queries) are stored in 7 | `Luminous.Dashboard`. 8 | 9 | The module defines a behaviour that must be implemented by concrete 10 | panels either inside Luminous (e.g. `Luminous.Panel.Chart`, 11 | `Luminous.Panel.Stat` etc.) or on the client side. 12 | 13 | When a Panel is refreshed (`refresh/3`), the execution flow is as follows: 14 | 15 | - for each query: 16 | - execute the query (`Luminous.Query.execute()`) 17 | - `transform/2` the query result 18 | - `reduce/3` (aggregate) the transformed query results 19 | """ 20 | 21 | use Phoenix.Component 22 | 23 | alias Luminous.{Attributes, Dashboard, Query, TimeRange, Variable} 24 | 25 | defmacro __using__(_opts) do 26 | quote do 27 | use Phoenix.Component 28 | 29 | @behaviour Luminous.Panel 30 | end 31 | end 32 | 33 | @type t :: map() 34 | 35 | @doc """ 36 | transform a query result to view data acc. to the panel type 37 | """ 38 | @callback transform(Query.result(), t()) :: any() 39 | 40 | @doc """ 41 | aggregate all transformed results to a single map 42 | that will be sent for visualization 43 | """ 44 | @callback reduce(list(), t(), Dashboard.t()) :: map() 45 | 46 | @doc """ 47 | The phoenix function component that renders the panel. The panel's 48 | title, description tooltip, contextual menu etc. are rendered 49 | elsewhere. See `Luminous.Components.panel/1` for a description of the available assigns. 50 | """ 51 | @callback render(map()) :: Phoenix.LiveView.Rendered.t() 52 | 53 | @doc """ 54 | A list of the available panel actions that will be transmitted as events using JS.dispatch 55 | using the format "panel:${panel_id}:${event}" 56 | The `label` will be shown in the dropdown. 57 | This is an optional callback -- if undefined (or if it returns []), then the dropdown is not rendered 58 | """ 59 | @callback actions() :: [%{event: binary(), label: binary()}] 60 | 61 | @doc """ 62 | Define custom attributes specific to the concrete panel type 63 | These will be used to parse, validate and populate the client's input 64 | """ 65 | @callback panel_attributes() :: Attributes.Schema.t() 66 | 67 | @doc """ 68 | Define the panel type's supported data attributes 69 | These will be used to parse, validate and populate the client's input 70 | """ 71 | @callback data_attributes() :: Attributes.Schema.t() 72 | 73 | @optional_callbacks panel_attributes: 0, data_attributes: 0, actions: 0 74 | 75 | @doc """ 76 | 77 | Initialize a panel. Verifies all supplied options both generic 78 | and the concrete panel's attributes. Will raise if 79 | the validation fails. The supported generic attributes are: 80 | 81 | #{NimbleOptions.docs(Attributes.Schema.panel())} 82 | 83 | """ 84 | @spec define!(Keyword.t()) :: t() 85 | def define!(opts \\ []) do 86 | mod = fetch_panel_module!(opts) 87 | schema = Keyword.merge(Attributes.Schema.panel(), get_attributes!(mod, :panel_attributes)) 88 | 89 | case Attributes.parse(opts, schema) do 90 | {:ok, panel} -> 91 | Map.put(panel, :data_attributes, validate_data_attributes!(panel)) 92 | 93 | {:error, message} -> 94 | raise message 95 | end 96 | end 97 | 98 | @doc """ 99 | Refresh all panel queries. 100 | """ 101 | @spec refresh(t(), [Variable.t()], TimeRange.t()) :: [any()] 102 | def refresh(panel, variables, time_range) do 103 | Enum.reduce(panel.queries, [], fn query, results -> 104 | # perform query 105 | result = Query.execute(query, time_range, variables) 106 | # transform result and add to results 107 | case apply(panel.type, :transform, [result, panel]) do 108 | data when is_list(data) -> results ++ data 109 | data -> [data | results] 110 | end 111 | end) 112 | end 113 | 114 | defp fetch_panel_module!(opts) do 115 | case Keyword.fetch(opts, :type) do 116 | {:ok, mod} -> mod 117 | :error -> raise "Please specify the :type argument with the desired panel module" 118 | end 119 | end 120 | 121 | defp validate_data_attributes!(panel) do 122 | schema = 123 | Keyword.merge(Attributes.Schema.data(), get_attributes!(panel.type, :data_attributes)) 124 | 125 | panel.data_attributes 126 | |> Enum.map(fn {label, attrs} -> {label, Attributes.parse!(attrs, schema)} end) 127 | |> Map.new() 128 | end 129 | 130 | defp get_attributes!(panel_type, attribute_type) do 131 | # first, we need to ensure that the module `panel_type` is loaded 132 | # because if it isn't then function_exported?/3 will return false 133 | # even if the module is defined 134 | panel_type = 135 | case Code.ensure_loaded(panel_type) do 136 | {:module, mod} -> mod 137 | {:error, reason} -> raise "failed to load module #{panel_type}: #{reason}" 138 | end 139 | 140 | if function_exported?(panel_type, attribute_type, 0) do 141 | apply(panel_type, attribute_type, []) 142 | else 143 | [] 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /test/luminous/time_range_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Luminous.TimeRangeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Luminous.TimeRange 5 | 6 | @tz "Europe/Athens" 7 | 8 | describe "Preset functions" do 9 | test "today/1 should return an inclusive right limit" do 10 | %{from: from, to: to} = TimeRange.today(@tz) 11 | assert DateTime.to_date(from) == DateTime.to_date(to) 12 | end 13 | 14 | test "yesterday/1 should return an inclusive right limit" do 15 | %{from: from, to: to} = TimeRange.yesterday(@tz) 16 | assert DateTime.to_date(from) == DateTime.to_date(to) 17 | end 18 | 19 | test "tomorrow/1 should return an inclusive right limit" do 20 | %{from: from, to: to} = TimeRange.tomorrow(@tz) 21 | assert DateTime.to_date(from) == DateTime.to_date(to) 22 | end 23 | 24 | test "last_n_days/2 should return an inclusive right limit" do 25 | %{from: from, to: to} = TimeRange.last_n_days(7, @tz) 26 | assert Date.diff(DateTime.to_date(to), DateTime.to_date(from)) == 6 27 | end 28 | 29 | test "this_week/1 should return an inclusive right limit" do 30 | %{from: from, to: to} = TimeRange.this_week(@tz) 31 | assert Date.diff(DateTime.to_date(to), DateTime.to_date(from)) == 6 32 | end 33 | 34 | test "last_week/1 should return an inclusive right limit" do 35 | %{from: from, to: to} = TimeRange.last_week(@tz) 36 | assert Date.diff(DateTime.to_date(to), DateTime.to_date(from)) == 6 37 | end 38 | 39 | test "this_month/1 should return an inclusive right limit" do 40 | days_in_current_month = 41 | @tz 42 | |> DateTime.now!() 43 | |> DateTime.to_date() 44 | |> Date.days_in_month() 45 | 46 | %{from: from, to: to} = TimeRange.this_month(@tz) 47 | days = Date.diff(DateTime.to_date(to), DateTime.to_date(from)) 48 | assert days == days_in_current_month - 1 49 | end 50 | 51 | test "last_month/1 should return an inclusive right limit" do 52 | days_in_previous_month = 53 | @tz 54 | |> DateTime.now!() 55 | |> DateTime.to_date() 56 | |> Date.beginning_of_month() 57 | |> Date.add(-1) 58 | |> Date.days_in_month() 59 | 60 | %{from: from, to: to} = TimeRange.last_month(@tz) 61 | assert Date.diff(DateTime.to_date(to), DateTime.to_date(from)) == days_in_previous_month - 1 62 | end 63 | end 64 | 65 | describe "DST changes [winter -> summer]" do 66 | test "today" do 67 | now = DateTime.new!(~D[2023-03-26], ~T[01:00:00], @tz) 68 | 69 | expected = %TimeRange{ 70 | from: DateTime.new!(~D[2023-03-26], ~T[00:00:00], @tz), 71 | to: DateTime.new!(~D[2023-03-26], ~T[23:59:59], @tz) 72 | } 73 | 74 | assert ^expected = TimeRange.today(@tz, now) 75 | end 76 | 77 | test "yesterday" do 78 | now = DateTime.new!(~D[2023-03-27], ~T[01:00:00], @tz) 79 | 80 | expected = %TimeRange{ 81 | from: DateTime.new!(~D[2023-03-26], ~T[00:00:00], @tz), 82 | to: DateTime.new!(~D[2023-03-26], ~T[23:59:59], @tz) 83 | } 84 | 85 | assert ^expected = TimeRange.yesterday(@tz, now) 86 | end 87 | 88 | test "tomorrow" do 89 | now = DateTime.new!(~D[2023-03-25], ~T[01:00:00], @tz) 90 | 91 | expected = %TimeRange{ 92 | from: DateTime.new!(~D[2023-03-26], ~T[00:00:00], @tz), 93 | to: DateTime.new!(~D[2023-03-26], ~T[23:59:59], @tz) 94 | } 95 | 96 | assert ^expected = TimeRange.tomorrow(@tz, now) 97 | end 98 | 99 | test "last_n_days" do 100 | now = DateTime.new!(~D[2023-03-27], ~T[01:00:00], @tz) 101 | 102 | expected = %TimeRange{ 103 | from: DateTime.new!(~D[2023-03-26], ~T[00:00:00], @tz), 104 | to: DateTime.new!(~D[2023-03-27], ~T[23:59:59], @tz) 105 | } 106 | 107 | assert ^expected = TimeRange.last_n_days(2, @tz, now) 108 | end 109 | 110 | test "this_week" do 111 | now = DateTime.new!(~D[2023-03-23], ~T[01:00:00], @tz) 112 | 113 | expected = %TimeRange{ 114 | from: DateTime.new!(~D[2023-03-20], ~T[00:00:00], @tz), 115 | to: DateTime.new!(~D[2023-03-26], ~T[23:59:59], @tz) 116 | } 117 | 118 | assert ^expected = TimeRange.this_week(@tz, now) 119 | end 120 | 121 | test "last_week" do 122 | now = DateTime.new!(~D[2023-03-28], ~T[01:00:00], @tz) 123 | 124 | expected = %TimeRange{ 125 | from: DateTime.new!(~D[2023-03-20], ~T[00:00:00], @tz), 126 | to: DateTime.new!(~D[2023-03-26], ~T[23:59:59], @tz) 127 | } 128 | 129 | assert ^expected = TimeRange.last_week(@tz, now) 130 | end 131 | 132 | test "this_month" do 133 | now = DateTime.new!(~D[2023-03-28], ~T[01:00:00], @tz) 134 | 135 | expected = %TimeRange{ 136 | from: DateTime.new!(~D[2023-03-01], ~T[00:00:00], @tz), 137 | to: DateTime.new!(~D[2023-03-31], ~T[23:59:59], @tz) 138 | } 139 | 140 | assert ^expected = TimeRange.this_month(@tz, now) 141 | end 142 | 143 | test "last_month" do 144 | now = DateTime.new!(~D[2023-04-11], ~T[01:00:00], @tz) 145 | 146 | expected = %TimeRange{ 147 | from: DateTime.new!(~D[2023-03-01], ~T[00:00:00], @tz), 148 | to: DateTime.new!(~D[2023-03-31], ~T[23:59:59], @tz) 149 | } 150 | 151 | assert ^expected = TimeRange.last_month(@tz, now) 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/luminous/live.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Live do 2 | @moduledoc """ 3 | This module defines a macro that contains the functionality of a 4 | dashboard LiveView. It needs to be used (`use Luminous.Live`) 5 | inside a client application module with the appropriate options (as 6 | specified in `Luminous.Dashboard.define!/1`). 7 | 8 | More details and examples in the project README. 9 | """ 10 | 11 | defmacro __using__(opts) do 12 | quote do 13 | use Phoenix.LiveView 14 | use Luminous.Dashboard 15 | 16 | defp __init__(), do: Luminous.Dashboard.define!(unquote(opts)) 17 | 18 | @impl true 19 | def mount(_, _, socket) do 20 | dashboard = Luminous.Dashboard.populate(__init__(), socket.assigns) 21 | 22 | {:ok, assign(socket, dashboard: dashboard)} 23 | end 24 | 25 | @impl true 26 | def handle_params(params, _uri, socket) do 27 | socket = 28 | if connected?(socket) do 29 | # get time from params 30 | time_range = lmn_get_time_range(socket.assigns.dashboard, params) 31 | 32 | # get variable values from params 33 | variables = 34 | Enum.map( 35 | socket.assigns.dashboard.variables, 36 | &Luminous.Variable.update_current(&1, params["#{&1.id}"], socket.assigns) 37 | ) 38 | 39 | # update dashboard 40 | dashboard = 41 | socket.assigns.dashboard 42 | |> Luminous.Dashboard.update_variables(variables) 43 | |> Luminous.Dashboard.update_current_time_range(time_range) 44 | 45 | # refresh all panel data 46 | socket = 47 | Enum.reduce(dashboard.panels, socket, fn panel, sock -> 48 | Task.async(fn -> 49 | {panel, Luminous.Panel.refresh(panel, variables, time_range)} 50 | end) 51 | 52 | lmn_push_panel_load_event(sock, :start, panel.id) 53 | end) 54 | 55 | socket 56 | |> assign(dashboard: dashboard) 57 | |> lmn_push_time_range_event(Luminous.TimeRangeSelector.id(), time_range) 58 | else 59 | socket 60 | end 61 | 62 | {:noreply, socket} 63 | end 64 | 65 | @impl true 66 | def handle_event( 67 | "lmn_time_range_change", 68 | %{"from" => from_iso, "to" => to_iso}, 69 | %{assigns: %{dashboard: dashboard}} = socket 70 | ) do 71 | time_range = 72 | Luminous.TimeRange.from_iso(from_iso, to_iso) 73 | |> Luminous.TimeRange.shift_zone!(dashboard.time_zone) 74 | 75 | url_params = 76 | Luminous.Dashboard.url_params(dashboard, from: time_range.from, to: time_range.to) 77 | 78 | {:noreply, push_patch(socket, to: dashboard_path(socket, url_params))} 79 | end 80 | 81 | def handle_event( 82 | "lmn_preset_time_range_selected", 83 | %{"preset" => preset}, 84 | %{assigns: %{dashboard: dashboard}} = socket 85 | ) do 86 | time_range = 87 | case Luminous.TimeRangeSelector.get_time_range_for(preset, dashboard.time_zone) do 88 | nil -> lmn_get_default_time_range(dashboard) 89 | time_range -> time_range 90 | end 91 | 92 | url_params = 93 | Luminous.Dashboard.url_params(dashboard, from: time_range.from, to: time_range.to) 94 | 95 | {:noreply, push_patch(socket, to: dashboard_path(socket, url_params))} 96 | end 97 | 98 | def handle_event( 99 | "lmn_variable_updated", 100 | %{"variable" => variable, "value" => value}, 101 | %{assigns: %{dashboard: dashboard}} = socket 102 | ) do 103 | value = if value == [], do: "none", else: value 104 | 105 | url_params = 106 | Luminous.Dashboard.url_params(dashboard, [ 107 | {String.to_existing_atom(variable), value} 108 | ]) 109 | 110 | {:noreply, push_patch(socket, to: dashboard_path(socket, url_params))} 111 | end 112 | 113 | @impl true 114 | def handle_info({_task_ref, {%{type: type, id: id} = panel, datasets}}, socket) do 115 | panel_data = apply(type, :reduce, [datasets, panel, socket.assigns.dashboard]) 116 | 117 | socket = 118 | socket 119 | |> assign( 120 | dashboard: Luminous.Dashboard.update_data(socket.assigns.dashboard, id, panel_data) 121 | ) 122 | |> lmn_push_panel_load_event(:end, id) 123 | 124 | socket = 125 | if is_nil(panel.hook), 126 | do: socket, 127 | else: push_event(socket, "#{Luminous.Utils.dom_id(panel)}::refresh-data", panel_data) 128 | 129 | {:noreply, socket} 130 | end 131 | 132 | # this will be called each time a panel refresh async task terminates 133 | def handle_info({:DOWN, _task_ref, :process, _, _}, socket) do 134 | {:noreply, socket} 135 | end 136 | 137 | defp lmn_get_time_range(dashboard, %{"from" => from_unix, "to" => to_unix}) do 138 | Luminous.TimeRange.from_unix( 139 | String.to_integer(from_unix), 140 | String.to_integer(to_unix) 141 | ) 142 | |> Luminous.TimeRange.shift_zone!(dashboard.time_zone) 143 | end 144 | 145 | defp lmn_get_time_range(dashboard, _), do: lmn_get_default_time_range(dashboard) 146 | 147 | defp lmn_get_default_time_range(dashboard) do 148 | if function_exported?(__MODULE__, :default_time_range, 1) do 149 | apply(__MODULE__, :default_time_range, [dashboard.time_zone]) 150 | else 151 | Luminous.TimeRange.default(dashboard.time_zone) 152 | end 153 | end 154 | 155 | defp lmn_push_panel_load_event(socket, :start, panel_id), 156 | do: push_event(socket, "panel:load:start", %{id: panel_id}) 157 | 158 | defp lmn_push_panel_load_event(socket, :end, panel_id), 159 | do: push_event(socket, "panel:load:end", %{id: panel_id}) 160 | 161 | defp lmn_push_time_range_event(socket, time_range_selector_id, %Luminous.TimeRange{} = tr) do 162 | topic = "#{time_range_selector_id}::refresh-data" 163 | payload = %{time_range: Luminous.TimeRange.to_map(tr)} 164 | push_event(socket, topic, payload) 165 | end 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/luminous/variable.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Variable do 2 | @moduledoc """ 3 | A variable is defined inside a Dashboard and its values are 4 | determined at runtime. The variable also stores a current value 5 | that can be updated. A variable value can be simple (just a value) 6 | or descriptive in that it contains a label (for display purposes) 7 | and the actual value. 8 | 9 | Variables are visualized as dropdowns in the dashboard view. 10 | 11 | There are two Variable types: 12 | - `single`: only one value can be selected by the user (default type) 13 | - `multi`: multiple values can be selected by the user 14 | 15 | A Variable can also be hidden in which case: 16 | - it will not be rendered as a dropdown in the dashboard 17 | - it will not be included in the URL params 18 | 19 | Hidden variables are a means for keeping some kind of state for 20 | framework clients. A typical use case is implementing custom panels 21 | which need some state (e.g. pagination). 22 | """ 23 | 24 | alias Luminous.Attributes 25 | 26 | @doc """ 27 | A module must implement this behaviour to be passed as an argument to `define!/1`. 28 | The function receives the variable id and the LV socket assigns. 29 | """ 30 | @callback variable(atom(), map()) :: [simple_value() | descriptive_value()] 31 | 32 | @type t :: map() 33 | 34 | @type simple_value :: binary() 35 | @type descriptive_value :: %{label: binary(), value: binary()} 36 | 37 | @attributes [ 38 | id: [type: :atom, required: true], 39 | label: [type: :string, required: true], 40 | module: [type: :atom, required: true], 41 | type: [type: {:in, [:single, :multi]}, default: :single], 42 | multi_default: [type: {:in, [:all, :none]}, default: :all], 43 | search: [type: :boolean, default: false], 44 | hidden: [type: :boolean, default: false] 45 | ] 46 | 47 | @doc """ 48 | Defines a new variable and returns a map. The following options can be passed: 49 | #{NimbleOptions.docs(@attributes)} 50 | """ 51 | @spec define!(keyword()) :: t() 52 | def define!(opts) do 53 | variable = Attributes.parse!(opts, @attributes) 54 | 55 | if variable.id in [:from, :to] do 56 | raise ":from and :to are reserved atoms in luminous and can not be used as variable IDs" 57 | end 58 | 59 | variable 60 | end 61 | 62 | @doc """ 63 | Find and return the variable with the specified id in the supplied variables. 64 | """ 65 | @spec find([t()], atom()) :: t() | nil 66 | def find(variables, id), do: Enum.find(variables, fn v -> v.id == id end) 67 | 68 | @doc """ 69 | Uses the callback to populate the variables's values and returns the 70 | updated variable. Additionally, it sets the current value to be the 71 | first of the available values in the case of a single variable or 72 | all of the available values in the case of a multi variable. 73 | """ 74 | @spec populate(t(), map()) :: t() 75 | def populate(var, socket_assigns) do 76 | values = 77 | var.module 78 | |> apply(:variable, [var.id, socket_assigns]) 79 | |> Enum.map(fn 80 | m when is_map(m) -> m 81 | s when is_binary(s) -> %{label: s, value: s} 82 | end) 83 | 84 | case var.type do 85 | :single -> 86 | var 87 | |> Map.put(:values, values) 88 | |> Map.put(:current, List.first(values)) 89 | 90 | :multi -> 91 | current = 92 | case var.multi_default do 93 | :all -> values 94 | :none -> [] 95 | end 96 | 97 | var 98 | |> Map.put(:values, values) 99 | |> Map.put(:current, current) 100 | end 101 | end 102 | 103 | @doc """ 104 | Returns the variable's current (descriptive) value(s) or `nil`. 105 | """ 106 | @spec get_current(t()) :: descriptive_value() | [descriptive_value()] | nil 107 | def get_current(nil), do: nil 108 | def get_current(%{current: value}), do: value 109 | 110 | @doc """ 111 | Find the variable with the supplied `id` in the supplied variables 112 | and return its current extracted value. 113 | """ 114 | @spec get_current_and_extract_value([t()], atom()) :: binary() | [binary()] | nil 115 | def get_current_and_extract_value(variables, variable_id) do 116 | variables 117 | |> find(variable_id) 118 | |> get_current() 119 | |> extract_value() 120 | end 121 | 122 | @doc """ 123 | Returns the label based on the variable type and current value selection 124 | """ 125 | @spec get_current_label(t()) :: binary() | nil 126 | def get_current_label(%{current: nil}), do: nil 127 | def get_current_label(%{current: %{label: label}}), do: label 128 | 129 | def get_current_label(%{current: []}), do: "None" 130 | def get_current_label(%{current: [value]}), do: value.label 131 | 132 | def get_current_label(%{current: current} = var) when is_list(current) do 133 | if length(current) == length(var.values) do 134 | "All" 135 | else 136 | "#{length(current)} selected" 137 | end 138 | end 139 | 140 | @doc """ 141 | Extract and return the value from the descriptive variable value. 142 | """ 143 | @spec extract_value(descriptive_value()) :: binary() | [binary()] | nil 144 | def extract_value(nil), do: nil 145 | def extract_value(%{value: value}), do: value 146 | def extract_value(values) when is_list(values), do: Enum.map(values, & &1.value) 147 | 148 | @doc """ 149 | Replaces the variables current value with the new value and returns the map. 150 | It performs a check whether the supplied value is a valid value (i.e. exists in values). 151 | If it's not, then it returns the map unchanged. 152 | The special "none" case is for when the variable's type is :multi and none of the 153 | values are selected (empty list) 154 | """ 155 | @spec update_current(t(), nil | binary() | [binary()], map()) :: t() 156 | def update_current(var, nil, assigns), do: populate(var, assigns) 157 | def update_current(%{type: :multi} = var, "none", _), do: %{var | current: []} 158 | 159 | def update_current(%{hidden: hidden} = var, new_value, _) when is_binary(new_value) do 160 | new_val = 161 | if hidden do 162 | %{value: new_value, label: new_value} 163 | else 164 | Enum.find(var.values, fn val -> val.value == new_value end) 165 | end 166 | 167 | if is_nil(new_val), do: var, else: %{var | current: new_val} 168 | end 169 | 170 | def update_current(%{hidden: hidden} = var, new_values, _) when is_list(new_values) do 171 | legitimate_values = Enum.filter(var.values, fn %{value: value} -> value in new_values end) 172 | 173 | new_values = 174 | if hidden do 175 | Enum.map(new_values, fn v -> %{label: v, value: v} end) 176 | else 177 | if length(new_values) == length(legitimate_values) do 178 | legitimate_values 179 | else 180 | var.current 181 | end 182 | end 183 | 184 | %{var | current: new_values} 185 | end 186 | 187 | @doc """ 188 | Returns true if the variable was declared to include a search field for the listed items, 189 | otherwise false. 190 | """ 191 | @spec show_search?(t()) :: boolean() 192 | def show_search?(%{search: value}), do: value 193 | end 194 | -------------------------------------------------------------------------------- /lib/luminous/time_range.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.TimeRange do 2 | @moduledoc """ 3 | This module defines a struct with two fields (`:from` and `:to`) to represent a time range. 4 | Additionally, various helper functions are defined that operate on time ranges. 5 | 6 | It also specifies a behaviour that can be (optionally) implemented 7 | by client-side dashboards in order to override the dashboard's 8 | default time range (which is "today"). 9 | """ 10 | 11 | @type t() :: %__MODULE__{from: DateTime.t(), to: DateTime.t()} 12 | @type time_zone :: binary() 13 | 14 | @spec default_time_zone() :: time_zone() 15 | def default_time_zone(), do: "Europe/Athens" 16 | 17 | defstruct [:from, :to] 18 | 19 | @doc """ 20 | Implement inside a client-side dashboard in order to return the 21 | dashboard's default time range. 22 | """ 23 | @callback default_time_range(time_zone()) :: t() 24 | 25 | @spec new(DateTime.t(), DateTime.t()) :: t() 26 | def new(from, to), do: %__MODULE__{from: from, to: to} 27 | 28 | @spec from_iso(binary(), binary()) :: t() 29 | def from_iso(from_iso, to_iso) do 30 | [from, to] = 31 | Enum.map([from_iso, to_iso], fn iso -> 32 | {:ok, dt, _} = DateTime.from_iso8601(iso) 33 | dt 34 | end) 35 | 36 | new(from, to) 37 | end 38 | 39 | @spec from_unix(non_neg_integer(), non_neg_integer()) :: t() 40 | def from_unix(from_unix, to_unix) do 41 | [from, to] = 42 | Enum.map([from_unix, to_unix], fn ut -> 43 | DateTime.from_unix!(ut) 44 | end) 45 | 46 | new(from, to) 47 | end 48 | 49 | @doc """ 50 | Convert the time range to a map of unix timestamps. 51 | 52 | If the time range or any of its attributes (from, to) is nil 53 | then convert the second (default) argument to unix timestamps. 54 | 55 | If the default is also nil, then return an empty map. 56 | """ 57 | @spec to_unix(t() | nil, t() | nil) :: %{from: non_neg_integer(), to: non_neg_integer()} | %{} 58 | 59 | def to_unix(_, default \\ nil) 60 | 61 | def to_unix(nil, nil), do: %{} 62 | def to_unix(nil, default), do: to_unix(default) 63 | 64 | def to_unix(%{from: from, to: to}, default) when is_nil(from) or is_nil(to), 65 | do: to_unix(default) 66 | 67 | def to_unix(%{from: from, to: to}, _), 68 | do: %{from: DateTime.to_unix(from), to: DateTime.to_unix(to)} 69 | 70 | @spec to_map(t()) :: map() 71 | def to_map(time_range), do: Map.from_struct(time_range) 72 | 73 | @spec shift_zone!(t(), time_zone()) :: t() 74 | def shift_zone!(time_range, time_zone) do 75 | new( 76 | DateTime.shift_zone!(time_range.from, time_zone), 77 | DateTime.shift_zone!(time_range.to, time_zone) 78 | ) 79 | end 80 | 81 | @spec default(time_zone()) :: t() 82 | def default(tz), do: today(tz) 83 | 84 | @spec today(time_zone(), DateTime.t() | nil) :: t() 85 | def today(tz, now \\ nil) do 86 | now = now || DateTime.now!(tz) 87 | from = round(now, :day) 88 | to = now |> add(1, :day) |> round(:day) |> add(-1, :second) 89 | new(from, to) 90 | end 91 | 92 | @spec yesterday(time_zone(), DateTime.t() | nil) :: t() 93 | def yesterday(tz, now \\ nil) do 94 | now = now || DateTime.now!(tz) 95 | to = now |> round(:day) |> add(-1, :second) 96 | from = now |> add(-1, :day) |> round(:day) 97 | new(from, to) 98 | end 99 | 100 | @spec tomorrow(time_zone(), DateTime.t() | nil) :: t() 101 | def tomorrow(tz, now \\ nil) do 102 | now = now || DateTime.now!(tz) 103 | from = now |> add(1, :day) |> round(:day) 104 | to = now |> add(2, :day) |> round(:day) |> add(-1, :second) 105 | new(from, to) 106 | end 107 | 108 | @spec last_n_days(non_neg_integer(), time_zone(), DateTime.t() | nil) :: t() 109 | def last_n_days(n, tz, now \\ nil) do 110 | now = now || DateTime.now!(tz) 111 | to = now |> round(:day) |> add(1, :day) |> add(-1, :second) 112 | from = now |> add(1 - n, :day) |> round(:day) 113 | new(from, to) 114 | end 115 | 116 | @spec this_week(time_zone(), DateTime.t() | nil) :: t() 117 | def this_week(tz, now \\ nil) do 118 | today = DateTime.to_date(now || DateTime.now!(tz)) 119 | from = today |> Date.beginning_of_week() |> DateTime.new!(~T[00:00:00], tz) 120 | to = today |> Date.end_of_week() |> DateTime.new!(~T[23:59:59], tz) 121 | new(from, to) 122 | end 123 | 124 | @spec last_week(time_zone(), DateTime.t() | nil) :: t() 125 | def last_week(tz, now \\ nil) do 126 | same_day_last_week = (now || DateTime.now!(tz)) |> DateTime.to_date() |> Date.add(-7) 127 | from = same_day_last_week |> Date.beginning_of_week() |> DateTime.new!(~T[00:00:00], tz) 128 | to = same_day_last_week |> Date.end_of_week() |> DateTime.new!(~T[23:59:59], tz) 129 | new(from, to) 130 | end 131 | 132 | @spec this_month(time_zone(), DateTime.t() | nil) :: t() 133 | def this_month(tz, now \\ nil) do 134 | now = now || DateTime.now!(tz) 135 | from = round(now, :month) 136 | to = now |> add(1, :month) |> round(:month) |> add(-1, :second) 137 | new(from, to) 138 | end 139 | 140 | @spec last_month(time_zone(), DateTime.t() | nil) :: t() 141 | def last_month(tz, now \\ nil) do 142 | now = now || DateTime.now!(tz) 143 | to = now |> round(:month) |> add(-1, :second) 144 | from = now |> add(-1, :month) |> round(:month) 145 | new(from, to) 146 | end 147 | 148 | @spec round(DateTime.t(), atom()) :: DateTime.t() 149 | def round(dt, :day) do 150 | start_of_day = Time.new!(0, 0, 0) 151 | DateTime.new!(DateTime.to_date(dt), start_of_day, dt.time_zone) 152 | end 153 | 154 | def round(dt, :week) do 155 | start_of_day = Time.new!(0, 0, 0) 156 | start_of_week = Date.beginning_of_week(dt) 157 | DateTime.new!(start_of_week, start_of_day, dt.time_zone) 158 | end 159 | 160 | def round(dt, :month) do 161 | start_of_day = Time.new!(0, 0, 0) 162 | start_of_month = Date.beginning_of_month(dt) 163 | DateTime.new!(start_of_month, start_of_day, dt.time_zone) 164 | end 165 | 166 | @spec add(DateTime.t(), integer(), atom()) :: DateTime.t() 167 | def add(dt, n, :second) do 168 | DateTime.add(dt, n, :second) 169 | end 170 | 171 | def add(dt, n, :minute) do 172 | DateTime.add(dt, n * 60, :second) 173 | end 174 | 175 | def add(dt, n, :hour), do: add(dt, n * 60, :minute) 176 | def add(dt, n, :day), do: add(dt, 24 * n, :hour) 177 | 178 | def add(%DateTime{:year => year, :month => month} = dt, n, :month) do 179 | m = month + n 180 | 181 | shifted = 182 | cond do 183 | m > 0 -> 184 | years = div(m - 1, 12) 185 | month = rem(m - 1, 12) + 1 186 | %{dt | :year => year + years, :month => month} 187 | 188 | m <= 0 -> 189 | years = div(m, 12) - 1 190 | month = 12 + rem(m, 12) 191 | %{dt | :year => year + years, :month => month} 192 | end 193 | 194 | # If the shift fails, it's because it's a high day number, and the month 195 | # shifted to does not have that many days. This will be handled by always 196 | # shifting to the last day of the month shifted to. 197 | case :calendar.valid_date({shifted.year, shifted.month, shifted.day}) do 198 | false -> 199 | last_day = :calendar.last_day_of_the_month(shifted.year, shifted.month) 200 | 201 | cond do 202 | shifted.day <= last_day -> 203 | shifted 204 | 205 | :else -> 206 | %{shifted | :day => last_day} 207 | end 208 | 209 | true -> 210 | shifted 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/luminous/panel/chart.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Panel.Chart do 2 | alias Luminous.{Attributes, Dashboard, Panel, Utils} 3 | 4 | use Panel 5 | 6 | @impl true 7 | def data_attributes(), 8 | do: [ 9 | type: [type: :atom, default: :line], 10 | fill: [type: :boolean, default: true], 11 | order: [type: :non_neg_integer, default: 0] 12 | ] 13 | 14 | @impl true 15 | def panel_attributes(), 16 | do: [ 17 | xlabel: [type: :string, default: ""], 18 | ylabel: [type: :string, default: ""], 19 | stacked_x: [type: :boolean, default: false], 20 | stacked_y: [type: :boolean, default: false], 21 | y_min_value: [type: {:or, [:integer, :float, nil]}, default: nil], 22 | y_max_value: [type: {:or, [:integer, :float, nil]}, default: nil], 23 | hook: [type: :string, default: "ChartJSHook"] 24 | ] 25 | 26 | @impl true 27 | def transform(data, panel) when is_list(data) do 28 | # first, let's see if there's a specified ordering in var attrs 29 | order = 30 | Enum.reduce(panel.data_attributes, %{}, fn {label, attrs}, acc -> 31 | Map.put(acc, label, attrs.order) 32 | end) 33 | 34 | data 35 | |> extract_labels() 36 | |> Enum.map(fn label -> 37 | data = 38 | Enum.map(data, fn row -> 39 | {x, y} = 40 | case row do 41 | # row is a list of {label, value} tuples 42 | l when is_list(l) -> 43 | x = 44 | case Keyword.get(row, :time) do 45 | %DateTime{} = time -> DateTime.to_unix(time, :millisecond) 46 | _ -> nil 47 | end 48 | 49 | y = 50 | Enum.find_value(l, fn 51 | {^label, value} -> value 52 | _ -> nil 53 | end) 54 | 55 | {x, y} 56 | 57 | # row is a map where labels map to values 58 | m when is_map(m) -> 59 | x = 60 | case Map.get(row, :time) do 61 | %DateTime{} = time -> DateTime.to_unix(time, :millisecond) 62 | _ -> nil 63 | end 64 | 65 | {x, Map.get(m, label)} 66 | 67 | # row is a single number 68 | n when is_number(n) -> 69 | {nil, n} 70 | 71 | _ -> 72 | raise "Can not process data row #{inspect(row)}" 73 | end 74 | 75 | case {x, y} do 76 | {nil, y} -> %{y: convert_to_decimal(y)} 77 | {x, y} -> %{x: x, y: convert_to_decimal(y)} 78 | end 79 | end) 80 | |> Enum.reject(&is_nil(&1.y)) 81 | 82 | attrs = 83 | Map.get(panel.data_attributes, label) || 84 | Map.get(panel.data_attributes, to_string(label)) || 85 | Attributes.parse!([], data_attributes() ++ Attributes.Schema.data()) 86 | 87 | %{ 88 | rows: data, 89 | label: to_string(label), 90 | attrs: attrs, 91 | stats: statistics(data, to_string(label)) 92 | } 93 | end) 94 | |> Enum.sort_by(fn dataset -> order[dataset.label] end) 95 | end 96 | 97 | @impl true 98 | def reduce(datasets, panel, dashboard) do 99 | %{ 100 | datasets: datasets, 101 | ylabel: panel.ylabel, 102 | xlabel: panel.xlabel, 103 | stacked_x: panel.stacked_x, 104 | stacked_y: panel.stacked_y, 105 | y_min_value: panel.y_min_value, 106 | y_max_value: panel.y_max_value, 107 | time_zone: dashboard.time_zone 108 | } 109 | end 110 | 111 | @impl true 112 | def render(assigns) do 113 | ~H""" 114 |
115 |
116 | 121 | 122 |
123 | <%= if data = Dashboard.get_data(@dashboard, @panel.id) do %> 124 | <.panel_statistics stats={Enum.map(data.datasets, & &1.stats)} /> 125 | <% end %> 126 |
127 | """ 128 | end 129 | 130 | @impl true 131 | def actions() do 132 | [ 133 | %{event: "download:csv", label: "Download CSV"}, 134 | %{event: "download:png", label: "Download Image"} 135 | ] 136 | end 137 | 138 | def statistics(rows, label) do 139 | init_stats = %{n: 0, sum: nil, min: nil, max: nil, max_decimal_digits: 0} 140 | 141 | stats = 142 | Enum.reduce(rows, init_stats, fn %{y: y}, stats -> 143 | min = Map.fetch!(stats, :min) || y 144 | max = Map.fetch!(stats, :max) || y 145 | sum = Map.fetch!(stats, :sum) 146 | n = Map.fetch!(stats, :n) 147 | max_decimal_digits = Map.fetch!(stats, :max_decimal_digits) 148 | 149 | new_sum = 150 | case {sum, y} do 151 | {nil, y} -> y 152 | {sum, nil} -> sum 153 | {sum, y} -> Decimal.add(y, sum) 154 | end 155 | 156 | decimal_digits = 157 | with y when not is_nil(y) <- y, 158 | [_, dec] <- Decimal.to_string(y, :normal) |> String.split(".") do 159 | String.length(dec) 160 | else 161 | _ -> 0 162 | end 163 | 164 | stats 165 | |> Map.put(:min, if(!is_nil(y) && Decimal.lt?(y, min), do: y, else: min)) 166 | |> Map.put(:max, if(!is_nil(y) && Decimal.gt?(y, max), do: y, else: max)) 167 | |> Map.put(:sum, new_sum) 168 | |> Map.put(:n, if(is_nil(y), do: n, else: n + 1)) 169 | |> Map.put( 170 | :max_decimal_digits, 171 | if(decimal_digits > max_decimal_digits, do: decimal_digits, else: max_decimal_digits) 172 | ) 173 | end) 174 | 175 | # we use this to determine the rounding for the average dataset value 176 | max_decimal_digits = Map.fetch!(stats, :max_decimal_digits) 177 | 178 | # calculate the average 179 | avg = 180 | cond do 181 | stats[:n] == 0 -> 182 | nil 183 | 184 | is_nil(stats[:sum]) -> 185 | nil 186 | 187 | true -> 188 | Decimal.div(stats[:sum], Decimal.new(stats[:n])) |> Decimal.round(max_decimal_digits) 189 | end 190 | 191 | stats 192 | |> Map.put(:avg, avg) 193 | |> Map.put(:label, label) 194 | |> Map.delete(:max_decimal_digits) 195 | end 196 | 197 | defp convert_to_decimal(nil), do: nil 198 | 199 | defp convert_to_decimal(value) do 200 | case Decimal.cast(value) do 201 | {:ok, dec} -> dec 202 | _ -> value 203 | end 204 | end 205 | 206 | # example: [{:time, #DateTime<2022-10-01 01:00:00+00:00 UTC UTC>}, {"foo", #Decimal<0.65>}] 207 | defp extract_labels(rows) when is_list(rows) do 208 | rows 209 | |> Enum.flat_map(fn 210 | row -> 211 | row 212 | |> Enum.map(fn {label, _value} -> label end) 213 | |> Enum.reject(&(&1 == :time)) 214 | end) 215 | |> Enum.uniq() 216 | end 217 | 218 | attr :stats, :map, required: true 219 | 220 | def panel_statistics(assigns) do 221 | if is_nil(assigns.stats) || length(assigns.stats) == 0 do 222 | ~H"" 223 | else 224 | ~H""" 225 |
226 |
227 |
N
228 |
Min
229 |
Max
230 |
Avg
231 |
Total
232 | 233 | <%= for var <- @stats do %> 234 |
<%= var.label %>
235 |
<%= var.n %>
236 |
<%= Utils.print_number(var.min) %>
237 |
<%= Utils.print_number(var.max) %>
238 |
<%= Utils.print_number(var.avg) %>
239 |
<%= Utils.print_number(var.sum) %>
240 | <% end %> 241 |
242 | """ 243 | end 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![test](https://github.com/elinverd/luminous/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/elinverd/luminous/actions/workflows/test.yml) 2 | [![Hex.pm](https://img.shields.io/hexpm/v/luminous)](https://hex.pm/packages/luminous) 3 | 4 | # Luminous 5 | 6 | Luminous is a framework for creating dashboards within [Phoenix Live 7 | View](https://www.phoenixframework.org/). 8 | 9 | Dashboards are defined by the client application (framework consumer) 10 | using elixir code and consist of Panels (`Luminous.Panel`) which are 11 | responsible for visualizing the results of multiple client-side 12 | queries (`Luminous.Query`). 13 | 14 | Three different types of Panels are currently offered out of the box 15 | by Luminous: 16 | 17 | - `Luminous.Panel.Chart` for visualizing 2-d data (including time 18 | series) using the [chartjs](https://www.chartjs.org/) library 19 | (embedded in a JS hook). Currently, only `:line` and `:bar`are 20 | supported. 21 | - `Luminous.Panel.Stat` for displaying single or multiple numerical or 22 | other values (e.g. strings) 23 | - `Luminous.Panel.Table` for displaying tabular data 24 | 25 | A client application can implement its own custom panels by 26 | implementing the `Luminous.Panel` behaviour. 27 | 28 | Dashboards are parameterized by: 29 | 30 | - a date range (using the [flatpickr](https://flatpickr.js.org/) library) 31 | - user-defined variables (`Luminous.Variable`) in the form of dropdown menus 32 | 33 | All panels are refreshed whenever at least one of these paramaters 34 | (date range, variables) change. The parameter values are available to 35 | client-side queries. 36 | 37 | ## Features 38 | 39 | - Date range selection and automatic asynchronous (i.e. non-blocking 40 | for the UI) refresh of all dashboard panel queries 41 | - User-facing variable dropdowns (with single- or multi- selection) 42 | whose selected values are available to panel queries 43 | - Client-side zoom in charts with automatic update of the entire 44 | dashboard with the new date range 45 | - Panel data downloads depending on the panel type (CSV, PNG) 46 | - Stat panels (show single or multiple stats) 47 | - Table panels using [tabulator](https://tabulator.info/) 48 | - Summary statistics in charts 49 | 50 | ## Installation 51 | 52 | The package can be installed from `hex.pm` as follows: 53 | 54 | ```elixir 55 | def deps do 56 | [ 57 | {:luminous, "~> 2.6.1"} 58 | ] 59 | end 60 | ``` 61 | 62 | In order to be able to use the provided components, the library's 63 | `javascript` and `CSS` files must be imported to your project: 64 | 65 | In `assets/js/app.js`: 66 | 67 | ```javascript 68 | import { ChartJSHook, TableHook, TimeRangeHook, MultiSelectVariableHook } from "luminous" 69 | 70 | let Hooks = { 71 | TimeRangeHook: new TimeRangeHook(), 72 | ChartJSHook: new ChartJSHook(), 73 | TableHook: new TableHook(), 74 | MultiSelectVariableHook: new MultiSelectVariableHook() 75 | } 76 | 77 | ... 78 | 79 | let liveSocket = new LiveSocket("/live", Socket, { 80 | ... 81 | hooks: Hooks 82 | }) 83 | ... 84 | ``` 85 | 86 | Finally, in `assets/css/app.css`: 87 | ```CSS 88 | @import "../../deps/luminous/dist/luminous.css"; 89 | ``` 90 | 91 | ## Usage 92 | 93 | ### Live View 94 | 95 | The dashboard live view is defined client-side like so: 96 | 97 | ```elixir 98 | defmodule ClientApp.DashboardLive do 99 | alias ClientApp.Router.Helpers, as: Routes 100 | 101 | use Luminous.Live, 102 | title: "My Title", 103 | time_zone: "Europe/Paris", 104 | panels: [ 105 | ... 106 | ], 107 | variables: [ 108 | ... 109 | ] 110 | 111 | # the dashboard can be rendered by leveraging the corresponding functionality 112 | # from `Luminous.Components` 113 | def render(assigns) do 114 | ~H""" 115 | 116 | """ 117 | end 118 | 119 | # we also need to implement the function that generates the LV path 120 | @impl Luminous.Dashboard 121 | def dashboard_path(socket, url_params) do 122 | Routes.dashboard_path(socket, :index, url_params) 123 | end 124 | end 125 | ``` 126 | 127 | The client-side dashboard can also (optionally) implement the 128 | `Luminous.TimeRange` behaviour in order to override the dashboard's 129 | default time range value which is "today". 130 | 131 | ### Panels and Queries 132 | 133 | Client-side queries must be included in a module that implements the 134 | `Luminous.Query` behaviour: 135 | 136 | ```elixir 137 | defmodule ClientApp.DashboardLive do 138 | 139 | defmodule Queries do 140 | @behaviour Luminous.Query 141 | 142 | @impl true 143 | def query(:my_query, _time_range, _variables) do 144 | [ 145 | [{:time, ~U[2022-08-19T10:00:00Z]}, {"foo", 10}, {"bar", 100}], 146 | [{:time, ~U[2022-08-19T11:00:00Z]}, {"foo", 11}, {"bar", 101}] 147 | ] 148 | end 149 | end 150 | 151 | use Luminous.Live, 152 | ... 153 | panels: [ 154 | Panel.define!( 155 | type: Luminous.Panel.Chart, 156 | id: :simple_time_series, 157 | title: "Simple Time Series", 158 | queries: [ 159 | Luminous.Query.define(:my_query, Queries) 160 | ], 161 | description: """ 162 | This will be rendered as a tooltip 163 | when hovering over the panel's title 164 | """ 165 | ), 166 | ], 167 | ... 168 | end 169 | ``` 170 | 171 | A panel may include multiple queries. When a panel is automatically 172 | refreshed, the execution flow is as follows: 173 | 174 | - for each query: 175 | - execute the user query callback 176 | - execute the panel's `transform/2` callback with the query result output 177 | - aggregate the transformed query results 178 | - update the dashboard state variable with the panel's data 179 | (possible server-side re-rendering) 180 | - send a JS event to the browser (for panel hooks) 181 | 182 | The above flow needs to be understood when implementing custom 183 | panels. If the client application uses the panels provided by 184 | luminous, then the panel refresh flow is handled automatically and 185 | only `use Luminous.Live` with the appropriate options is necessary. 186 | 187 | ### Variables 188 | 189 | Variables represent user-facing elements in the form of dropdowns in 190 | which the user can select single (variable type: `:single`) or 191 | multiple (variable type: `:multi`) values. 192 | 193 | Variable selections trigger the refresh of all panels in the 194 | dashboard. The state of all variables is available within the `query` 195 | callback that is implemented by the client application. 196 | 197 | Just like queries, variables must be included in a module that 198 | implements the `Luminous.Variable` behaviour: 199 | 200 | ```elixir 201 | defmodule ClientApp.DashboardLive do 202 | 203 | defmodule Variables do 204 | @behaviour Luminous.Variable 205 | 206 | @impl true 207 | def variable(:simple_var, _assigns), do: ["hour", "day", "week"] 208 | 209 | def variable(:descriptive_var, _assigns) do 210 | [ 211 | %{label: "Visible Value 1", value: "val1"}, 212 | %{label: "Visible Value 2", value: "val2"}, 213 | ] 214 | end 215 | end 216 | 217 | use Luminous.Live, 218 | ... 219 | variables: [ 220 | Luminous.Variable.define!(id: :simple_var, label: "Select one value", module: Variables), 221 | Luminous.Variable.define!(id: :descriptive_var, label: "Select one value", module: Variables), 222 | ], 223 | ... 224 | end 225 | ``` 226 | 227 | The variable callback will receive the live view socket assigns as the 228 | second argument, however it is important to note that the `variable/2` 229 | callback is executed once when the dashboard is loaded for populating 230 | the dropdown values. 231 | 232 | A `Variable` can be marked as hidden by passing `hidden: true` to 233 | `Variable.define!/1`. Hidden variables are a means for framework 234 | clients to store some kind of state expecially in the case of custom 235 | Panels (a typical use case is pagination). As such, hidden variables 236 | are not rendered as dropdowns in the dashboard and are not included in 237 | URL params. 238 | 239 | ### Demo 240 | 241 | Luminous provides a demo dashboard that showcases some of Luminous' 242 | capabilities. The demo dashboard can be inspected live using the 243 | project's development server (run `mix run` in the project and then 244 | visit [this page](http://localhost:5000)). 245 | -------------------------------------------------------------------------------- /env/dev/dashboard.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Dev.DashboardLive do 2 | @moduledoc false 3 | # This module demonstrates the functionality of a dashboard using `Luminous.Live`. 4 | 5 | alias Luminous.Dev.Router.Helpers, as: Routes 6 | alias Luminous.{Variable, Query, Components} 7 | 8 | defmodule Variables do 9 | @moduledoc false 10 | # This is where we implement the `Luminous.Variable` behaviour, i.e. define 11 | # the dashboard's variables displayed as dropdowns in the view 12 | 13 | # The first value in each list is the default one. 14 | 15 | # Values can be either simple (a single binary) or descriptive 16 | # (label different than the value). 17 | 18 | # Variables values are available within queries where they can serve 19 | # as parameters. 20 | 21 | # More details in `Luminous.Variable`. 22 | 23 | @behaviour Variable 24 | @impl true 25 | def variable(:multiplier_var, _) do 26 | ["1", "10", "100"] 27 | end 28 | 29 | def variable(:interval_var, _), do: ["hour", "day"] 30 | 31 | def variable(:region_var, _), 32 | do: [ 33 | %{label: "north (l)", value: "north"}, 34 | %{label: "south (l)", value: "south"}, 35 | %{label: "east (l)", value: "east"}, 36 | %{label: "west (l)", value: "west"}, 37 | %{label: "north west (l)", value: "north west"}, 38 | %{label: "south west (l)", value: "south west"}, 39 | %{label: "north east (l)", value: "north east"}, 40 | %{label: "south east (l)", value: "south east"} 41 | ] 42 | 43 | def variable(:region_var2, _), do: ["north2", "south2", "east2", "west2"] 44 | end 45 | 46 | defmodule Queries do 47 | @moduledoc false 48 | # This is where we implement the `Luminous.Query` behaviour, i.e. all queries 49 | # that will be visualized in the dashboard's panels (a panel can 50 | # have multiple queries). 51 | 52 | # All queries have access to the current dashboard variable values 53 | # and the selected time range. 54 | 55 | # More details in `Luminous.Query`. 56 | 57 | @behaviour Query 58 | @impl true 59 | def query(:simple_time_series, time_range, variables) do 60 | interval = 61 | variables 62 | |> Variable.get_current_and_extract_value(:interval_var) 63 | |> String.to_existing_atom() 64 | 65 | multiplier = 66 | variables 67 | |> Variable.get_current_and_extract_value(:multiplier_var) 68 | |> String.to_integer() 69 | 70 | Luminous.Generator.generate(time_range, multiplier, interval, "simple variable") 71 | end 72 | 73 | def query(:multiple_time_series_with_diff, time_range, variables) do 74 | time_range 75 | |> multiple_time_series(variables) 76 | |> Enum.map(fn row -> 77 | a = Enum.find_value(row, fn {var, val} -> if var == "a", do: val end) 78 | b = Enum.find_value(row, fn {var, val} -> if var == "b", do: val end) 79 | [{"a-b", Decimal.sub(a, b)} | row] 80 | end) 81 | end 82 | 83 | def query(:multiple_time_series_with_total, time_range, variables) do 84 | time_range 85 | |> multiple_time_series(variables) 86 | |> Enum.map(fn row -> 87 | a = Enum.find_value(row, fn {var, val} -> if var == "a", do: val end) 88 | b = Enum.find_value(row, fn {var, val} -> if var == "b", do: val end) 89 | [{"total", Decimal.add(a, b)} | row] 90 | end) 91 | end 92 | 93 | def query(:single_stat, _time_range, variables) do 94 | multiplier = 95 | variables 96 | |> Variable.get_current_and_extract_value(:multiplier_var) 97 | |> String.to_integer() 98 | 99 | value = 100 | :rand.uniform() 101 | |> Decimal.from_float() 102 | |> Decimal.mult(multiplier) 103 | |> Decimal.round(2) 104 | 105 | %{"foo" => value} 106 | end 107 | 108 | def query(:string_stat, %{from: from}, _variables) do 109 | s = Calendar.strftime(from, "%b %Y") 110 | 111 | %{:string_stat => s} 112 | end 113 | 114 | def query(:more_stats, _time_range, variables) do 115 | multiplier = 116 | variables 117 | |> Variable.get_current_and_extract_value(:multiplier_var) 118 | |> String.to_integer() 119 | 120 | Enum.map(1..2, fn i -> 121 | v = 122 | :rand.uniform() 123 | |> Decimal.from_float() 124 | |> Decimal.mult(multiplier) 125 | |> Decimal.round(2) 126 | 127 | {"var_#{i}", v} 128 | end) 129 | |> Map.new() 130 | end 131 | 132 | def query(:regions, _, variables) do 133 | variables 134 | |> Variable.find(:region_var) 135 | |> Variable.get_current() 136 | end 137 | 138 | def query(:tabular_data_1, %{from: t}, _variables) do 139 | case DateTime.compare(t, DateTime.utc_now()) do 140 | :lt -> 141 | [ 142 | %{"foo" => 1301, "bar" => 88_555_666.2}, 143 | %{"foo" => 1400, "bar" => 22_111_444.6332} 144 | ] 145 | 146 | _ -> 147 | [ 148 | %{"foo" => 300.2, "bar" => 88999.4}, 149 | %{"foo" => 400.234, "bar" => 99_888_777.21} 150 | ] 151 | end 152 | end 153 | 154 | def query(:tabular_data_2, %{from: t}, _variables) do 155 | case DateTime.compare(t, DateTime.utc_now()) do 156 | :lt -> 157 | [ 158 | %{"label" => "row1"}, 159 | %{"label" => "row2"} 160 | ] 161 | 162 | _ -> 163 | [ 164 | %{"label" => "row1"}, 165 | %{"label" => "row2"} 166 | ] 167 | end 168 | end 169 | 170 | def query(:variable_columns, time_range, _variables) do 171 | Luminous.Generator.generate(time_range, 100, :hour, ["var1", "var2"]) 172 | end 173 | 174 | defp multiple_time_series(time_range, variables) do 175 | interval = 176 | variables 177 | |> Variable.get_current_and_extract_value(:interval_var) 178 | |> String.to_existing_atom() 179 | 180 | multiplier = 181 | variables 182 | |> Variable.get_current_and_extract_value(:multiplier_var) 183 | |> String.to_integer() 184 | 185 | Luminous.Generator.generate(time_range, multiplier, interval, ["a", "b"]) 186 | end 187 | end 188 | 189 | # This is where the actual dashboard is defined (compile-time) by 190 | # specifying all of its components. 191 | alias Luminous.{Components, Panel, Query, Variable} 192 | 193 | # In general, a dashboard can have multiple panels and each panel 194 | # can have multiple queries. A dashboard also has a set of variables 195 | # and a time range component from which the user can select 196 | # arbitrary time windows. 197 | use Luminous.Live, 198 | title: "Demo Dashboard", 199 | time_zone: "UTC", 200 | panels: [ 201 | Panel.define!( 202 | type: Panel.Chart, 203 | id: :simple_time_series, 204 | title: "Simple Time Series", 205 | queries: [ 206 | Query.define(:simple_time_series, Queries) 207 | ], 208 | description: """ 209 | This is a (possibly) long description of the particular 210 | dashboard. It is meant to explain in more depth with the user 211 | is seeing, the underlying assumptions etc. 212 | """, 213 | ylabel: "Description" 214 | ), 215 | Panel.define!( 216 | type: Panel.Table, 217 | id: :tabular_data, 218 | title: "Tabular Data", 219 | queries: [ 220 | Query.define(:tabular_data_1, Queries), 221 | Query.define(:tabular_data_2, Queries) 222 | ], 223 | description: "This is a panel with tabular data", 224 | data_attributes: %{ 225 | "label" => [title: "Label", order: 0, halign: :center], 226 | "foo" => [ 227 | title: "Foo", 228 | order: 1, 229 | halign: :right, 230 | table_totals: :avg, 231 | number_formatting: [ 232 | thousand_separator: ".", 233 | decimal_separator: ",", 234 | precision: 1 235 | ] 236 | ], 237 | "bar" => [ 238 | title: "Bar", 239 | order: 2, 240 | halign: :right, 241 | table_totals: :sum, 242 | number_formatting: [ 243 | thousand_separator: "_", 244 | decimal_separator: ".", 245 | precision: 4 246 | ] 247 | ] 248 | } 249 | ), 250 | Panel.define!( 251 | type: Panel.Table, 252 | id: :selected_regions, 253 | title: "Selected Regions", 254 | queries: [Query.define(:regions, Queries)], 255 | description: 256 | "This is a table that displays the selected regions and is dynamically updated every time the variable selection changes", 257 | data_attributes: %{ 258 | "label" => [title: "Label", order: 0], 259 | "value" => [title: "Value", order: 1] 260 | } 261 | ), 262 | Panel.define!( 263 | type: Panel.Table, 264 | id: :variable_columns, 265 | title: "Tabular data with variable columns", 266 | queries: [Query.define(:variable_columns, Queries)], 267 | page_size: 24, 268 | description: """ 269 | This is a panel with tabular data and variable number of columns. 270 | If the dataset is a list of maps, the maps' keys will be used as column titles. 271 | If the dataset is a list of lists of tuples, the tuples' first element will be 272 | used as column titles. 273 | In the latter case, the ordering of the tuples inside the lists will determine 274 | the ordering of the columns. 275 | """ 276 | ), 277 | Panel.define!( 278 | type: Panel.Stat, 279 | id: :single_stat, 280 | title: "Single-stat panel", 281 | queries: [Query.define(:single_stat, Queries)], 282 | data_attributes: %{ 283 | "foo" => [title: "Just a date", order: 0] 284 | } 285 | ), 286 | Panel.define!( 287 | type: Panel.Stat, 288 | id: :multi_stat, 289 | title: "This is a multi-stat panel", 290 | queries: [ 291 | Query.define(:string_stat, Queries), 292 | Query.define(:more_stats, Queries) 293 | ], 294 | data_attributes: %{ 295 | "foo" => [title: "Just a date", order: 0], 296 | "var_1" => [title: "Var A", order: 1], 297 | "var_2" => [title: "Var B", order: 2] 298 | } 299 | ), 300 | Panel.define!( 301 | type: Panel.Chart, 302 | id: :multiple_time_series_with_diff, 303 | title: "Multiple Time Series with Ordering", 304 | queries: [Query.define(:multiple_time_series_with_diff, Queries)], 305 | ylabel: "Description", 306 | data_attributes: %{ 307 | "a" => [type: :line, unit: "μCKR", order: 0], 308 | "b" => [type: :line, unit: "μFOO", order: 1], 309 | "a-b" => [type: :bar, order: 2] 310 | } 311 | ), 312 | Panel.define!( 313 | type: Panel.Chart, 314 | id: :multiple_time_series_with_stacking, 315 | title: "Multiple Time Series with Stacking", 316 | queries: [Query.define(:multiple_time_series_with_total, Queries)], 317 | ylabel: "Description", 318 | stacked_x: true, 319 | stacked_y: true, 320 | data_attributes: %{ 321 | "a" => [type: :bar, order: 0], 322 | "b" => [type: :bar, order: 1], 323 | "total" => [fill: false] 324 | } 325 | ) 326 | ], 327 | variables: [ 328 | Variable.define!(id: :multiplier_var, label: "Multiplier", module: Variables), 329 | Variable.define!(id: :interval_var, label: "Interval", module: Variables), 330 | Variable.define!( 331 | id: :region_var, 332 | label: "Region", 333 | module: Variables, 334 | type: :multi, 335 | search: true 336 | ) 337 | ] 338 | 339 | @impl Luminous.Dashboard 340 | def dashboard_path(socket, url_params), do: Routes.dashboard_path(socket, :index, url_params) 341 | 342 | @doc false 343 | # Here, we make use of the default component (`dashboard`) that 344 | # renders all the other components on screen 345 | # A live dashboard can also specify custom layouts by making use of 346 | # individual components from `Luminous.Components` or completely 347 | # custom components 348 | def render(assigns) do 349 | ~H""" 350 | 351 | """ 352 | end 353 | end 354 | -------------------------------------------------------------------------------- /assets/js/components/chartjs_hook.js: -------------------------------------------------------------------------------- 1 | import { Chart, registerables, Tooltip } from 'chart.js' 2 | import { DateTime } from 'luxon' 3 | import "chartjs-adapter-luxon" 4 | import zoomPlugin from 'chartjs-plugin-zoom'; 5 | import { sendFileToClient } from './utils' 6 | 7 | let colors = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a", "#ffee65", "#beb9db", "#fdcce5", "#8bd3c7"] 8 | 9 | // The following defines a new option to use as tooltip position 10 | // see: config.options.plugins.tooltip.position 11 | // for more: https://www.chartjs.org/docs/latest/configuration/tooltip.html#custom-position-modes 12 | Tooltip.positioners.cursor = function (elements, eventPosition) { 13 | return eventPosition 14 | } 15 | 16 | Chart.register(zoomPlugin) 17 | 18 | Chart.register({ 19 | id: 'nodata', 20 | afterDraw: function (chart, args, options) { 21 | if (chart.data.datasets.length === 0) { 22 | chart.ctx.save(); 23 | chart.ctx.textAlign = 'center'; 24 | chart.ctx.textBaseline = 'middle'; 25 | chart.ctx.font = "22px Arial"; 26 | chart.ctx.fillStyle = "gray"; 27 | chart.ctx.fillText('No data available', chart.chartArea.width / 2, chart.chartArea.height / 2); 28 | chart.ctx.restore(); 29 | } 30 | 31 | // draw a vertical line at cursor 32 | if (chart.tooltip?._active?.length) { 33 | let x = chart.tooltip._active[0].element.x; 34 | let yAxis = chart.scales.y; 35 | let ctx = chart.ctx; 36 | ctx.save(); 37 | ctx.beginPath(); 38 | ctx.moveTo(x, yAxis.top); 39 | ctx.lineTo(x, yAxis.bottom); 40 | ctx.lineWidth = 1; 41 | ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)'; 42 | ctx.stroke(); 43 | ctx.restore(); 44 | } 45 | } 46 | }) 47 | 48 | function ChartJSHook() { 49 | this.mounted = function () { 50 | Chart.register(...registerables); 51 | 52 | // chart configuration 53 | let config = { 54 | type: 'line', 55 | data: { 56 | datasets: [] 57 | }, 58 | options: { 59 | animation: false, 60 | interaction: { 61 | mode: 'x' 62 | }, 63 | scales: { 64 | x: { 65 | type: "time", 66 | grid: { 67 | display: false 68 | }, 69 | time: { 70 | minUnit: 'hour', 71 | tooltipFormat: "yyyy-MM-dd HH:mm", 72 | displayFormats: { 73 | hour: 'HH:mm', 74 | day: 'MMM dd' 75 | } 76 | }, 77 | ticks: { 78 | source: 'data', 79 | major: { 80 | enabled: true 81 | }, 82 | // Automatically adjusts the number of the ticks by increasing the padding between them, 83 | // when autoSkip is enabled (it is by default) 84 | autoSkipPadding: 10, 85 | font: { 86 | size: 12 87 | } 88 | }, 89 | adapters: { 90 | date: { 91 | setZone: true 92 | } 93 | } 94 | }, 95 | y: { 96 | title: { 97 | font: { 98 | size: 16 99 | } 100 | }, 101 | ticks: { 102 | font: { 103 | size: 14 104 | } 105 | }, 106 | grid: { 107 | display: true 108 | } 109 | } 110 | }, 111 | plugins: { 112 | tooltip: { 113 | position: 'cursor', 114 | intersect: false, 115 | callbacks: { 116 | label: function (context) { 117 | let label = context.dataset.label || '' 118 | 119 | if (label) { 120 | label += ': ' 121 | } 122 | if (context.parsed.y !== null) { 123 | label += context.parsed.y + " " + context.dataset.unit 124 | } 125 | return label 126 | } 127 | } 128 | }, 129 | legend: { 130 | onClick: this.legendClickHandler 131 | } 132 | } 133 | } 134 | } 135 | 136 | let canvas = document.getElementById(this.el.id) 137 | let ctx = canvas.getContext("2d") 138 | 139 | // configure zoom functionality 140 | let time_range_selector_id = canvas.getAttribute("time-range-selector-id") 141 | if (time_range_selector_id !== null) { 142 | config.options.plugins.zoom = { 143 | zoom: { 144 | wheel: { 145 | enabled: false 146 | }, 147 | pinch: { 148 | enabled: true 149 | }, 150 | drag: { 151 | enabled: true 152 | }, 153 | mode: 'x', 154 | onZoomComplete: function (chart) { 155 | if (chart.chart.triggerZoomCallbacks) { 156 | ticks = chart.chart.scales.x.ticks 157 | // zoom only there is at least 1 tick inside user selected area 158 | if (ticks.length > 0) { 159 | let from = DateTime.fromMillis(ticks[0].value).toFormat("yyyy-MM-dd'T'HH:mm:ssZZ") 160 | // We add one hour because we want the last tick of the selected area 161 | // to be included in the result 162 | let to = DateTime.fromMillis(ticks[ticks.length - 1].value) 163 | .plus({ hours: 1 }) 164 | .toFormat("yyyy-MM-dd'T'HH:mm:ssZZ") 165 | let e = new CustomEvent('zoomCompleted', { detail: { from: from, to: to } }) 166 | document.getElementById(time_range_selector_id).dispatchEvent(e) 167 | } 168 | 169 | // The zoom level has to be reset even in the case that the user 170 | // doesn't select any ticks. Otherwise, an exception is raised and 171 | // the chart stops working as expected. 172 | chart.chart.triggerZoomCallbacks = false 173 | chart.chart.resetZoom() 174 | chart.chart.triggerZoomCallbacks = true 175 | } 176 | } 177 | } 178 | } 179 | } 180 | 181 | // create the chart 182 | this.chart = new Chart(ctx, config) 183 | // we use this flag to prevent the infinite callback loop 184 | // when we call resetZoom() on the chart 185 | this.chart.triggerZoomCallbacks = true 186 | // setup the data refresh event handler (LV) 187 | this.handleEvent(this.el.id + "::refresh-data", this.handler()) 188 | // setup the download CSV event handler 189 | canvas.addEventListener("panel:" + this.el.id + ":download:csv", this.downloadCSV()) 190 | // setup the download PNG event handler 191 | canvas.addEventListener("panel:" + this.el.id + ":download:png", this.downloadPNG()) 192 | // listeners to detect when "Control" button is pressed 193 | // used by legend click handler to alter its behavior 194 | document.addEventListener("keydown", e => { 195 | if (e.key === "Control") { 196 | this.chart.isCtrlPressed = true; 197 | } 198 | }) 199 | document.addEventListener("keyup", e => { 200 | if (e.key === "Control") { 201 | this.chart.isCtrlPressed = false; 202 | } 203 | }) 204 | } 205 | 206 | this.legendClickHandler = (e, legendItem, legend) => { 207 | // when the user holds the control key pressed 208 | // toggle the visibility of the clicked item 209 | if (legend.chart.isCtrlPressed) { 210 | this.toggleLegendItem(legendItem, legend); 211 | return; 212 | } 213 | 214 | // when all legend items are hidden, by clicking any one 215 | // will make all legends items visible 216 | if (this.allLegendItemsHidden(legend)) { 217 | legend.legendItems.forEach(item => this.toggleLegendItem(item, legend)); 218 | return; 219 | } 220 | 221 | // when the user clicks on a visible legend item 222 | // the visibility of the rest is toggled 223 | if (legend.chart.isDatasetVisible(legendItem.datasetIndex)) { 224 | this.toggleOtherLegendItems(legendItem, legend); 225 | return; 226 | } 227 | 228 | // in any other case highlight the clicked legend item 229 | // by hiding all the rest 230 | this.highlightLegendItem(legendItem, legend); 231 | } 232 | 233 | this.toggleOtherLegendItems = (legendItem, legend) => { 234 | legend.legendItems.forEach(item => { 235 | if (item.datasetIndex != legendItem.datasetIndex) { 236 | this.toggleLegendItem(item, legend); 237 | } 238 | }); 239 | } 240 | 241 | this.toggleLegendItem = (legendItem, legend) => { 242 | if (legendItem.hidden) { 243 | legend.chart.show(legendItem.datasetIndex); 244 | } else { 245 | legend.chart.hide(legendItem.datasetIndex); 246 | } 247 | legendItem.hidden = !legendItem.hidden; 248 | } 249 | 250 | this.highlightLegendItem = (legendItem, legend) => { 251 | legend.legendItems.forEach(item => { 252 | if (item.datasetIndex == legendItem.datasetIndex) { 253 | legend.chart.show(item.datasetIndex); 254 | item.hidden = false; 255 | } else { 256 | legend.chart.hide(item.datasetIndex); 257 | item.hidden = true; 258 | } 259 | }); 260 | } 261 | 262 | this.allLegendItemsHidden = legend => { 263 | let result = true; 264 | for (i = 0; i < legend.legendItems.length; i++) { 265 | if (!legend.legendItems[i].hidden) { 266 | result = false; 267 | break; 268 | } 269 | } 270 | return result; 271 | } 272 | 273 | // download the chart's data as CSV 274 | this.downloadCSV = function () { 275 | return (event) => { 276 | // determine column labels 277 | labels = this.chart.data.datasets.map((dataset) => { 278 | return '\"' + dataset.label + '\"' 279 | }) 280 | // we will keep rows as a map 281 | // where keys are unix timestamps 282 | // and values are arrays of a fixed size (number of datasets) 283 | // js maps preserve their order, so we then just need to 284 | // iterate over all (key, values) pairs and generate the csv 285 | rows = new Map() 286 | n = this.chart.data.datasets.length 287 | this.chart.data.datasets.forEach((dataset, idx) => { 288 | dataset.data.forEach((row) => { 289 | // what happens with time points that exist in one dataset but not in another? 290 | // this is why values have a fixed size (number of datasets) 291 | // and we set the value explicitly based on the current dataset's index 292 | values = rows.get(row.x) || Array(n).fill('') 293 | values[idx] = row.y 294 | rows.set(row.x, values) 295 | }) 296 | }) 297 | // the first row is for excel to automatically recognize the field separator 298 | csvRows = ['sep=,', '\"time\",' + labels.join(',')] 299 | rows.forEach((values, time) => { 300 | row = DateTime.fromMillis(time).toFormat("yyyy/MM/dd'T'HH:mm:ssZZZ") 301 | csvRows.push(row + ',' + values.join(',')) 302 | }) 303 | csv = csvRows.join('\r\n') 304 | 305 | var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); 306 | var url = URL.createObjectURL(blob); 307 | sendFileToClient(url, this.el.id + ".csv") 308 | } 309 | } 310 | 311 | // download the canvas as a png image 312 | this.downloadPNG = function () { 313 | return (event) => { 314 | url = this.el.toDataURL('image/png') 315 | sendFileToClient(url, this.el.id + ".png") 316 | } 317 | } 318 | 319 | this.handler = function () { 320 | return (payload) => { 321 | n = payload.datasets.length 322 | datasets = payload.datasets.map(function (dataset, idx) { 323 | return { 324 | label: dataset.label, 325 | unit: dataset.attrs.unit, 326 | borderColor: colors[idx % colors.length] + "FF", // full opaque 327 | backgroundColor: colors[idx % colors.length] + '40', // 1/4 opaque 328 | borderWidth: 1, 329 | pointRadius: 1, 330 | fill: dataset.attrs.fill ? 'origin' : false, 331 | type: dataset.attrs.type, 332 | data: dataset.rows 333 | } 334 | }) 335 | this.chart.data = { datasets: datasets } 336 | 337 | // This prevents the first and the last bars in a bar chart 338 | // to be cut in half. Even though the `offset` option is set 339 | // to `true` by default for bar charts, the initial value is 340 | // `false` because the chart's is declared as `line`. 341 | if (Array.isArray(datasets) && datasets.length > 0 && datasets[0].type === "bar") { 342 | this.chart.options.scales.x.offset = true 343 | } 344 | 345 | // set time zone 346 | this.chart.options.scales.x.adapters.date.zone = payload.time_zone 347 | 348 | // display ylabel 349 | this.chart.options.scales.y.title.display = true 350 | this.chart.options.scales.y.title.text = payload.ylabel 351 | 352 | // set min and max values of Y axis 353 | this.chart.options.scales.y.suggestedMin = payload.y_min_value 354 | this.chart.options.scales.y.suggestedMax = payload.y_max_value 355 | 356 | // toggle stacking 357 | this.chart.options.scales.y.stacked = payload.stacked_x 358 | this.chart.options.scales.x.stacked = payload.stacked_y 359 | 360 | // if we have no data, turn off some displays 361 | this.chart.options.scales.y.grid.display = (n > 0) 362 | this.chart.options.scales.y.display = (n > 0) 363 | this.chart.options.scales.x.display = (n > 0) 364 | 365 | this.chart.update() 366 | } 367 | } 368 | } 369 | 370 | export default ChartJSHook 371 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "assert_eventually": {:hex, :assert_eventually, "1.0.0", "f1539f28ba3ffa99a712433c77723c7103986932aa341d05eee94c333a920d15", [:mix], [{:ex_doc, ">= 0.0.0", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "c658ac4103c8bd82d0cf72a2fdb77477ba3fbc6b15228c5c801003d239625c69"}, 3 | "assert_html": {:hex, :assert_html, "0.1.4", "e8bc89f4cfdb5d686b75c2509e4e3cbce364f8d13a345bd88075b6134ccf32df", [:mix], [{:floki, "~> 0.21", [hex: :floki, repo: "hexpm", optional: false]}], "hexpm", "9cbe2792bce2535ed343d9ebc4eebef23f0c9bf75a27c740d9b7b05d44ea213b"}, 4 | "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, 5 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 6 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 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.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 9 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 10 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 11 | "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, 12 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 13 | "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, 14 | "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, 15 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 16 | "floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"}, 17 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.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.3.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", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 18 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 19 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 20 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 21 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 22 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 23 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 24 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 25 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 26 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 27 | "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, 28 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 29 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 30 | "phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [: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", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, 31 | "phoenix_html": {:hex, :phoenix_html, "4.0.0", "4857ec2edaccd0934a923c2b0ba526c44a173c86b847e8db725172e9e51d11d6", [:mix], [], "hexpm", "cee794a052f243291d92fa3ccabcb4c29bb8d236f655fb03bcbdc3a8214b8d13"}, 32 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [: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", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, 33 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.3", "8b6406bc0a451f295407d7acff7f234a6314be5bbe0b3f90ed82b07f50049878", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8e4385e05618b424779f894ed2df97d3c7518b7285fcd11979077ae6226466b"}, 34 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 35 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 36 | "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"}, 37 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [: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", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, 38 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.2", "753611b23b29231fb916b0cdd96028084b12aff57bfd7b71781bd04b1dbeb5c9", [: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", "951ed2433df22f4c97b85fdb145d4cee561f36b74854d64c06d896d7cd2921a7"}, 39 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 40 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 41 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 42 | "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, 43 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 44 | "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, 45 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 46 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 47 | "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"}, 48 | } 49 | -------------------------------------------------------------------------------- /test/luminous/panel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Luminous.PanelTest do 2 | use ExUnit.Case 3 | 4 | alias Luminous.{Attributes, Query, Panel} 5 | 6 | defmodule ChartQueries do 7 | alias Luminous.Panel 8 | 9 | @behaviour Query 10 | @impl true 11 | def query(:normal, _time_range, _variables) do 12 | [ 13 | %{:time => ~U[2022-08-03T00:00:00Z], :l1 => 1, :l2 => 11, "l3" => 111}, 14 | %{:time => ~U[2022-08-04T00:00:00Z], :l1 => 2, :l2 => 12, "l3" => 112} 15 | ] 16 | end 17 | 18 | def query(:sparse, _time_range, _variables) do 19 | [ 20 | %{:time => ~U[2022-08-03T00:00:00Z], :l1 => 1, "l3" => 111}, 21 | %{:time => ~U[2022-08-04T00:00:00Z], :l2 => 12, "l3" => 112} 22 | ] 23 | end 24 | 25 | def query(:null, _time_range, _variables) do 26 | [ 27 | [{:time, ~U[2022-08-03T00:00:00Z]}, {:l1, 1}, {:l2, nil}] 28 | ] 29 | end 30 | end 31 | 32 | describe "Chart Panel" do 33 | test "fetches and transforms the data from the actual query" do 34 | panel = 35 | Panel.define!(type: Panel.Chart, id: :foo, queries: [Query.define(:normal, ChartQueries)]) 36 | 37 | results = 38 | panel 39 | |> Panel.refresh([], nil) 40 | |> Panel.Chart.reduce(panel, %{time_zone: "Etc/UTC"}) 41 | 42 | t1 = DateTime.to_unix(~U[2022-08-03T00:00:00Z], :millisecond) 43 | t2 = DateTime.to_unix(~U[2022-08-04T00:00:00Z], :millisecond) 44 | 45 | default_attrs = 46 | Attributes.parse!([], Panel.Chart.data_attributes() ++ Attributes.Schema.data()) 47 | 48 | expected_datasets = [ 49 | %{ 50 | rows: [%{x: t1, y: Decimal.new(1)}, %{x: t2, y: Decimal.new(2)}], 51 | label: "l1", 52 | attrs: default_attrs, 53 | stats: %{ 54 | avg: Decimal.new(2), 55 | label: "l1", 56 | max: Decimal.new(2), 57 | min: Decimal.new(1), 58 | n: 2, 59 | sum: Decimal.new(3) 60 | } 61 | }, 62 | %{ 63 | rows: [%{x: t1, y: Decimal.new(11)}, %{x: t2, y: Decimal.new(12)}], 64 | label: "l2", 65 | attrs: default_attrs, 66 | stats: %{ 67 | avg: Decimal.new(12), 68 | label: "l2", 69 | max: Decimal.new(12), 70 | min: Decimal.new(11), 71 | n: 2, 72 | sum: Decimal.new(23) 73 | } 74 | }, 75 | %{ 76 | rows: [%{x: t1, y: Decimal.new(111)}, %{x: t2, y: Decimal.new(112)}], 77 | label: "l3", 78 | attrs: default_attrs, 79 | stats: %{ 80 | avg: Decimal.new(112), 81 | label: "l3", 82 | max: Decimal.new(112), 83 | min: Decimal.new(111), 84 | n: 2, 85 | sum: Decimal.new(223) 86 | } 87 | } 88 | ] 89 | 90 | expected_results = %{ 91 | datasets: expected_datasets, 92 | ylabel: panel.ylabel, 93 | xlabel: panel.xlabel, 94 | stacked_x: panel.stacked_x, 95 | stacked_y: panel.stacked_y, 96 | y_min_value: panel.y_min_value, 97 | y_max_value: panel.y_max_value, 98 | time_zone: "Etc/UTC" 99 | } 100 | 101 | assert ^expected_results = results 102 | end 103 | 104 | test "can fetch and transform sparse data from the query" do 105 | panel = 106 | Panel.define!( 107 | type: Panel.Chart, 108 | id: :foo, 109 | queries: [Query.define(:sparse, ChartQueries)], 110 | data_attributes: %{ 111 | "l1" => [type: :bar, order: 0], 112 | "l2" => [order: 1], 113 | "l3" => [type: :bar, order: 2] 114 | } 115 | ) 116 | 117 | results = 118 | panel 119 | |> Panel.refresh([], nil) 120 | |> Panel.Chart.reduce(panel, %{time_zone: "Etc/UTC"}) 121 | 122 | t1 = DateTime.to_unix(~U[2022-08-03T00:00:00Z], :millisecond) 123 | t2 = DateTime.to_unix(~U[2022-08-04T00:00:00Z], :millisecond) 124 | 125 | default_schema = Attributes.Schema.data() ++ Panel.Chart.data_attributes() 126 | 127 | expected_results = %{ 128 | datasets: [ 129 | %{ 130 | rows: [%{x: t1, y: Decimal.new(1)}], 131 | label: "l1", 132 | attrs: Attributes.parse!([type: :bar, order: 0], default_schema), 133 | stats: %{ 134 | avg: Decimal.new(1), 135 | label: "l1", 136 | max: Decimal.new(1), 137 | min: Decimal.new(1), 138 | n: 1, 139 | sum: Decimal.new(1) 140 | } 141 | }, 142 | %{ 143 | rows: [%{x: t2, y: Decimal.new(12)}], 144 | label: "l2", 145 | attrs: Attributes.parse!([order: 1], default_schema), 146 | stats: %{ 147 | avg: Decimal.new(12), 148 | label: "l2", 149 | max: Decimal.new(12), 150 | min: Decimal.new(12), 151 | n: 1, 152 | sum: Decimal.new(12) 153 | } 154 | }, 155 | %{ 156 | rows: [%{x: t1, y: Decimal.new(111)}, %{x: t2, y: Decimal.new(112)}], 157 | label: "l3", 158 | attrs: Attributes.parse!([type: :bar, order: 2], default_schema), 159 | stats: %{ 160 | avg: Decimal.new(112), 161 | label: "l3", 162 | max: Decimal.new(112), 163 | min: Decimal.new(111), 164 | n: 2, 165 | sum: Decimal.new(223) 166 | } 167 | } 168 | ], 169 | ylabel: panel.ylabel, 170 | xlabel: panel.xlabel, 171 | stacked_x: panel.stacked_x, 172 | stacked_y: panel.stacked_y, 173 | y_min_value: panel.y_min_value, 174 | y_max_value: panel.y_max_value, 175 | time_zone: "Etc/UTC" 176 | } 177 | 178 | assert ^expected_results = results 179 | end 180 | 181 | test "can fetch and transform query results that contain nil" do 182 | panel = 183 | Panel.define!( 184 | type: Panel.Chart, 185 | id: :foo, 186 | queries: [Query.define(:null, ChartQueries)] 187 | ) 188 | 189 | results = 190 | panel 191 | |> Panel.refresh([], nil) 192 | |> Panel.Chart.reduce(panel, %{time_zone: "Etc/UTC"}) 193 | 194 | default_attrs = 195 | Attributes.parse!([], Panel.Chart.data_attributes() ++ Attributes.Schema.data()) 196 | 197 | t = DateTime.to_unix(~U[2022-08-03T00:00:00Z], :millisecond) 198 | 199 | expected_results = %{ 200 | datasets: [ 201 | %{ 202 | rows: [%{x: t, y: Decimal.new(1)}], 203 | label: "l1", 204 | attrs: default_attrs, 205 | stats: %{ 206 | avg: Decimal.new(1), 207 | label: "l1", 208 | max: Decimal.new(1), 209 | min: Decimal.new(1), 210 | n: 1, 211 | sum: Decimal.new(1) 212 | } 213 | }, 214 | %{ 215 | rows: [], 216 | label: "l2", 217 | attrs: default_attrs, 218 | stats: %{avg: nil, label: "l2", max: nil, min: nil, n: 0, sum: nil} 219 | } 220 | ], 221 | ylabel: panel.ylabel, 222 | xlabel: panel.xlabel, 223 | stacked_x: panel.stacked_x, 224 | stacked_y: panel.stacked_y, 225 | y_min_value: panel.y_min_value, 226 | y_max_value: panel.y_max_value, 227 | time_zone: "Etc/UTC" 228 | } 229 | 230 | assert ^expected_results = results 231 | end 232 | end 233 | 234 | describe "Chart Statistics" do 235 | test "calculates the basic statistics" do 236 | ds = [%{y: Decimal.new(1)}, %{y: Decimal.new(3)}, %{y: Decimal.new(-2)}] 237 | 238 | assert %{label: "foo", n: 3, min: min, max: max, avg: avg, sum: sum} = 239 | Panel.Chart.statistics(ds, "foo") 240 | 241 | assert min == Decimal.new(-2) 242 | assert max == Decimal.new(3) 243 | assert avg == Decimal.new(1) 244 | assert sum == Decimal.new(2) 245 | end 246 | 247 | test "rounds the average acc. to the max number of decimal digits in the dataset" do 248 | ds = [%{y: Decimal.new("1.234")}, %{y: Decimal.new("1.11")}] 249 | 250 | assert %{avg: avg, sum: sum} = Panel.Chart.statistics(ds, "foo") 251 | assert avg == Decimal.new("1.172") 252 | assert sum == Decimal.new("2.344") 253 | end 254 | 255 | test "can handle an empty dataset" do 256 | assert %{n: 0, avg: nil, sum: nil, min: nil, max: nil} = Panel.Chart.statistics([], "foo") 257 | end 258 | 259 | test "can handle datasets that contain nils" do 260 | ds = [ 261 | %{y: Decimal.new(4)}, 262 | %{y: nil}, 263 | %{y: Decimal.new(3)}, 264 | %{y: Decimal.new(5)}, 265 | %{y: nil} 266 | ] 267 | 268 | assert %{n: 3, avg: avg, sum: sum, min: min, max: max} = Panel.Chart.statistics(ds, "foo") 269 | assert min == Decimal.new(3) 270 | assert max == Decimal.new(5) 271 | assert sum == Decimal.new(12) 272 | assert avg == Decimal.new(4) 273 | end 274 | 275 | test "can handle datasets with nils only" do 276 | ds = [%{y: nil}, %{y: nil}] 277 | assert %{n: 0, avg: nil, sum: nil, min: nil, max: nil} = Panel.Chart.statistics(ds, "foo") 278 | end 279 | end 280 | 281 | defmodule StatQueries do 282 | @behaviour Query 283 | @impl true 284 | def query(:single_stat, _time_range, _variables), do: %{"foo" => 666} 285 | 286 | def query(:multiple_stats, _time_range, _variables) do 287 | %{"foo" => 11, "bar" => 13} 288 | end 289 | end 290 | 291 | describe "Stat Panel" do 292 | test "single stat" do 293 | panel = 294 | Panel.define!( 295 | type: Panel.Stat, 296 | id: :foo, 297 | queries: [Query.define(:single_stat, StatQueries)] 298 | ) 299 | 300 | results = 301 | panel 302 | |> Panel.refresh([], nil) 303 | |> Panel.Stat.reduce(panel, "") 304 | 305 | assert %{stats: [%{title: nil, unit: nil, value: 666}]} = results 306 | end 307 | 308 | test "multiple stats" do 309 | panel = 310 | Panel.define!( 311 | type: Panel.Stat, 312 | id: :foo, 313 | queries: [Query.define(:multiple_stats, StatQueries)], 314 | data_attributes: %{ 315 | "bar" => [order: 0], 316 | "foo" => [title: "Foo", unit: "mckk", order: 1] 317 | } 318 | ) 319 | 320 | results = 321 | panel 322 | |> Panel.refresh([], nil) 323 | |> Panel.Stat.reduce(panel, "") 324 | 325 | assert %{ 326 | stats: [ 327 | %{ 328 | title: "", 329 | unit: "", 330 | value: 13 331 | }, 332 | %{ 333 | title: "Foo", 334 | unit: "mckk", 335 | value: 11 336 | } 337 | ] 338 | } = results 339 | end 340 | end 341 | 342 | defmodule TableQueries do 343 | @behaviour Query 344 | @impl true 345 | def query(:table_1, _time_range, _variables) do 346 | [%{"foo" => 666, "bar" => "hello"}, %{"foo" => 667, "bar" => "goodbye"}] 347 | end 348 | 349 | def query(:table_2, _time_range, _variables) do 350 | [%{"baz" => 1}, %{"baz" => 2}] 351 | end 352 | 353 | def query(:column_ordering, _time_range, _variables) do 354 | [[{:time, 1}, {"bar", 1}, {"baz", 1}], [{:time, 2}, {"bar", 2}]] 355 | end 356 | end 357 | 358 | describe "Table panel" do 359 | test "table should show all query columns even those with no data_attributes" do 360 | panel = 361 | Panel.define!( 362 | type: Panel.Table, 363 | id: :ttt, 364 | queries: [Query.define(:table_1, TableQueries)], 365 | # no data attribute for "bar" 366 | data_attributes: %{"foo" => [halign: :right]} 367 | ) 368 | 369 | assert %{rows: [row | _], columns: columns} = 370 | panel 371 | |> Panel.refresh([], nil) 372 | |> Panel.Table.reduce(panel, nil) 373 | 374 | # both labels are included in the results 375 | col = Enum.find(columns, &(&1.field == "foo")) 376 | refute is_nil(col) 377 | assert :right = col.hozAlign 378 | 379 | assert row["foo"] == 666 380 | 381 | col = Enum.find(columns, &(&1.field == "bar")) 382 | refute is_nil(col) 383 | assert :left = col.hozAlign 384 | 385 | assert row["bar"] == "hello" 386 | end 387 | 388 | test "table should include the results from multiple queries" do 389 | panel = 390 | Panel.define!( 391 | type: Panel.Table, 392 | id: :ttt, 393 | queries: [ 394 | Query.define(:table_1, TableQueries), 395 | Query.define(:table_2, TableQueries) 396 | ], 397 | # no data attribute for "bar" 398 | data_attributes: %{"foo" => [halign: :right]} 399 | ) 400 | 401 | assert %{rows: [row | _], columns: columns} = 402 | panel 403 | |> Panel.refresh([], nil) 404 | |> Panel.Table.reduce(panel, nil) 405 | 406 | assert Enum.find(columns, &(&1.field == "foo")) 407 | assert row["foo"] == 666 408 | 409 | assert Enum.find(columns, &(&1.field == "baz")) 410 | assert row["baz"] == 1 411 | end 412 | 413 | test "column ordering should be preserved when no data attributes are defined" do 414 | panel = 415 | Panel.define!( 416 | type: Panel.Table, 417 | id: :ttt, 418 | queries: [Query.define(:column_ordering, TableQueries)] 419 | ) 420 | 421 | assert %{rows: [row | _], columns: columns} = 422 | panel 423 | |> Panel.refresh([], nil) 424 | |> Panel.Table.reduce(panel, nil) 425 | 426 | assert row[:time] == 1 427 | assert row["bar"] == 1 428 | assert row["baz"] == 1 429 | 430 | assert 3 == length(columns) 431 | 432 | col = Enum.at(columns, 0) 433 | assert col.field == :time 434 | assert col.title == "time" 435 | 436 | col = Enum.at(columns, 1) 437 | assert col.field == "bar" 438 | assert col.title == "bar" 439 | 440 | col = Enum.at(columns, 2) 441 | assert col.field == "baz" 442 | assert col.title == "baz" 443 | end 444 | end 445 | end 446 | -------------------------------------------------------------------------------- /lib/luminous/components.ex: -------------------------------------------------------------------------------- 1 | defmodule Luminous.Components do 2 | @moduledoc """ 3 | Phoenix function components for visualizing a dashboard and its constituent components. 4 | """ 5 | 6 | use Phoenix.Component 7 | alias Phoenix.LiveView.JS 8 | alias Luminous.{TimeRangeSelector, Variable, Utils} 9 | 10 | @doc """ 11 | This component is responsible for setting up various dashboard prerequisites 12 | """ 13 | attr :nonce, :string, 14 | required: false, 15 | default: nil, 16 | doc: "CSP nonce to be included as a script tag attribute if present" 17 | 18 | def setup(assigns) do 19 | ~H""" 20 | 34 | """ 35 | end 36 | 37 | @doc """ 38 | The dashboard component is responsible for rendering all the necessary elements: 39 | - title 40 | - variables 41 | - time range selector 42 | - panels 43 | 44 | Additionally, it registers callbacks for reacting to panel loading states. 45 | """ 46 | attr :dashboard, :map, required: true 47 | 48 | attr :nonce, :string, 49 | required: false, 50 | default: nil, 51 | doc: "CSP nonce to be included as a script tag attribute if present" 52 | 53 | def dashboard(assigns) do 54 | ~H""" 55 | <.setup nonce={@nonce} /> 56 |
57 |
58 |
<%= @dashboard.title %>
59 |
60 |
61 | <%= for var <- @dashboard.variables, !var.hidden do %> 62 | <.variable variable={var} /> 63 | <% end %> 64 |
65 | 66 |
67 | <.time_range time_zone={@dashboard.time_zone} /> 68 |
69 |
70 |
71 | 72 |
73 | <%= for panel <- @dashboard.panels do %> 74 | <.panel panel={panel} dashboard={@dashboard} /> 75 | <% end %> 76 |
77 |
78 | """ 79 | end 80 | 81 | @doc """ 82 | This component is responsible for rendering a `Panel` by rendering all the common panel elements 83 | and then delegating the rendering to the concrete `Panel` 84 | """ 85 | attr :panel, :map, required: true 86 | attr :dashboard, :map, required: true 87 | 88 | def panel(assigns) do 89 | ~H""" 90 |
91 |
92 | 116 | 117 | <%= if has_panel_actions?(@panel) do %> 118 |
123 |
128 | 129 | 130 | 131 |
132 | 154 |
155 | <% end %> 156 | 157 |
158 |
159 | <%= interpolate(@panel.title, @dashboard.variables) %> 160 |
161 | <%= unless is_nil(@panel.description) do %> 162 |
163 | 170 | 176 | 177 |
178 | <%= @panel.description %> 179 |
180 |
181 | <% end %> 182 |
183 |
184 | 185 | <%= apply(@panel.type, :render, [assigns]) %> 186 |
187 | """ 188 | end 189 | 190 | defp has_panel_actions?(panel), do: panel |> get_panel_actions |> length > 0 191 | 192 | defp get_panel_actions(panel) do 193 | if function_exported?(panel.type, :actions, 0) do 194 | apply(panel.type, :actions, []) 195 | else 196 | [] 197 | end 198 | end 199 | 200 | @doc """ 201 | This component is responsible for rendering the `Luminous.TimeRange` component. 202 | It consists of a date range picker and a presets dropdown. 203 | """ 204 | attr :time_zone, :string, required: true 205 | attr :presets, :list, required: false, default: nil 206 | attr :class, :string, required: false, default: "" 207 | 208 | def time_range(assigns) do 209 | presets = 210 | if is_nil(assigns.presets), do: Luminous.TimeRangeSelector.presets(), else: assigns.presets 211 | 212 | assigns = assign(assigns, presets: presets) 213 | 214 | ~H""" 215 |
216 |
217 | 218 | 225 | 226 |
227 | 242 | 261 |
262 |
263 |
264 | <%= @time_zone |> DateTime.now!() |> Calendar.strftime("%Z") %> 265 |
266 |
267 | """ 268 | end 269 | 270 | @doc """ 271 | This component is responsible for rendering the dropdown of the assigned `Variable`. 272 | """ 273 | attr :variable, :map, required: true 274 | 275 | def variable(%{variable: %{type: :single}} = assigns) do 276 | ~H""" 277 |
282 | 304 | 305 | 325 |
326 | """ 327 | end 328 | 329 | def variable(%{variable: %{type: :multi}} = assigns) do 330 | ~H""" 331 |
JS.dispatch("clickAway", detail: %{"var_id" => @variable.id}) 338 | } 339 | > 340 | 367 | 368 |
369 |
370 | 401 |
402 | 410 | 418 |
419 |
420 |
    421 |
  • 426 | 441 |
  • 442 |
443 |
444 |
445 | """ 446 | end 447 | 448 | defp show_dropdown(dropdown_id) do 449 | JS.show( 450 | to: "##{dropdown_id}", 451 | transition: 452 | {"lmn-dropdown-transition-enter", "lmn-dropdown-transition-start", 453 | "lmn-dropdown-transition-end"} 454 | ) 455 | end 456 | 457 | defp hide_dropdown(dropdown_id) do 458 | JS.hide(to: "##{dropdown_id}") 459 | end 460 | 461 | # Interpolate all occurences of variable IDs in the format `$variable.id` in the string 462 | # with the variable's descriptive value label. For example, the string: "Energy for asset $asset_var" 463 | # will be replaced by the label of the variable with id `:asset_var` in variables. 464 | defp interpolate(nil, _), do: "" 465 | 466 | defp interpolate(string, variables) do 467 | Enum.reduce(variables, string, fn var, title -> 468 | String.replace(title, "$#{var.id}", "#{Variable.get_current_label(var)}") 469 | end) 470 | end 471 | end 472 | -------------------------------------------------------------------------------- /test/luminous/live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Luminous.LiveTest do 2 | alias Luminous.TimeRange 3 | use Luminous.ConnCase, async: true 4 | use AssertHTML 5 | use AssertEventually, timeout: 500, interval: 10 6 | 7 | alias Luminous.{Attributes, Query, Panel, TimeRange, Variable} 8 | 9 | defmodule Variables do 10 | @moduledoc false 11 | 12 | @behaviour Variable 13 | @impl true 14 | def variable(:var1, _), do: ["a", "b", "c"] 15 | def variable(:var2, _), do: ["1", "2", "3"] 16 | def variable(:multi_var, _), do: ["north", "south", "east", "west"] 17 | def variable(:empty, _), do: [] 18 | end 19 | 20 | defmodule Queries do 21 | @moduledoc false 22 | 23 | @behaviour Query 24 | @impl true 25 | def query(:q1, _time_range, _variables) do 26 | [ 27 | [{:time, ~U[2022-08-19T10:00:00Z]}, {"foo", 10}, {"bar", 100}], 28 | [{:time, ~U[2022-08-19T11:00:00Z]}, {"foo", 11}, {"bar", 101}] 29 | ] 30 | end 31 | 32 | def query(:q2, _time_range, _variables) do 33 | %{"foo" => 666} 34 | end 35 | 36 | def query(:q3, time_range, _variables) do 37 | val = 38 | if DateTime.compare(time_range.to, ~U[2022-09-24T20:59:59Z]) == :eq do 39 | 666 40 | else 41 | Decimal.new(0) 42 | end 43 | 44 | %{foo: val} 45 | end 46 | 47 | def query(:q4, _time_range, _variables) do 48 | %{"foo" => 666} 49 | end 50 | 51 | def query(:q5, _time_range, _variables) do 52 | %{"foo" => 66, "bar" => 88} 53 | end 54 | 55 | def query(:q6, _time_range, _variables) do 56 | %{"str" => "Just show this"} 57 | end 58 | 59 | def query(:q7, _time_range, _variables) do 60 | [ 61 | %{"label" => "row1", "foo" => 3, "bar" => 88}, 62 | %{"label" => "row2", "foo" => 4, "bar" => 99} 63 | ] 64 | end 65 | 66 | def query(:q8, _time_range, _variables) do 67 | 11 68 | end 69 | 70 | def query(:q9, _time_range, _variables) do 71 | [] 72 | end 73 | 74 | def query(:q10, _time_range, _variables) do 75 | [ 76 | {"foo", "452,64"}, 77 | {"bar", "260.238,4"} 78 | ] 79 | end 80 | 81 | def query(:q11, _time_range, _variables) do 82 | [{"foo", nil}] 83 | end 84 | end 85 | 86 | def set_dashboard(view, dashboard), do: send(view.pid, {self(), {:dashboard, dashboard}}) 87 | 88 | describe "panels" do 89 | test "sends the correct data to the chart panel", %{conn: conn} do 90 | dashboard = [ 91 | title: "Test", 92 | panels: [ 93 | Panel.define!( 94 | type: Panel.Chart, 95 | id: :p1, 96 | title: "Panel 1", 97 | queries: [Query.define(:q1, Queries)], 98 | ylabel: "Foo (μCKR)", 99 | data_attributes: %{ 100 | "foo" => [type: :line, unit: "μCKR", fill: true], 101 | "bar" => [type: :bar, unit: "μCKR"] 102 | } 103 | ) 104 | ] 105 | ] 106 | 107 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 108 | 109 | set_dashboard(view, dashboard) 110 | 111 | assert view |> element("#panel-p1-title") |> render() =~ "Panel 1" 112 | 113 | schema = Attributes.Schema.data() ++ Panel.Chart.data_attributes() 114 | 115 | expected_data = %{ 116 | datasets: [ 117 | %{ 118 | attrs: 119 | Attributes.parse!( 120 | [ 121 | fill: true, 122 | type: :line, 123 | unit: "μCKR" 124 | ], 125 | schema 126 | ), 127 | label: "foo", 128 | rows: [ 129 | %{x: 1_660_903_200_000, y: Decimal.new(10)}, 130 | %{x: 1_660_906_800_000, y: Decimal.new(11)} 131 | ], 132 | stats: %{ 133 | avg: Decimal.new(11), 134 | label: "foo", 135 | max: Decimal.new(11), 136 | min: Decimal.new(10), 137 | n: 2, 138 | sum: Decimal.new(21) 139 | } 140 | }, 141 | %{ 142 | attrs: 143 | Attributes.parse!( 144 | [ 145 | type: :bar, 146 | unit: "μCKR" 147 | ], 148 | schema 149 | ), 150 | label: "bar", 151 | rows: [ 152 | %{x: 1_660_903_200_000, y: Decimal.new(100)}, 153 | %{x: 1_660_906_800_000, y: Decimal.new(101)} 154 | ], 155 | stats: %{ 156 | avg: Decimal.new(101), 157 | label: "bar", 158 | max: Decimal.new(101), 159 | min: Decimal.new(100), 160 | n: 2, 161 | sum: Decimal.new(201) 162 | } 163 | } 164 | ], 165 | stacked_x: false, 166 | stacked_y: false, 167 | time_zone: "Europe/Athens", 168 | xlabel: "", 169 | ylabel: "Foo (μCKR)", 170 | y_min_value: nil, 171 | y_max_value: nil 172 | } 173 | 174 | assert_push_event(view, "panel-p1::refresh-data", ^expected_data) 175 | end 176 | 177 | test "renders the correct data in the stat panels", %{conn: conn} do 178 | dashboard = [ 179 | title: "Test", 180 | panels: [ 181 | Panel.define!( 182 | type: Panel.Stat, 183 | id: :p2, 184 | title: "Panel 2", 185 | queries: [Query.define(:q2, Queries)], 186 | data_attributes: %{ 187 | "foo" => [unit: "$", title: "Bar ($)"] 188 | } 189 | ), 190 | Panel.define!( 191 | type: Panel.Stat, 192 | id: :p4, 193 | title: "Panel 4", 194 | queries: [Query.define(:q4, Queries)], 195 | data_attributes: %{"foo" => [unit: "$"]} 196 | ), 197 | Panel.define!( 198 | type: Panel.Stat, 199 | id: :p5, 200 | title: "Panel 5", 201 | queries: [Query.define(:q5, Queries)], 202 | data_attributes: %{ 203 | "foo" => [unit: "$"], 204 | "bar" => [unit: "€"] 205 | } 206 | ), 207 | Panel.define!( 208 | type: Panel.Stat, 209 | id: :p6, 210 | title: "Panel 6", 211 | queries: [Query.define(:q6, Queries)] 212 | ), 213 | Panel.define!( 214 | type: Panel.Stat, 215 | id: :p8, 216 | title: "Panel 8 (stat with simple value)", 217 | queries: [Query.define(:q8, Queries)] 218 | ), 219 | Panel.define!( 220 | type: Panel.Stat, 221 | id: :p9, 222 | title: "Panel 9 (empty stat)", 223 | queries: [Query.define(:q9, Queries)] 224 | ), 225 | Panel.define!( 226 | type: Panel.Stat, 227 | id: :p10, 228 | title: "Panel 10 (stats as list of 2-tuples)", 229 | queries: [Query.define(:q10, Queries)], 230 | data_attributes: %{ 231 | "foo" => [unit: "$"], 232 | "bar" => [unit: "€"] 233 | } 234 | ), 235 | Panel.define!( 236 | type: Panel.Stat, 237 | id: :p11, 238 | title: "Panel 11 (nil stat)", 239 | queries: [Query.define(:q11, Queries)] 240 | ) 241 | ] 242 | ] 243 | 244 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 245 | set_dashboard(view, dashboard) 246 | 247 | view 248 | |> render() 249 | |> assert_html("#panel-p2-stat-0-value", text: "666") 250 | |> assert_eventually() 251 | 252 | html = render(view) 253 | 254 | assert_html(html, "#panel-p2-title", text: "Panel 2") 255 | assert_html(html, "#panel-p2-stat-0-unit", text: "$") 256 | assert_html(html, "#panel-p2-stat-0-column-title", text: "Bar ($)") 257 | 258 | assert_html(html, "#panel-p4-title", text: "Panel 4") 259 | assert_html(html, "#panel-p4-stat-0-value", text: "666") 260 | assert_html(html, "#panel-p4-stat-0-unit", text: "$") 261 | 262 | assert_html(html, "#panel-p5-title", text: "Panel 5") 263 | assert_html(html, "#panel-p5-stat-0-value", text: "88") 264 | assert_html(html, "#panel-p5-stat-0-unit", text: "€") 265 | assert_html(html, "#panel-p5-stat-1-value", text: "66") 266 | assert_html(html, "#panel-p5-stat-1-unit", text: "$") 267 | 268 | assert_html(html, "#panel-p6-title", text: "Panel 6") 269 | assert_html(html, "#panel-p6-stat-0-value", text: "Just show this") 270 | 271 | assert_html(html, "#panel-p8-title", text: "Panel 8 (stat with simple value)") 272 | assert_html(html, "#panel-p8-stat-0-value", text: "11") 273 | 274 | assert_html(html, "#panel-p9-title", text: "Panel 9 (empty stat)") 275 | assert_html(html, "#panel-p9-stat-values", text: "-") 276 | 277 | assert_html(html, "#panel-p10-title", text: "Panel 10 (stats as list of 2-tuples)") 278 | assert_html(html, "#panel-p10-stat-0-value", text: "452,64") 279 | assert_html(html, "#panel-p10-stat-0-unit", text: "$") 280 | assert_html(html, "#panel-p10-stat-1-value", text: "260.238,4") 281 | assert_html(html, "#panel-p10-stat-1-unit", text: "€") 282 | 283 | assert_html(html, "#panel-p11-title", text: "Panel 11 (nil stat)") 284 | assert_html(html, "#panel-p11-stat-0-value", text: "-") 285 | end 286 | 287 | test "does not push the event in the case of the stat panel", %{conn: conn} do 288 | dashboard = [ 289 | title: "Test", 290 | panels: [ 291 | Panel.define!( 292 | type: Panel.Stat, 293 | id: :p2, 294 | title: "Panel 2", 295 | queries: [Query.define(:q2, Queries)], 296 | data_attributes: %{ 297 | "foo" => [unit: "$", title: "Bar ($)"] 298 | } 299 | ) 300 | ] 301 | ] 302 | 303 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 304 | set_dashboard(view, dashboard) 305 | 306 | # let's assert that we will not receive the message in the case of the Stat panel 307 | # not the most solid of tests but with a timeout of 500ms it should do the job 308 | 309 | assert %{proxy: {ref, _topic, _}} = view 310 | 311 | refute_receive {^ref, 312 | {:push_event, "panel-p2::refresh-data", 313 | %{stats: [%{title: "Bar ($)", unit: "$", value: 666}]}}}, 314 | 200 315 | end 316 | 317 | test "sends the correct data to the table panel", %{conn: conn} do 318 | dashboard = [ 319 | title: "Test", 320 | panels: [ 321 | Panel.define!( 322 | type: Panel.Table, 323 | id: :p7, 324 | title: "Panel 7 (table)", 325 | queries: [Query.define(:q7, Queries)], 326 | data_attributes: %{ 327 | "label" => [title: "Label", order: 0, halign: :center], 328 | "foo" => [title: "Foo", order: 1, halign: :right, table_totals: :sum], 329 | "bar" => [ 330 | title: "Bar", 331 | order: 2, 332 | halign: :right, 333 | table_totals: :avg, 334 | number_formatting: [ 335 | thousand_separator: ".", 336 | decimal_separator: ",", 337 | precision: 2 338 | ] 339 | ] 340 | } 341 | ) 342 | ] 343 | ] 344 | 345 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 346 | set_dashboard(view, dashboard) 347 | 348 | assert view |> element("#panel-p7-title") |> render() =~ "Panel 7 (table)" 349 | 350 | expected_data = %{ 351 | columns: [ 352 | %{ 353 | field: "label", 354 | headerHozAlign: :center, 355 | hozAlign: :center, 356 | title: "Label", 357 | formatter: "textarea" 358 | }, 359 | %{ 360 | field: "foo", 361 | headerHozAlign: :right, 362 | hozAlign: :right, 363 | title: "Foo", 364 | bottomCalc: :sum, 365 | formatter: "textarea" 366 | }, 367 | %{ 368 | field: "bar", 369 | headerHozAlign: :right, 370 | hozAlign: :right, 371 | title: "Bar", 372 | formatter: "money", 373 | formatterParams: %{decimal: ",", thousand: ".", precision: 2}, 374 | bottomCalc: :avg, 375 | bottomCalcFormatter: "money", 376 | bottomCalcFormatterParams: %{decimal: ",", thousand: ".", precision: 2} 377 | } 378 | ], 379 | rows: [ 380 | %{"bar" => 88, "foo" => 3, "label" => "row1"}, 381 | %{"bar" => 99, "foo" => 4, "label" => "row2"} 382 | ], 383 | attributes: %{page_size: 10} 384 | } 385 | 386 | assert_push_event(view, "panel-p7::refresh-data", ^expected_data) 387 | end 388 | 389 | test "sends the loading/loaded event to all panels", %{conn: conn} do 390 | dashboard = [ 391 | title: "Test", 392 | panels: [ 393 | Panel.define!( 394 | type: Panel.Chart, 395 | id: :p1, 396 | title: "Panel 1", 397 | queries: [Query.define(:q1, Queries)], 398 | ylabel: "Foo (μCKR)", 399 | data_attributes: %{ 400 | "foo" => [type: :line, unit: "μCKR", fill: true], 401 | "bar" => [type: :bar, unit: "μCKR"] 402 | } 403 | ), 404 | Panel.define!( 405 | type: Panel.Stat, 406 | id: :p2, 407 | title: "Panel 2", 408 | queries: [Query.define(:q2, Queries)], 409 | data_attributes: %{ 410 | "foo" => [unit: "$", title: "Bar ($)"] 411 | } 412 | ) 413 | ] 414 | ] 415 | 416 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 417 | set_dashboard(view, dashboard) 418 | 419 | assert_push_event(view, "panel:load:start", %{id: :p1}) 420 | assert_push_event(view, "panel:load:start", %{id: :p2}) 421 | 422 | assert_push_event(view, "panel:load:end", %{id: :p1}) 423 | assert_push_event(view, "panel:load:end", %{id: :p2}) 424 | end 425 | end 426 | 427 | describe "time range" do 428 | test "when the selected time range changes", %{conn: conn} do 429 | dashboard = [ 430 | title: "Test", 431 | panels: [ 432 | Panel.define!( 433 | type: Panel.Stat, 434 | id: :p3, 435 | title: "Panel 3", 436 | queries: [Query.define(:q3, Queries)], 437 | data_attributes: %{ 438 | foo: [unit: "$", title: "Bar ($)"] 439 | } 440 | ) 441 | ] 442 | ] 443 | 444 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 445 | set_dashboard(view, dashboard) 446 | 447 | assert has_element?(view, "#panel-p3-title", "Panel 3") 448 | assert has_element?(view, "#panel-p3-stat-values", "0") 449 | assert has_element?(view, "#panel-p3-stat-values", "$") 450 | assert has_element?(view, "#panel-p3-stat-values", "Bar ($)") 451 | 452 | from = DateTime.new!(~D[2022-09-19], ~T[00:00:00], "Europe/Athens") 453 | to = DateTime.new!(~D[2022-09-24], ~T[23:59:59], "Europe/Athens") 454 | 455 | # select a different time range 456 | view 457 | |> element("#time-range-selector") 458 | |> render_hook("lmn_time_range_change", %{ 459 | "from" => DateTime.to_iso8601(from), 460 | "to" => DateTime.to_iso8601(to) 461 | }) 462 | 463 | refute has_element?(view, "#panel-p3-stat-values", "0") 464 | assert has_element?(view, "#panel-p3-stat-values", "666") 465 | end 466 | 467 | test "when a time range preset is selected", %{conn: conn} do 468 | dashboard = [ 469 | title: "Test", 470 | variables: [ 471 | Variable.define!(id: :var1, label: "Var 1", module: Variables) 472 | ] 473 | ] 474 | 475 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 476 | 477 | set_dashboard(view, dashboard) 478 | 479 | view 480 | |> element("#time-range-preset-Yesterday") 481 | |> render_click() 482 | 483 | # we use "Europe/Athens" because this is the time zone defined in TestDashboardLive module 484 | yesterday = DateTime.now!("Europe/Athens") |> DateTime.to_date() |> Date.add(-1) 485 | 486 | from = 487 | yesterday 488 | |> DateTime.new!(~T[00:00:00], "Europe/Athens") 489 | |> DateTime.to_unix() 490 | 491 | to = DateTime.new!(yesterday, ~T[23:59:59], "Europe/Athens") |> DateTime.to_unix() 492 | 493 | assert_patched( 494 | view, 495 | Routes.dashboard_path(conn, :index, 496 | var1: "a", 497 | from: from, 498 | to: to 499 | ) 500 | ) 501 | end 502 | 503 | test "when the default time range preset is selected", %{conn: conn} do 504 | dashboard = [ 505 | title: "Test" 506 | ] 507 | 508 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 509 | 510 | set_dashboard(view, dashboard) 511 | 512 | view 513 | |> element("#time-range-preset-Default") 514 | |> render_click() 515 | 516 | default = TimeRange.default(TimeRange.default_time_zone()) 517 | 518 | assert_patched( 519 | view, 520 | Routes.dashboard_path(conn, :index, 521 | from: DateTime.to_unix(default.from), 522 | to: DateTime.to_unix(default.to) 523 | ) 524 | ) 525 | end 526 | end 527 | 528 | describe "variables" do 529 | test "displays all current variable values", %{conn: conn} do 530 | dashboard = [ 531 | title: "Test", 532 | variables: [ 533 | Variable.define!(id: :var1, label: "Var 1", module: Variables), 534 | Variable.define!(id: :var2, label: "Var 2", module: Variables) 535 | ] 536 | ] 537 | 538 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 539 | 540 | set_dashboard(view, dashboard) 541 | 542 | assert has_element?(view, "#var1-dropdown li", "a") 543 | assert has_element?(view, "#var2-dropdown li", "1") 544 | end 545 | 546 | test "when a variable value is selected", %{conn: conn} do 547 | dashboard = [ 548 | title: "Test", 549 | variables: [ 550 | Variable.define!(id: :var1, label: "Var 1", module: Variables), 551 | Variable.define!(id: :var2, label: "Var 2", module: Variables) 552 | ] 553 | ] 554 | 555 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 556 | 557 | set_dashboard(view, dashboard) 558 | 559 | view |> element("#var1-b") |> render_click() 560 | 561 | # we use "Europe/Athens" because this is the time zone defined in TestDashboardLive module 562 | default = Luminous.TimeRange.default("Europe/Athens") 563 | 564 | assert_patched( 565 | view, 566 | Routes.dashboard_path(conn, :index, 567 | var1: "b", 568 | var2: 1, 569 | from: DateTime.to_unix(default.from), 570 | to: DateTime.to_unix(default.to) 571 | ) 572 | ) 573 | 574 | view |> element("#var2-3") |> render_click() 575 | 576 | assert_patched( 577 | view, 578 | Routes.dashboard_path(conn, :index, 579 | var1: "b", 580 | var2: 3, 581 | from: DateTime.to_unix(default.from), 582 | to: DateTime.to_unix(default.to) 583 | ) 584 | ) 585 | end 586 | 587 | test "should not display hidden variables", %{conn: conn} do 588 | dashboard = [ 589 | title: "Test", 590 | variables: [ 591 | Variable.define!(id: :var1, label: "Var 1", module: Variables), 592 | Variable.define!(id: :var2, label: "Var 2", module: Variables, hidden: true), 593 | Variable.define!(id: :multi_var, label: "Multi", module: Variables) 594 | ] 595 | ] 596 | 597 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 598 | 599 | set_dashboard(view, dashboard) 600 | 601 | assert has_element?(view, "#var1-dropdown") 602 | refute has_element?(view, "#var2-dropdown") 603 | assert has_element?(view, "#multi_var-dropdown") 604 | end 605 | 606 | test "should handle current variable value that is nil", %{conn: conn} do 607 | dashboard = [ 608 | title: "Test", 609 | variables: [ 610 | Variable.define!(id: :var1, label: "Var 1", module: Variables), 611 | Variable.define!(id: :empty, label: "Empty", module: Variables) 612 | ] 613 | ] 614 | 615 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 616 | 617 | set_dashboard(view, dashboard) 618 | 619 | assert view |> element("#empty-dropdown-label") |> render() =~ ">Empty: <" 620 | end 621 | end 622 | 623 | describe "multi-select variables" do 624 | setup do 625 | # we use "Europe/Athens" because this is the time zone defined in TestDashboardLive module 626 | default = Luminous.TimeRange.default("Europe/Athens") 627 | 628 | %{from: DateTime.to_unix(default.from), to: DateTime.to_unix(default.to)} 629 | end 630 | 631 | test "when a single value is selected", %{conn: conn, from: from, to: to} do 632 | dashboard = [ 633 | title: "Test", 634 | variables: [ 635 | Variable.define!(id: :multi_var, type: :multi, label: "Multi", module: Variables) 636 | ] 637 | ] 638 | 639 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 640 | 641 | set_dashboard(view, dashboard) 642 | 643 | assert has_element?(view, "#multi_var-dropdown", "Multi: All") 644 | 645 | view 646 | |> element("#multi_var-dropdown") 647 | |> render_hook("lmn_variable_updated", %{variable: "multi_var", value: ["north"]}) 648 | 649 | assert_patched( 650 | view, 651 | Routes.dashboard_path(conn, :index, 652 | multi_var: ["north"], 653 | from: from, 654 | to: to 655 | ) 656 | ) 657 | 658 | assert has_element?(view, "#multi_var-dropdown", "Multi: north") 659 | end 660 | 661 | test "when two values are selected", %{conn: conn, from: from, to: to} do 662 | dashboard = [ 663 | title: "Test", 664 | variables: [ 665 | Variable.define!(id: :multi_var, type: :multi, label: "Multi", module: Variables) 666 | ] 667 | ] 668 | 669 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 670 | 671 | set_dashboard(view, dashboard) 672 | 673 | assert has_element?(view, "#multi_var-dropdown", "Multi: All") 674 | 675 | view 676 | |> element("#multi_var-dropdown") 677 | |> render_hook("lmn_variable_updated", %{variable: "multi_var", value: ["north", "south"]}) 678 | 679 | assert_patched( 680 | view, 681 | Routes.dashboard_path(conn, :index, 682 | multi_var: ["north", "south"], 683 | from: from, 684 | to: to 685 | ) 686 | ) 687 | 688 | assert has_element?(view, "#multi_var-dropdown", "Multi: 2 selected") 689 | end 690 | 691 | test "when no value is selected", %{conn: conn, from: from, to: to} do 692 | dashboard = [ 693 | title: "Test", 694 | variables: [ 695 | Variable.define!(id: :multi_var, type: :multi, label: "Multi", module: Variables) 696 | ] 697 | ] 698 | 699 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index)) 700 | 701 | set_dashboard(view, dashboard) 702 | 703 | assert has_element?(view, "#multi_var-dropdown", "Multi: All") 704 | 705 | view 706 | |> element("#multi_var-dropdown") 707 | |> render_hook("lmn_variable_updated", %{variable: "multi_var", value: []}) 708 | 709 | assert_patched( 710 | view, 711 | Routes.dashboard_path(conn, :index, multi_var: "none", from: from, to: to) 712 | ) 713 | 714 | assert has_element?(view, "#multi_var-dropdown", "Multi: None") 715 | end 716 | end 717 | end 718 | --------------------------------------------------------------------------------