├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── ex_top.ex └── ex_top │ ├── collector.ex │ └── view.ex ├── mix.exs └── test ├── ex_top_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | /ex_top 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 Utkarsh Kukreti 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExTop 2 | 3 | ExTop is an interactive monitor for the Erlang VM written in Elixir. 4 | 5 | ## Demo 6 | 7 | ![Demo](https://i.imgur.com/G9grRie.gif) 8 | 9 | ## Prerequisites 10 | 11 | * Erlang/OTP and Elixir 12 | * A terminal emulator supporting ANSI escape sequences and having 120 or more 13 | columns and 14 or more rows. 14 | 15 | ## Installation 16 | 17 | Clone this repository and execute `mix escript.build`. This will generate an 18 | escript executable named `ex_top`, which can be executed by typing `./ex_top` 19 | 20 | ``` 21 | $ git clone https://github.com/utkarshkukreti/ex_top 22 | $ cd ex_top 23 | $ mix escript.build 24 | $ ./ex_top 25 | ``` 26 | 27 | ## Usage 28 | 29 | ### Keyboard Shortcuts 30 | 31 | Key | Use 32 | ----|----- 33 | j or Down Arrow | Select the next process. 34 | k or Up Arrow | Select the previous process. 35 | g | Select the first process. 36 | G | Select the last process. 37 | 1-6 | Sort by the Nth column. Press again to toggle the sort order. 38 | p | Pause/Unpause data collection. 39 | q | Quit. 40 | 41 | ### Connecting to other nodes 42 | 43 | ``` 44 | ./ex_top --cookie 45 | ``` 46 | 47 | ## License 48 | MIT 49 | -------------------------------------------------------------------------------- /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 for your application as: 12 | # 13 | # config :ex_top, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:ex_top, :key) 18 | # 19 | # Or 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 | -------------------------------------------------------------------------------- /lib/ex_top.ex: -------------------------------------------------------------------------------- 1 | defmodule ExTop do 2 | use GenServer 3 | 4 | defstruct [ 5 | :node, 6 | :data, 7 | :schedulers_snapshot, 8 | :rows, 9 | selected: 0, 10 | offset: 0, 11 | sort_by: :pid, 12 | sort_order: :ascending, 13 | paused?: false 14 | ] 15 | 16 | def start_link(opts \\ []) do 17 | GenServer.start_link(ExTop, opts) 18 | end 19 | 20 | def main(args) do 21 | {opts, args, _} = OptionParser.parse(args) 22 | 23 | {node_name, node_type} = 24 | cond do 25 | sname = Keyword.get(opts, :sname) -> 26 | {String.to_atom(sname), :shortnames} 27 | 28 | name = Keyword.get(opts, :name) -> 29 | {String.to_atom(name), :longnames} 30 | 31 | true -> 32 | {:ex_top, :shortnames} 33 | end 34 | 35 | case Node.start(node_name, node_type) do 36 | {:ok, _} -> 37 | :ok 38 | 39 | {:error, _} -> 40 | IO.write([ 41 | IO.ANSI.red(), 42 | "Failed to start a distributed Node.\n", 43 | "Make sure `epmd` (the Erlang Port Mapper Daemon) is ", 44 | "running by executing `epmd -daemon` in your shell.\n", 45 | IO.ANSI.reset() 46 | ]) 47 | 48 | :erlang.halt() 49 | end 50 | 51 | if cookie = Keyword.get(opts, :cookie) do 52 | Node.set_cookie(String.to_atom(cookie)) 53 | end 54 | 55 | node = 56 | case args do 57 | [] -> 58 | IO.puts("Select a Node to connect to:") 59 | {:ok, names} = :net_adm.names() 60 | [_, host] = Node.self() |> Atom.to_string() |> String.split("@") 61 | 62 | nodes = 63 | for {name, _} <- names do 64 | String.to_atom("#{name}@#{host}") 65 | end 66 | 67 | for {node, index} <- nodes |> Enum.with_index() do 68 | IO.puts("#{index}: #{node}") 69 | end 70 | 71 | which = gets() |> String.trim_trailing() |> String.to_integer() 72 | Enum.fetch!(nodes, which) 73 | 74 | [node] -> 75 | String.to_atom(node) 76 | end 77 | 78 | if Node.ping(node) == :pang do 79 | IO.write([ 80 | IO.ANSI.red(), 81 | "Could not connect to node #{node} with cookie #{Node.get_cookie()}\n", 82 | IO.ANSI.reset() 83 | ]) 84 | 85 | :erlang.halt() 86 | end 87 | 88 | # Load ExTop.Collector on the target node. 89 | {mod, bin, file} = :code.get_object_code(ExTop.Collector) 90 | :rpc.call(node, :code, :load_binary, [mod, file, bin]) 91 | # Enable :scheduler_wall_time on the target node. 92 | # FIXME: Is this a good idea? 93 | :rpc.call(node, :erlang, :system_flag, [:scheduler_wall_time, true]) 94 | 95 | {:ok, _} = ExTop.start_link(node: node) 96 | :timer.sleep(:infinity) 97 | end 98 | 99 | def init(opts) do 100 | Port.open({:spawn, "tty_sl -c -e"}, [:binary, :eof]) 101 | IO.write(IO.ANSI.clear()) 102 | send(self(), :collect) 103 | {rows, 0} = System.cmd("tput", ["lines"]) 104 | rows = rows |> String.trim_trailing() |> String.to_integer() 105 | {:ok, %ExTop{node: Keyword.get(opts, :node, Node.self()), rows: rows}} 106 | end 107 | 108 | def handle_info(:collect, %{paused?: paused?} = state) do 109 | if paused? do 110 | Process.send_after(self(), :collect, 1000) 111 | {:noreply, state} 112 | else 113 | GenServer.cast(self(), :render) 114 | schedulers_snapshot = state.data && state.data.schedulers 115 | data = :rpc.call(state.node, ExTop.Collector, :collect, []) 116 | Process.send_after(self(), :collect, 1000) 117 | {:noreply, %{state | data: data, schedulers_snapshot: schedulers_snapshot}} 118 | end 119 | end 120 | 121 | def handle_info({port, {:data, "\e[A" <> rest}}, state) do 122 | GenServer.cast(self(), {:key, :up}) 123 | send(self(), {port, {:data, rest}}) 124 | {:noreply, state} 125 | end 126 | 127 | def handle_info({port, {:data, "\e[B" <> rest}}, state) do 128 | GenServer.cast(self(), {:key, :down}) 129 | send(self(), {port, {:data, rest}}) 130 | {:noreply, state} 131 | end 132 | 133 | def handle_info({port, {:data, "j" <> rest}}, state) do 134 | GenServer.cast(self(), {:key, :down}) 135 | send(self(), {port, {:data, rest}}) 136 | {:noreply, state} 137 | end 138 | 139 | def handle_info({port, {:data, "k" <> rest}}, state) do 140 | GenServer.cast(self(), {:key, :up}) 141 | send(self(), {port, {:data, rest}}) 142 | {:noreply, state} 143 | end 144 | 145 | def handle_info({port, {:data, <>}}, state) when ch in '123456' do 146 | sort_by = 147 | case ch - ?0 do 148 | 1 -> :pid 149 | 2 -> :name_or_initial_call 150 | 3 -> :memory 151 | 4 -> :reductions 152 | 5 -> :message_queue_len 153 | 6 -> :current_function 154 | end 155 | 156 | state = 157 | if state.sort_by == sort_by do 158 | if state.sort_order == :ascending do 159 | %{state | sort_order: :descending} 160 | else 161 | %{state | sort_order: :ascending} 162 | end 163 | else 164 | %{state | sort_by: sort_by, sort_order: :ascending} 165 | end 166 | 167 | GenServer.cast(self(), :render) 168 | send(self(), {port, {:data, rest}}) 169 | {:noreply, state} 170 | end 171 | 172 | def handle_info({port, {:data, "g" <> rest}}, state) do 173 | state = %{state | offset: 0, selected: 0} 174 | GenServer.cast(self(), :render) 175 | send(self(), {port, {:data, rest}}) 176 | {:noreply, state} 177 | end 178 | 179 | def handle_info({port, {:data, "G" <> rest}}, state) do 180 | last = Enum.count(state.data.processes) 181 | state = %{state | offset: last - (state.rows - 13), selected: state.rows - 14} 182 | GenServer.cast(self(), :render) 183 | send(self(), {port, {:data, rest}}) 184 | {:noreply, state} 185 | end 186 | 187 | def handle_info({port, {:data, "p" <> rest}}, %{paused?: paused?} = state) do 188 | state = %{state | paused?: not paused?} 189 | send(self(), {port, {:data, rest}}) 190 | {:noreply, state} 191 | end 192 | 193 | def handle_info({_port, {:data, "q" <> _rest}}, _state) do 194 | :erlang.halt() 195 | end 196 | 197 | def handle_info({_port, {:data, _}}, state) do 198 | {:noreply, state} 199 | end 200 | 201 | def handle_cast({:key, :up}, state) do 202 | state = 203 | case {state.selected, state.offset} do 204 | {0, 0} -> state 205 | {0, n} -> %{state | offset: n - 1} 206 | {n, _} -> %{state | selected: n - 1} 207 | end 208 | 209 | GenServer.cast(self(), :render) 210 | {:noreply, state} 211 | end 212 | 213 | def handle_cast({:key, :down}, state) do 214 | max = Enum.count(state.data[:processes]) - 1 215 | 216 | state = 217 | cond do 218 | state.offset + state.selected >= max -> state 219 | state.selected == state.rows - 14 -> %{state | offset: state.offset + 1} 220 | true -> %{state | selected: state.selected + 1} 221 | end 222 | 223 | GenServer.cast(self(), :render) 224 | {:noreply, state} 225 | end 226 | 227 | def handle_cast(:render, state) do 228 | processes = 229 | state.data[:processes] 230 | |> Enum.sort_by(fn process -> process[state.sort_by] end) 231 | |> (fn processes -> 232 | if state.sort_order == :ascending do 233 | processes 234 | else 235 | Enum.reverse(processes) 236 | end 237 | end).() 238 | |> Enum.drop(state.offset) 239 | |> Enum.take(state.rows - 13) 240 | 241 | data = 242 | %{state.data | processes: processes} 243 | |> Map.put(:schedulers_snapshot, state.schedulers_snapshot) 244 | 245 | IO.write([IO.ANSI.home(), ExTop.View.render(data, selected: state.selected)]) 246 | {:noreply, state} 247 | end 248 | 249 | # Read a line from stdin (fd = 0) 250 | defp gets do 251 | port = Port.open({:fd, 0, 1}, [:in, :binary, line: 256]) 252 | 253 | receive do 254 | {^port, {:data, {:eol, line}}} -> 255 | Port.close(port) 256 | line 257 | end 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /lib/ex_top/collector.ex: -------------------------------------------------------------------------------- 1 | defmodule ExTop.Collector do 2 | def collect do 3 | memory = :erlang.memory() 4 | 5 | processes = 6 | for pid <- :erlang.processes() do 7 | info = 8 | :erlang.process_info(pid, [ 9 | :current_function, 10 | :initial_call, 11 | :memory, 12 | :message_queue_len, 13 | :reductions, 14 | :registered_name 15 | ]) 16 | 17 | case info do 18 | :undefined -> 19 | nil 20 | 21 | info -> 22 | name_or_initial_call = 23 | case info[:registered_name] do 24 | [] -> info[:initial_call] 25 | otherwise -> otherwise 26 | end 27 | 28 | [{:pid, pid}, {:name_or_initial_call, name_or_initial_call} | info] 29 | end 30 | end 31 | |> Enum.reject(&is_nil/1) 32 | 33 | schedulers = :erlang.statistics(:scheduler_wall_time) |> Enum.sort() 34 | 35 | {{:input, io_input}, {:output, io_output}} = :erlang.statistics(:io) 36 | run_queue = :erlang.statistics(:run_queue) 37 | process_count = :erlang.system_info(:process_count) 38 | process_limit = :erlang.system_info(:process_limit) 39 | uptime = :erlang.statistics(:wall_clock) |> elem(0) |> div(1000) 40 | 41 | %{ 42 | memory: memory, 43 | processes: processes, 44 | schedulers: schedulers, 45 | statistics: %{ 46 | io_input: io_input, 47 | io_output: io_output, 48 | process_count: process_count, 49 | process_limit: process_limit, 50 | run_queue: run_queue, 51 | uptime: uptime 52 | } 53 | } 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/ex_top/view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExTop.View do 2 | def render(data, opts \\ []) do 3 | [ 4 | concat3( 5 | schedulers(data.schedulers_snapshot, data.schedulers), 6 | memory(data.memory), 7 | statistics(data.statistics) 8 | ), 9 | processes_separator(), 10 | processes_heading(), 11 | processes_separator(), 12 | processes_rows(data.processes, opts), 13 | processes_separator() 14 | ] 15 | |> Enum.intersperse("\n\r") 16 | end 17 | 18 | defp statistics(statistics) do 19 | [ 20 | "+-----------------+----------------+", 21 | "| Statistics |", 22 | "+-----------------+----------------+", 23 | "| Uptime | #{just(inspect(statistics.uptime), 13, :right)}s |", 24 | "| Process Count | #{just(inspect(statistics.process_count), 14, :right)} |", 25 | "| Process Limit | #{just(inspect(statistics.process_limit), 14, :right)} |", 26 | "| Run Queue | #{just(inspect(statistics.run_queue), 14, :right)} |", 27 | "| IO Input | #{just(inspect(statistics.io_input), 14, :right)} |", 28 | "| IO Output | #{just(inspect(statistics.io_output), 14, :right)} |" 29 | ] 30 | end 31 | 32 | defp memory(memory) do 33 | [ 34 | "+------------+---------------", 35 | "| Memory ", 36 | "+------------+---------------", 37 | "| Total | #{just(inspect(memory[:total]), 13, :right)} ", 38 | "| Processes | #{just(inspect(memory[:processes]), 13, :right)} ", 39 | "| Atom | #{just(inspect(memory[:atom]), 13, :right)} ", 40 | "| Binary | #{just(inspect(memory[:binary]), 13, :right)} ", 41 | "| Code | #{just(inspect(memory[:code]), 13, :right)} ", 42 | "| ETS | #{just(inspect(memory[:ets]), 13, :right)} " 43 | ] 44 | end 45 | 46 | defp schedulers(old, new) do 47 | usages = 48 | if old do 49 | for {{n, a1, t1}, {n, a2, t2}} <- Enum.zip(old, new) |> Enum.take(8) do 50 | {n, (a2 - a1) / (t2 - t1)} 51 | end 52 | else 53 | [] 54 | end 55 | 56 | ["+------------------------------------------------------"] ++ 57 | for {n, usage} <- usages do 58 | [ 59 | "| ", 60 | inspect(n), 61 | " [", 62 | IO.ANSI.green(), 63 | just(String.duplicate("|", trunc(usage * 41)), 41, :left), 64 | IO.ANSI.reset(), 65 | just(:erlang.float_to_binary(usage * 100, decimals: 2) <> "%", 6, :right), 66 | " ] " 67 | ] 68 | end ++ 69 | for _ <- 0..(8 - Enum.count(usages)) do 70 | ["| "] 71 | end 72 | end 73 | 74 | @processes_columns [ 75 | {"PID", 13, :right}, 76 | {"Name or Initial Call", 24, :left}, 77 | {"Memory", 9, :right}, 78 | {"Reductions", 10, :right}, 79 | {"Message Queue", 13, :right}, 80 | {"Current Function", 32, :left} 81 | ] 82 | 83 | defp processes_separator do 84 | [ 85 | ?+, 86 | for {_, size, _} <- @processes_columns do 87 | String.duplicate("-", size + 2) 88 | end 89 | |> Enum.intersperse(?+), 90 | ?+ 91 | ] 92 | end 93 | 94 | defp processes_heading do 95 | [ 96 | "| ", 97 | for {name, size, align} <- @processes_columns do 98 | just(name, size, align) 99 | end 100 | |> Enum.intersperse(" | "), 101 | " |" 102 | ] 103 | end 104 | 105 | defp processes_rows(processes, opts) do 106 | for {process, index} <- Enum.with_index(processes) do 107 | row = [ 108 | "| ", 109 | for {name, size, align} <- @processes_columns do 110 | text = 111 | case name do 112 | "PID" -> 113 | IO.iodata_to_binary(:erlang.pid_to_list(process[:pid])) 114 | 115 | "Name or Initial Call" -> 116 | case process[:name_or_initial_call] do 117 | {m, f, a} -> "#{inspect(m)}.#{f}/#{a}" 118 | name -> inspect(name) 119 | end 120 | 121 | "Memory" -> 122 | inspect(process[:memory]) 123 | 124 | "Reductions" -> 125 | inspect(process[:reductions]) 126 | 127 | "Message Queue" -> 128 | inspect(process[:message_queue_len]) 129 | 130 | "Current Function" -> 131 | {m, f, a} = process[:current_function] 132 | "#{inspect(m)}.#{f}/#{a}" 133 | end 134 | 135 | just(text, size, align) 136 | end 137 | |> Enum.intersperse(" | "), 138 | " |" 139 | ] 140 | 141 | if opts[:selected] == index do 142 | [IO.ANSI.blue_background(), IO.ANSI.white(), row, IO.ANSI.reset()] 143 | else 144 | row 145 | end 146 | end 147 | |> Enum.intersperse("\n\r") 148 | end 149 | 150 | defp just(string, length, align) do 151 | if String.length(string) > length do 152 | String.slice(string, 0, length - 1) <> "…" 153 | else 154 | case align do 155 | :left -> String.pad_trailing(string, length) 156 | :right -> String.pad_leading(string, length) 157 | end 158 | end 159 | end 160 | 161 | defp concat3(a, b, c) do 162 | for {{a, b}, c} <- Enum.zip(Enum.zip(a, b), c) do 163 | [a, b, c] 164 | end 165 | |> Enum.intersperse("\r\n") 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExTop.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ex_top, 7 | version: "0.0.1", 8 | elixir: "~> 1.1", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | escript: escript() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application 17 | # 18 | # Type "mix help compile.app" for more information 19 | def application do 20 | [applications: [:logger]] 21 | end 22 | 23 | # Dependencies can be Hex packages: 24 | # 25 | # {:mydep, "~> 0.3.0"} 26 | # 27 | # Or git/path repositories: 28 | # 29 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 30 | # 31 | # Type "mix help deps" for more examples and options 32 | defp deps do 33 | [] 34 | end 35 | 36 | defp escript do 37 | [main_module: ExTop, emu_args: "-elixir ansi_enabled true -noinput"] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/ex_top_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExTopTest do 2 | use ExUnit.Case 3 | doctest ExTop 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------