├── test ├── test_helper.exs ├── pulsar_test.exs └── dashboard_test.exs ├── assets └── pulsar-demo.gif ├── mix.lock ├── CHANGES.md ├── lib ├── pulsar │ ├── application.ex │ ├── dashboard │ │ └── terminal.ex │ ├── dashboard_server.ex │ └── dashboard.ex └── pulsar.ex ├── .gitignore ├── config └── config.exs ├── mix.exs ├── demo └── batcave.exs ├── README.md └── LICENSE.txt /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /assets/pulsar-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walmartlabs/pulsar/HEAD/assets/pulsar-demo.gif -------------------------------------------------------------------------------- /test/pulsar_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PulsarTest do 2 | use ExUnit.Case 3 | doctest Pulsar 4 | 5 | end 6 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [], [], "hexpm"}, 2 | "ex_doc": {:hex, :ex_doc, "0.17.1", "39f777415e769992e6732d9589dc5846ea587f01412241f4a774664c746affbb", [], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}} 3 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Release History 2 | 3 | ## 0.3.0 -- UNRELEASED 4 | 5 | Changed the logic so that completed jobs *do not* bubble up. 6 | Completed jobs are culled only when they are above all completed jobs. 7 | 8 | ## 0.2.0 -- 13 Oct 2017 9 | 10 | Added `Pulsar.prefix/2`: set a prefix displayed immediately before the job message. 11 | 12 | Added `Pulsar.pause/0` and `Pulsar.resume/0`: temporarily disable the 13 | dashboard to allow for other console output. 14 | 15 | ## 0.1.0 -- 9 Oct 2017 16 | 17 | Initial release. 18 | -------------------------------------------------------------------------------- /lib/pulsar/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulsar.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | # List all child processes to be supervised 10 | children = [ 11 | {Pulsar.DashboardServer, name: Pulsar.DashboardServer} 12 | ] 13 | 14 | {:ok, _} = Supervisor.start_link(children, 15 | strategy: :one_for_one, 16 | name: Pulsar.Supervisor) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | pulsar.sublime-* 23 | 24 | tags 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /lib/pulsar/dashboard/terminal.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulsar.Dashboard.Terminal do 2 | @moduledoc """ 3 | Functions to handle termcap style screen rendering. 4 | 5 | This is currently hard-coded for xterm. 6 | """ 7 | 8 | def cursor_invisible(), do: <<0x1b, 0x5b, 0x3f, 0x32, 0x35, 0x6c>> # civis 9 | 10 | def save_cursor_position, do: <<0x1b, 0x37>> # sc 11 | 12 | def cursor_up(lines) do 13 | if lines > 0 do 14 | <<0x1b, 0x5b>> <> to_string(lines) <> <<0x41>> 15 | end 16 | end 17 | 18 | def leftmost_column(), do: <<0x1b, 0x5b, 0x31, 0x47>> # hpa 0 19 | 20 | def clear_to_eol(), do: <<0x1b, 0x5b, 0x4b>> # ce 21 | 22 | def clear_to_eos, do: <<0x1b, 0x5b, 0x4a>> # cd 23 | 24 | def restore_cursor_position(), do: <<0x1b, 0x38>> # rc 25 | 26 | def cursor_visible(), do: <<0x1b, 0x5b, 0x3f, 0x31, 0x32, 0x6c, 0x1b, 0x5b, 0x3f, 0x32, 0x35, 0x68>> # cnorm 27 | 28 | end 29 | -------------------------------------------------------------------------------- /test/dashboard_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DashboardTest do 2 | use ExUnit.Case 3 | doctest Pulsar.Dashboard 4 | 5 | alias Pulsar.Dashboard, as: D 6 | 7 | @root D.new_dashboard(1000) 8 | 9 | # Uses a bit of inside knowledge about the structure of the dashboard 10 | 11 | test "new dashboard is empty" do 12 | assert Enum.count(@root.jobs) == 0 13 | end 14 | 15 | test "jobs are added at bottom" do 16 | dashboard = @root 17 | |> add_job(1, "first") 18 | |> add_job(2, "second") 19 | |> flush() 20 | 21 | assert messages(dashboard) == ["first", "second"] 22 | end 23 | 24 | defp add_job(dashboard, id, message) do 25 | D.add_job(dashboard, id) 26 | |> D.update_job(id, message: message) 27 | end 28 | 29 | defp flush(dashboard) do 30 | {new_dashboard, _} = D.flush(dashboard) 31 | new_dashboard 32 | end 33 | 34 | defp messages(dashboard) do 35 | dashboard.jobs 36 | |> Map.values() 37 | |> Enum.sort_by(fn m -> m.line end, &>=/2) 38 | |> Enum.map(fn m -> m.message end) 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :pulsar, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:pulsar, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Pulsar.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :pulsar, 7 | version: "0.3.0", 8 | elixir: "~> 1.5", 9 | start_permanent: Mix.env == :prod, 10 | deps: deps(), 11 | package: package(), 12 | docs: docs(), 13 | 14 | description: description(), 15 | source_url: "https://github.com/walmartlabs/pulsar", 16 | name: "Pulsar", 17 | ] 18 | end 19 | 20 | # Run "mix help compile.app" to learn about applications. 21 | def application do 22 | [ 23 | mod: {Pulsar.Application, nil}, 24 | registered: [Pulsar.DashboardServer], 25 | env: [ 26 | flush_interval: 100, 27 | active_highlight_duration: 1000 28 | ], 29 | ] 30 | end 31 | 32 | # Run "mix help deps" to learn about dependencies. 33 | defp deps do 34 | [ 35 | {:ex_doc, "~> 0.16", only: :dev, runtime: false}, 36 | ] 37 | end 38 | 39 | defp package do 40 | [ 41 | maintainers: ["Howard M. Lewis Ship"], 42 | licenses: ["Apache 2.0"], 43 | links: %{"GitHub" => "https://github.com/walmartlabs/pulsar"} 44 | ] 45 | end 46 | 47 | defp description() do 48 | """ 49 | A text-based, dynamic dashboard. Jobs update in place, using xterm command codes. 50 | """ 51 | end 52 | 53 | def docs() do 54 | [ 55 | source_url: "http://github.com/walmartlabs/pulsar", 56 | extras: ["README.md", "CHANGES.md"], 57 | assets: "assets", 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /demo/batcave.exs: -------------------------------------------------------------------------------- 1 | defmodule Batcave do 2 | 3 | def sleep(ms), do: Process.sleep(ms) 4 | 5 | def progress_bar(current, target) do 6 | completed = Float.floor(30 * current / target) |> trunc 7 | 8 | "" 9 | |> String.pad_leading(completed, "\u2593") 10 | |> String.pad_trailing(30, "\u2591") 11 | 12 | end 13 | 14 | def progress(message, target, rate) do 15 | job = Pulsar.new_job() 16 | 17 | for i <- 0..target do 18 | Pulsar.message(job, 19 | "#{String.pad_trailing(message, 30)} #{progress_bar(i, target)} #{i}/#{target}") 20 | sleep(rate) 21 | end 22 | 23 | job 24 | |> Pulsar.status(:ok) 25 | |> Pulsar.complete() 26 | end 27 | 28 | def timed(message, ms) do 29 | job = Pulsar.new_job() |> Pulsar.message(message) 30 | 31 | sleep(ms) 32 | 33 | job 34 | |> Pulsar.message(message <> " \u2713") 35 | |> Pulsar.status(:ok) 36 | |> Pulsar.complete() 37 | 38 | end 39 | 40 | def run() do 41 | 42 | n = 2 # tuning factor 43 | 44 | tasks = [ 45 | Task.async(__MODULE__, :timed, ["Atomic turbines to speed", n * 2000]), 46 | Task.async(__MODULE__, :progress, ["Rotating Batmobile platform", 180, n * 25]), 47 | Task.async(__MODULE__, :timed, ["Initializing on-board Bat-computer", n * 3000]), 48 | Task.async(__MODULE__, :progress, ["Loading Bat-fuel rods", 5, n * 750]) 49 | ] 50 | 51 | sleep(n * 2500) 52 | Pulsar.pause() 53 | IO.write( 54 | """ 55 | _, _ _ ,_ 56 | .o888P Y8o8Y Y888o. 57 | d88888 88888 88888b 58 | d888888b_ _d88888b_ _d888888b Booting On-Board Bat-Computer ... 59 | 8888888888888888888888888888888 60 | 8888888888888888888888888888888 61 | YJGS8P"Y888P"Y888P"Y888P"Y8888P 62 | Y888 '8' Y8P '8' 888Y 63 | '8o V o8' 64 | ` ` 65 | """) 66 | Pulsar.resume() 67 | 68 | for task <- tasks, do: Task.await(task, 20000) 69 | 70 | Pulsar.new_job() |> Pulsar.message("Please fasten your Bat-seatbelts") 71 | 72 | # Give it a moment for final updates and all 73 | sleep(1500) 74 | end 75 | 76 | end 77 | 78 | 79 | Batcave.run() 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pulsar 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/Pulsar.svg)](https://hex.pm/packages/pulsar) 4 | 5 | Writing a command line tool and want to keep the user informed about what's going on? 6 | Too often, applications either write nothing, or overwhelm the user with logged updates. 7 | Pulsar is used to provide just enough information to keep the user informed. 8 | 9 | Pulsar is a text-based, dynamic dashboard that lets processes communicate their status. 10 | Jobs can be created, updated, and completed asynchronously, and update in-place. 11 | This is intended for use in Elixir applications that run as command line tools. 12 | 13 | ![Demo](assets/pulsar-demo.gif) 14 | 15 | Jobs have a lifecycle: they are created, periodically updated, and eventually completed. 16 | Whenever a job is updated, it is highlighted for one second using bright, bold text. 17 | 18 | In the above demo, completed jobs were also set to status `:ok`, which displays them in green. 19 | 20 | [API Documentation](https://hexdocs.pm/pulsar/api-reference.html) 21 | 22 | ## Installation 23 | 24 | The package can be installed by adding `pulsar` to your list of dependencies in `mix.exs`: 25 | 26 | ```elixir 27 | def deps do 28 | [ 29 | {:pulsar, "~> 0.1.0"} 30 | ] 31 | end 32 | ``` 33 | ## Limitations 34 | 35 | Pulsar doesn't know the dimensions of the screen; large numbers of jobs in 36 | a short window will not render correctly. 37 | Likewise, long lines that wrap will cause incorrect output. 38 | 39 | Pulsar is currently hard-coded for xterm; in the future it will use the terminal capabilities 40 | database to identify what command codes generate each effect. 41 | 42 | Pulsar doesn't have any way to prevent other output to the console; 43 | that will cause confusing output unless `Pulsar.pause/0` and `Pulsar.resume/0` are 44 | used. 45 | 46 | Pulsar works best when either all the jobs have a similar lifecycle, or 47 | when new jobs are added and existing jobs are completed. 48 | 49 | ## Inspiration 50 | 51 | `docker pull`. That's it. Just wanted to have a tool that could keep me 52 | updated, cleanly, of a reasonable number of active jobs operating 53 | concurrently. 54 | 55 | ## License 56 | 57 | Released under the Apache Software License 2.0. 58 | -------------------------------------------------------------------------------- /lib/pulsar/dashboard_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulsar.DashboardServer do 2 | 3 | alias Pulsar.Dashboard, as: D 4 | 5 | @moduledoc """ 6 | Responsible for managing a Dashboard, updating it based on received messages, and 7 | periodically flushing it to output. 8 | 9 | The `Pulsar` module is the client API for creating and updating jobs. 10 | 11 | The `:pulsar` application defines two configuration values: 12 | 13 | * `:flush_interval` - interval at which output is written to the console 14 | 15 | * `:active_highlight_duration` - how long an updated job is "bright" 16 | 17 | Both values are in milliseconds. 18 | 19 | Updates to jobs accumluate between flushes; this reduces the amount of output 20 | that must be written. 21 | """ 22 | 23 | use GenServer 24 | 25 | def start_link(state) do 26 | GenServer.start_link(__MODULE__, state, name: __MODULE__) 27 | end 28 | 29 | def init(_) do 30 | enqueue_flush() 31 | 32 | dashboard = D.new_dashboard(Application.get_env(:pulsar, :active_highlight_duration)) 33 | 34 | {:ok, %{dashboard: dashboard, paused: false}} 35 | end 36 | 37 | def terminate(_reason, state) do 38 | # TODO: Shutdown the dashboard properly, marking all jobs as complete 39 | {_, output} = D.flush(state.dashboard) 40 | 41 | IO.write(output) 42 | end 43 | 44 | # -- requests sent from the client -- 45 | 46 | def handle_call(:job, _from, state) do 47 | jobid = System.unique_integer() 48 | 49 | {:reply, jobid, update_in(state.dashboard, &(D.add_job(&1, jobid)))} 50 | end 51 | 52 | def handle_call(:pause, _from, state) do 53 | if state.paused do 54 | {:reply, :ok, state} 55 | end 56 | {new_dashboard, output} = D.pause(state.dashboard) 57 | IO.write(output) 58 | {:reply, :ok, %{state | dashboard: new_dashboard, paused: true}} 59 | end 60 | 61 | def handle_cast(:resume, state) do 62 | {:noreply, %{state | paused: false}} 63 | end 64 | 65 | def handle_cast({:update, jobid, message}, state) do 66 | update_job(state, jobid, message: message) 67 | end 68 | 69 | def handle_cast({:complete, jobid}, state) do 70 | {:noreply, update_in(state.dashboard, &(D.complete_job(&1, jobid)))} 71 | end 72 | 73 | def handle_cast({:status, jobid, status}, state) do 74 | update_job(state, jobid, status: status) 75 | end 76 | 77 | def handle_cast({:prefix, jobid, prefix}, state) do 78 | update_job(state, jobid, prefix: prefix) 79 | end 80 | 81 | # -- internal callbacks 82 | 83 | def handle_info(:flush, state) do 84 | enqueue_flush() 85 | 86 | if state.paused do 87 | {:noreply, state} 88 | else 89 | {new_dashboard, output} = state.dashboard 90 | |> D.update() 91 | |> D.flush() 92 | 93 | IO.write(output) 94 | 95 | {:noreply, %{state | dashboard: new_dashboard}} 96 | end 97 | 98 | end 99 | 100 | defp enqueue_flush() do 101 | Process.send_after(self(), :flush, Application.get_env(:pulsar, :flush_interval)) 102 | end 103 | 104 | defp update_job(state, jobid, job_data) do 105 | new_dashboard = D.update_job(state.dashboard, jobid, job_data) 106 | {:noreply, %{state | dashboard: new_dashboard}} 107 | end 108 | 109 | end 110 | -------------------------------------------------------------------------------- /lib/pulsar.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulsar do 2 | @moduledoc """ 3 | This is the client API for Pulsar. 4 | 5 | Pulsar manages a simple text-mode dashboard of jobs. 6 | 7 | Jobs can be updated at any time; updates appear *in place*. 8 | 9 | When a job is updated, it will briefly be repainted in bold and/or bright text, 10 | then be redrawn in standard text. 11 | This is to draw attention to changes. 12 | 13 | Completed jobs bubble up above any incomplete jobs. 14 | 15 | Jobs may have a status, which drives font color. Normal jobs are in white. 16 | Jobs with status `:ok` are in green. 17 | Jobs with status `:error` are in red. 18 | 19 | Note that the actual colors are driven by the configuration of your terminal. 20 | 21 | Pulsar has no way to determine if other output is occuring. 22 | Care should be taken that logging is redirected to a file. 23 | Pulsar is appropriate to generally short-lived applications such as command line tools, 24 | who can ensure that output, including logging, is directed away from the console. 25 | 26 | """ 27 | 28 | @app_name Pulsar.DashboardServer 29 | 30 | @doc """ 31 | Creates a new job using the local server. 32 | 33 | Returns a job tuple that may be passed to the other functions. 34 | """ 35 | def new_job() do 36 | request_new_job(@app_name) 37 | end 38 | 39 | @doc """ 40 | Creates a new job using a remote server, from the `node` parameter. 41 | """ 42 | def new_job(node) do 43 | request_new_job({@app_name, node}) 44 | end 45 | 46 | @doc """ 47 | Given a previously created job, updates the message for the job. 48 | 49 | This will cause the job's line in the dashboard to update, and will briefly be 50 | highlighted. 51 | 52 | Returns the job. 53 | """ 54 | def message(job, message) do 55 | {process, jobid} = job 56 | GenServer.cast(process, {:update, jobid, message}) 57 | job 58 | end 59 | 60 | @doc """ 61 | Completes a previously created job. No further updates to the job 62 | should be sent. 63 | 64 | Returns the job. 65 | """ 66 | def complete(job) do 67 | {process, jobid} = job 68 | GenServer.cast(process, {:complete, jobid}) 69 | end 70 | 71 | @doc """ 72 | Updates the status of the job. 73 | `status` should be `:normal`, `:ok`, or `:error`. 74 | 75 | Returns the job. 76 | """ 77 | def status(job, status) do 78 | {process, jobid} = job 79 | GenServer.cast(process, {:status, jobid, status}) 80 | job 81 | end 82 | 83 | @doc """ 84 | Pauses the dashboard. 85 | 86 | The dashboard will clear itself when paused. 87 | Console output can then be written. 88 | 89 | Returns :ok, after the dashboard is cleared. 90 | 91 | To restore normal behavior to the dashboard, invoke `resume/0`. 92 | """ 93 | def pause() do 94 | GenServer.call(@app_name, :pause) 95 | end 96 | 97 | @doc """ 98 | Pauses the dashboard on the indicated node. 99 | """ 100 | def pause(node) do 101 | GenServer.call({@app_name, node}, :pause) 102 | end 103 | 104 | @doc """ 105 | Resumes the dashboard after a `pause/0`. 106 | """ 107 | 108 | def resume() do 109 | GenServer.cast(@app_name, :resume) 110 | end 111 | 112 | @doc """ 113 | Resumes the dashboard after a `pause/1`. 114 | """ 115 | def resume(node) do 116 | GenServer.cast({@app_name, node}, :resume) 117 | end 118 | 119 | @doc """ 120 | Sets the prefix for the job; this immediately precedes the message. 121 | Generally, the prefix provides a job with a title. 122 | 123 | There is no seperator between the prefix and the message, a prefix 124 | typically ends with ": " or "- ". 125 | 126 | Returns the job. 127 | """ 128 | def prefix(job, prefix) do 129 | {process, jobid} = job 130 | GenServer.cast(process, {:prefix, jobid, prefix}) 131 | job 132 | end 133 | 134 | defp request_new_job(server) do 135 | process = GenServer.whereis(server) 136 | {process, GenServer.call(process, :job)} 137 | end 138 | 139 | end 140 | -------------------------------------------------------------------------------- /lib/pulsar/dashboard.ex: -------------------------------------------------------------------------------- 1 | defmodule Pulsar.Dashboard do 2 | @moduledoc """ 3 | The logic for managing a set of jobs and updating them. 4 | """ 5 | 6 | alias IO.ANSI 7 | alias __MODULE__.Terminal, as: T 8 | 9 | # jobs is a map from job id to job 10 | # new_jobs is a count of the number of jobs added since the most recent flush, e.g., number of new lines to print on next flush 11 | defstruct jobs: %{}, new_jobs: 0, active_highlight_duration: 0 12 | 13 | @doc """ 14 | Creates a new, empty dashboard. 15 | 16 | `active_highlight_duration` is the number of milliseconds that a newly added or updated job 17 | should be rendered as active (bright). `clear_inactive` is used periodically to identify 18 | jobs that should be downgraded to inactive and re-rendered. 19 | """ 20 | def new_dashboard(active_highlight_duration) when active_highlight_duration > 0 do 21 | %__MODULE__{active_highlight_duration: active_highlight_duration} 22 | end 23 | 24 | 25 | @doc """ 26 | Updates an existing job in the dashboard, returning a modified dashboard. 27 | 28 | If the job does not exist, or is marked completed, the dashboard is returned unchanged. 29 | 30 | `job_data` is a keyword list of changes to make to the job. Supported keys are: 31 | 32 | * `:message` - a string 33 | * `:prefix` - a string 34 | * `:status` - an atom, one of `:normal`, `:error`, or `:ok` 35 | 36 | Returns the updated dashboard. 37 | """ 38 | def update_job(dashboard = %__MODULE__{}, jobid, job_data) do 39 | 40 | job = dashboard.jobs[jobid] 41 | 42 | if job && not(job.completed) do 43 | new_job = Enum.into(job_data, %{job | dirty: true, 44 | active: true, 45 | active_until: active_until(dashboard)}) 46 | 47 | put_in(dashboard.jobs[jobid], new_job) 48 | else 49 | dashboard 50 | end 51 | end 52 | 53 | @doc """ 54 | Add a new job to the dashboard. 55 | 56 | Returns the dashboard unchanged if the jobid already exists. 57 | """ 58 | def add_job(dashboard = %__MODULE__{}, jobid) when jobid != nil do 59 | if Map.has_key?(dashboard.jobs, jobid) do 60 | dashboard 61 | else 62 | job = %{ 63 | status: :normal, 64 | prefix: nil, 65 | message: nil, 66 | dirty: true, 67 | line: 1, 68 | active: true, 69 | completed: false, 70 | active_until: active_until(dashboard), 71 | } 72 | 73 | updater = fn (jobs) -> 74 | jobs 75 | |> move_each_job_up() 76 | |> Map.put(jobid, job) 77 | end 78 | 79 | dashboard 80 | |> update_jobs(updater) 81 | |> Map.update!(:new_jobs, &(&1 + 1)) 82 | end 83 | end 84 | 85 | @doc """ 86 | Marks a job as completed. Completed jobs float to the top of the list, above any 87 | non-completed jobs. Once marked as complete, a job is removed from the dashboard at the next 88 | flush. 89 | 90 | Returns the updated dashboard, or the input dashboard if the job doesn't exist or is already completed. 91 | """ 92 | def complete_job(dashboard = %__MODULE__{}, jobid) when jobid != nil do 93 | jobs = dashboard.jobs 94 | job = jobs[jobid] 95 | unless job && not(job.completed) do 96 | dashboard # job gone or missing 97 | else 98 | new_job = %{job | dirty: true, 99 | completed: true, 100 | active: true, 101 | active_until: active_until(dashboard)} 102 | 103 | new_jobs = jobs 104 | |> Map.put(jobid, new_job) 105 | 106 | Map.put(dashboard, :jobs, new_jobs) 107 | end 108 | end 109 | 110 | @doc """ 111 | Invoked periodically to clear the active flag of any job that has not been updated recently. 112 | Inactive jobs are marked dirty, to force a redisplay. 113 | """ 114 | def update(dashboard = %__MODULE__{}) do 115 | now = System.system_time(:milliseconds) 116 | 117 | updater = fn (job) -> 118 | if job.active and job.active_until <= now do 119 | %{job | active: false, dirty: true} 120 | else 121 | job 122 | end 123 | end 124 | 125 | update_each_job(dashboard, updater) 126 | end 127 | 128 | @doc """ 129 | Identify jobs that are 'dirty' (have pending updates) and redraws just those jobs 130 | in the dashboard. 131 | 132 | Returns a tuple of the updated dashboard and a enum of strings to send to `IO.write`. 133 | """ 134 | def flush(dashboard=%__MODULE__{}) do 135 | 136 | # When there are new jobs, add blank lines to the output for those new jobs 137 | 138 | new_job_lines = if dashboard.new_jobs == 0 do 139 | [] 140 | else 141 | for _ <- 1..dashboard.new_jobs, do: "\n" 142 | end 143 | 144 | dirty_job_groups = for {_, job} <- dashboard.jobs do 145 | if job.dirty do 146 | [ 147 | T.cursor_invisible(), 148 | T.save_cursor_position(), 149 | T.cursor_up(job.line), 150 | T.leftmost_column(), 151 | (case {job.active, job.status} do 152 | {true, :error} -> ANSI.light_red() 153 | {_, :error} -> ANSI.red() 154 | {true, :ok} -> ANSI.light_green() 155 | {_, :ok} -> ANSI.green() 156 | {true, _} -> ANSI.light_white() 157 | _ -> nil 158 | end), 159 | job.prefix, 160 | job.message, 161 | T.clear_to_eol(), 162 | T.restore_cursor_position(), 163 | T.cursor_visible(), 164 | ANSI.reset() 165 | ] 166 | end 167 | end 168 | 169 | # IO.write hates nils, so we have to filter out nil groups, 170 | # and nil chunks. 171 | all_chunks = dirty_job_groups 172 | |> Enum.reject(&nil?/1) 173 | |> Enum.reduce([], &Enum.into/2) 174 | |> Enum.reject(&nil?/1) 175 | |> Enum.into(new_job_lines) 176 | 177 | incomplete_line = dashboard.jobs 178 | |> Map.values() 179 | |> Enum.reject(fn m -> m.completed end) 180 | |> Enum.map(fn m -> m.line end) 181 | |> Enum.reduce(0, &max/2) 182 | 183 | # Everything has been flushed to screen and is no longer dirty. 184 | # Inactive, completed lines above incomplete_line are no longer 185 | # needed. 186 | new_jobs = Enum.reduce(dashboard.jobs, 187 | %{}, 188 | fn {jobid, job}, m -> 189 | if job.completed && not(job.active) && job.line > incomplete_line do 190 | m 191 | else 192 | Map.put(m, jobid, %{job | dirty: false}) 193 | end 194 | end) 195 | 196 | new_dashboard = %{dashboard | jobs: new_jobs, new_jobs: 0} 197 | 198 | {new_dashboard, all_chunks} 199 | end 200 | 201 | @doc """ 202 | A variant of flush used to temporarily shut down the dashboard before 203 | some other output. 204 | 205 | Returns a tuple of the updated dashboard, and output. 206 | 207 | The output moves the cursor to the top line of the dashboard, 208 | then clears to the end of the screen. This temporarily removes 209 | the dashboard from visibility, so that other output can be produced. 210 | 211 | The returned dashboard is configured so that the next call to `flush/1` 212 | will add new lines for all jobs, and repaint all lines (e.g., as if 213 | every job was freshly added). 214 | """ 215 | def pause(dashboard=%__MODULE__{}) do 216 | lines = Enum.count(dashboard.jobs) - dashboard.new_jobs 217 | output = if lines > 0 do 218 | [ 219 | T.leftmost_column(), 220 | T.cursor_up(lines), 221 | T.clear_to_eos() 222 | ] 223 | end 224 | 225 | new_dashboard = dashboard 226 | |> update_each_job(fn job -> put_in(job.dirty, true) end) 227 | |> Map.put(:new_jobs, Enum.count(dashboard.jobs)) 228 | 229 | {new_dashboard, output} 230 | end 231 | 232 | # -- PRIVATE -- 233 | 234 | defp nil?(x), do: x == nil 235 | 236 | defp move_each_job_up(jobs) do 237 | # This shifts "up" all existing lines but *does not* mark them dirty 238 | # (because they are on the screen just like they should be). 239 | map_values(jobs, fn model -> update_in(model.line, &(&1 + 1)) end) 240 | end 241 | 242 | defp map_values(m = %{}, f) do 243 | m 244 | |> Enum.map(fn {k, v} -> {k, f.(v)} end) 245 | |> Enum.into(%{}) 246 | end 247 | 248 | defp update_jobs(dashboard = %__MODULE__{}, f) do 249 | %{dashboard | jobs: f.(dashboard.jobs)} 250 | end 251 | 252 | defp update_each_job(dashboard , f) do 253 | update_jobs(dashboard, & map_values(&1, f)) 254 | end 255 | 256 | defp active_until(dashboard) do 257 | System.system_time(:milliseconds) + dashboard.active_highlight_duration 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------