├── test ├── test_helper.exs ├── wobserver │ ├── util │ │ ├── allocator_test.exs │ │ ├── port_test.exs │ │ ├── metrics │ │ │ ├── prometheus_test.exs │ │ │ └── formatter_test.exs │ │ ├── application_test.exs │ │ ├── helper_test.exs │ │ ├── node │ │ │ ├── remote_test.exs │ │ │ └── discovery_test.exs │ │ ├── metrics_test.exs │ │ ├── table_test.exs │ │ └── process_test.exs │ ├── system_test.exs │ ├── system │ │ ├── scheduler_test.exs │ │ ├── memory_test.exs │ │ ├── statistics_test.exs │ │ └── info_test.exs │ ├── web │ │ ├── router │ │ │ ├── helper_test.exs │ │ │ ├── system_test.exs │ │ │ ├── static_test.exs │ │ │ ├── api_test.exs │ │ │ └── metrics_test.exs │ │ └── router_test.exs │ ├── application_test.exs │ └── page_test.exs └── wobserver_test.exs ├── src ├── js │ ├── external │ │ └── sorttable.js │ ├── app.js │ ├── interface │ │ ├── popup.js │ │ ├── application_graph.js │ │ ├── table_detail.js │ │ ├── node_dialog.js │ │ ├── process_detail.js │ │ └── chart.js │ ├── wobserver_api_fallback.js │ ├── wobserver.js │ └── wobserver_client.js ├── css │ ├── lib │ │ ├── _footer.scss │ │ ├── _reset.scss │ │ ├── _graph.scss │ │ ├── _content.scss │ │ ├── _base.scss │ │ ├── _button.scss │ │ ├── _theme.scss │ │ ├── _table.scss │ │ └── _popup.scss │ ├── main.scss │ └── _chart.scss └── html │ └── index.html ├── config └── config.exs ├── gulpfile.js ├── index.js ├── tasks │ ├── watch.js │ ├── html.js │ ├── css.js │ └── js.js └── config.json ├── coveralls.json ├── .travis.yml ├── lib ├── wobserver │ ├── web │ │ ├── router.ex │ │ ├── router │ │ │ ├── base.ex │ │ │ ├── helper.ex │ │ │ ├── system.ex │ │ │ ├── metrics.ex │ │ │ ├── static.ex │ │ │ └── api.ex │ │ ├── phoenix_socket.ex │ │ ├── client_proxy.ex │ │ ├── security.ex │ │ └── client.ex │ ├── system │ │ ├── memory.ex │ │ ├── statistics.ex │ │ ├── scheduler.ex │ │ └── info.ex │ ├── util │ │ ├── port.ex │ │ ├── allocator.ex │ │ ├── node │ │ │ ├── remote.ex │ │ │ └── discovery.ex │ │ ├── table.ex │ │ ├── helper.ex │ │ ├── application.ex │ │ ├── metrics │ │ │ ├── prometheus.ex │ │ │ └── formatter.ex │ │ ├── metrics.ex │ │ └── process.ex │ ├── system.ex │ ├── application.ex │ └── page.ex ├── mix │ └── tasks │ │ ├── analyze.ex │ │ └── build.ex └── wobserver.ex ├── dialyzer.ignore-warnings ├── .gitignore ├── LICENSE ├── package.json ├── CHANGELOG.md ├── mix.exs └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /src/js/external/sorttable.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shinyscorpion/wobserver/HEAD/src/js/external/sorttable.js -------------------------------------------------------------------------------- /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 | config :wobserver, 6 | assets: "" -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | import {Wobserver} from './wobserver'; 2 | 3 | let host = window.location.host + window.location.pathname; 4 | if( host.endsWith("/") ) { 5 | host = host.substr(0, host.length - 1); 6 | } 7 | 8 | let wobserver = new Wobserver(host); 9 | -------------------------------------------------------------------------------- /src/css/lib/_footer.scss: -------------------------------------------------------------------------------- 1 | #footer { 2 | position: fixed; 3 | bottom: 0; 4 | right: 0; 5 | left: 0; 6 | 7 | color: #eee; 8 | background-color: lighten($menu-background-color, 10%); 9 | 10 | display: none; 11 | @media screen and (min-width: $breakpoint-medium) { 12 | display: block; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/css/main.scss: -------------------------------------------------------------------------------- 1 | // @import 'base'; 2 | @import 'chart'; 3 | 4 | @import './lib/base'; 5 | 6 | .about { 7 | max-width: 100%; 8 | table { 9 | width: 100%; 10 | th{ 11 | font-weight: bold; 12 | padding: 0 1em 0 1em; 13 | &::after { 14 | content: ':'; 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /gulpfile.js/index.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const gulp = require('gulp'); 3 | const requireDir = require('require-dir') 4 | 5 | requireDir('./tasks', { recurse: false }) 6 | 7 | gulp.task('build', config.build); 8 | gulp.task('deploy', config.deploy); 9 | gulp.task('default', ['build', 'watch']); 10 | -------------------------------------------------------------------------------- /gulpfile.js/tasks/watch.js: -------------------------------------------------------------------------------- 1 | const config = require('../config') 2 | const gulp = require('gulp') 3 | const path = require('path') 4 | const watch = require('gulp-watch') 5 | 6 | gulp.task('watch', () => { 7 | config.watch.forEach(type => { 8 | if( config[type] ){ 9 | gulp.watch(path.join(config.root.src, config[type].src + config[type].pattern), [type]) 10 | } 11 | }) 12 | }); -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/mix/tasks", 4 | "folder/file_to_skip.ex", 5 | "lib/wobserver/web/client.ex", // For now... 6 | "lib/wobserver/web/client_socket.ex", 7 | "lib/wobserver/web/client_proxy.ex", 8 | "lib/wobserver/web/phoenix_socket.ex", 9 | "lib/wobserver/web/security.ex", 10 | "lib/wobserver/web/router/static.ex" 11 | ], 12 | "coverage_options": { 13 | "minimum_coverage": 90 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: elixir 3 | elixir: 4 | - 1.4.0 5 | - 1.4.1 6 | otp_release: 7 | - 19.0 8 | - 19.1 9 | install: 10 | - nvm install v6.9.4 11 | - npm install 12 | - mix local.rebar --force # for Elixir 1.3.0 and up 13 | - mix local.hex --force 14 | - mix deps.get 15 | - mix build 16 | after_script: 17 | - MIX_ENV=test mix do deps.get, compile, inch.report, coveralls.travis 18 | cache: 19 | directories: 20 | - node_modules 21 | - _build 22 | - deps 23 | -------------------------------------------------------------------------------- /lib/wobserver/web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router do 2 | @moduledoc ~S""" 3 | Main router. 4 | 5 | Splits into two paths: 6 | - `/api`, for all json api calls, handled by `Wobserver.Web.Router.Api`. 7 | - `/`, for all static assets, handled by `Wobserver.Web.Router.Static`. 8 | """ 9 | 10 | use Wobserver.Web.Router.Base 11 | 12 | forward "/api", to: Wobserver.Web.Router.Api 13 | forward "/metrics", to: Wobserver.Web.Router.Metrics 14 | forward "/", to: Wobserver.Web.Router.Static 15 | end 16 | -------------------------------------------------------------------------------- /gulpfile.js/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": ["css", "js", "html"], 3 | "deploy": ["css-prod", "js-prod", "html-prod"], 4 | "watch": ["css", "js", "html"], 5 | "root": { 6 | "src": "./src", 7 | "dest": "./assets" 8 | }, 9 | "css": { 10 | "src": "css", 11 | "dest": "", 12 | "pattern": "/**/*.scss" 13 | }, 14 | "js": { 15 | "src": "js", 16 | "dest": "", 17 | "pattern": "/**/*.js" 18 | }, 19 | "html": { 20 | "src": "html", 21 | "dest": "", 22 | "pattern": "/**/*" 23 | } 24 | } -------------------------------------------------------------------------------- /test/wobserver/util/allocator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.AllocatorTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Allocator 5 | 6 | describe "list" do 7 | test "returns a list" do 8 | assert is_list(Allocator.list) 9 | end 10 | 11 | test "returns a list of maps" do 12 | assert is_map(List.first(Allocator.list)) 13 | end 14 | 15 | test "returns a list of table information" do 16 | assert %{ 17 | type: _, 18 | block: _, 19 | carrier: _, 20 | } = List.first(Allocator.list) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/wobserver/system_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.SystemTest do 2 | use ExUnit.Case 3 | 4 | describe "overview" do 5 | test "returns system struct" do 6 | assert %Wobserver.System{} = Wobserver.System.overview 7 | end 8 | 9 | test "returns values" do 10 | %Wobserver.System{ 11 | architecture: architecture, 12 | cpu: cpu, 13 | memory: memory, 14 | statistics: statistics, 15 | } = Wobserver.System.overview 16 | 17 | assert architecture 18 | assert cpu 19 | assert memory 20 | assert statistics 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/wobserver/system/scheduler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System.SchedulerTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.System.Scheduler 5 | 6 | test "returns results as list" do 7 | assert is_list(Scheduler.utilization) 8 | end 9 | 10 | test "returns results as list of floats" do 11 | all_floats = 12 | Scheduler.utilization 13 | |> Enum.map(&is_float/1) 14 | |> Enum.reduce(&Kernel.and/2) 15 | 16 | assert all_floats 17 | end 18 | 19 | test "returns results querying multiple times" do 20 | assert is_list(Scheduler.utilization) 21 | assert is_list(Scheduler.utilization) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/wobserver/util/port_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.PortTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Port 5 | 6 | describe "list" do 7 | test "returns a list" do 8 | assert is_list(Port.list) 9 | end 10 | 11 | test "returns a list of maps" do 12 | assert is_map(List.first(Port.list)) 13 | end 14 | 15 | test "returns a list of table information" do 16 | assert %{ 17 | id: _, 18 | port: _, 19 | name: _, 20 | links: _, 21 | connected: _, 22 | input: _, 23 | output: _, 24 | os_pid: _, 25 | } = List.first(Port.list) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/wobserver/system/memory_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System.MemoryTest do 2 | use ExUnit.Case 3 | 4 | test "usage return memory struct" do 5 | assert %Wobserver.System.Memory{} = Wobserver.System.Memory.usage 6 | end 7 | 8 | test "usage returns values" do 9 | %Wobserver.System.Memory{ 10 | atom: atom, 11 | binary: binary, 12 | code: code, 13 | ets: ets, 14 | process: process, 15 | total: total, 16 | } = Wobserver.System.Memory.usage 17 | 18 | assert atom > 0 19 | assert binary > 0 20 | assert code > 0 21 | assert ets > 0 22 | assert process > 0 23 | assert total > 0 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /gulpfile.js/tasks/html.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | if(!config.html) return; 3 | 4 | const gulp = require('gulp'); 5 | const path = require('path') 6 | const htmlmin = require('gulp-htmlmin'); 7 | 8 | gulp.task('html', () => { 9 | gulp.src(path.join(config.root.src, config.html.src, config.html.pattern)) 10 | .pipe(gulp.dest(path.join(config.root.dest, config.html.dest))); 11 | }); 12 | 13 | gulp.task('html-prod', () => { 14 | gulp.src(path.join(config.root.src, config.html.src, config.html.pattern)) 15 | .pipe(htmlmin({collapseWhitespace: true})) 16 | .pipe(gulp.dest(path.join(config.root.dest, config.html.dest))); 17 | }); -------------------------------------------------------------------------------- /test/wobserver/util/metrics/prometheus_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Metrics.PrometheusTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Wobserver.Util.Metrics.Prometheus 5 | 6 | describe "merge_metrics" do 7 | test "removes duplicate help" do 8 | assert Prometheus.merge_metrics([ 9 | "# HELP double Bla bla\n", 10 | "# HELP double Bla bla\n", 11 | ]) == "# HELP double Bla bla\n" 12 | end 13 | 14 | test "removes duplicate type" do 15 | assert Prometheus.merge_metrics([ 16 | "# TYPE double gauge\n", 17 | "# TYPE double gauge\n", 18 | ]) == "# TYPE double gauge\n" 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/wobserver/web/router/base.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.Base do 2 | @moduledoc ~S""" 3 | Base Router module, includes standard helpers and plugs. 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | use Plug.Router 9 | 10 | @doc false 11 | @spec init(opts :: Plug.opts) :: Plug.opts 12 | def init(opts) do 13 | opts 14 | end 15 | 16 | @doc false 17 | @spec call(conn :: Plug.Conn.t, opts :: Plug.opts) :: Plug.Conn.t 18 | def call(conn, opts) do 19 | plug_builder_call(conn, opts) 20 | end 21 | 22 | import Wobserver.Web.Router.Helper, only: [send_json_resp: 2] 23 | 24 | plug :match 25 | plug :dispatch 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/js/interface/popup.js: -------------------------------------------------------------------------------- 1 | let popup = null; 2 | 3 | function getPopup() { 4 | if( popup ){ 5 | return popup; 6 | } 7 | 8 | popup = document.createElement('div'); 9 | 10 | popup.id = 'popup-overlay'; 11 | popup.style.display = 'none'; 12 | 13 | popup.addEventListener('click', (e) => { 14 | if( e.target.id == 'popup-overlay' ){ 15 | Popup.hide(); 16 | } 17 | }); 18 | 19 | document.body.appendChild(popup); 20 | 21 | return popup; 22 | } 23 | 24 | const Popup = { 25 | show: (content) => { 26 | let popup = getPopup(); 27 | 28 | popup.innerHTML = content; 29 | 30 | popup.style.display = 'flex'; 31 | }, 32 | hide: () => { 33 | getPopup().style.display = 'none'; 34 | } 35 | } 36 | 37 | export{ Popup } 38 | -------------------------------------------------------------------------------- /src/css/_chart.scss: -------------------------------------------------------------------------------- 1 | .wobserver-chart { 2 | display: inline-block; 3 | 4 | width: 100% !important; 5 | height: 20em; 6 | 7 | vertical-align: top; 8 | margin-bottom: 2em !important; 9 | 10 | canvas { 11 | margin-bottom: 2em; 12 | } 13 | 14 | .chart-legend { 15 | margin-top: -4.2em; 16 | ul { 17 | list-style: none; 18 | li { 19 | display: inline-block; 20 | & + li { 21 | margin-left: 1em; 22 | } 23 | } 24 | span { 25 | position: relative; 26 | top: 0.2em; 27 | display: inline-block; 28 | border-radius: 0.3em; 29 | border: solid 1px #000; 30 | width: 1em; 31 | height: 1em; 32 | margin-right: 0.5em; 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /dialyzer.ignore-warnings: -------------------------------------------------------------------------------- 1 | lib/wobserver/web/client.ex:5: The pattern {'ok', _@4, _@5} can never match the type {'ok',#{}} 2 | lib/mix/tasks/analyze.ex:1: Callback info about the 'Elixir.Mix.Task' behaviour is not available 3 | lib/mix/tasks/build.ex:1: Callback info about the 'Elixir.Mix.Task' behaviour is not available 4 | lib/wobserver/web/client.ex:18: Overloaded contract for 'Elixir.Wobserver.Web.Client':client_handle/2 has overlapping domains; such contracts are currently unsupported and are simply ignored 5 | lib/wobserver/web/client.ex:19: Overloaded contract for 'Elixir.Wobserver.Web.Client':client_handle/2 has overlapping domains; such contracts are currently unsupported and are simply ignored 6 | Unknown types: 7 | :0: Unknown type 'Elixir.Access':get/2 8 | done (warnings were emitted) -------------------------------------------------------------------------------- /lib/wobserver/web/phoenix_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.PhoenixSocket do 2 | @moduledoc """ 3 | Drop-in Phoenix Socket for easier integration in to Phoenix endpoints 4 | 5 | Example: 6 | ```elixir 7 | defmodule MyPhoenixServer.Endpoint do 8 | socket "/wobserver", Wobserver.Web.PhoenixSocket # The path should be the same as the router path 9 | 10 | ... 11 | end 12 | ``` 13 | """ 14 | 15 | @doc false 16 | def __transports__ do 17 | config = [ 18 | cowboy: Wobserver.Web.Client 19 | ] 20 | callback_module = Wobserver.Web.PhoenixSocket 21 | transport_path = :ws 22 | websocket_socket = {transport_path, {callback_module, config}} 23 | # Only handling one type, websocket, no longpolling or anything else 24 | [ 25 | websocket_socket 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/wobserver/system/statistics_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System.StatisticsTest do 2 | use ExUnit.Case 3 | 4 | describe "overview" do 5 | test "returns statistics struct" do 6 | assert %Wobserver.System.Statistics{} = Wobserver.System.Statistics.overview 7 | end 8 | 9 | test "returns values" do 10 | %Wobserver.System.Statistics{ 11 | uptime: uptime, 12 | process_running: process_running, 13 | process_total: process_total, 14 | process_max: process_max, 15 | input: input, 16 | output: output, 17 | } = Wobserver.System.Statistics.overview 18 | 19 | assert uptime > 0 20 | assert process_running >= 0 21 | assert process_total > 0 22 | assert process_max > 0 23 | assert input >= 0 24 | assert output >= 0 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.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 | /docs 13 | 14 | # Ignore .fetch files in case you like to edit your project deps locally. 15 | /.fetch 16 | 17 | # If the VM crashes, it generates a dump, let's ignore it too. 18 | erl_crash.dump 19 | 20 | # Also ignore archive artifacts (built via "mix archive.build"). 21 | *.ez 22 | 23 | # Node / Gulp / Web 24 | node_modules 25 | assets/*.map 26 | 27 | # For now there is no reason to include the assets 28 | assets 29 | 30 | # Ignore environment settings 31 | .envrc 32 | 33 | # Ignore OS generated files 34 | .DS_Store 35 | 36 | # Ignore generated asset modules 37 | /lib/wobserver/assets.ex -------------------------------------------------------------------------------- /src/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Wobserver 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | -------------------------------------------------------------------------------- /lib/wobserver/system/memory.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System.Memory do 2 | @moduledoc ~S""" 3 | Handles memory information. 4 | """ 5 | 6 | @typedoc ~S""" 7 | Memory information. 8 | """ 9 | @type t :: %__MODULE__{ 10 | atom: integer, 11 | binary: integer, 12 | code: integer, 13 | ets: integer, 14 | process: integer, 15 | total: integer, 16 | } 17 | 18 | defstruct [ 19 | atom: 0, 20 | binary: 0, 21 | code: 0, 22 | ets: 0, 23 | process: 0, 24 | total: 0, 25 | ] 26 | 27 | @doc ~S""" 28 | Returns memory usage. 29 | """ 30 | @spec usage :: Wobserver.System.Memory.t 31 | def usage do 32 | mem = :erlang.memory 33 | 34 | %__MODULE__{ 35 | atom: Keyword.get(mem, :atom), 36 | binary: Keyword.get(mem, :binary), 37 | code: Keyword.get(mem, :code), 38 | ets: Keyword.get(mem, :ets), 39 | process: Keyword.get(mem, :processes), 40 | total: Keyword.get(mem, :total), 41 | } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/wobserver/util/application_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.ApplicationTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Util.Application 5 | 6 | describe "list" do 7 | test "returns a list" do 8 | assert is_list(Application.list) 9 | end 10 | 11 | test "returns a list of map" do 12 | assert is_map(List.first(Application.list)) 13 | end 14 | 15 | test "returns a list of process summary information" do 16 | assert %{ 17 | name: _, 18 | description: _, 19 | version: _ 20 | } = List.first(Application.list) 21 | end 22 | end 23 | 24 | describe "info" do 25 | test "returns a structure with application information" do 26 | assert %{ 27 | pid: pid, 28 | name: name, 29 | meta: %{ 30 | class: :application 31 | }, 32 | children: children, 33 | } = Application.info(:wobserver) 34 | 35 | assert is_pid(pid) 36 | assert is_binary(name) 37 | assert is_list(children) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /gulpfile.js/tasks/css.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | if(!config.css) return; 3 | 4 | const gulp = require('gulp'); 5 | const autoprefixer = require('gulp-autoprefixer'); 6 | const sass = require('gulp-sass') 7 | const sourcemaps = require('gulp-sourcemaps'); 8 | const path = require('path') 9 | 10 | const sassOptions = { 11 | errLogToConsole: true, 12 | outputStyle: 'compressed', //compressed | expanded 13 | }; 14 | 15 | gulp.task('css', () => { 16 | gulp.src(path.join(config.root.src, config.css.src, config.css.pattern)) 17 | .pipe(sourcemaps.init()) 18 | .pipe(sass(sassOptions).on('error', sass.logError)) 19 | .pipe(sourcemaps.write()) 20 | .pipe(autoprefixer()) 21 | .pipe(gulp.dest(path.join(config.root.dest, config.css.dest))); 22 | }); 23 | 24 | gulp.task('css-prod', () => { 25 | gulp.src(path.join(config.root.src, config.css.src, config.css.pattern)) 26 | .pipe(sass(sassOptions).on('error', sass.logError)) 27 | .pipe(autoprefixer()) 28 | .pipe(gulp.dest(path.join(config.root.dest, config.css.dest))); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/wobserver/web/router/helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.Helper do 2 | @moduledoc ~S""" 3 | Helper methods for routers. 4 | """ 5 | 6 | alias Plug.Conn 7 | 8 | @doc ~S""" 9 | Sends a JSON encoded response back to the client. 10 | 11 | The https status will be `200` or `500` if the given `data` can not be JSON encoded. 12 | 13 | The `conn` content type is set to `application/json`, only if the data could be encoded. 14 | """ 15 | @spec send_json_resp( 16 | data :: atom | String.t | map | list, 17 | conn :: Plug.Conn.t 18 | ) :: Plug.Conn.t 19 | def send_json_resp(data, conn) 20 | 21 | def send_json_resp(:page_not_found, conn) do 22 | conn 23 | |> Conn.send_resp(404, "Page not Found") 24 | end 25 | 26 | def send_json_resp(data, conn) do 27 | case Poison.encode(data) do 28 | {:ok, json} -> 29 | conn 30 | |> Conn.put_resp_content_type("application/json") 31 | |> Conn.send_resp(200, json) 32 | _ -> 33 | conn 34 | |> Conn.send_resp(500, "Response could not be generated.") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/wobserver/web/router/helper_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.HelperTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias Wobserver.Web.Router.Helper 6 | 7 | test "returns 200 with atom" do 8 | conn = conn(:get, "/") 9 | 10 | conn = Helper.send_json_resp(:info, conn) 11 | 12 | assert conn.status == 200 13 | end 14 | 15 | test "returns 200 with String" do 16 | conn = conn(:get, "/") 17 | 18 | conn = Helper.send_json_resp("info", conn) 19 | 20 | assert conn.status == 200 21 | end 22 | 23 | test "returns 200 with map" do 24 | conn = conn(:get, "/") 25 | 26 | conn = Helper.send_json_resp(%{data: "info"}, conn) 27 | 28 | assert conn.status == 200 29 | end 30 | 31 | test "returns 200 with list" do 32 | conn = conn(:get, "/") 33 | 34 | conn = Helper.send_json_resp(["info", "list"], conn) 35 | 36 | assert conn.status == 200 37 | end 38 | 39 | test "returns 500 with invalid data" do 40 | conn = conn(:get, "/") 41 | 42 | conn = Helper.send_json_resp({:invalid}, conn) 43 | 44 | assert conn.status == 500 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/wobserver/web/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.RouterTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias Wobserver.Web.Router 6 | 7 | @opts Router.init([]) 8 | 9 | test "/api/nodes returns nodes" do 10 | conn = conn(:get, "/api/nodes") 11 | 12 | conn = Router.call(conn, @opts) 13 | 14 | assert conn.state == :sent 15 | assert conn.status == 200 16 | assert Poison.encode!(Wobserver.Util.Node.Discovery.discover) == conn.resp_body 17 | end 18 | 19 | test "/ returns 200" do 20 | conn = conn(:get, "/") 21 | 22 | conn = Router.call(conn, @opts) 23 | 24 | assert conn.state == :sent 25 | assert conn.status == 200 26 | end 27 | 28 | test "/metrics returns 200" do 29 | conn = conn(:get, "/metrics") 30 | 31 | conn = Router.call(conn, @opts) 32 | 33 | assert conn.state == :sent 34 | assert conn.status == 200 35 | end 36 | 37 | test "unknown url returns 404" do 38 | conn = conn(:get, "/unknown") 39 | 40 | conn = Router.call(conn, @opts) 41 | 42 | assert conn.state == :sent 43 | assert conn.status == 404 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ian Luites 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 | -------------------------------------------------------------------------------- /src/js/interface/application_graph.js: -------------------------------------------------------------------------------- 1 | function process_to_node(process) { 2 | return { 3 | HTMLclass: process.meta.class, 4 | text: { 5 | name: process.name.replace(/^Elixir\./, ''), 6 | title: process.pid 7 | }, 8 | meta: { 9 | process 10 | }, 11 | children: process.children.map(process_to_node) 12 | }; 13 | } 14 | 15 | const ApplicationGraph = { 16 | show: (application, graph_id) => { 17 | var structure = process_to_node(application); 18 | 19 | var tree_structure = { 20 | chart: { 21 | container: "#" + graph_id, 22 | rootOrientation: "WEST", 23 | levelSeparation: 30, 24 | siblingSeparation: 15, 25 | subTeeSeparation: 25, 26 | 27 | node: { 28 | HTMLclass: 'process-node', 29 | drawLineThrough: false 30 | }, 31 | connectors: { 32 | type: "straight", 33 | style: { 34 | "stroke-width": 2, 35 | "stroke": "#ccc" 36 | } 37 | } 38 | }, 39 | 40 | nodeStructure: structure 41 | } 42 | 43 | new Treant( tree_structure ); 44 | } 45 | } 46 | 47 | export{ ApplicationGraph } 48 | -------------------------------------------------------------------------------- /lib/wobserver/web/router/system.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.System do 2 | @moduledoc ~S""" 3 | System router. 4 | 5 | Returns the following resources: 6 | - `/` => `Wobserver.System.overview/0`. 7 | - `/architecture` => `Wobserver.System.Info.architecture/0`. 8 | - `/cpu` => `Wobserver.System.Info.cpu/0`. 9 | - `/memory` => `Wobserver.System.Memory.usage/0`. 10 | - `/statistics` => `Wobserver.System.Statistics.overview/0`. 11 | """ 12 | 13 | use Wobserver.Web.Router.Base 14 | 15 | alias Wobserver.System 16 | alias System.Info 17 | alias System.Memory 18 | alias System.Statistics 19 | 20 | get "/" do 21 | System.overview 22 | |> send_json_resp(conn) 23 | end 24 | 25 | get "/architecture" do 26 | Info.architecture 27 | |> send_json_resp(conn) 28 | end 29 | 30 | get "/cpu" do 31 | Info.cpu 32 | |> send_json_resp(conn) 33 | end 34 | 35 | get "/memory" do 36 | Memory.usage 37 | |> send_json_resp(conn) 38 | end 39 | 40 | get "/statistics" do 41 | Statistics.overview 42 | |> send_json_resp(conn) 43 | end 44 | 45 | match _ do 46 | conn 47 | |> send_resp(404, "Pages not Found") 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wobserver", 3 | "version": "0.1.0", 4 | "description": "Wobserver web build tools.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/shinyscorpion/wobserver" 8 | }, 9 | "main": "gulpfile.js", 10 | "engines": { 11 | "node": ">=0.12.0 ~5.9.0", 12 | "npm": ">=2.14.12 ~3.7.3" 13 | }, 14 | "dependencies": {}, 15 | "devDependencies": { 16 | "babel-core": "^6.10.4", 17 | "babel-loader": "6.2.4", 18 | "babel-preset-es2015": "^6.6.0", 19 | "babel-preset-stage-1": "6.5.0", 20 | "babelify": "^7.3.0", 21 | "browserify": "^14.0.0", 22 | "gulp": "^3.9.1", 23 | "gulp-autoprefixer": "^3.1.1", 24 | "gulp-babel": "^6.1.2", 25 | "gulp-concat": "^2.6.1", 26 | "gulp-htmlmin": "^3.0.0", 27 | "gulp-sass": "^3.1.0", 28 | "gulp-sourcemaps": "^2.4.0", 29 | "gulp-uglify": "^2.0.1", 30 | "gulp-watch": "^4.3.11", 31 | "require-dir": "^0.3.1", 32 | "vinyl-buffer": "^1.0.0", 33 | "vinyl-source-stream": "^1.1.0" 34 | }, 35 | "scripts": { 36 | "start": "gulp", 37 | "gulp": "gulp", 38 | "development": "gulp", 39 | "production": "gulp build" 40 | }, 41 | "author": "Ian Luites", 42 | "license": "MIT" 43 | } 44 | -------------------------------------------------------------------------------- /src/js/wobserver_api_fallback.js: -------------------------------------------------------------------------------- 1 | function build_url(host, command, node = "") { 2 | if( node == "local" ){ 3 | return 'http://' + host + '/api/' + encodeURI(command).replace(/#/g, '%23'); 4 | } else { 5 | return 'http://' + host + '/api/' + encodeURI(node) + "/" + encodeURI(command).replace(/#/g, '%23'); 6 | } 7 | } 8 | 9 | class WobserverApiFallback { 10 | constructor(host, node = "local") { 11 | this.host = host; 12 | this.node = node; 13 | this.connected = true; 14 | } 15 | 16 | command(command, data = null) { 17 | fetch(build_url(this.host, command, this.node)) 18 | } 19 | 20 | command_promise(command, data = null) { 21 | return fetch(build_url(this.host, command, this.node)) 22 | .then(res => res.json()) 23 | .then(data => { return { 24 | data: data, 25 | timestamp: Date.now() / 1000 | 0, 26 | type: command, 27 | } } ) 28 | .then(e => { 29 | if( !this.connected ){ 30 | this.connected = true; 31 | this.on_reconnect() 32 | } 33 | 34 | return e; 35 | }) 36 | .catch(_ => { 37 | this.connected = false; 38 | this.on_disconnect(); 39 | }); 40 | } 41 | 42 | set_node(node) { 43 | this.node = node; 44 | } 45 | } 46 | 47 | export{ WobserverApiFallback } 48 | -------------------------------------------------------------------------------- /lib/wobserver/util/port.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Port do 2 | @moduledoc ~S""" 3 | Handles Ports and their information. 4 | """ 5 | 6 | @doc ~S""" 7 | Lists ports and their block and carrier size. 8 | 9 | The returned maps contain the following information: 10 | - `id` 11 | - `port` 12 | - `name` 13 | - `links` 14 | - `connected` 15 | - `input` 16 | - `output` 17 | - `os_pid` 18 | """ 19 | @spec list :: list(map) 20 | def list do 21 | :erlang.ports 22 | |> Enum.map(&info/1) 23 | |> Enum.filter(fn 24 | :port_not_found -> false 25 | _ -> true 26 | end) 27 | end 28 | 29 | defp info(port) do 30 | port_data = 31 | port 32 | |> :erlang.port_info 33 | 34 | case port_data do 35 | :undefined -> 36 | :port_not_found 37 | data -> 38 | %{ 39 | id: Keyword.get(data, :id, -1), 40 | port: port, 41 | name: to_string(Keyword.get(data, :name, '')), 42 | links: Keyword.get(data, :links, []), 43 | connected: Keyword.get(data, :connected, nil), 44 | input: Keyword.get(data, :input, 0), 45 | output: Keyword.get(data, :output, 0), 46 | os_pid: Keyword.get(data, :os_pid, nil), 47 | } 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/wobserver/util/allocator.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Allocator do 2 | @moduledoc ~S""" 3 | Handles memory allocators and their block and carrier size. 4 | """ 5 | 6 | @doc ~S""" 7 | Lists memory allocators and their block and carrier size. 8 | 9 | The returned maps contain the following information: 10 | - `type`, the type of the memory allocator. 11 | - `block`, the block size of the memory allocator. (summed over all schedulers) 12 | - `carrier`, the carrier size of the memory allocator. (summed over all schedulers) 13 | """ 14 | @spec list :: list(map) 15 | def list do 16 | :alloc_util_allocators 17 | |> :erlang.system_info 18 | |> info() 19 | end 20 | 21 | defp info(type) do 22 | {:allocator_sizes, type} 23 | |> :erlang.system_info 24 | |> Enum.map(&sum_data/1) 25 | |> Enum.filter(&non_zero?/1) 26 | end 27 | 28 | defp non_zero?(%{carrier: c, block: b}), do: c != 0 && b != 0 29 | 30 | defp sum_data({type, data}) do 31 | data 32 | |> Enum.map(fn {_, _, d} -> Keyword.get(d,:mbcs) end) 33 | |> Enum.map(&block_and_carrier_size/1) 34 | |> Enum.reduce({0,0}, fn {x, y}, {a, b} -> {a + x, y + b} end) 35 | |> (fn {x, y} -> %{type: type, block: x, carrier: y} end).() 36 | end 37 | 38 | defp block_and_carrier_size([{_, x, _, _}, {_, y, _, _}]), do: {x, y} 39 | end 40 | -------------------------------------------------------------------------------- /src/js/interface/table_detail.js: -------------------------------------------------------------------------------- 1 | import {Popup} from './popup.js'; 2 | 3 | class TableDetail { 4 | constructor(table, wobserver) { 5 | this.table = table; 6 | this.wobserver = wobserver; 7 | } 8 | 9 | show() { 10 | this.wobserver.client.command_promise('table/' + this.table) 11 | .then(e => { 12 | let table = e.data; 13 | if( table == 'error' ){ 14 | Popup.show(` 15 |
16 | Can not show table. 17 |
18 | `); 19 | return; 20 | } 21 | 22 | if( table.data.length <= 0 ){ 23 | return Popup.show(` 24 |
25 | Table has no content. 26 |
27 | `); 28 | } 29 | 30 | let table_data = table.data.map((row, index) => { 31 | let formatted_row = row.map(field => `
${field}
`).join(''); 32 | return `${index+1}${formatted_row}`; 33 | }).join(''); 34 | 35 | 36 | Popup.show(` 37 |
38 | Table Information: 39 |
40 | 41 | ${table_data} 42 |
43 |
44 |
45 | `); 46 | }); 47 | } 48 | 49 | hide() { 50 | Popup.hide(); 51 | } 52 | } 53 | 54 | export{ TableDetail } -------------------------------------------------------------------------------- /src/js/interface/node_dialog.js: -------------------------------------------------------------------------------- 1 | import {Popup} from './popup.js'; 2 | 3 | class NodeDialog { 4 | constructor(wobserver) { 5 | this.wobserver = wobserver; 6 | } 7 | 8 | show() { 9 | this.wobserver.client.command_promise('nodes') 10 | .then(e => { 11 | let nodes = e.data; 12 | 13 | Popup.show(` 14 |
15 | Select node: 16 | 18 |
19 | `); 20 | 21 | let node_options = document.getElementById('node_options'); 22 | 23 | nodes.forEach((node) => { 24 | let li = document.createElement('li'); 25 | let selected = (this.wobserver.client.node == node.name); 26 | 27 | li.className = selected ? 'node selected' : 'node'; 28 | 29 | let local_class = node['local?'] ? ' (local)' : ''; 30 | 31 | li.innerHTML = `${node.name}${local_class}${node.host}:${node.port}` 32 | 33 | if( selected ) { 34 | li.addEventListener('click', () => this.hide()); 35 | } else { 36 | li.addEventListener('click', () =>{ 37 | this.wobserver.client.set_node(node.name); 38 | this.hide(); 39 | }); 40 | } 41 | 42 | node_options.appendChild(li); 43 | }); 44 | }); 45 | } 46 | 47 | hide() { 48 | Popup.hide(); 49 | } 50 | } 51 | 52 | export{ NodeDialog } -------------------------------------------------------------------------------- /lib/wobserver/system/statistics.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System.Statistics do 2 | @moduledoc ~S""" 3 | Handles system statistics. 4 | """ 5 | 6 | @typedoc ~S""" 7 | System statistics. 8 | """ 9 | @type t :: %__MODULE__{ 10 | uptime: integer, 11 | process_running: integer, 12 | process_total: integer, 13 | process_max: integer, 14 | input: integer, 15 | output: integer, 16 | } 17 | 18 | defstruct [ 19 | uptime: 0, 20 | process_running: 0, 21 | process_total: 0, 22 | process_max: 0, 23 | input: 0, 24 | output: 0, 25 | ] 26 | 27 | @doc ~S""" 28 | Returns system statistics. 29 | """ 30 | @spec overview :: Wobserver.System.Statistics.t 31 | def overview do 32 | {input, output} = io() 33 | {running, total, max} = process() 34 | 35 | %__MODULE__{ 36 | uptime: uptime(), 37 | process_running: running, 38 | process_total: total, 39 | process_max: max, 40 | input: input, 41 | output: output, 42 | } 43 | end 44 | 45 | defp uptime do 46 | {time, _?} = :erlang.statistics(:wall_clock) 47 | 48 | time 49 | end 50 | 51 | defp io do 52 | { 53 | {:input, input}, 54 | {:output, output} 55 | } = :erlang.statistics(:io) 56 | 57 | {input, output} 58 | end 59 | 60 | defp process do 61 | { 62 | :erlang.statistics(:run_queue), 63 | :erlang.system_info(:process_count), 64 | :erlang.system_info(:process_limit), 65 | } 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /src/js/wobserver.js: -------------------------------------------------------------------------------- 1 | import {WobserverClient} from './wobserver_client'; 2 | import {WobserverRender} from './wobserver_render'; 3 | 4 | function setup_hooks(wobserver) { 5 | wobserver.client.on_disconnect = () => WobserverRender.disconnect_popup(true); 6 | wobserver.client.on_reconnect = () => WobserverRender.disconnect_popup(false); 7 | } 8 | 9 | class Wobserver { 10 | constructor(host) { 11 | this.host = host; 12 | this.update_interval = 1000; 13 | this.refreshTimer = null; 14 | 15 | WobserverRender.init(this); 16 | 17 | this.client = new WobserverClient(host); 18 | this.client.connect(n => WobserverRender.set_node(n), 19 | client => { 20 | this.client = client; 21 | setup_hooks(this); 22 | }, () => WobserverRender.load_menu(this)); 23 | 24 | setup_hooks(this); 25 | 26 | window.show_process = process => WobserverRender.show_process(process, this); 27 | window.show_table = table => WobserverRender.show_table(table, this); 28 | } 29 | 30 | 31 | 32 | display(command, renderer) { 33 | this.client.command_promise(command) 34 | .then(e => renderer(e)) 35 | } 36 | 37 | open(command, refresh, renderer) { 38 | clearInterval(this.refreshTimer); 39 | this.display(command, renderer); 40 | 41 | if( refresh > 0 ) { 42 | this.refreshTimer = setInterval( () => this.display(command, renderer), refresh * this.update_interval); 43 | } 44 | } 45 | 46 | close(command, refresh) { 47 | 48 | } 49 | } 50 | 51 | export{ Wobserver } 52 | 53 | -------------------------------------------------------------------------------- /lib/wobserver/system.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System do 2 | @moduledoc ~S""" 3 | Provides System information. 4 | """ 5 | 6 | alias Wobserver.System.Info 7 | alias Wobserver.System.Memory 8 | alias Wobserver.System.Scheduler 9 | alias Wobserver.System.Statistics 10 | 11 | @typedoc ~S""" 12 | System overview information. 13 | 14 | Including: 15 | - `architecture`, architecture information. 16 | - `cpu`, cpu information. 17 | - `memory`, memory usage. 18 | - `statistics`, general System statistics. 19 | - `scheduler`, scheduler utilization per scheduler. 20 | """ 21 | @type t :: %__MODULE__{ 22 | architecture: Info.t, 23 | cpu: map, 24 | memory: Memory.t, 25 | statistics: Statistics.t, 26 | scheduler: list(float) 27 | } 28 | 29 | defstruct [ 30 | :architecture, 31 | :cpu, 32 | :memory, 33 | :statistics, 34 | :scheduler, 35 | ] 36 | 37 | @doc ~S""" 38 | Provides a overview of all System information. 39 | 40 | Including: 41 | - `architecture`, architecture information. 42 | - `cpu`, cpu information. 43 | - `memory`, memory usage. 44 | - `statistics`, general System statistics. 45 | - `scheduler`, scheduler utilization per scheduler. 46 | """ 47 | @spec overview :: Wobserver.System.t 48 | def overview do 49 | %__MODULE__{ 50 | architecture: Info.architecture, 51 | cpu: Info.cpu, 52 | memory: Memory.usage, 53 | statistics: Statistics.overview, 54 | scheduler: Scheduler.utilization, 55 | } 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/mix/tasks/analyze.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Analyze do 2 | @moduledoc ~S""" 3 | Analyze wobserver code and exit if errors have been found. 4 | """ 5 | 6 | use Mix.Task 7 | 8 | @shortdoc "Analyze wobserver code and exit if errors have been found." 9 | 10 | @doc ~S""" 11 | Analyze wobserver code and exit if errors have been found. 12 | 13 | The following steps are performed: 14 | - `credo` (strict) 15 | - `dialyzer` 16 | - `coveralls` (>=90% coverage pass) 17 | """ 18 | @spec run([binary]) :: any 19 | def run(_) do 20 | IO.puts "Running:" 21 | execute "mix", ["credo", "--strict"] 22 | execute "mix", ["dialyzer", "--halt-exit-status"] 23 | execute "mix", ["coveralls.html"], fn output -> 24 | if !String.contains?(output, "[TOTAL] 100.0%"), do: IO.write output 25 | end 26 | end 27 | 28 | @spec execute(String.t, [String.t], fun | nil) :: any 29 | defp execute(command, options, post_process \\ nil) do 30 | commands = ["-q", "/dev/null", command] 31 | 32 | label = "\e[1m#{command} #{Enum.join(options, " ")}\e[0m" 33 | 34 | " " 35 | |> Kernel.<>(label) 36 | |> String.pad_trailing(60, " ") 37 | |> IO.write 38 | 39 | case System.cmd("script", commands ++ options) do 40 | {output, 0} -> 41 | IO.puts "\e[32msuccess\e[0m" 42 | 43 | if post_process, do: post_process.(output) 44 | {output, _} -> 45 | IO.puts "\e[31mfailed\e[0m" 46 | IO.puts output 47 | IO.puts "#{label} \e[31mfailed\e[0m" 48 | System.halt(1) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 0.1.8 4 | 5 | 6 | ## 0.1.7 7 | 8 | * Improved assets over HTTPS and baking them in. (#17, #25) 9 | * Fixed not being able to select in tables. (#30) 10 | * Fixed divided by 0 in utilization. (#18) 11 | 12 | 13 | ## 0.1.6 14 | 15 | * Improved interface style. (#9) 16 | * Improved documentation. (#12) 17 | * Fixed assets over HTTPS. (#7) 18 | * Fixed overactive keep-alive. (#10) 19 | * Fixed url redirect. (#6) 20 | * Fixed disconnect/reconnect of websocket. (#5) 21 | * Fixed crash involving port list. (#13) 22 | 23 | 24 | ## 0.1.5 25 | 26 | * Bake assets into wobserver. 27 | * Remove custom tasks from hex package. 28 | * Improved web build tools. 29 | * Improved package build tools. 30 | * Improved node discovery in plug mode. 31 | * Fixed distillery support. (#3) 32 | 33 | 34 | ## 0.1.4 35 | 36 | * Added :plug mode to run wobserver without cowboy. 37 | * Fixed menu loading. 38 | * Fixed custom page rendering. 39 | 40 | 41 | ## 0.1.3 42 | 43 | * Added custom metrics. 44 | * Fixed web interface socket keep-alive. 45 | * Fixed metrics data handling. 46 | 47 | 48 | ## 0.1.2 49 | 50 | * Added custom pages and commands. 51 | * Added dynamic page registration. 52 | 53 | 54 | ## 0.1.1 55 | 56 | * Code cleanup and general fixes. 57 | 58 | 59 | ## 0.1.0 60 | 61 | * Added the following pages: Load Charts, Memory Allocators, Applications, Processes, Ports, and Table Viewer. 62 | 63 | 64 | ## 0.0.3 65 | 66 | * Added remote node connections and discovery behind load balancers and firewalls. 67 | 68 | 69 | ## 0.0.2 70 | 71 | * Added System page. 72 | 73 | 74 | ## 0.0.1 75 | 76 | * First official release. 77 | -------------------------------------------------------------------------------- /test/wobserver/util/helper_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.HelperTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Util.Helper 5 | alias Wobserver.Util.Process 6 | 7 | describe "string_to_module" do 8 | test "returns module name without dots" do 9 | assert Helper.string_to_module("Logger") == Logger 10 | end 11 | 12 | test "returns module name with dots" do 13 | assert Helper.string_to_module("Logger.Supervisor") == Logger.Supervisor 14 | end 15 | 16 | test "returns atom" do 17 | assert Helper.string_to_module("atom") == :atom 18 | end 19 | 20 | test "returns atom with spaces" do 21 | assert Helper.string_to_module("has spaces") == :"has spaces" 22 | end 23 | end 24 | 25 | describe "JSON implementations" do 26 | test "PID" do 27 | pid = Process.pid(33); 28 | encoder = Poison.Encoder.impl_for(pid) 29 | 30 | assert encoder.encode(pid, []) == [34, ["#PID<0.33.0>"], 34] 31 | end 32 | 33 | test "Port" do 34 | port = :erlang.ports |> List.first 35 | encoder = Poison.Encoder.impl_for(port) 36 | 37 | assert encoder.encode(port, []) == [34, ["#Port<0.0>"], 34] 38 | end 39 | end 40 | 41 | describe "format_function" do 42 | test "nil" do 43 | assert Helper.format_function(nil) == nil 44 | end 45 | 46 | test "with function typle" do 47 | assert Helper.format_function({Logger, :log, 2}) == "Elixir.Logger.log/2" 48 | end 49 | 50 | test "returns function atom" do 51 | assert Helper.format_function(:format_function) == "format_function" 52 | end 53 | end 54 | 55 | describe "parallel_map" do 56 | test "maps" do 57 | assert Helper.parallel_map([1, 2, 3], fn x -> x * 2 end) == [2, 4, 6] 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/wobserver/util/node/remote_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Node.RemoteTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Wobserver.Util.Node.Remote 5 | alias Wobserver.Web.ClientProxy 6 | 7 | describe "metrics" do 8 | test "does remote calls" do 9 | :meck.new HTTPoison, [:passthrough] 10 | :meck.expect HTTPoison, :get, fn _ -> {:ok, %{body: "ok"}} end 11 | 12 | on_exit(fn -> :meck.unload end) 13 | 14 | assert Remote.metrics(%{host: "", port: 80, local?: false}) == "ok" 15 | end 16 | end 17 | 18 | describe "socket_proxy" do 19 | test ":local returns {nil, local}" do 20 | assert Remote.socket_proxy(:local) == {nil, "local"} 21 | end 22 | 23 | test "local node returns {nil, local}" do 24 | assert Remote.socket_proxy(%{local?: true}) == {nil, "local"} 25 | end 26 | 27 | test "remote local node returns {nil, local}" do 28 | assert Remote.socket_proxy({:remote, %{local?: true}}) == {nil, "local"} 29 | end 30 | 31 | test "invalid argument returns error" do 32 | assert {:error, _} = Remote.socket_proxy(:invalid) 33 | end 34 | 35 | test "remote opens connection (on ok)" do 36 | :meck.new ClientProxy, [:passthrough] 37 | :meck.expect ClientProxy, :connect, fn _ -> {:ok, 5} end 38 | 39 | on_exit(fn -> :meck.unload end) 40 | 41 | assert Remote.socket_proxy(%{host: "", port: 1, name: "a", local?: false}) == {5, "a"} 42 | end 43 | 44 | test "remote opens connection (on error)" do 45 | :meck.new ClientProxy, [:passthrough] 46 | :meck.expect ClientProxy, :connect, fn _ -> :invalid end 47 | 48 | on_exit(fn -> :meck.unload end) 49 | 50 | assert {:error, _} = Remote.socket_proxy(%{host: "", port: 1, name: ""}) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /src/css/lib/_reset.scss: -------------------------------------------------------------------------------- 1 | @import 'theme'; 2 | 3 | html, body, div, span, applet, object, iframe, 4 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 5 | a, abbr, acronym, address, big, cite, code, 6 | del, dfn, em, font, img, ins, kbd, q, s, samp, 7 | small, strike, strong, sub, sup, tt, var, 8 | dl, dt, dd, ol, ul, li, 9 | fieldset, form, label, legend, 10 | table, caption, tbody, tfoot, thead, tr, th, td { 11 | margin: 0; 12 | padding: 0; 13 | border: 0; 14 | outline: 0; 15 | font-weight: inherit; 16 | font-style: inherit; 17 | font-size: 100%; 18 | font-family: inherit; 19 | vertical-align: baseline; 20 | } 21 | :focus { 22 | outline: 0; 23 | } 24 | 25 | html, body{ 26 | width: 100%; 27 | height: 100%; 28 | 29 | color: $content-color; 30 | background-color: $content-background-color; 31 | font-family: $base-font; 32 | 33 | text-rendering: optimizeLegibility; 34 | text-rendering: geometricPrecision; 35 | font-smooth: always; 36 | 37 | font-smoothing: antialiased; 38 | -moz-font-smoothing: antialiased; 39 | -webkit-font-smoothing: antialiased; 40 | -webkit-font-smoothing: subpixel-antialiased; 41 | -webkit-font-smoothing: antialiased; 42 | -moz-osx-font-smoothing: grayscale; 43 | } 44 | input, select, textarea{ 45 | font-family: $base-font; 46 | 47 | } 48 | a{ 49 | text-decoration: none; 50 | cursor: pointer; 51 | 52 | color: $highlight-color; 53 | } 54 | a:hover{ 55 | cursor: pointer; 56 | color: saturate(lighten( $highlight-color, 20% ), 10%); 57 | } 58 | p{ 59 | text-indent: 2em; 60 | } 61 | p + p{ 62 | margin-top: 1em; 63 | } 64 | 65 | @mixin headings { 66 | h1, h2, h3, 67 | h4, h5, h6 { 68 | @content; 69 | } 70 | } 71 | @include headings { 72 | @extend %headings !optional; 73 | } 74 | ul, ol{ 75 | list-style-position: inside; 76 | } -------------------------------------------------------------------------------- /test/wobserver_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WobserverTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Page 5 | alias Wobserver.Util.Metrics 6 | 7 | describe "about" do 8 | test "includes name" do 9 | assert %{name: "Wobserver"} = Wobserver.about 10 | end 11 | 12 | test "includes version" do 13 | version = 14 | case :application.get_key(:wobserver, :vsn) do 15 | {:ok, v} -> List.to_string v 16 | _ -> "Unknown" 17 | end 18 | 19 | assert %{version: ^version} = Wobserver.about 20 | end 21 | 22 | test "includes description" do 23 | assert %{description: "Web based metrics, monitoring, and observer."} = Wobserver.about 24 | end 25 | 26 | test "includes license" do 27 | %{license: license} = Wobserver.about 28 | assert license.name == "MIT" 29 | end 30 | 31 | test "includes links" do 32 | %{links: links} = Wobserver.about 33 | 34 | assert Enum.count(links) > 0 35 | end 36 | end 37 | 38 | describe "register" do 39 | test "can register page" do 40 | assert Wobserver.register(:page, {"Test", :test, fn -> 5 end}) 41 | end 42 | 43 | test "can register page and also call it" do 44 | Wobserver.register(:page, {"Test", :test, fn -> 5 end}) 45 | 46 | assert Page.call(:test) == 5 47 | end 48 | 49 | test "registers a metric" do 50 | assert Wobserver.register :metric, [example: {fn -> [{5, []}] end, :gauge, "Description"}] 51 | 52 | assert Keyword.has_key?(Metrics.overview, :example) 53 | end 54 | 55 | test "registers a metric generator" do 56 | assert Wobserver.register :metric, [ 57 | fn -> [generated: {fn -> [{5, []}] end, :gauge, "Description"}] end 58 | ] 59 | 60 | assert Keyword.has_key?(Metrics.overview, :generated) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /src/css/lib/_graph.scss: -------------------------------------------------------------------------------- 1 | .process-node { 2 | font-size: 90%; 3 | 4 | border: 1px solid #000; 5 | border-radius: 1em; 6 | 7 | cursor: pointer; 8 | 9 | p { 10 | margin: 0.3em 0.7em 0.3em -1em; 11 | 12 | &:nth-child(even) { 13 | font-style: italic; 14 | } 15 | } 16 | } 17 | 18 | .gen_server { 19 | background-color: #ddd; 20 | } 21 | .supervisor { 22 | background-color: #7ff; 23 | } 24 | .application { 25 | background-color: #9f9; 26 | } 27 | .unknown { 28 | background-color: #fff; 29 | } 30 | .process-node:hover { 31 | border: 1px solid #000; 32 | border-radius: 1em; 33 | background-color: #adf; 34 | } 35 | 36 | #applications_app_list { 37 | margin-left: 0.3em; 38 | } 39 | #applications_header { 40 | position: absolute; 41 | top: 1em; 42 | left: 1em; 43 | z-index: 1; 44 | 45 | @media screen and (min-width: $breakpoint-medium) { 46 | top: 6em; 47 | } 48 | } 49 | #application_chart { 50 | box-sizing: border-box; 51 | position: absolute; 52 | left:0; right:0; 53 | // height: 100%; 54 | top: 1em; 55 | bottom: 1em; 56 | 57 | @media screen and (min-width: $breakpoint-medium) { 58 | top: 6em; 59 | bottom: 3em; 60 | } 61 | } 62 | /* required LIB STYLES */ 63 | /* .Treant se automatski dodaje na svaki chart conatiner */ 64 | .Treant { position: relative; overflow: hidden; padding: 0 !important; } 65 | .Treant > .node, 66 | .Treant > .pseudo { position: absolute; display: block; visibility: hidden; } 67 | .Treant.Treant-loaded .node, 68 | .Treant.Treant-loaded .pseudo { visibility: visible; } 69 | .Treant > .pseudo { width: 0; height: 0; border: none; padding: 0; } 70 | .Treant .collapse-switch { width: 3px; height: 3px; display: block; border: 1px solid black; position: absolute; top: 1px; right: 1px; cursor: pointer; } 71 | .Treant .collapsed .collapse-switch { background-color: #868DEE; } 72 | .Treant > .node img { border: none; float: left; } -------------------------------------------------------------------------------- /test/wobserver/system/info_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System.InfoTest do 2 | use ExUnit.Case 3 | 4 | describe "architecture" do 5 | test "returns info struct" do 6 | assert %Wobserver.System.Info{} = Wobserver.System.Info.architecture 7 | end 8 | 9 | test "returns values" do 10 | %Wobserver.System.Info{ 11 | otp_release: otp_release, 12 | elixir_version: elixir_version, 13 | erts_version: erts_version, 14 | system_architecture: system_architecture, 15 | kernel_poll: kernel_poll, 16 | smp_support: smp_support, 17 | threads: threads, 18 | thread_pool_size: thread_pool_size, 19 | wordsize_internal: wordsize_internal, 20 | wordsize_external: wordsize_external, 21 | } = Wobserver.System.Info.architecture 22 | 23 | assert is_binary(otp_release) 24 | assert is_binary(elixir_version) 25 | assert is_binary(erts_version) 26 | assert is_binary(system_architecture) 27 | assert is_boolean(kernel_poll) 28 | assert is_boolean(smp_support) 29 | assert is_boolean(threads) 30 | assert is_integer(thread_pool_size) 31 | assert is_integer(wordsize_internal) 32 | assert is_integer(wordsize_external) 33 | end 34 | end 35 | 36 | test "cpu returns values" do 37 | %{ 38 | logical_processors: logical_processors, 39 | logical_processors_online: logical_processors_online, 40 | logical_processors_available: logical_processors_available, 41 | schedulers: schedulers, 42 | schedulers_online: schedulers_online, 43 | schedulers_available: schedulers_available, 44 | } = Wobserver.System.Info.cpu 45 | 46 | assert is_integer(logical_processors) 47 | assert is_integer(logical_processors_online) 48 | assert is_integer(logical_processors_available) || logical_processors_available == :unknown 49 | assert is_integer(schedulers) 50 | assert is_integer(schedulers_online) 51 | assert is_integer(schedulers_available) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/wobserver/web/router/metrics.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.Metrics do 2 | @moduledoc ~S""" 3 | Metrics router. 4 | 5 | Returns the following resources: 6 | - `/` => All metrics for the local node. 7 | - `/memory` => Memory metrics for the local node. 8 | - `/io` => IO metrics for the local node. 9 | """ 10 | 11 | use Wobserver.Web.Router.Base 12 | 13 | alias Wobserver.Util.Metrics 14 | alias Wobserver.Util.Metrics.Formatter 15 | alias Wobserver.Util.Node.Discovery 16 | alias Wobserver.Util.Node.Remote 17 | 18 | match "/" do 19 | data = 20 | Discovery.discover 21 | |> Enum.map(&Remote.metrics/1) 22 | |> Formatter.merge_metrics 23 | 24 | case data do 25 | :error -> send_resp(conn, 500, "Can not generate metrics.") 26 | _ -> send_resp(conn, 200, data) 27 | end 28 | end 29 | 30 | match "/n/:node_name" do 31 | case Discovery.find(node_name) do 32 | :local -> 33 | Metrics.overview 34 | |> send_metrics(conn) 35 | {:remote, remote_node} -> 36 | data = 37 | remote_node 38 | |> Remote.metrics 39 | 40 | case data do 41 | :error -> 42 | conn 43 | |> send_resp(500, "Node #{node_name} not responding.") 44 | result -> 45 | conn 46 | |> send_resp(200, result) 47 | end 48 | :unknown -> 49 | conn 50 | |> send_resp(404, "Node #{node_name} not Found") 51 | end 52 | end 53 | 54 | match "/memory" do 55 | Metrics.memory 56 | |> send_metrics(conn) 57 | end 58 | 59 | match "/io" do 60 | Metrics.memory 61 | |> send_metrics(conn) 62 | end 63 | 64 | match _ do 65 | conn 66 | |> send_resp(404, "Page not Found") 67 | end 68 | 69 | # Helpers 70 | 71 | defp send_metrics(data, conn) do 72 | case Formatter.format_all(data) do 73 | :error -> send_resp(conn, 500, "Can not generate metrics.") 74 | metrics -> send_resp(conn, 200, metrics) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/wobserver/application_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.ApplicationTest do 2 | use ExUnit.Case 3 | 4 | describe "port" do 5 | test "returns a default port" do 6 | assert Wobserver.Application.port > 0 7 | end 8 | 9 | test "returns a set port" do 10 | :meck.new Application, [:passthrough] 11 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 12 | case option do 13 | :mode -> :plug 14 | :pages -> [] 15 | :metrics -> [] 16 | :discovery -> :none 17 | :port -> 8888 18 | end 19 | end 20 | 21 | on_exit(fn -> :meck.unload end) 22 | 23 | assert Wobserver.Application.port == 8888 24 | end 25 | end 26 | 27 | describe "start" do 28 | test "as app starts cowboy with :standalone set" do 29 | :meck.new Application, [:passthrough] 30 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 31 | case option do 32 | :mode -> :standalone 33 | :discovery -> :none 34 | :pages -> [] 35 | :metrics -> [] 36 | :port -> 8888 37 | end 38 | end 39 | 40 | on_exit(fn -> :meck.unload end) 41 | 42 | case Wobserver.Application.start(:normal, []) do 43 | {:error, data} -> assert data == {:already_started, Process.whereis(Wobserver.Supervisor)} 44 | data -> assert data == {:ok, Process.whereis(Wobserver.Supervisor)} 45 | end 46 | end 47 | 48 | test "as plug returns metrics storage pid" do 49 | :meck.new Application, [:passthrough] 50 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 51 | case option do 52 | :mode -> :plug 53 | :discovery -> :none 54 | :pages -> [] 55 | :metrics -> [] 56 | :port -> 8888 57 | end 58 | end 59 | 60 | on_exit(fn -> :meck.unload end) 61 | 62 | assert Wobserver.Application.start(:normal, []) == {:ok, Process.whereis(:wobserver_metrics)} 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/wobserver.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver do 2 | @moduledoc """ 3 | Web based metrics, monitoring, and observer. 4 | """ 5 | 6 | alias Wobserver.Page 7 | alias Wobserver.Util.Metrics 8 | 9 | @doc ~S""" 10 | Registers external application to integrate with `:wobserver`. 11 | 12 | The registration is done by passing a `type` and the `data` to register. 13 | The `data` is usually passed on to a specialized function. 14 | 15 | The following types can be registered: 16 | - `:page`, see: `Wobserver.Page.register/1`. 17 | - `:metric`, see: `Wobserver.Util.Metrics.register/1`. 18 | """ 19 | @spec register(type :: atom, data :: any) :: boolean 20 | def register(type, data) 21 | 22 | def register(:page, page), do: Page.register(page) 23 | def register(:metric, metric), do: Metrics.register(metric) 24 | 25 | @doc ~S""" 26 | Information about Wobserver. 27 | 28 | Returns a map containing: 29 | - `name`, name of `:wobserver`. 30 | - `version`, used `:wobserver` version. 31 | - `description`, description of `:wobserver`. 32 | - `license`, `:wobserver` license name and link. 33 | - `links`, list of name + url for `:wobserver` related information. 34 | """ 35 | @spec about :: map 36 | def about do 37 | version = 38 | case :application.get_key(:wobserver, :vsn) do 39 | {:ok, v} -> List.to_string v 40 | _ -> "Unknown" 41 | end 42 | 43 | %{ 44 | name: "Wobserver", 45 | version: version, 46 | description: "Web based metrics, monitoring, and observer.", 47 | license: %{ 48 | name: "MIT", 49 | url: "license", 50 | }, 51 | links: [ 52 | %{ 53 | name: "Hex", 54 | url: "https://hex.pm/packages/wobserver", 55 | }, 56 | %{ 57 | name: "Docs", 58 | url: "https://hexdocs.pm/wobserver/", 59 | }, 60 | %{ 61 | name: "Github", 62 | url: "https://github.com/shinyscorpion/wobserver", 63 | }, 64 | ], 65 | } 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /src/css/lib/_content.scss: -------------------------------------------------------------------------------- 1 | #content { 2 | display: block; 3 | padding: 1em 1em 1em 1em; 4 | position: relative; 5 | box-sizing: border-box; 6 | min-height: 100%; 7 | 8 | background-color: $content-background-color; 9 | 10 | transition: margin-left 1s, margin-left 1s, opacity 0.3s linear; 11 | margin-left: $menu-width-retracted; 12 | margin-right: $sidebar-width-retracted; 13 | @media screen and (min-width: $breakpoint-small) { 14 | margin-left: $menu-width-extended; 15 | padding-top: 1em; 16 | } 17 | @media screen and (min-width: $breakpoint-medium) { 18 | margin-left: 0; 19 | margin-right: 0; 20 | padding-top: $menu-header-height + 4em; 21 | padding-bottom: 3em; 22 | //margin-right: $sidebar-width-extended; 23 | } 24 | 25 | // display: -webkit-flex; 26 | // display: flex; 27 | // -webkit-align-items: center; 28 | // align-items: center; 29 | // -webkit-flex-flow: column wrap; 30 | // flex-flow: column wrap; 31 | 32 | // -webkit-justify-content: center; 33 | // justify-content: center; 34 | // -webkit-align-content: center; 35 | // align-content: center; 36 | 37 | // display: -webkit-flex; 38 | // display: flex; 39 | // -webkit-align-items: flex-start; 40 | // align-items: flex-start; 41 | // // -webkit-flex-flow: row wrap; 42 | // // flex-flow: row wrap; 43 | 44 | // // text-align: center; 45 | // & > * { 46 | // //text-align: left; 47 | // align-self: center; 48 | // } 49 | 50 | text-align: center; 51 | & > div{ 52 | text-align: left; 53 | display:inline-block; 54 | width: auto; 55 | margin: auto; 56 | } 57 | & > table { 58 | text-align: left; 59 | } 60 | 61 | 62 | & > footer { 63 | display: block; 64 | @media screen and (min-width: $breakpoint-small) { 65 | display: none; 66 | } 67 | 68 | position: absolute; 69 | bottom: 0.8em; 70 | left: 0; 71 | right: 0; 72 | 73 | text-align: center; 74 | color: darken($content-background-color, 10%); 75 | } 76 | 77 | select { 78 | font-size: 100%; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/wobserver/util/metrics_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.MetricsTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Util.Metrics 5 | 6 | describe "overview" do 7 | test "returns a list" do 8 | assert is_list(Metrics.overview) 9 | end 10 | 11 | test "returns a keyword list" do 12 | assert Keyword.keyword?(Metrics.overview) 13 | end 14 | end 15 | 16 | describe "register" do 17 | test "registers a metric" do 18 | assert Metrics.register [example: {fn -> [{5, []}] end, :gauge, "Description"}] 19 | 20 | assert Keyword.has_key?(Metrics.overview, :example) 21 | end 22 | 23 | test "registers a metric generator" do 24 | assert Metrics.register [ 25 | fn -> [generated: {fn -> [{5, []}] end, :gauge, "Description"}] end 26 | ] 27 | 28 | assert Keyword.has_key?(Metrics.overview, :generated) 29 | end 30 | 31 | test "registers a string metric generator" do 32 | assert Metrics.register [ 33 | "fn -> [generated_s: {fn -> [{5, []}] end, :gauge, \"Description\"}] end" 34 | ] 35 | 36 | assert Keyword.has_key?(Metrics.overview, :generated_s) 37 | end 38 | end 39 | 40 | describe "load_config" do 41 | setup do 42 | :meck.new Application, [:passthrough] 43 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 44 | case option do 45 | :metrics -> [ 46 | additional: [config_example: {fn -> [{5, []}] end, :gauge, "Description"}], 47 | generators: [fn -> [config_generated: {fn -> [{5, []}] end, :gauge, "Description"}] end] 48 | ] 49 | :discovery -> :none 50 | :port -> 4001 51 | end 52 | end 53 | 54 | on_exit(fn -> :meck.unload end) 55 | 56 | Metrics.load_config 57 | 58 | :ok 59 | end 60 | 61 | test "loads metric from config" do 62 | assert Keyword.has_key?(Metrics.overview, :config_example) 63 | end 64 | test "loads generated metrics from config" do 65 | assert Keyword.has_key?(Metrics.overview, :config_generated) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /src/css/lib/_base.scss: -------------------------------------------------------------------------------- 1 | @import 'reset'; 2 | 3 | 4 | $menu-background-color-selected: darken($menu-background-color, 10%) !default; 5 | $menu-drop-down-background-color: darken($menu-background-color, 20%) !default; 6 | 7 | $table-header-background-color: $menu-background-color-selected !default; 8 | $content-header-background-color: $table-header-background-color !default; 9 | 10 | $breakpoint-small: 720px; 11 | $breakpoint-medium: (1920px / 2); 12 | $breakpoint-large: 2000px; 13 | 14 | $menu-width-retracted: 3em; 15 | $menu-width-extended: 10em; 16 | $menu-button-height: 2.5em; 17 | $menu-button-height-horizontal: $menu-button-height; 18 | $menu-header-height: 3em; 19 | 20 | $menu-summary-width: 20em; 21 | $menu-drop-down-height: 15em; 22 | 23 | $sidebar-width-retracted: 0; 24 | $sidebar-width-extended: 15em; 25 | 26 | $breakpoint-medium: (9 * $menu-width-extended) + 1.1em; // 1.1em is scrollbar width 27 | 28 | h1 { 29 | font-size: 1.7em; 30 | font-weight: bold; 31 | 32 | color: $primary-color; 33 | } 34 | 35 | h2 { 36 | font-size: 1.4em; 37 | font-weight: bold; 38 | 39 | color: $primary-color; 40 | } 41 | 42 | h1, h2 { 43 | &::before { 44 | //content: "\f292"; 45 | font-family: FontAwesome, sans-serif; 46 | 47 | font-weight: normal; 48 | color: rgba(0, 0, 0, 0.5); 49 | font-size: 1rem; 50 | padding-right: 0.7em; 51 | position: relative; 52 | left: 0.2em; 53 | bottom: 0.1em; 54 | } 55 | 56 | } 57 | 58 | p + h1, p + h2 { 59 | margin-top: 0.5em; 60 | } 61 | 62 | @mixin user-select( $type ) { 63 | -webkit-touch-callout: $type; /* iOS Safari */ 64 | -webkit-user-select: $type; /* Chrome/Safari/Opera */ 65 | -khtml-user-select: $type; /* Konqueror */ 66 | -moz-user-select: $type; /* Firefox */ 67 | -ms-user-select: $type; /* Internet Explorer/Edge */ 68 | user-select: $type; 69 | } 70 | 71 | #wobserver { 72 | position: relative; 73 | padding: 0; 74 | height: 100%; 75 | width: 100%; 76 | margin: auto; 77 | } 78 | 79 | @import 'button'; 80 | @import 'menu'; 81 | @import 'content'; 82 | @import 'footer'; 83 | @import 'popup'; 84 | @import 'graph'; 85 | @import 'table'; 86 | -------------------------------------------------------------------------------- /lib/wobserver/web/router/static.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.Static do 2 | @moduledoc ~S""" 3 | Static router mostly for the browsers interface. 4 | 5 | Returns the following resources: 6 | - `/`, for the main html page. 7 | - `/main.css`, for main css stylesheet. 8 | - `/app.js`, for the Javascript code. 9 | - `/license`, for the *MIT* license information. 10 | """ 11 | 12 | use Wobserver.Web.Router.Base 13 | 14 | alias Wobserver.Assets 15 | 16 | @security Application.get_env(:wobserver, :security, Wobserver.Security) 17 | 18 | get "/" do 19 | conn = @security.authenticate(conn) 20 | 21 | case String.ends_with?(conn.request_path, "/") do 22 | true -> 23 | conn 24 | |> put_resp_content_type("text/html") 25 | |> send_asset("assets/index.html", &Assets.html/0) 26 | false -> 27 | conn 28 | |> put_resp_header("location", conn.request_path <> "/") 29 | |> resp(301, "Redirecting to Wobserver.") 30 | end 31 | end 32 | 33 | get "/main.css" do 34 | conn 35 | |> put_resp_content_type("text/css") 36 | |> send_asset("assets/main.css", &Assets.css/0) 37 | end 38 | 39 | get "/app.js" do 40 | conn 41 | |> put_resp_content_type("application/javascript") 42 | |> send_asset("assets/app.js", &Assets.js/0) 43 | end 44 | 45 | get "/license" do 46 | conn 47 | |> send_asset("LICENSE", &Assets.license/0) 48 | end 49 | 50 | match _ do 51 | conn 52 | |> send_resp(404, "Page not Found") 53 | end 54 | 55 | # Helpers 56 | 57 | case Application.get_env(:wobserver, :assets, false) do 58 | false -> 59 | defp send_asset(conn, _asset, fallback) do 60 | conn 61 | |> send_resp(200, fallback.()) 62 | end 63 | root -> 64 | defp send_asset(conn, asset, fallback) do 65 | case File.exists?(unquote(root) <> asset) do 66 | true -> 67 | conn 68 | |> send_file(200, unquote(root) <> asset) 69 | false -> 70 | conn 71 | |> send_resp(200, fallback.()) 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /src/css/lib/_button.scss: -------------------------------------------------------------------------------- 1 | $button-color: #333; 2 | $button-border-color: #ccc; 3 | $button-background: #fff; 4 | 5 | input[type=submit] { 6 | -webkit-appearance: button; 7 | cursor: pointer; 8 | box-sizing: content-box; 9 | } 10 | 11 | button { 12 | box-sizing: content-box; 13 | font-size: 1rem; 14 | height: 1.2em; 15 | //top: -0.1em; 16 | } 17 | 18 | .button { 19 | @include user-select(none); 20 | 21 | position: relative; 22 | display: inline-block; 23 | 24 | margin: 0.3em 0.3em; 25 | padding: 0.5em 1em; 26 | 27 | color: $button-color; 28 | background-color: $button-background; 29 | border: 0; 30 | 31 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); 32 | box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.25); 33 | 34 | font-weight: 400; 35 | text-align: center; 36 | text-indent: 0; 37 | 38 | cursor: pointer; 39 | 40 | transition: background-color 0.1s; 41 | @include user-select(none); 42 | 43 | &:hover { 44 | color: $button-color; 45 | background-color: #e6e6e6; 46 | border-color: #adadad; 47 | } 48 | 49 | &:after { 50 | position: absolute; 51 | 52 | content: ""; 53 | 54 | left: 0; 55 | bottom: 0; 56 | right: 0; 57 | height: 0.2em; 58 | 59 | background-color: rgba(0, 0, 0, 0.4); 60 | } 61 | 62 | &:active { 63 | &:after { 64 | background-color: rgba(0, 0, 0, 0.2); 65 | } 66 | } 67 | .disabled { 68 | opacity: 0.6; 69 | cursor: not-allowed; 70 | } 71 | } 72 | 73 | @mixin button( $color, $background, $background-hover: darken( $background, 10% ) ) { 74 | @extend .button; 75 | 76 | color: $color; 77 | background-color: $background; 78 | 79 | &:hover { 80 | color: $color; 81 | background-color: $background-hover; 82 | } 83 | } 84 | 85 | .button-primary { 86 | @include button(#eee, lighten($primary-color, 5%), darken($primary-color, 5%)); 87 | } 88 | 89 | .button-success { 90 | @include button(#fff, #4cae4c, #449d44); 91 | } 92 | 93 | .button-danger { 94 | @include button(#fff, #e44); 95 | } 96 | 97 | .button-info { 98 | //326bff 99 | @include button(#fff, #4d7fff); 100 | } 101 | -------------------------------------------------------------------------------- /lib/wobserver/system/scheduler.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System.Scheduler do 2 | @moduledoc ~S""" 3 | Scheduler utilization per scheduler. 4 | 5 | Example: 6 | ```bash 7 | Wobserver.System.Scheduler.utilization 8 | [1.0, 0.0306945631032665, 0.03640598025269633, 0.05220935570330663, 9 | 0.04884165187164101, 0.08352432821297966, 0.11547042454628796, 10 | 0.2861211090456038] 11 | ``` 12 | """ 13 | 14 | @table :wobserver_scheduler 15 | @lookup_key :last_utilization 16 | 17 | @doc ~S""" 18 | Calculates scheduler utilization per scheduler. 19 | 20 | Returns a list of floating point values range (0-1) indicating 0-100% utlization. 21 | 22 | Example: 23 | ```bash 24 | Wobserver.System.Scheduler.utilization 25 | [1.0, 0.0306945631032665, 0.03640598025269633, 0.05220935570330663, 26 | 0.04884165187164101, 0.08352432821297966, 0.11547042454628796, 27 | 0.2861211090456038] 28 | ``` 29 | """ 30 | @spec utilization :: list(float) 31 | def utilization do 32 | ensure_started() 33 | 34 | case last_utilization() do 35 | false -> 36 | get_utilization() 37 | |> Enum.map(fn {_, u, t} -> percentage(u, t) end) 38 | last -> 39 | get_utilization() 40 | |> Enum.zip(last) 41 | |> Enum.map(fn {{_, u0, t0}, {_, u1, t1}} -> 42 | percentage((u1 - u0), (t1 - t0)) 43 | end) 44 | end 45 | end 46 | 47 | defp percentage(_, 0), do: 0 48 | defp percentage(u, t), do: u / t 49 | 50 | defp get_utilization do 51 | util = 52 | :scheduler_wall_time 53 | |> :erlang.statistics 54 | |> :lists.sort() 55 | 56 | :ets.insert @table, {@lookup_key, util} 57 | 58 | util 59 | end 60 | 61 | defp last_utilization do 62 | case :ets.lookup(@table, @lookup_key) do 63 | [{@lookup_key, util}] -> util 64 | _ -> false 65 | end 66 | end 67 | 68 | defp ensure_started do 69 | case :ets.info(@table) do 70 | :undefined -> 71 | :erlang.system_flag(:scheduler_wall_time, true) 72 | :ets.new @table, [:named_table, :public] 73 | _ -> 74 | true 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/wobserver/web/client_proxy.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.ClientProxy do 2 | @moduledoc ~S""" 3 | ClientProxy will proxy websocket requests from `Wobserver.Web.ClientSocket` to a remote node. 4 | 5 | TODO: Needs config. 6 | """ 7 | 8 | @behaviour :websocket_client 9 | 10 | @doc false 11 | @spec connect(url :: String.t, client :: pid) :: 12 | {:ok, pid} 13 | | any 14 | def connect(url, client \\ self()) do 15 | connect = 16 | try do 17 | :websocket_client.start_link(url, __MODULE__, [%{client: client}]) 18 | catch 19 | error -> error 20 | end 21 | 22 | case connect do 23 | {:ok, pid} -> 24 | Process.unlink pid 25 | 26 | {:ok, pid} 27 | error -> 28 | error 29 | end 30 | end 31 | 32 | @doc false 33 | @spec init(state :: [any]) :: {:reconnect, map} 34 | def init([state]) do 35 | {:reconnect, state} 36 | end 37 | 38 | @doc false 39 | @spec onconnect(any, state :: map) :: {:ok, map} 40 | def onconnect(_websocket_request, state) do 41 | {:ok, state} 42 | end 43 | 44 | @doc false 45 | @spec ondisconnect(any, state :: map) :: {:close, any, map} 46 | def ondisconnect(reason, state) do 47 | send state.client, :proxy_disconnect 48 | 49 | {:close, reason, state} 50 | end 51 | 52 | @doc false 53 | @spec websocket_info({:proxy, data :: String.t}, any, state :: map) :: 54 | {:reply, {:text, String.t}, map} 55 | def websocket_info({:proxy, data}, _connection, state) do 56 | {:reply, {:text, data}, state} 57 | end 58 | 59 | @spec websocket_info(:disconnect, any, state :: map) :: 60 | {:close, String.t, map} 61 | def websocket_info(:disconnect, _connection, state) do 62 | {:close, "Disconnect", state} 63 | end 64 | 65 | @doc false 66 | @spec websocket_terminate(any, any, map) :: :ok 67 | def websocket_terminate(_reason, _conn, _state), do: :ok 68 | 69 | @doc false 70 | @spec websocket_handle(any, any, state :: map) :: {:ok, map} 71 | def websocket_handle(message, conn, state) 72 | 73 | def websocket_handle({:text, message}, _conn, state) do 74 | send state.client, {:proxy, message} 75 | {:ok, state} 76 | end 77 | 78 | def websocket_handle(_, _conn, state), do: {:ok, state} 79 | end 80 | -------------------------------------------------------------------------------- /test/wobserver/web/router/system_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.SystemTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias Wobserver.Web.Router.System 6 | 7 | @opts System.init([]) 8 | 9 | test "/ returns overview" do 10 | conn = conn(:get, "/") 11 | 12 | conn = System.call(conn, @opts) 13 | 14 | assert conn.state == :sent 15 | assert conn.status == 200 16 | assert %{ 17 | "architecture" => _, 18 | "cpu" => _, 19 | "memory" => _, 20 | "statistics" => _, 21 | } = Poison.decode!(conn.resp_body) 22 | end 23 | 24 | test "/architecture returns architecture" do 25 | conn = conn(:get, "/architecture") 26 | 27 | conn = System.call(conn, @opts) 28 | 29 | assert conn.state == :sent 30 | assert conn.status == 200 31 | assert Poison.encode!(Wobserver.System.Info.architecture) == conn.resp_body 32 | end 33 | 34 | test "/cpu returns cpu" do 35 | conn = conn(:get, "/cpu") 36 | 37 | conn = System.call(conn, @opts) 38 | 39 | assert conn.state == :sent 40 | assert conn.status == 200 41 | assert Poison.encode!(Wobserver.System.Info.cpu) == conn.resp_body 42 | end 43 | 44 | test "/memory returns memory" do 45 | conn = conn(:get, "/memory") 46 | 47 | conn = System.call(conn, @opts) 48 | 49 | assert conn.state == :sent 50 | assert conn.status == 200 51 | assert %{ 52 | "atom" => _, 53 | "binary" => _, 54 | "code" => _, 55 | "ets" => _, 56 | "process" => _, 57 | "total" => _, 58 | } = Poison.decode!(conn.resp_body) 59 | end 60 | 61 | test "/statistics returns statistics" do 62 | conn = conn(:get, "/statistics") 63 | 64 | conn = System.call(conn, @opts) 65 | 66 | assert conn.state == :sent 67 | assert conn.status == 200 68 | assert %{ 69 | "uptime" => _, 70 | "process_running" => _, 71 | "process_total" => _, 72 | "process_max" => _, 73 | "input" => _, 74 | "output" => _, 75 | } = Poison.decode!(conn.resp_body) 76 | end 77 | 78 | test "unknown url returns 404" do 79 | conn = conn(:get, "/unknown") 80 | 81 | conn = System.call(conn, @opts) 82 | 83 | assert conn.state == :sent 84 | assert conn.status == 404 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /gulpfile.js/tasks/js.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | if(!config.js) return; 3 | 4 | const gulp = require('gulp'); 5 | const path = require('path') 6 | 7 | 8 | const sourcemaps = require('gulp-sourcemaps'); 9 | const browserify = require('browserify'); 10 | const babelify = require('babelify'); 11 | const source = require('vinyl-source-stream'); 12 | const buffer = require('vinyl-buffer'); 13 | const uglify = require('gulp-uglify'); 14 | const concat = require("gulp-concat"); 15 | 16 | gulp.task('create-js', () => { 17 | return browserify({entries: path.join(config.root.src, config.js.src, 'app.js'), extensions: ['.js'], debug: true}) 18 | .transform(babelify, { 19 | sourceMapsAbsolute: true, 20 | presets: ['es2015'] 21 | }) 22 | .bundle() 23 | .pipe(source('app.js')) 24 | .pipe(buffer()) 25 | .pipe(sourcemaps.init({ loadMaps: true })) 26 | .pipe(sourcemaps.write('./')) 27 | .pipe(gulp.dest(path.join(config.root.dest, config.js.dest))); 28 | }); 29 | 30 | gulp.task('create-js-prod', () => { 31 | return browserify({entries: path.join(config.root.src, config.js.src, 'app.js'), extensions: ['.js'], debug: false}) 32 | .transform(babelify, { 33 | sourceMapsAbsolute: false, 34 | presets: ['es2015'] 35 | }) 36 | .bundle() 37 | .pipe(source('app.js')) 38 | .pipe(buffer()) 39 | .pipe(uglify()) 40 | .pipe(gulp.dest(path.join(config.root.dest, config.js.dest))); 41 | }); 42 | 43 | gulp.task('js', ['create-js'], function() { 44 | return gulp.src([ 45 | path.join(config.root.src, config.js.src, 'external/*'), 46 | path.join(config.root.dest, config.js.dest, 'app.js') 47 | ]) 48 | .pipe(buffer()) 49 | .pipe(sourcemaps.init({loadMaps: true})) 50 | .pipe(concat('app.js')) 51 | .pipe(sourcemaps.write('./')) 52 | .pipe(gulp.dest(path.join(config.root.dest, config.js.dest))); 53 | }); 54 | 55 | gulp.task('js-prod', ['create-js-prod'], function() { 56 | return gulp.src([ 57 | path.join(config.root.src, config.js.src, 'external/*'), 58 | path.join(config.root.dest, config.js.dest, 'app.js') 59 | ]) 60 | .pipe(buffer()) 61 | .pipe(uglify()) 62 | .pipe(concat('app.js')) 63 | .pipe(gulp.dest(path.join(config.root.dest, config.js.dest))); 64 | }); 65 | -------------------------------------------------------------------------------- /lib/wobserver/web/security.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Security do 2 | @moduledoc ~S""" 3 | Handles basic websocket authentication. 4 | 5 | A different module with the following methods can be set as `:security` in the config: 6 | 7 | - `authenticate(Conn.t) :: Conn.t` 8 | - `authenticated?(Conn.t) :: boolean` 9 | - `authenticated?(:cowboy_req.req) :: boolean` 10 | """ 11 | 12 | alias Plug.Conn 13 | 14 | @secret Application.get_env(:wobserver, :security_key, "secret-key-setting") 15 | 16 | @doc ~S""" 17 | Authenticates a given `conn`. 18 | """ 19 | @spec authenticate(Conn.t) :: Conn.t 20 | def authenticate(conn) do 21 | Conn.put_resp_cookie( 22 | conn, 23 | "_wobserver", 24 | generate(conn.remote_ip, conn.req_headers) 25 | ) 26 | end 27 | 28 | @doc ~S""" 29 | Checks whether a given `conn` is authenticated. 30 | """ 31 | @spec authenticated?(Conn.t) :: boolean 32 | def authenticated?(conn = %Conn{}) do 33 | conn = Conn.fetch_cookies(conn) 34 | 35 | case conn.cookies["_wobserver"] do 36 | nil -> false 37 | key -> key == generate(conn.remote_ip, conn.req_headers) 38 | end 39 | end 40 | 41 | @doc ~S""" 42 | Checks whether a given `req` is authenticated. 43 | """ 44 | @spec authenticated?(:cowboy_req.req) :: boolean 45 | def authenticated?(req) do 46 | {ip, _} = elem(req, 7) 47 | headers = elem(req, 16) 48 | 49 | cookies = 50 | Enum.find_value(headers, "", fn 51 | {"cookie", value} -> value 52 | _ -> false 53 | end) 54 | 55 | String.contains? cookies, ("_wobserver=" <> generate(ip, headers)) 56 | end 57 | 58 | @spec generate(tuple, list(tuple)) :: String.t 59 | defp generate(remote_ip, headers) do 60 | ip = 61 | remote_ip 62 | |> Tuple.to_list 63 | |> Enum.map(&to_string/1) 64 | |> Enum.join(".") 65 | 66 | user_agent = 67 | Enum.find_value(headers, "unknown", fn 68 | {"user-agent", value} -> value 69 | _ -> false 70 | end) 71 | 72 | @secret 73 | |> hmac(ip) 74 | |> hmac(user_agent) 75 | |> Base.encode16 76 | |> String.downcase 77 | end 78 | 79 | @spec hmac(String.t | list(String.t), String.t) :: String.t 80 | defp hmac(encryption_key, data), 81 | do: :crypto.hmac(:sha256, encryption_key, data) 82 | end 83 | -------------------------------------------------------------------------------- /lib/wobserver/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Application do 2 | @moduledoc ~S""" 3 | Sets up the main routers with Cowboy. 4 | """ 5 | 6 | use Application 7 | 8 | alias Plug.Adapters.Cowboy 9 | 10 | alias Wobserver.Page 11 | alias Wobserver.Util.Metrics 12 | 13 | @doc ~S""" 14 | The port the application uses. 15 | """ 16 | @spec port :: integer 17 | def port do 18 | Application.get_env(:wobserver, :port, 4001) 19 | end 20 | 21 | @doc ~S""" 22 | Starts `wobserver`. 23 | 24 | The option `:mode` is used to determine how to start `wobserver`. 25 | 26 | The following values are possible: 27 | - `:standalone`, starts a supervisor that supervises cowboy. 28 | - `:plug`, passes the Agent storage of the metrics back as pid, without starting any extra processes. 29 | 30 | In `:plug` mode no cowboy/ranch server is started, so the `wobserver` router will need to be called from somewhere else. 31 | 32 | **Note:** both `type` and `args` are unused. 33 | """ 34 | @spec start(term, term) :: 35 | {:ok, pid} | 36 | {:ok, pid, state :: any} | 37 | {:error, reason :: term} 38 | def start(_type, _args) do 39 | # Load pages and metrics from config 40 | Page.load_config 41 | Metrics.load_config 42 | 43 | # Start cowboy 44 | case supervisor_children() do 45 | [] -> 46 | # Return the metric storage if we're not going to start an application. 47 | {:ok, Process.whereis(:wobserver_metrics)} 48 | children -> 49 | import Supervisor.Spec, warn: false 50 | 51 | opts = [strategy: :one_for_one, name: Wobserver.Supervisor] 52 | Supervisor.start_link(children, opts) 53 | end 54 | end 55 | 56 | defp supervisor_children do 57 | case Application.get_env(:wobserver, :mode, :standalone) do 58 | :standalone -> 59 | [ 60 | cowboy_child_spec(), 61 | ] 62 | :plug -> 63 | [] 64 | end 65 | end 66 | 67 | defp cowboy_child_spec do 68 | options = [ 69 | # Options 70 | acceptors: 10, 71 | port: Wobserver.Application.port, 72 | dispatch: [ 73 | {:_, [ 74 | {"/ws", Wobserver.Web.Client, []}, 75 | {:_, Cowboy.Handler, {Wobserver.Web.Router, []}} 76 | ]} 77 | ], 78 | ] 79 | 80 | Cowboy.child_spec(:http, Wobserver.Web.Router, [], options) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :wobserver, 7 | version: "0.1.8", 8 | elixir: "~> 1.4", 9 | description: "Web based metrics, monitoring, and observer.", 10 | package: package(), 11 | build_embedded: Mix.env == :prod, 12 | start_permanent: Mix.env == :prod, 13 | deps: deps(), 14 | # Testing 15 | test_coverage: [tool: ExCoveralls], 16 | preferred_cli_env: ["coveralls": :test, "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test], 17 | dialyzer: [ignore_warnings: "dialyzer.ignore-warnings"], 18 | # Docs 19 | name: "Wobserver", 20 | source_url: "https://github.com/shinyscorpion/wobserver", 21 | homepage_url: "https://github.com/shinyscorpion/wobserver", 22 | docs: [ 23 | main: "readme", 24 | extras: ["README.md"], 25 | ], 26 | ] 27 | end 28 | 29 | def package do 30 | [ 31 | name: :wobserver, 32 | maintainers: ["Ian Luites"], 33 | licenses: ["MIT"], 34 | files: [ 35 | "lib/wobserver", "lib/wobserver.ex", "mix.exs", "README*", "LICENSE*", # Elixir 36 | ], 37 | links: %{ 38 | "GitHub" => "https://github.com/shinyscorpion/wobserver", 39 | }, 40 | ] 41 | end 42 | 43 | # Configuration for the OTP application 44 | # 45 | # Type "mix help compile.app" for more information 46 | def application do 47 | # Specify extra applications you'll use from Erlang/Elixir 48 | [ 49 | extra_applications: [ 50 | :logger, 51 | :httpoison, 52 | ], 53 | mod: {Wobserver.Application, []},] 54 | end 55 | 56 | # Dependencies can be Hex packages: 57 | # 58 | # {:my_dep, "~> 0.3.0"} 59 | # 60 | # Or git/path repositories: 61 | # 62 | # {:my_dep, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 63 | # 64 | # Type "mix help deps" for more examples and options 65 | defp deps do 66 | [ 67 | {:cowboy, "~> 1.1"}, 68 | {:credo, "~> 0.7", only: [:dev, :test]}, 69 | {:dialyxir, "~> 0.5", only: [:dev], runtime: false}, 70 | {:ex_doc, "~> 0.15", only: :dev}, 71 | {:excoveralls, "~> 0.6", only: :test}, 72 | {:httpoison, "~> 0.11 or ~> 0.12"}, 73 | {:inch_ex, "~> 0.5", only: [:dev, :test]}, 74 | {:meck, "~> 0.8.4", only: :test}, 75 | {:plug, "~> 1.3 or ~> 1.4"}, 76 | {:poison, "~> 2.0 or ~> 3.1"}, 77 | {:websocket_client, "~> 1.2"}, 78 | ] 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/wobserver/system/info.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.System.Info do 2 | @moduledoc ~S""" 3 | Handles general System info like architecture and cpu. 4 | """ 5 | 6 | alias Wobserver.System.Info 7 | 8 | @typedoc ~S""" 9 | Architecture information. 10 | """ 11 | @type t :: %__MODULE__{ 12 | otp_release: String.t, 13 | elixir_version: String.t, 14 | erts_version: String.t, 15 | system_architecture: String.t, 16 | kernel_poll: boolean, 17 | smp_support: boolean, 18 | threads: boolean, 19 | thread_pool_size: integer, 20 | wordsize_internal: integer, 21 | wordsize_external: integer, 22 | } 23 | 24 | defstruct [ 25 | otp_release: "", 26 | elixir_version: "", 27 | erts_version: "", 28 | system_architecture: "", 29 | kernel_poll: false, 30 | smp_support: false, 31 | threads: false, 32 | thread_pool_size: 0, 33 | wordsize_internal: 0, 34 | wordsize_external: 0, 35 | ] 36 | 37 | @doc ~S""" 38 | Returns architecture information. 39 | """ 40 | @spec architecture :: Info.t 41 | def architecture do 42 | %Info{ 43 | otp_release: to_string(:erlang.system_info(:otp_release)), 44 | elixir_version: System.version, 45 | erts_version: to_string(:erlang.system_info(:version)), 46 | system_architecture: to_string(:erlang.system_info(:system_architecture)), 47 | kernel_poll: :erlang.system_info(:kernel_poll), 48 | smp_support: :erlang.system_info(:smp_support), 49 | threads: :erlang.system_info(:threads), 50 | thread_pool_size: :erlang.system_info(:thread_pool_size), 51 | wordsize_internal: :erlang.system_info({:wordsize, :internal}), 52 | wordsize_external: :erlang.system_info({:wordsize, :internal}), 53 | 54 | } 55 | end 56 | 57 | @doc ~S""" 58 | Returns cpu information. 59 | """ 60 | def cpu do 61 | schedulers = :erlang.system_info(:logical_processors) 62 | schedulers_available = 63 | case :erlang.system_info(:multi_scheduling) do 64 | :enabled -> schedulers 65 | _ -> 1 66 | end 67 | 68 | %{ 69 | logical_processors: 70 | :erlang.system_info(:logical_processors), 71 | logical_processors_online: 72 | :erlang.system_info(:logical_processors_online), 73 | logical_processors_available: 74 | :erlang.system_info(:logical_processors_available), 75 | schedulers: 76 | :erlang.system_info(:schedulers), 77 | schedulers_online: 78 | schedulers, 79 | schedulers_available: 80 | schedulers_available, 81 | } 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /src/css/lib/_theme.scss: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Roboto); 2 | $base-font: 'Roboto', 'Open Sans', 'Lato', 'Droid Sans', Verdana, Geneva, sans-serif !default; 3 | 4 | // Fran colours 5 | // $primary-color: #000 !default; 6 | // $secondary-color: #000 !default; 7 | 8 | // $menu-color: #eee; 9 | // $menu-background-color: #222; 10 | // $content-color: #000; 11 | // $content-background-color: #fff; 12 | 13 | // Default 14 | // $primary-color: #ff442e !default; 15 | // $secondary-color: #ff3535 !default; 16 | // $primary-color: #2c65ff !default; 17 | // $secondary-color: #00f !default; 18 | // $secondary-color: #ff3535 !default; 19 | 20 | 21 | // Test colours 22 | // $primary-color: #519 !default; 23 | // $secondary-color: $primary-color !default; 24 | // $highlight-color: $secondary-color !default; 25 | 26 | // $content-font: $base-font !default; 27 | // $content-color: #000 !default; 28 | // $content-background-color: change_color($primary-color, $saturation: 75%, $lightness: 95%) !default; 29 | 30 | // $menu-font: $base-font !default; 31 | // $menu-background-color: change_color($primary-color, $saturation: 75%, $lightness: 45%) !default; 32 | // $menu-color: lighten($menu-background-color, 40%) !default; //lighten($menu-background-color, 35%) !default; 33 | 34 | // $menu-background-color-selected: #2d0047; 35 | // $menu-header-background-color: #43008d; 36 | 37 | // Final colours 38 | $primary-color: #58229a; 39 | $secondary-color: #64d041; 40 | $highlight-color: #614ed9; 41 | 42 | $content-font: $base-font !default; 43 | $content-color: #131313 !default; 44 | $content-background-color: #fefefe; 45 | 46 | $menu-font: $base-font !default; 47 | $menu-background-color: #543094; 48 | $menu-color: #e5e5e5; 49 | 50 | // Alts on final 51 | $menu-background-color: #463366; 52 | $primary-color: #472e66; 53 | $highlight-color: #574ba6; 54 | 55 | // Inbetween 56 | // $menu-background-color: #472966; 57 | // $primary-color: #472e66; 58 | // $highlight-color: #574ba6; 59 | 60 | // For Alts 61 | $content-header-background-color: #564376; 62 | $table-header-background-color: $content-header-background-color; 63 | $menu-headerd-color: #eee; 64 | 65 | 66 | // $content-header-background-color: #56388b; 67 | // $table-header-background-color: $content-header-background-color; 68 | 69 | // $primary-color: #6a5189; 70 | // $secondary-color: #64d041; 71 | // $highlight-color: #614ed9; 72 | 73 | // $content-font: $base-font !default; 74 | // $content-color: #d8d8d8 !default; 75 | // $content-background-color: #232020; 76 | 77 | // $menu-font: $base-font !default; 78 | // $menu-background-color: #372a4d; 79 | // $menu-color: #e5e5e5; 80 | 81 | // $menu-headerd-color: #eee; 82 | 83 | -------------------------------------------------------------------------------- /lib/wobserver/util/node/remote.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Node.Remote do 2 | @moduledoc ~S""" 3 | Remote node. 4 | 5 | TODO: Needs config. 6 | """ 7 | 8 | alias Wobserver.Web.ClientProxy 9 | alias Wobserver.Util.Metrics 10 | alias Wobserver.Util.Metrics.Formatter 11 | 12 | @typedoc "Remote node information." 13 | @type t :: %__MODULE__{ 14 | name: String.t, 15 | host: String.t, 16 | port: integer, 17 | local?: boolean, 18 | } 19 | 20 | defstruct [ 21 | :name, 22 | :host, 23 | port: 4001, 24 | local?: false, 25 | ] 26 | 27 | @remote_url_prefix Application.get_env(:wobserver, :remote_url_prefix, "") 28 | 29 | @spec call(map, endpoint :: String.t) :: String.t | :error 30 | defp call(%{host: host, port: port}, endpoint) do 31 | request = 32 | %URI{scheme: "http", host: host, port: port, path: endpoint} 33 | |> URI.to_string 34 | |> HTTPoison.get 35 | 36 | case request do 37 | {:ok, %{body: result}} -> result 38 | _ -> :error 39 | end 40 | end 41 | 42 | @doc ~S""" 43 | Collects metrics from a given `remote_node`. 44 | """ 45 | @spec metrics(remote_node :: map) :: String.t | :error 46 | def metrics(remote_node) 47 | 48 | def metrics(remote_node = %{local?: false}) do 49 | remote_node 50 | |> call("#{@remote_url_prefix}/metrics/n/local") 51 | end 52 | 53 | def metrics(%{local?: true}) do 54 | Metrics.overview 55 | |> Formatter.format_all 56 | end 57 | 58 | @doc ~S""" 59 | Performs an api call using the `path` on the `remote_node` and returns the result. 60 | """ 61 | @spec api(remote_node :: map, path :: String.t) :: String.t | :error 62 | def api(remote_node, path) do 63 | remote_node 64 | |> call("#{@remote_url_prefix}/api" <> path) 65 | end 66 | 67 | @doc ~S""" 68 | Sets up a websocket connection to the given `remote_node`. 69 | """ 70 | @spec socket_proxy(atom | map) :: {pid, String.t} | {:error, String.t} 71 | def socket_proxy(remote_node) 72 | 73 | def socket_proxy(%{local?: true}), do: socket_proxy(:local) 74 | 75 | def socket_proxy(%{name: name, host: host, port: port}) do 76 | connection = 77 | %URI{scheme: "ws", host: host, port: port, path: "#{@remote_url_prefix}/ws"} 78 | |> URI.to_string 79 | |> ClientProxy.connect 80 | 81 | case connection do 82 | {:ok, pid} -> {pid, name} 83 | _ -> {:error, "Can not connect to node."} 84 | end 85 | end 86 | 87 | def socket_proxy(type) do 88 | case type do 89 | :local -> {nil, "local"} 90 | {:remote, remote_node} -> socket_proxy(remote_node) 91 | _ -> {:error, "Can not find node."} 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/wobserver/util/table.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Table do 2 | @moduledoc ~S""" 3 | Table (ets) information and listing. 4 | """ 5 | 6 | import Wobserver.Util.Helper, only: [string_to_module: 1] 7 | 8 | @doc ~S""" 9 | Lists all tables with basic information. 10 | 11 | Note: data is not included. 12 | """ 13 | @spec list :: list(map) 14 | def list do 15 | :ets.all 16 | |> Enum.map(&info/1) 17 | end 18 | 19 | @doc """ 20 | Creates an overview of table information based on the given `table` atom or number. 21 | 22 | If `include_data` is set to `true`, it will also contain the table data. 23 | """ 24 | @spec info(table :: atom | integer, include_data :: boolean) :: map 25 | def info(table, include_data \\ false) 26 | 27 | def info(table, false) do 28 | %{ 29 | id: table, 30 | name: :ets.info(table, :name), 31 | type: :ets.info(table, :type), 32 | size: :ets.info(table, :size), 33 | memory: :ets.info(table, :memory), 34 | owner: :ets.info(table, :owner), 35 | protection: :ets.info(table, :protection), 36 | meta: %{ 37 | read_concurrency: :ets.info(table, :read_concurrency), 38 | write_concurrency: :ets.info(table, :write_concurrency), 39 | compressed: :ets.info(table, :compressed), 40 | }, 41 | } 42 | end 43 | 44 | def info(table, true) do 45 | table 46 | |> info(false) 47 | |> Map.put(:data, data(table)) 48 | end 49 | 50 | @doc ~S""" 51 | Sanitizes a `table` name and returns either the table id or name. 52 | 53 | Example: 54 | ```bash 55 | iex> Wobserver.Table.sanitize :code 56 | :code 57 | ``` 58 | ```bash 59 | iex> Wobserver.Table.sanitize 1 60 | 1 61 | ``` 62 | ```bash 63 | iex> Wobserver.Table.sanitize "code" 64 | :code 65 | ``` 66 | ```bash 67 | iex> Wobserver.Table.sanitize "1" 68 | 1 69 | ``` 70 | """ 71 | @spec sanitize(table :: atom | integer | String.t) :: atom | integer 72 | def sanitize(table) when is_atom(table), do: table 73 | def sanitize(table) when is_integer(table), do: table 74 | def sanitize(table) when is_binary(table) do 75 | case Integer.parse(table) do 76 | {nr, ""} -> nr 77 | _ -> table |> string_to_module() 78 | end 79 | end 80 | 81 | # Helpers 82 | 83 | defp data(table) do 84 | case :ets.info(table, :protection) do 85 | :private -> 86 | [] 87 | _ -> 88 | table 89 | |> :ets.match(:"$1") 90 | |> Enum.map(&data_row/1) 91 | end 92 | end 93 | 94 | defp data_row([row]) do 95 | row 96 | |> Tuple.to_list 97 | |> Enum.map(&(to_string(:io_lib.format("~tp", [&1])))) 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/wobserver/util/table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.TableTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Table 5 | 6 | describe "sanitize" do 7 | test "with integer" do 8 | assert Table.sanitize(1) == 1 9 | end 10 | 11 | test "with integer as string" do 12 | assert Table.sanitize("1") == 1 13 | end 14 | 15 | test "with atom" do 16 | assert Table.sanitize(:code) == :code 17 | end 18 | 19 | test "with atom as string" do 20 | assert Table.sanitize("code") == :code 21 | end 22 | end 23 | 24 | describe "list" do 25 | test "returns a list" do 26 | assert is_list(Table.list) 27 | end 28 | 29 | test "returns a list of maps" do 30 | assert is_map(List.first(Table.list)) 31 | end 32 | 33 | test "returns a list of table information" do 34 | assert %{ 35 | id: _, 36 | name: _, 37 | type: _, 38 | size: _, 39 | memory: _, 40 | owner: _, 41 | protection: _, 42 | meta: %{ 43 | read_concurrency: _, 44 | write_concurrency: _, 45 | compressed: _, 46 | } 47 | } = List.first(Table.list) 48 | end 49 | end 50 | 51 | 52 | describe "info" do 53 | test "returns table information with defaults" do 54 | assert %{ 55 | id: 1, 56 | name: :code, 57 | type: :set, 58 | size: _, 59 | memory: _, 60 | owner: _, 61 | protection: :private, 62 | meta: %{ 63 | read_concurrency: false, 64 | write_concurrency: false, 65 | compressed: false, 66 | } 67 | } = Table.info(1) 68 | end 69 | 70 | test "returns table information without table, when set to false" do 71 | assert_raise MatchError, fn -> %{data: _} = Table.info(1) end 72 | end 73 | 74 | test "returns table information with table, when set to true" do 75 | assert %{ 76 | data: _ 77 | } = Table.info(1, true) 78 | end 79 | 80 | test "returns empty data, when set to true, but protection private" do 81 | assert %{ 82 | protection: :private, 83 | data: [] 84 | } = Table.info(1, true) 85 | end 86 | 87 | test "returns non empty data, when set to true, but protection protected" do 88 | assert %{ 89 | protection: :protected, 90 | data: data 91 | } = Table.info(:cowboy_clock, true) 92 | 93 | refute data == [] 94 | end 95 | 96 | test "returns non empty data, when set to true, but protection public" do 97 | assert %{ 98 | protection: :public, 99 | data: data 100 | } = Table.info(:hex_version, true) 101 | 102 | refute data == [] 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/mix/tasks/build.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Build do 2 | @moduledoc ~S""" 3 | Run through all the build steps for wobserver. 4 | """ 5 | 6 | use Mix.Task 7 | 8 | @shortdoc "Run through all the build steps for wobserver." 9 | 10 | @doc ~S""" 11 | Run through all the build steps for wobserver. 12 | 13 | The following steps are performed: 14 | - `gulp` (deploy) 15 | - `building assets.ex` 16 | - `compile` 17 | - `docs` 18 | - `hex.build` 19 | """ 20 | @spec run([binary]) :: any 21 | def run(_) do 22 | IO.puts "Building \e[44mwobserver\e[0m:" 23 | 24 | execute "Building web assets", Path.absname("node_modules/.bin/gulp"), ["deploy"] 25 | IO.write " Building asset module... " 26 | pack() 27 | IO.puts " \e[32msuccess\e[0m" 28 | execute "Compiling wobserver", "mix", ["compile"] 29 | execute "Building documentation", "mix", ["docs"] 30 | execute "Packaging wobserver", "mix", ["hex.build"] 31 | 32 | IO.puts "\n\e[44mwobserver\e[0m packaged." 33 | end 34 | 35 | @spec execute(String.t, String.t, [String.t]) :: any 36 | defp execute(label, command, options) do 37 | label_padded = String.pad_trailing(" #{label}...", 30, " ") 38 | IO.write label_padded 39 | case System.cmd(command, options, [stderr_to_stdout: true]) do 40 | {_, 0} -> 41 | IO.puts " \e[32msuccess\e[0m" 42 | {output, _} -> 43 | IO.puts " \e[31mfailed\e[0m" 44 | IO.puts output 45 | System.halt(1) 46 | end 47 | end 48 | 49 | defp load_asset(asset) do 50 | asset 51 | |> File.read! 52 | |> String.replace("\"\"\"", "\\\"\"\"", global: true) 53 | end 54 | 55 | defp pack do 56 | html = load_asset "./assets/index.html" 57 | css = load_asset "./assets/main.css" 58 | js = load_asset "./assets/app.js" 59 | license = load_asset "./LICENSE" 60 | 61 | File.write! "./lib/wobserver/assets.ex", 62 | """ 63 | defmodule Wobserver.Assets do 64 | @moduledoc false 65 | 66 | @lint false 67 | @doc false 68 | @spec html :: String.t 69 | def html do 70 | ~S\""" 71 | #{html} 72 | \""" 73 | end 74 | _ = @lint 75 | 76 | @lint false 77 | @doc false 78 | @spec css :: String.t 79 | def css do 80 | ~S\""" 81 | #{css} 82 | \""" 83 | end 84 | _ = @lint 85 | 86 | @lint false 87 | @doc false 88 | @spec js :: String.t 89 | def js do 90 | ~S\""" 91 | #{js} 92 | \""" 93 | end 94 | _ = @lint 95 | 96 | @lint false 97 | @doc false 98 | @spec license :: String.t 99 | def license do 100 | ~S\""" 101 | #{license} 102 | \""" 103 | end 104 | _ = @lint 105 | end 106 | """ 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/wobserver/web/router/static_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.StaticTest do 2 | use ExUnit.Case, async: false 3 | use Plug.Test 4 | 5 | alias Wobserver.Web.Router.Static 6 | 7 | @opts Static.init([]) 8 | 9 | describe "with config set to false" do 10 | setup do 11 | :meck.new Application, [:passthrough] 12 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 13 | case option do 14 | :assets -> false 15 | :port -> 4001 16 | end 17 | end 18 | 19 | on_exit(fn -> :meck.unload end) 20 | 21 | :ok 22 | end 23 | 24 | test "/ returns 200" do 25 | conn = conn(:get, "/") 26 | 27 | conn = Static.call(conn, @opts) 28 | 29 | assert conn.state == :sent 30 | assert conn.status == 200 31 | end 32 | 33 | test "/main.css returns 200" do 34 | conn = conn(:get, "/main.css") 35 | 36 | conn = Static.call(conn, @opts) 37 | 38 | assert conn.state == :sent 39 | assert conn.status == 200 40 | end 41 | 42 | test "/app.js returns 200" do 43 | conn = conn(:get, "/app.js") 44 | 45 | conn = Static.call(conn, @opts) 46 | 47 | assert conn.state == :sent 48 | assert conn.status == 200 49 | end 50 | 51 | test "/license returns 200" do 52 | conn = conn(:get, "/license") 53 | 54 | conn = Static.call(conn, @opts) 55 | 56 | assert conn.state == :sent 57 | assert conn.status == 200 58 | end 59 | end 60 | 61 | describe "with config set to \"\"" do 62 | setup do 63 | :meck.new Application, [:passthrough] 64 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 65 | case option do 66 | :assets -> "" 67 | :port -> 4001 68 | end 69 | end 70 | 71 | on_exit(fn -> :meck.unload end) 72 | 73 | :ok 74 | end 75 | 76 | test "/ returns 200" do 77 | conn = conn(:get, "/") 78 | 79 | conn = Static.call(conn, @opts) 80 | 81 | assert conn.state == :sent 82 | assert conn.status == 200 83 | end 84 | 85 | test "/main.css returns 200" do 86 | conn = conn(:get, "/main.css") 87 | 88 | conn = Static.call(conn, @opts) 89 | 90 | assert conn.state == :sent 91 | assert conn.status == 200 92 | end 93 | 94 | test "/app.js returns 200" do 95 | conn = conn(:get, "/app.js") 96 | 97 | conn = Static.call(conn, @opts) 98 | 99 | assert conn.state == :sent 100 | assert conn.status == 200 101 | end 102 | 103 | test "/license returns 200" do 104 | conn = conn(:get, "/license") 105 | 106 | conn = Static.call(conn, @opts) 107 | 108 | assert conn.state == :sent 109 | assert conn.status == 200 110 | end 111 | end 112 | 113 | test "unknown url returns 404" do 114 | conn = conn(:get, "/unknown") 115 | 116 | conn = Static.call(conn, @opts) 117 | 118 | assert conn.state == :sent 119 | assert conn.status == 404 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /src/js/interface/process_detail.js: -------------------------------------------------------------------------------- 1 | import {Popup} from './popup.js'; 2 | 3 | function format_pid_url(pid) { 4 | if( pid.startsWith('#Port') ){ 5 | return pid 6 | } 7 | 8 | return `
  • ${pid.replace(/^Elixir\./, '')}
  • ` 9 | } 10 | 11 | class ProcessDetail { 12 | constructor(process, wobserver) { 13 | this.process = process; 14 | this.wobserver = wobserver; 15 | } 16 | 17 | show() { 18 | this.wobserver.client.command_promise('process/' + this.process) 19 | .then(e => { 20 | let process = e.data; 21 | if( process == 'error' ){ 22 | Popup.show(` 23 |
    24 | Process information: 25 |

    Process is either dead or protected and therefore can not be shown.

    26 |
    27 | `); 28 | return; 29 | } 30 | // 31 | let links = process.relations.links.map(pid => format_pid_url(pid) ).join(''); 32 | let ancestors = process.relations.ancestors.map(pid => format_pid_url(pid) ).join(''); 33 | let monitors = ''; 34 | 35 | let safeState = process.state.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); 36 | 37 | Popup.show(` 38 |
    39 | Process information: 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
    Overview
    Process id:${process.pid}
    Registered name:${process.registered_name}
    Status:${process.meta.status}
    Message Queue Length:${process.message_queue_len}
    Group Leader:${process.relations.group_leader}
    48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
    Memory
    Total:${process.memory.total}
    Heap Size:${process.memory.heap_size}
    Stack Size:${process.memory.stack_size}
    GC Min Heap Size:${process.memory.gc_min_heap_size}
    GC FullSweep After:${process.memory.gc_full_sweep_after}
    56 |
    57 |
    58 | Links 59 |
      60 | ${links} 61 |
    62 |
    63 |
    64 | Ancestors 65 |
      66 | ${ancestors} 67 |
    68 |
    69 |
    70 | Monitors 71 |
      72 | ${monitors} 73 |
    74 |
    75 |
    76 | State 77 |
    ${safeState}
    78 |
    79 | `); 80 | }); 81 | } 82 | 83 | hide() { 84 | Popup.hide(); 85 | } 86 | } 87 | 88 | export{ ProcessDetail } -------------------------------------------------------------------------------- /lib/wobserver/util/helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Helper do 2 | @moduledoc ~S""" 3 | Helper functions and JSON encoders. 4 | """ 5 | 6 | alias Poison.Encoder 7 | alias Encoder.BitString 8 | 9 | defimpl Encoder, for: PID do 10 | @doc ~S""" 11 | JSON encodes a `PID`. 12 | 13 | Uses `inspect/1` to turn the `pid` into a String and passes the `options` to `BitString.encode/1`. 14 | """ 15 | @spec encode(pid :: pid, options :: any) :: String.t 16 | def encode(pid, options) do 17 | pid 18 | |> inspect 19 | |> BitString.encode(options) 20 | end 21 | end 22 | 23 | defimpl Encoder, for: Port do 24 | @doc ~S""" 25 | JSON encodes a `Port`. 26 | 27 | Uses `inspect/1` to turn the `port` into a String and passes the `options` to `BitString.encode/1`. 28 | """ 29 | @spec encode(port :: port, options :: any) :: String.t 30 | def encode(port, options) do 31 | port 32 | |> inspect 33 | |> BitString.encode(options) 34 | end 35 | end 36 | 37 | defimpl Encoder, for: Reference do 38 | @doc ~S""" 39 | JSON encodes a `Reference`. 40 | 41 | Uses `inspect/1` to turn the `reference` into a String and passes the `options` to `BitString.encode/1`. 42 | """ 43 | @spec encode(reference :: reference, options :: any) :: String.t 44 | def encode(reference, options) do 45 | reference 46 | |> inspect 47 | |> BitString.encode(options) 48 | end 49 | end 50 | 51 | @doc ~S""" 52 | Converts Strings to module names or atoms. 53 | 54 | The given `module` string will be turned into atoms that get concatted. 55 | """ 56 | @spec string_to_module(module :: String.t) :: atom 57 | def string_to_module(module) do 58 | first_letter = String.first(module) 59 | 60 | case String.capitalize(first_letter) do 61 | ^first_letter -> 62 | module 63 | |> String.split(".") 64 | |> Enum.map(&String.to_atom/1) 65 | |> Module.concat 66 | _ -> 67 | module 68 | |> String.to_atom 69 | end 70 | end 71 | 72 | @doc ~S""" 73 | Formats function information as readable string. 74 | 75 | Only name will be return if only `name` is given. 76 | 77 | Example: 78 | ```bash 79 | iex> format_function {Logger, :log, 2} 80 | "Logger.log/2" 81 | ``` 82 | ```bash 83 | iex> format_function :format_function 84 | "format_function" 85 | ``` 86 | ```bash 87 | iex> format_function nil 88 | nil 89 | ``` 90 | """ 91 | @spec format_function(nil | {atom, atom, integer} | atom) :: String.t | nil 92 | def format_function(nil), do: nil 93 | def format_function({module, name, arity}), do: "#{module}.#{name}/#{arity}" 94 | def format_function(name), do: "#{name}" 95 | 96 | @doc ~S""" 97 | Parallel map implemented with `Task`. 98 | 99 | Maps the `function` over the `enum` using `Task.async/1` and `Task.await/1`. 100 | """ 101 | @spec parallel_map(enum :: list, function :: fun) :: list 102 | def parallel_map(enum, function) do 103 | enum 104 | |> Enum.map(&(Task.async(fn -> function.(&1) end))) 105 | |> Enum.map(&Task.await/1) 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /src/css/lib/_table.scss: -------------------------------------------------------------------------------- 1 | table.inline { 2 | //width: 27em; 3 | max-width: 27em; 4 | width: 100%; 5 | display: inline-block; 6 | background-color: rgba(0,0,0, 0.1); 7 | border-radius: 0.5em; 8 | border: solid 1px rgba(0,0,0, 0.1); 9 | 10 | margin: 0.8em; 11 | vertical-align: top; 12 | 13 | caption { 14 | @include user-select(none); 15 | 16 | font-size: 110%; 17 | background-color: rgba(0,0,0, 0.1); 18 | border-bottom: solid 1px rgba(0,0,0, 0.1); 19 | 20 | padding: 0.1em 0 0.1em 0; 21 | } 22 | 23 | th { 24 | text-align: left; 25 | width: 14em; 26 | 27 | &::after { 28 | content: ':'; 29 | } 30 | } 31 | 32 | td { 33 | width: 13em; 34 | 35 | .load { 36 | display: inline-block; 37 | width: 2em; 38 | text-align: right; 39 | } 40 | .high { 41 | color: #f00; 42 | } 43 | } 44 | } 45 | 46 | div.table-holder { 47 | max-width: 100%; 48 | overflow: auto; 49 | } 50 | table.process_table { 51 | border-collapse: collapse; 52 | white-space: nowrap; 53 | 54 | border: solid 1px rgba(0,0,0, 0.2); 55 | 56 | td, th{ 57 | vertical-align: bottom; 58 | border-bottom: 1px solid rgba($table-header-background-color, 0.1); 59 | } 60 | 61 | thead { 62 | color: #fff; 63 | background-color: $table-header-background-color; 64 | //background-color: rgba(0,0,0, 0.1); 65 | font-size: 105%; 66 | cursor: pointer; 67 | 68 | th { 69 | padding: 0.5em; 70 | } 71 | } 72 | td { 73 | padding: 0.3em; 74 | @if (lightness($content-background-color) > 50) { 75 | background-color: rgba(255,255,255, 0.8); 76 | } @else { 77 | background-color: rgba(0,0,0, 0.5); 78 | } 79 | } 80 | 81 | tr:nth-child(even) { 82 | @if (lightness($content-background-color) > 50) { 83 | background-color: rgba($table-header-background-color, 0.1); 84 | } @else { 85 | background-color: #181818; 86 | } 87 | } 88 | } 89 | 90 | table.generic_array_table { 91 | border-collapse: collapse; 92 | white-space: nowrap; 93 | 94 | border: solid 1px rgba(0,0,0, 0.2); 95 | 96 | td, th{ 97 | vertical-align: bottom; 98 | border-bottom: 1px solid rgba($table-header-background-color, 0.1); 99 | } 100 | 101 | caption { 102 | font-size: 1.7em; 103 | font-weight: bold; 104 | 105 | color: $primary-color; 106 | text-transform: capitalize; 107 | } 108 | thead { 109 | color: #fff; 110 | background-color: $table-header-background-color; 111 | //background-color: rgba(0,0,0, 0.1); 112 | font-size: 105%; 113 | cursor: pointer; 114 | 115 | th { 116 | padding: 0.5em; 117 | } 118 | } 119 | td { 120 | padding: 0.3em; 121 | @if (lightness($content-background-color) > 50) { 122 | background-color: rgba(255,255,255, 0.8); 123 | } @else { 124 | background-color: rgba(0,0,0, 0.5); 125 | } 126 | } 127 | 128 | tr:nth-child(even) { 129 | @if (lightness($content-background-color) > 50) { 130 | background-color: rgba($table-header-background-color, 0.1); 131 | } @else { 132 | background-color: #181818; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /lib/wobserver/web/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Client do 2 | @moduledoc ~S""" 3 | Modules handles WebSocket connects to the client. 4 | """ 5 | use Wobserver.Web.ClientSocket 6 | 7 | alias Wobserver.Allocator 8 | alias Wobserver.Page 9 | alias Wobserver.Table 10 | alias Wobserver.System 11 | alias Wobserver.Util.Application 12 | alias Wobserver.Util.Process 13 | alias Wobserver.Util.Node.Discovery 14 | 15 | @doc ~S""" 16 | Starts the websocket client. 17 | 18 | Returns a map as state. 19 | """ 20 | @spec client_init :: {:ok, map} 21 | def client_init do 22 | {:ok, %{}} 23 | end 24 | 25 | @doc ~S""" 26 | Handles the `command` given by the websocket interface. 27 | 28 | The current `state` is passed and can be modified. 29 | 30 | Returns a map as state. 31 | """ 32 | @spec client_handle(atom | list(atom), state :: map) :: 33 | {:reply, atom | list(atom), map, map} 34 | | {:reply, atom | list(atom), map} 35 | | {:noreply, map} 36 | def client_handle(command, state) 37 | 38 | def client_handle(:hello, state) do 39 | {:reply, :ehlo, Discovery.local, state} 40 | end 41 | 42 | def client_handle(:ping, state) do 43 | {:reply, :pong, state} 44 | end 45 | 46 | def client_handle(:system, state) do 47 | {:reply, :system, System.overview, state} 48 | end 49 | 50 | def client_handle(:about, state) do 51 | {:reply, :about, Wobserver.about, state} 52 | end 53 | 54 | def client_handle(:application, state) do 55 | {:reply, :application, Application.list, state} 56 | end 57 | 58 | def client_handle([:application, app], state) do 59 | {:reply, [:application, app], Application.info(app), state} 60 | end 61 | 62 | def client_handle(:process, state) do 63 | {:reply, :process, Process.list, state} 64 | end 65 | 66 | def client_handle([:process, process], state) do 67 | data = 68 | process 69 | |> Atom.to_string 70 | |> Process.info 71 | 72 | {:reply, [:process, process], data, state} 73 | end 74 | 75 | def client_handle(:ports, state) do 76 | {:reply, :ports, Wobserver.Port.list, state} 77 | end 78 | 79 | def client_handle(:allocators, state) do 80 | {:reply, :allocators, Allocator.list, state} 81 | end 82 | 83 | def client_handle(:table, state) do 84 | {:reply, :table, Table.list, state} 85 | end 86 | 87 | def client_handle([:table, table], state) do 88 | data = 89 | table 90 | |> Atom.to_string 91 | |> Table.sanitize 92 | |> Table.info(true) 93 | 94 | {:reply, [:table, table], data, state} 95 | end 96 | 97 | def client_handle(:custom, state) do 98 | {:reply, :custom, Page.list, state} 99 | end 100 | 101 | def client_handle(custom, state) do 102 | case Page.call(custom) do 103 | :page_not_found -> {:noreply, state} 104 | data -> {:reply, custom, data, state} 105 | end 106 | end 107 | 108 | @doc ~S""" 109 | Handles process messages. 110 | 111 | Should not be used, the `do` is ignored and the `state` is returned unmodified. 112 | """ 113 | @spec client_info(any, state :: map) :: {:noreply, map} 114 | def client_info(_do, state) do 115 | {:noreply, state} 116 | end 117 | 118 | @doc false 119 | @spec terminate(any, any, any) :: :ok 120 | def terminate(_,_,_), do: :ok 121 | 122 | @doc false 123 | @spec handle(:cowboy_req.req, any) :: {:ok, :cowboy_req.req, any} 124 | def handle(req, state), do: {:ok, req, state} 125 | end 126 | -------------------------------------------------------------------------------- /src/js/interface/chart.js: -------------------------------------------------------------------------------- 1 | const chart_steps = 100; 2 | 3 | function format_timestamp(timestamp, lastTimestamp = '') { 4 | if( Math.floor(lastTimestamp / 5) == Math.floor(timestamp / 5) ){ 5 | return ' '; 6 | } 7 | lastTimestamp = timestamp; 8 | 9 | let date = new Date(timestamp * 1000); 10 | 11 | let minutes = date.getMinutes(); 12 | if( minutes < 10 ){ 13 | minutes = '0' + minutes; 14 | } 15 | 16 | let seconds = date.getSeconds(); 17 | if( seconds < 10 ){ 18 | seconds = '0' + seconds; 19 | } 20 | 21 | return date.getHours() + ':' + minutes + ':' + seconds; 22 | } 23 | 24 | function generate_data_sets(data){ 25 | return data.map(item => { 26 | let color = 'rgba(' + item.r + ', ' + item.g + ', ' + item.b; 27 | 28 | return { 29 | label: item.label, 30 | type: 'line', 31 | fillColor: color + ', 0.1)', 32 | strokeColor: color + ', 0.8)', 33 | pointColor: color + ', 0)', 34 | pointStrokeColor: color + ', 0)', 35 | multiTooltipTemplate: item.label + ' - <%= value %>', 36 | data: Array(chart_steps).fill(0) 37 | } 38 | }); 39 | } 40 | 41 | function generate_chart(id, data, unit, chartOwner) { 42 | let container = document.getElementById(id); 43 | container.className = 'wobserver-chart' 44 | let canvas = document.createElement('canvas'); 45 | 46 | container.appendChild(canvas); 47 | 48 | let ctx = canvas.getContext('2d'); 49 | 50 | let starting_data = 51 | { 52 | labels: Array(chart_steps).fill(''), 53 | datasets: generate_data_sets(data) 54 | }; 55 | 56 | let options = { 57 | responsive: true, 58 | maintainAspectRatio: false, 59 | animationSteps: 1, 60 | scaleLabel: "<%=value%> " + unit, 61 | multiTooltipTemplate: "<%=value%> " + unit, 62 | scales: { 63 | xAxes: [{ 64 | type: 'time', 65 | time: { 66 | displayFormats: { 67 | 'millisecond': 'MMM DD', 68 | 'second': 'MMM DD', 69 | 'minute': 'MMM DD', 70 | 'hour': 'MMM DD', 71 | 'day': 'MMM DD', 72 | 'week': 'MMM DD', 73 | 'month': 'MMM DD', 74 | 'quarter': 'MMM DD', 75 | 'year': 'MMM DD' 76 | } 77 | } 78 | }] 79 | }, 80 | legendTemplate: '' 88 | }; 89 | 90 | let chart = new Chart(ctx).Line(starting_data, options); 91 | 92 | let legend_div = document.createElement('div'); 93 | legend_div.className = 'chart-legend'; 94 | legend_div.innerHTML = chart.generateLegend(); 95 | container.append(legend_div); 96 | 97 | container.chart = chartOwner; 98 | 99 | return chart; 100 | } 101 | 102 | 103 | class WobserverChart { 104 | constructor(id, data, unit) { 105 | this.chart = generate_chart(id, data, unit, this); 106 | this.last_timestamp = ''; 107 | } 108 | 109 | update(data) { 110 | this.chart.addData(data, format_timestamp(data.timestamp, this.last_timestamp)); 111 | this.chart.removeData(); 112 | this.last_timestamp = data.timestamp; 113 | } 114 | } 115 | 116 | export{ WobserverChart } -------------------------------------------------------------------------------- /lib/wobserver/web/router/api.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.Api do 2 | @moduledoc ~S""" 3 | Main api router. 4 | 5 | Returns the following resources: 6 | - `/about` => `Wobserver.about/0`. 7 | - `/nodes` => `Wobserver.NodeDiscovery.discover/0`. 8 | 9 | Splits into the following paths: 10 | - `/system`, for all system information, handled by `Wobserver.Web.Router.System`. 11 | 12 | All paths also include the option of entering a node name before the path. 13 | """ 14 | 15 | use Wobserver.Web.Router.Base 16 | 17 | alias Plug.Router.Utils 18 | 19 | alias Wobserver.Allocator 20 | alias Wobserver.Page 21 | alias Wobserver.Table 22 | alias Wobserver.Util.Application 23 | alias Wobserver.Util.Process 24 | alias Wobserver.Util.Node.Discovery 25 | alias Wobserver.Util.Node.Remote 26 | alias Wobserver.Web.Router.Api 27 | alias Wobserver.Web.Router.System 28 | 29 | match "/nodes" do 30 | Discovery.discover 31 | |> send_json_resp(conn) 32 | end 33 | 34 | match "/custom" do 35 | Page.list 36 | |> send_json_resp(conn) 37 | end 38 | 39 | match "/about" do 40 | Wobserver.about 41 | |> send_json_resp(conn) 42 | end 43 | 44 | get "/application/:app" do 45 | app 46 | |> String.downcase 47 | |> String.to_atom 48 | |> Application.info 49 | |> send_json_resp(conn) 50 | end 51 | 52 | get "/application" do 53 | Application.list 54 | |> send_json_resp(conn) 55 | end 56 | 57 | get "/process" do 58 | Process.list 59 | |> send_json_resp(conn) 60 | end 61 | 62 | get "/process/:pid" do 63 | pid 64 | |> Process.info 65 | |> send_json_resp(conn) 66 | end 67 | 68 | get "/ports" do 69 | Wobserver.Port.list 70 | |> send_json_resp(conn) 71 | end 72 | 73 | get "/allocators" do 74 | Allocator.list 75 | |> send_json_resp(conn) 76 | end 77 | 78 | get "/table" do 79 | Table.list 80 | |> send_json_resp(conn) 81 | end 82 | 83 | get "/table/:table" do 84 | table 85 | |> Table.sanitize 86 | |> Table.info(true) 87 | |> send_json_resp(conn) 88 | end 89 | 90 | forward "/system", to: System 91 | 92 | match "/:node_name/*glob" do 93 | case glob do 94 | [] -> 95 | node_name 96 | |> String.to_atom 97 | |> Page.call 98 | |> send_json_resp(conn) 99 | # conn 100 | # |> send_resp(501, "Custom commands not implemented yet.") 101 | _ -> 102 | node_forward(node_name, conn, glob) 103 | end 104 | end 105 | 106 | match _ do 107 | conn 108 | |> send_resp(404, "Page not Found") 109 | end 110 | 111 | # Helpers 112 | 113 | defp node_forward(node_name, conn, glob) do 114 | case Discovery.find(node_name) do 115 | :local -> local_forward(conn, glob) 116 | {:remote, remote_node} -> remote_forward(remote_node, conn, glob) 117 | :unknown -> send_resp(conn, 404, "Node #{node_name} not Found") 118 | end 119 | end 120 | 121 | defp local_forward(conn, glob) do 122 | Utils.forward( 123 | var!(conn), 124 | var!(glob), 125 | Api, 126 | Api.init([]) 127 | ) 128 | end 129 | 130 | defp remote_forward(remote_node, conn, glob) do 131 | path = 132 | glob 133 | |> Enum.join 134 | 135 | case Remote.api(remote_node, "/" <> path) do 136 | :error -> 137 | conn 138 | |> send_resp(500, "Node #{remote_node.name} not responding.") 139 | data -> 140 | conn 141 | |> put_resp_content_type("application/json") 142 | |> send_resp(200, data) 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /src/js/wobserver_client.js: -------------------------------------------------------------------------------- 1 | import {WobserverApiFallback} from './wobserver_api_fallback'; 2 | 3 | class WobserverClient { 4 | constructor(host) { 5 | this.host = host; 6 | this.socket = null; 7 | this.node = 'local'; 8 | this.promises = {} 9 | this.state = 0; 10 | this.url = (location.protocol == 'https:' ? 'wss' : 'ws') + '://' + this.host + '/ws' 11 | } 12 | 13 | connect(node_change, fallback_callback, connected_callback) { 14 | this.node_change = node_change; 15 | 16 | this.socket = new WebSocket(this.url); 17 | 18 | this.socket.onerror = (error) => { 19 | if( this.socket.readyState == 3 ){ 20 | if( this.state == 0 ){ 21 | console.log('Socket can not connect, falling back to json api.') 22 | fallback_callback(new WobserverApiFallback(this.host, this.node)); 23 | connected_callback(); 24 | } 25 | } 26 | } 27 | 28 | this.socket.onopen = () => { 29 | this.state = 1; 30 | 31 | connected_callback(); 32 | 33 | this.command('hello'); 34 | setInterval(_ => this.command('ping'), 55 * 1000 ); // Every 55 seconds 35 | } 36 | 37 | this.add_handlers(); 38 | } 39 | 40 | disconnected() { 41 | if( this.state == 1 ){ 42 | this.state = -1; 43 | 44 | this.reconnect(); 45 | } 46 | 47 | if( this.on_disconnect ){ 48 | this.on_disconnect(); 49 | } 50 | } 51 | 52 | reconnect() { 53 | let new_socket = new WebSocket(this.url); 54 | 55 | new_socket.onerror = (error) => { 56 | if( this.socket.readyState == 3 ){ 57 | console.log('Reconnect failed, trying again in 5 seconds.') 58 | setTimeout(_ => this.reconnect(), 5000); 59 | } 60 | } 61 | 62 | new_socket.onopen = () => { 63 | this.socket = new_socket; 64 | this.state = 1; 65 | 66 | this.add_handlers(); 67 | 68 | this.command('hello'); 69 | 70 | if( this.on_reconnect ){ 71 | this.on_reconnect(); 72 | } 73 | } 74 | } 75 | 76 | add_handlers() { 77 | this.socket.onmessage = (msg) => { 78 | let data = JSON.parse(msg.data); 79 | 80 | if( data.type == 'ehlo' ) { 81 | this.node = data.data.name; 82 | this.node_change(this.node); 83 | } else if( data.type == 'setup_proxy' && data.data.node) { 84 | this.node = data.data.node; 85 | this.node_change(this.node); 86 | } else { 87 | if( this.promises[data.type] ){ 88 | let promise = this.promises[data.type].pop(); 89 | if( promise ) { 90 | promise(data); 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | command(command, data = null) { 98 | if( this.socket.readyState == 3 ){ 99 | this.disconnected(); 100 | 101 | return; 102 | } 103 | 104 | let payload = JSON.stringify({ 105 | command: command, 106 | data: data 107 | }) 108 | 109 | this.socket.send(payload); 110 | } 111 | 112 | command_promise(command, data = null) { 113 | if( this.socket.readyState == 3 ){ 114 | this.disconnected(); 115 | 116 | return new Promise((s) => {}); 117 | } 118 | 119 | return new Promise((resolve) => { 120 | if( this.promises[command] ){ 121 | this.promises[command].push(resolve); 122 | } else { 123 | this.promises[command] = [resolve]; 124 | } 125 | 126 | this.command(command, data); 127 | }); 128 | } 129 | 130 | set_node(node) { 131 | if( this.node != node ) { 132 | this.command('setup_proxy', node) 133 | } 134 | } 135 | } 136 | 137 | export{ WobserverClient } 138 | -------------------------------------------------------------------------------- /lib/wobserver/util/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Application do 2 | @moduledoc ~S""" 3 | Application listing and process hierachy. 4 | """ 5 | 6 | alias Wobserver.Util.Process 7 | 8 | import Wobserver.Util.Helper, only: [parallel_map: 2] 9 | 10 | @doc ~S""" 11 | Lists all running applications. 12 | 13 | The application information is given as a tuple containing: `{name, description, version}`. 14 | The `name` is an atom, while both the description and version are Strings. 15 | """ 16 | @spec list :: list({atom, String.t, String.t}) 17 | def list do 18 | :application_controller.which_applications 19 | |> Enum.filter(&alive?/1) 20 | |> Enum.map(&structure_application/1) 21 | end 22 | 23 | @doc ~S""" 24 | Retreives information about the application. 25 | 26 | The given `app` atom is used to find the started application. 27 | 28 | Containing: 29 | - `pid`, the process id or port id. 30 | - `name`, the registered name or pid/port. 31 | - `meta`, the meta information of a process. (See: `Wobserver.Util.Process.meta/1`.) 32 | - `children`, the children of the process. 33 | """ 34 | @spec info(app :: atom) :: map 35 | def info(app) do 36 | app_pid = 37 | app 38 | |> :application_controller.get_master 39 | 40 | %{ 41 | pid: app_pid, 42 | children: app_pid |> :application_master.get_child |> structure_pid(), 43 | name: name(app_pid), 44 | meta: Process.meta(app_pid), 45 | } 46 | end 47 | 48 | # Helpers 49 | 50 | defp alive?({app, _, _}) do 51 | app 52 | |> :application_controller.get_master 53 | |> is_pid 54 | catch 55 | _, _ -> false 56 | end 57 | 58 | defp structure_application({name, description, version}) do 59 | %{ 60 | name: name, 61 | description: to_string(description), 62 | version: to_string(version), 63 | } 64 | end 65 | 66 | defp structure_pid({pid, name}) do 67 | child = structure_pid({name, pid, :supervisor, []}) 68 | 69 | {_, dictionary} = :erlang.process_info pid, :dictionary 70 | 71 | case Keyword.get(dictionary, :"$ancestors") do 72 | [parent] -> 73 | [%{ 74 | pid: parent, 75 | children: [child], 76 | name: name(parent), 77 | meta: Process.meta(parent), 78 | }] 79 | _ -> 80 | [child] 81 | end 82 | end 83 | 84 | defp structure_pid({_, :undefined, _, _}), do: nil 85 | 86 | defp structure_pid({_, pid, :supervisor, _}) do 87 | {:links, links} = :erlang.process_info(pid, :links) 88 | 89 | children = 90 | case Enum.count(links) do 91 | 1 -> 92 | [] 93 | _ -> 94 | pid 95 | |> :supervisor.which_children 96 | |> Kernel.++(Enum.filter(links, fn link -> is_port(link) end)) 97 | |> parallel_map(&structure_pid/1) 98 | |> Enum.filter(&(&1 != nil)) 99 | end 100 | 101 | %{ 102 | pid: pid, 103 | children: children, 104 | name: name(pid), 105 | meta: Process.meta(pid), 106 | } 107 | end 108 | 109 | defp structure_pid({_, pid, :worker, _}) do 110 | %{ 111 | pid: pid, 112 | children: [], 113 | name: name(pid), 114 | meta: Process.meta(pid), 115 | } 116 | end 117 | 118 | defp structure_pid(port) when is_port(port) do 119 | %{ 120 | pid: port, 121 | children: [], 122 | name: port, 123 | meta: %{ 124 | status: :port, 125 | init: "", 126 | current: "", 127 | class: :port, 128 | } 129 | } 130 | end 131 | 132 | defp structure_pid(_), do: nil 133 | 134 | defp name(pid) do 135 | case :erlang.process_info(pid, :registered_name) do 136 | {_, registered_name} -> to_string(registered_name) 137 | _ -> pid |> inspect |> String.trim_leading("#PID") 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/wobserver/util/metrics/prometheus.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Metrics.Prometheus do 2 | @moduledoc ~S""" 3 | Prometheus formatter. 4 | 5 | Formats metrics in a for Prometheus readable way. 6 | See: [https://prometheus.io/docs/instrumenting/writing_exporters/](https://prometheus.io/docs/instrumenting/writing_exporters/) 7 | """ 8 | 9 | @behaviour Wobserver.Util.Metrics.Formatter 10 | 11 | @doc ~S""" 12 | Format a set of `data` with a `label` for a Prometheus. 13 | 14 | The `data` must be given as a `list` of tuples with the following format: `{value, labels}`, where `labels` is a keyword list with labels and their values. 15 | 16 | The following options can also be given: 17 | - `type`, the type of the metric. The following values are currently supported: `:gauge`, `:counter`. 18 | - `help`, a single line text description of the metric. 19 | """ 20 | @spec format_data( 21 | name :: String.t, 22 | data :: [{integer | float, keyword}], 23 | type :: :atom, 24 | help :: String.t 25 | ) :: String.t 26 | def format_data(name, data, type, help) do 27 | "#{format_help name, help}#{format_type name, type}#{format_values name, data}" 28 | end 29 | 30 | @doc ~S""" 31 | Combines formatted metrics together. 32 | 33 | Arguments: 34 | - `metrics`, a list of formatted metrics for one node. 35 | 36 | Example: 37 | iex> combine_metrics ["metric1{node="127.0.0.1"} 5\n", "metric2{node="127.0.0.1"} 5\n"] 38 | "metric1{node="127.0.0.1"} 5\n", "metric2{node="127.0.0.1"} 5\n" 39 | """ 40 | @spec combine_metrics( 41 | metrics :: list[String.t] 42 | ) :: String.t 43 | def combine_metrics(metrics), do: Enum.join(metrics) 44 | 45 | @doc ~S""" 46 | Merges formatted sets of metrics from different nodes together. 47 | 48 | The merge will filter out double declarations of help and type. 49 | 50 | Arguments: 51 | - `metrics`, a list of formatted sets metrics for multiple node. 52 | 53 | Example: 54 | iex> combine_metrics ["metric{node="192.168.0.6"} 5\n", "metric{node="192.168.0.5"} 5\n"] 55 | "metric{node="192.168.0.6"} 5\n", "metric{node="192.168.0.7"} 5\n" 56 | """ 57 | @spec merge_metrics( 58 | metrics :: list[String.t] 59 | ) :: String.t 60 | def merge_metrics(metrics) do 61 | {combined, _} = Enum.reduce(metrics, {"", []}, &filter/2) 62 | 63 | combined 64 | end 65 | 66 | # Helpers 67 | 68 | defp format_help(_name, nil), do: "" 69 | defp format_help(name, help) do 70 | "\# HELP #{name} #{help}\n" 71 | end 72 | 73 | defp format_type(_name, nil), do: "" 74 | defp format_type(name, type) do 75 | "\# TYPE #{name} #{type}\n" 76 | end 77 | 78 | defp format_labels(labels) do 79 | labels 80 | |> Enum.map(fn {label, value} -> "#{label}=\"#{value}\"" end) 81 | |> Enum.join(",") 82 | end 83 | 84 | defp format_values(name, data) do 85 | data 86 | |> Enum.map(fn {value, labels} -> "#{name}{#{format_labels labels}} #{value}\n" end) 87 | |> Enum.join 88 | end 89 | 90 | defp analyze_metrics(metrics) do 91 | help = 92 | ~r/^\# HELP ([a-zA-Z_]+\ )/m 93 | |> Regex.scan(metrics) 94 | |> Enum.map(fn [match | _] -> match end) 95 | 96 | type = 97 | ~r/^\# TYPE ([a-zA-Z_]+\ )/m 98 | |> Regex.scan(metrics) 99 | |> Enum.map(fn [match | _] -> match end) 100 | 101 | help ++ type 102 | end 103 | 104 | defp filter_line(line, filter) do 105 | filter 106 | |> Enum.find_value(false, &String.starts_with?(line, &1)) 107 | |> Kernel.! 108 | end 109 | 110 | defp filter(metric, {metrics, filter}) do 111 | filtered_metric = 112 | metric 113 | |> String.split("\n") 114 | |> Enum.filter(&filter_line(&1, filter)) 115 | |> Enum.join("\n") 116 | 117 | updated_filter = 118 | filtered_metric 119 | |> analyze_metrics() 120 | |> Kernel.++(filter) 121 | 122 | {metrics <> filtered_metric, updated_filter} 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 2 | "certifi": {:hex, :certifi, "1.2.1", "c3904f192bd5284e5b13f20db3ceac9626e14eeacfbb492e19583cf0e37b22be", [:rebar3], [], "hexpm"}, 3 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, 5 | "credo": {:hex, :credo, "0.8.4", "4e50acac058cf6292d6066e5b0d03da5e1483702e1ccde39abba385c9f03ead4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "dialyxir": {:hex, :dialyxir, "0.5.0", "5bc543f9c28ecd51b99cc1a685a3c2a1a93216990347f259406a910cf048d1d7", [:mix], [], "hexpm"}, 7 | "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm"}, 8 | "ex_doc": {:hex, :ex_doc, "0.16.2", "3b3e210ebcd85a7c76b4e73f85c5640c011d2a0b2f06dcdf5acdb2ae904e5084", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "excoveralls": {:hex, :excoveralls, "0.7.1", "3dd659db19c290692b5e2c4a2365ae6d4488091a1ba58f62dcbdaa0c03da5491", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "hackney": {:hex, :hackney, "1.8.6", "21a725db3569b3fb11a6af17d5c5f654052ce9624219f1317e8639183de4a423", [:rebar3], [{:certifi, "1.2.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.0.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "httpoison": {:hex, :httpoison, "0.12.0", "8fc3d791c5afe6beb0093680c667dd4ce712a49d89c38c3fe1a43100dd76cf90", [:mix], [{:hackney, "~> 1.8.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "idna": {:hex, :idna, "5.0.2", "ac203208ada855d95dc591a764b6e87259cb0e2a364218f215ad662daa8cd6b4", [:rebar3], [{:unicode_util_compat, "0.2.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 15 | "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], [], "hexpm"}, 16 | "meck": {:hex, :meck, "0.8.7", "ebad16ca23f685b07aed3bc011efff65fbaf28881a8adf925428ef5472d390ee", [:rebar3], [], "hexpm"}, 17 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 18 | "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], [], "hexpm"}, 19 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 20 | "plug": {:hex, :plug, "1.4.2", "fba7b3c49ff4ceefb00bbaea3a3fe31b6160f39b81ddfc6a0121b41055d2ec9d", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, 21 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 22 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, 23 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 24 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.2.0", "dbbccf6781821b1c0701845eaf966c9b6d83d7c3bfc65ca2b78b88b8678bfa35", [:rebar3], [], "hexpm"}, 25 | "websocket_client": {:hex, :websocket_client, "1.3.0", "2275d7daaa1cdacebf2068891c9844b15f4fdc3de3ec2602420c2fb486db59b6", [:rebar3], [], "hexpm"}} 26 | -------------------------------------------------------------------------------- /test/wobserver/util/metrics/formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Metrics.FormatterTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Wobserver.Util.Metrics.{ 5 | Formatter, 6 | Prometheus, 7 | } 8 | 9 | def example_function do 10 | [point: 5] 11 | end 12 | 13 | def local_ip do 14 | with {:ok, ips} <- :inet.getif(), 15 | {ip, _, _} <- List.first(ips), 16 | {ip1, ip2, ip3, ip4} <- ip, 17 | do: "#{ip1}.#{ip2}.#{ip3}.#{ip4}" 18 | end 19 | 20 | describe "format" do 21 | test "returns with valid data" do 22 | assert Formatter.format( 23 | %{point: 5}, 24 | "data" 25 | ) == "data{node=\"#{local_ip()}\",type=\"point\"} 5\n" 26 | end 27 | 28 | test "returns with valid data and type" do 29 | assert Formatter.format( 30 | %{point: 5}, 31 | "data", 32 | :gauge 33 | ) == "# TYPE data gauge\ndata{node=\"#{local_ip()}\",type=\"point\"} 5\n" 34 | end 35 | 36 | test "returns with valid data, type, and help" do 37 | assert Formatter.format( 38 | %{point: 5}, 39 | "data", 40 | :gauge, 41 | "help" 42 | ) == "# HELP data help\n# TYPE data gauge\ndata{node=\"#{local_ip()}\",type=\"point\"} 5\n" 43 | end 44 | 45 | test "returns with valid data as integer" do 46 | assert Formatter.format( 47 | 5, 48 | "data" 49 | ) == "data{node=\"#{local_ip()}\"} 5\n" 50 | end 51 | 52 | test "returns with valid data as float" do 53 | assert Formatter.format( 54 | 5.4, 55 | "data" 56 | ) == "data{node=\"#{local_ip()}\"} 5.4\n" 57 | end 58 | 59 | test "returns with valid data as keywords" do 60 | assert Formatter.format( 61 | [point: 5], 62 | "data" 63 | ) == "data{node=\"#{local_ip()}\",type=\"point\"} 5\n" 64 | end 65 | 66 | test "returns with valid data as data String" do 67 | assert Formatter.format( 68 | "[point: 5]", 69 | "data" 70 | ) == "data{node=\"#{local_ip()}\",type=\"point\"} 5\n" 71 | end 72 | 73 | test "returns with valid data as anon function" do 74 | assert Formatter.format( 75 | "fn -> [point: 5] end", 76 | "data" 77 | ) == "data{node=\"#{local_ip()}\",type=\"point\"} 5\n" 78 | end 79 | 80 | test "returns with valid data as function call" do 81 | assert Formatter.format( 82 | "Wobserver.Util.Metrics.FormatterTest.example_function", 83 | "data" 84 | ) == "data{node=\"#{local_ip()}\",type=\"point\"} 5\n" 85 | end 86 | 87 | test "returns with valid data as function" do 88 | assert Formatter.format( 89 | "&Wobserver.Util.Metrics.FormatterTest.example_function/0", 90 | "data" 91 | ) == "data{node=\"#{local_ip()}\",type=\"point\"} 5\n" 92 | end 93 | 94 | test "returns with explicit formatter" do 95 | assert Formatter.format( 96 | [point: 5], 97 | "data", 98 | nil, 99 | nil, 100 | Wobserver.Util.Metrics.Prometheus 101 | ) == "data{node=\"#{local_ip()}\",type=\"point\"} 5\n" 102 | end 103 | 104 | test "returns with explicit formatter as String" do 105 | assert Formatter.format( 106 | [point: 5], 107 | "data", 108 | nil, 109 | nil, 110 | "Wobserver.Util.Metrics.Prometheus" 111 | ) == "data{node=\"#{local_ip()}\",type=\"point\"} 5\n" 112 | end 113 | end 114 | 115 | describe "format_all" do 116 | test "returns :error with invalid entry" do 117 | assert Formatter.format_all([ 118 | works: %{value: 8}, 119 | invalid: "w{", 120 | ]) == :error 121 | end 122 | 123 | test "returns with multiple entries" do 124 | assert Formatter.format_all([ 125 | works: %{value: 8}, 126 | also_works: %{value: 9}, 127 | ], Prometheus) == "works{node=\"#{local_ip()}\",type=\"value\"} 8\nalso_works{node=\"#{local_ip()}\",type=\"value\"} 9\n" 128 | end 129 | 130 | test "returns with multiple entries and type" do 131 | assert Formatter.format_all([ 132 | works: { 133 | %{value: 8}, 134 | :gauge 135 | }, 136 | also_works: %{value: 9}, 137 | ], Prometheus) == "# TYPE works gauge\nworks{node=\"#{local_ip()}\",type=\"value\"} 8\nalso_works{node=\"#{local_ip()}\",type=\"value\"} 9\n" 138 | end 139 | 140 | test "returns with multiple entries + type & help" do 141 | assert Formatter.format_all([ 142 | works: { 143 | %{value: 8}, 144 | :gauge, 145 | "Info" 146 | }, 147 | also_works: %{value: 9}, 148 | ], Prometheus) == "# HELP works Info\n# TYPE works gauge\nworks{node=\"#{local_ip()}\",type=\"value\"} 8\nalso_works{node=\"#{local_ip()}\",type=\"value\"} 9\n" 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/wobserver/util/metrics.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Metrics do 2 | @moduledoc ~S""" 3 | Metrics management for custom metrics and generators in wobserver. 4 | """ 5 | 6 | alias Wobserver.System.Memory 7 | 8 | @metrics_table :wobserver_metrics 9 | 10 | @doc ~S""" 11 | Lists all metrics. 12 | 13 | Every generator is called and the generated metrics are merged into the result. 14 | """ 15 | @spec overview :: keyword 16 | def overview do 17 | memory() 18 | |> Kernel.++(io()) 19 | |> Keyword.merge(custom_metrics()) 20 | end 21 | 22 | @doc ~S""" 23 | Memory metrics. 24 | 25 | See: `Wobserver.System.Memory.usage/0`. 26 | """ 27 | @spec memory :: keyword 28 | def memory do 29 | [erlang_vm_used_memory_bytes: { 30 | &Memory.usage/0, 31 | :gauge, 32 | "Memory usage of the Erlang VM." 33 | }] 34 | end 35 | 36 | @doc ~S""" 37 | IO metrics. 38 | """ 39 | @spec io :: keyword 40 | def io do 41 | [erlang_vm_used_io_bytes: { 42 | "Tuple.to_list(:erlang.statistics(:io))", 43 | :counter, 44 | "IO counter for the Erlang VM." 45 | }] 46 | end 47 | 48 | @doc ~S""" 49 | Registers a metrics or metric generators with `:wobserver`. 50 | 51 | The `metrics` parameter must always be a list of metrics or metric generators. 52 | 53 | Returns true if succesfully added. (otherwise false) 54 | 55 | The following inputs are accepted for metrics: 56 | - `keyword` list, the key is the name of the metric and the value is the metric data. 57 | 58 | The following inputs are accepted for metric generators: 59 | - `list` of callable functions. 60 | Every function should return a keyword list with as key the name of the metric and as value the metric data. 61 | 62 | For more information about how to format metric data see: `Wobserver.Util.Metrics.Formatter.format_all/1`. 63 | """ 64 | @spec register(metrics :: list) :: boolean 65 | def register(metrics) when is_list(metrics) do 66 | ensure_storage() 67 | 68 | case Keyword.keyword?(metrics) do 69 | true -> 70 | @metrics_table 71 | |> Agent.update(fn %{generators: g, metrics: m} -> 72 | %{generators: g, metrics: Keyword.merge(m, metrics)} 73 | end) 74 | false -> 75 | @metrics_table 76 | |> Agent.update(fn %{generators: g, metrics: m} -> 77 | %{generators: g ++ metrics, metrics: m} 78 | end) 79 | end 80 | 81 | true 82 | end 83 | 84 | def register(_), do: false 85 | 86 | @doc ~S""" 87 | Loads custom metrics and metric generators from configuration and adds them to `:wobserver`. 88 | 89 | To add custom metrics set the `:metrics` option. 90 | The `:metrics` option must be a keyword list with the following keys: 91 | - `additional`, for a keyword list with additional metrics. 92 | - `generators`, for a list of metric generators. 93 | 94 | For more information and types see: `Wobserver.Util.Metrics.register/1`. 95 | 96 | Example: 97 | ```elixir 98 | config :wobserver, 99 | metrics: [ 100 | additional: [ 101 | example: {fn -> [red: 5] end, :gauge, "Description"}, 102 | ], 103 | generators: [ 104 | "&MyApp.generator/0", 105 | fn -> [bottles: {fn -> [wall: 8, floor: 10] end, :gauge, "Description"}] end 106 | fn -> [server: {"MyApp.Server.metrics/0", :gauge, "Description"}] end 107 | ] 108 | ] 109 | ``` 110 | """ 111 | @spec load_config :: true 112 | def load_config do 113 | ensure_storage() 114 | 115 | metrics = 116 | :wobserver 117 | |> Application.get_env(:metrics, []) 118 | |> Keyword.get(:additional, []) 119 | 120 | generators = 121 | :wobserver 122 | |> Application.get_env(:metrics, []) 123 | |> Keyword.get(:generators, []) 124 | 125 | @metrics_table 126 | |> Agent.update(fn %{generators: g, metrics: m} -> 127 | %{generators: g ++ generators, metrics: Keyword.merge(m, metrics)} 128 | end) 129 | 130 | true 131 | end 132 | 133 | # Helpers 134 | 135 | defp custom_metrics do 136 | ensure_storage() 137 | 138 | metrics = 139 | @metrics_table 140 | |> Agent.get(fn metrics -> metrics end) 141 | 142 | generators = 143 | metrics.generators 144 | |> Enum.reduce([], &generate/2) 145 | 146 | metrics.metrics 147 | |> Keyword.merge(generators) 148 | end 149 | 150 | defp generate(generator, results) when is_binary(generator) do 151 | {eval_generator, []} = Code.eval_string generator 152 | 153 | eval_generator 154 | |> generate(results) 155 | end 156 | 157 | defp generate(generator, results) do 158 | result = generator.() 159 | Keyword.merge(results, result) 160 | end 161 | 162 | defp ensure_storage do 163 | Agent.start_link( 164 | fn -> %{generators: [], metrics: []} end, 165 | name: @metrics_table 166 | ) 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /src/css/lib/_popup.scss: -------------------------------------------------------------------------------- 1 | 2 | #node_selection { 3 | overflow: hidden; 4 | margin: auto; 5 | 6 | width: 20em; 7 | // height: 30em; 8 | 9 | border-radius: 0.5em; 10 | border: solid 1px #000; 11 | background-color: $content-background-color; 12 | 13 | & > span { 14 | color: #fff; 15 | background-color: $content-header-background-color; 16 | width: 100%; 17 | display: block; 18 | padding: 0.3em; 19 | font-size: 120%; 20 | } 21 | ul { 22 | margin: 0; padding: 0; 23 | list-style: none; 24 | 25 | li.node { 26 | padding: 0.5em; 27 | cursor: pointer; 28 | 29 | span { 30 | font-weight: bold; 31 | display: block; 32 | font-size: 120%; 33 | } 34 | 35 | detail { 36 | font-size: 90%; 37 | padding-left: 2em; 38 | font-style: italic; 39 | } 40 | 41 | &:hover{ 42 | background-color: rgba($menu-background-color-selected, 0.2); 43 | } 44 | } 45 | 46 | .selected { 47 | cursor: default !important; 48 | color: #999; 49 | 50 | &:hover{ 51 | background-color: rgba(0,0,0, 0) !important; 52 | } 53 | } 54 | } 55 | } 56 | 57 | #popup-overlay { 58 | position: fixed; 59 | left: 0; right:0; top: 0; bottom: 0; 60 | z-index: 15; 61 | 62 | background-color: rgba(0,0,0, 0.8); 63 | 64 | display: flex; 65 | align-content: center; 66 | } 67 | 68 | #process_information { 69 | overflow: hidden; 70 | position: absolute; 71 | left: 0; right:0; top: 0; bottom: 0; 72 | 73 | margin: auto; 74 | 75 | max-width: 35em; 76 | width: 100%; 77 | height: 50em; 78 | 79 | border-radius: 0.5em; 80 | border: solid 1px #000; 81 | //background-color: #eee; 82 | background-color: $content-background-color; 83 | 84 | pre { 85 | height: 19em; 86 | overflow: auto; 87 | margin: 1em; 88 | background-color: rgba(255,255,255, 0.5); 89 | border-radius: 0.1em; 90 | } 91 | 92 | & > span { 93 | color: #fff; 94 | background-color: $content-header-background-color; 95 | // background-color: rgba(0,0,0, 0.1); 96 | width: 100%; 97 | display: block; 98 | padding: 0.3em; 99 | font-size: 120%; 100 | } 101 | 102 | table { 103 | margin: 0 1em; 104 | 105 | caption { 106 | color: $content-header-background-color; 107 | font-weight: bold; 108 | font-size: 115%; 109 | } 110 | 111 | & + table { 112 | margin-top: 1em; 113 | } 114 | width: 100%; 115 | text-align: left; 116 | tr { 117 | th { 118 | width: 50%; 119 | font-weight: bold; 120 | } 121 | } 122 | } 123 | 124 | div#process_relations { 125 | text-align: center; 126 | margin-top: 1em; 127 | 128 | div { 129 | width: 30%; 130 | display: inline-block; 131 | text-align: left; 132 | 133 | span { 134 | font-weight: bold; 135 | } 136 | 137 | ul { 138 | margin-top: -0.1em; 139 | height: 4em; 140 | overflow: auto; 141 | background-color: rgba(255,255,255, 0.1); 142 | padding-left: 0; 143 | 144 | li { 145 | list-style: none; 146 | } 147 | } 148 | } 149 | } 150 | } 151 | 152 | #table_information { 153 | overflow: hidden; 154 | // position: absolute; 155 | // left: 0; right:0; top: 0; bottom: 0; 156 | 157 | margin: auto; 158 | 159 | max-width: 90%; 160 | width: auto; 161 | max-height: 95%; 162 | height: auto; 163 | // height: 35em; 164 | 165 | border-radius: 0.5em; 166 | border: solid 1px #000; 167 | background-color: #eee; 168 | 169 | & > span { 170 | color: #fff; 171 | background-color: $content-header-background-color; 172 | width: 100%; 173 | display: block; 174 | padding: 0.3em; 175 | font-size: 120%; 176 | } 177 | 178 | & > div { 179 | overflow: auto; 180 | height: auto; 181 | max-height: 90vh; 182 | } 183 | 184 | pre { 185 | margin: 0; 186 | } 187 | table { 188 | border-collapse: collapse; 189 | white-space: nowrap; 190 | padding: 0; 191 | margin: 0; 192 | 193 | min-width: 100%; 194 | 195 | tr:nth-child(even) { 196 | background-color: rgba($table-header-background-color, 0.1); 197 | } 198 | 199 | th { 200 | font-weight: bold; 201 | } 202 | 203 | td, th{ 204 | height: 100%; 205 | vertical-align: top; 206 | padding: 0.3em; 207 | background-color: rgba(255,255,255, 0.8); 208 | vertical-align: bottom; 209 | border-bottom: 1px solid rgba($table-header-background-color, 0.1); 210 | border-left: 1px solid rgba($table-header-background-color, 0.1); 211 | } 212 | } 213 | } 214 | 215 | #alloc_table { 216 | padding-top: 2em; 217 | table { 218 | padding: 0; 219 | margin: 0; 220 | 221 | min-width: 100%; 222 | 223 | tr:nth-child(even) { 224 | background-color: rgba(0,0,0, 0.1); 225 | } 226 | 227 | td { 228 | height: 100%; 229 | vertical-align: top; 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /test/wobserver/page_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.PageTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Page 5 | 6 | describe "list" do 7 | test "returns a list" do 8 | assert is_list(Page.list) 9 | end 10 | 11 | test "returns a list of maps" do 12 | assert is_map(List.first(Page.list)) 13 | end 14 | 15 | test "returns a list of table information" do 16 | assert %{ 17 | title: _, 18 | command: _, 19 | api_only: _, 20 | refresh: _, 21 | } = List.first(Page.list) 22 | end 23 | end 24 | 25 | describe "find" do 26 | test "returns :page_not_found for unknown page" do 27 | assert Page.find(:does_not_exist) == :page_not_found 28 | end 29 | 30 | test "returns data from a call based on registered page" do 31 | Page.register("Test", :test, fn -> 5 end) 32 | 33 | assert %Page{ 34 | title: "Test", 35 | command: :test, 36 | callback: _, 37 | options: %{api_only: false, refresh: 1.0} 38 | } = Page.find(:test) 39 | end 40 | end 41 | 42 | describe "call" do 43 | test "returns :page_not_found for :page_not_found" do 44 | assert Page.call(:page_not_found) == :page_not_found 45 | end 46 | 47 | test "returns :page_not_found for unknown page" do 48 | assert Page.call(:does_not_exist) == :page_not_found 49 | end 50 | 51 | test "returns data from a call based on structure" do 52 | assert Page.call(%Page{title: "", command: :command, callback: fn -> 5 end}) == 5 53 | end 54 | 55 | test "returns data from a call based on registered page" do 56 | Page.register("Test", :test, fn -> 5 end) 57 | 58 | assert Page.call(:test) == 5 59 | end 60 | end 61 | 62 | describe "register" do 63 | test "can register page" do 64 | Page.register "Test", :test, fn -> 5 end 65 | 66 | assert %Page{ 67 | title: "Test", 68 | command: :test, 69 | callback: _, 70 | options: %{api_only: false, refresh: 1.0} 71 | } = Page.find(:test) 72 | end 73 | 74 | test "can register page with options" do 75 | Page.register "Test", :test, fn -> 5 end, api_only: true 76 | 77 | assert %Page{ 78 | title: "Test", 79 | command: :test, 80 | callback: _, 81 | options: %{api_only: true, refresh: 1.0} 82 | } = Page.find(:test) 83 | end 84 | 85 | test "can register page with tuple" do 86 | Page.register {"Test", :test, fn -> 5 end} 87 | 88 | assert %Page{ 89 | title: "Test", 90 | command: :test, 91 | callback: _, 92 | options: %{api_only: false, refresh: 1.0} 93 | } = Page.find(:test) 94 | end 95 | 96 | test "can register page with tuple and options" do 97 | Page.register {"Test", :test, fn -> 5 end, [api_only: true]} 98 | 99 | assert %Page{ 100 | title: "Test", 101 | command: :test, 102 | callback: _, 103 | options: %{api_only: true, refresh: 1.0} 104 | } = Page.find(:test) 105 | end 106 | 107 | test "can register page with struct" do 108 | fun = fn -> 5 end 109 | 110 | Page.register %{ 111 | title: "Test", 112 | command: :test, 113 | callback: fun 114 | } 115 | 116 | assert %Page{ 117 | title: "Test", 118 | command: :test, 119 | callback: ^fun, 120 | options: %{api_only: false, refresh: 1.0} 121 | } = Page.find(:test) 122 | end 123 | 124 | test "can register page with struct and options" do 125 | fun = fn -> 5 end 126 | 127 | Page.register %{ 128 | title: "Test", 129 | command: :test, 130 | callback: fun, 131 | options: [api_only: true] 132 | } 133 | 134 | assert %Page{ 135 | title: "Test", 136 | command: :test, 137 | callback: ^fun, 138 | options: %{api_only: true, refresh: 1.0} 139 | } = Page.find(:test) 140 | end 141 | 142 | test "can register page with page" do 143 | fun = fn -> 5 end 144 | 145 | Page.register %Page{ 146 | title: "Test", 147 | command: :test, 148 | callback: fun, 149 | options: %{api_only: true, refresh: 1.0} 150 | } 151 | 152 | assert %Page{ 153 | title: "Test", 154 | command: :test, 155 | callback: ^fun, 156 | options: %{api_only: true, refresh: 1.0} 157 | } = Page.find(:test) 158 | end 159 | end 160 | 161 | describe "load_config" do 162 | test "loads from config" do 163 | fun = fn -> 5 end 164 | 165 | :meck.new Application, [:passthrough] 166 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 167 | case option do 168 | :pages -> [{"Test", :test, fun, [api_only: true]}] 169 | :discovery -> :none 170 | :port -> 4001 171 | end 172 | end 173 | 174 | on_exit(fn -> :meck.unload end) 175 | 176 | Page.load_config 177 | 178 | assert %Page{ 179 | title: "Test", 180 | command: :test, 181 | callback: ^fun, 182 | options: %{api_only: true, refresh: 1.0} 183 | } = Page.find(:test) 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /test/wobserver/web/router/api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.ApiTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias Wobserver.Util.Node.Remote 6 | alias Wobserver.Web.Router.Api 7 | 8 | @opts Api.init([]) 9 | 10 | test "/about returns about" do 11 | conn = conn(:get, "/about") 12 | 13 | conn = Api.call(conn, @opts) 14 | 15 | assert conn.state == :sent 16 | assert conn.status == 200 17 | assert Poison.encode!(Wobserver.about) == conn.resp_body 18 | end 19 | 20 | test "/nodes returns nodes" do 21 | conn = conn(:get, "/nodes") 22 | 23 | conn = Api.call(conn, @opts) 24 | 25 | assert conn.state == :sent 26 | assert conn.status == 200 27 | assert Poison.encode!(Wobserver.Util.Node.Discovery.discover) == conn.resp_body 28 | end 29 | 30 | test "/application returns 200" do 31 | conn = conn(:get, "/application") 32 | 33 | conn = Api.call(conn, @opts) 34 | 35 | assert conn.state == :sent 36 | assert conn.status == 200 37 | end 38 | 39 | test "/application/:app returns 200" do 40 | conn = conn(:get, "/application/wobserver") 41 | 42 | conn = Api.call(conn, @opts) 43 | 44 | assert conn.state == :sent 45 | assert conn.status == 200 46 | end 47 | 48 | test "/process returns 200" do 49 | conn = conn(:get, "/process") 50 | 51 | conn = Api.call(conn, @opts) 52 | 53 | assert conn.state == :sent 54 | assert conn.status == 200 55 | end 56 | 57 | test "/process/:process returns 200" do 58 | conn = conn(:get, "/process/Logger") 59 | 60 | conn = Api.call(conn, @opts) 61 | 62 | assert conn.state == :sent 63 | assert conn.status == 200 64 | end 65 | 66 | test "/table returns 200" do 67 | conn = conn(:get, "/table") 68 | 69 | conn = Api.call(conn, @opts) 70 | 71 | assert conn.state == :sent 72 | assert conn.status == 200 73 | end 74 | 75 | test "/table/:table returns 200" do 76 | conn = conn(:get, "/table/1") 77 | 78 | conn = Api.call(conn, @opts) 79 | 80 | assert conn.state == :sent 81 | assert conn.status == 200 82 | end 83 | 84 | test "/ports returns 200" do 85 | conn = conn(:get, "/ports") 86 | 87 | conn = Api.call(conn, @opts) 88 | 89 | assert conn.state == :sent 90 | assert conn.status == 200 91 | end 92 | 93 | test "/allocators returns 200" do 94 | conn = conn(:get, "/allocators") 95 | 96 | conn = Api.call(conn, @opts) 97 | 98 | assert conn.state == :sent 99 | assert conn.status == 200 100 | end 101 | 102 | test "/system returns 200" do 103 | conn = conn(:get, "/system") 104 | 105 | conn = Api.call(conn, @opts) 106 | 107 | assert conn.state == :sent 108 | assert conn.status == 200 109 | end 110 | 111 | test "/local/system returns 200" do 112 | conn = conn(:get, "/local/system") 113 | 114 | conn = Api.call(conn, @opts) 115 | 116 | assert conn.state == :sent 117 | assert conn.status == 200 118 | end 119 | 120 | test "/blurb/system returns 404" do 121 | conn = conn(:get, "/blurb/system") 122 | 123 | conn = Api.call(conn, @opts) 124 | 125 | assert conn.state == :sent 126 | assert conn.status == 404 127 | end 128 | 129 | test "/remote/system returns 500 (can't load)" do 130 | :meck.new Application, [:passthrough] 131 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 132 | case field do 133 | :port -> 4001 134 | :discovery -> :custom 135 | :discovery_search -> fn -> [%Wobserver.Util.Node.Remote{name: "remote", host: "85.65.12.4", port: 0}] end 136 | end 137 | end 138 | 139 | on_exit(fn -> :meck.unload end) 140 | 141 | conn = conn(:get, "/remote/system") 142 | 143 | conn = Api.call(conn, @opts) 144 | 145 | assert conn.state == :sent 146 | assert conn.status == 500 147 | end 148 | 149 | test "/remote/system returns 200" do 150 | :meck.new Application, [:passthrough] 151 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 152 | case field do 153 | :port -> 4001 154 | :discovery -> :custom 155 | :discovery_search -> fn -> [%Wobserver.Util.Node.Remote{name: "remote", host: "85.65.12.4", port: 0}] end 156 | end 157 | end 158 | 159 | :meck.new Remote, [:passthrough] 160 | :meck.expect Remote, :api, fn _, _ -> "data" end 161 | 162 | on_exit(fn -> :meck.unload end) 163 | 164 | conn = conn(:get, "/remote/system") 165 | 166 | conn = Api.call(conn, @opts) 167 | 168 | assert conn.state == :sent 169 | assert conn.status == 200 170 | end 171 | 172 | test "custom url returns 200 for custom list" do 173 | conn = conn(:get, "/custom") 174 | 175 | conn = Api.call(conn, @opts) 176 | 177 | assert conn.state == :sent 178 | assert conn.status == 200 179 | end 180 | 181 | test "unknown url returns 200 for known custom commands" do 182 | Wobserver.register(:page, {"Test", :test, fn -> 5 end}) 183 | 184 | conn = conn(:get, "/test") 185 | 186 | conn = Api.call(conn, @opts) 187 | 188 | assert conn.state == :sent 189 | assert conn.status == 200 190 | end 191 | 192 | test "unknown url returns 404 for unknown custom commands" do 193 | conn = conn(:get, "/unknown") 194 | 195 | conn = Api.call(conn, @opts) 196 | 197 | assert conn.state == :sent 198 | assert conn.status == 404 199 | end 200 | 201 | test "unknown url returns 404 for index" do 202 | conn = conn(:get, "/") 203 | 204 | conn = Api.call(conn, @opts) 205 | 206 | assert conn.state == :sent 207 | assert conn.status == 404 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /test/wobserver/util/process_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.ProcessTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Util.Process 5 | 6 | defp pid(pid), 7 | do: "<0.#{pid}.0>" |> String.to_charlist |> :erlang.list_to_pid 8 | 9 | describe "pid" do 10 | test "returns for pid" do 11 | assert Process.pid(pid(33)) == pid(33) 12 | end 13 | 14 | test "returns for integer" do 15 | assert Process.pid(33) == pid(33) 16 | end 17 | 18 | test "returns for atom" do 19 | refute Process.pid(:cowboy_sup) == nil 20 | end 21 | 22 | test "returns for module" do 23 | refute Process.pid(Logger) == nil 24 | end 25 | 26 | test "returns for integer list" do 27 | assert Process.pid([0, 33, 0]) == pid(33) 28 | end 29 | 30 | test "returns for chart list" do 31 | assert Process.pid('<0.33.0>') == pid(33) 32 | end 33 | 34 | test "returns for integer tuple" do 35 | assert Process.pid({0, 33, 0}) == pid(33) 36 | end 37 | 38 | test "returns for string with #PID" do 39 | refute Process.pid("#PID<0.33.0>") == nil 40 | end 41 | 42 | test "returns for string without PID" do 43 | refute Process.pid("<0.33.0>") == nil 44 | end 45 | 46 | test "returns for string atom" do 47 | refute Process.pid("cowboy_sup") == nil 48 | end 49 | 50 | test "returns for string module" do 51 | refute Process.pid("Logger") == nil 52 | end 53 | 54 | test "returns for invalid" do 55 | assert Process.pid(4.5) == nil 56 | end 57 | end 58 | 59 | describe "pid!" do 60 | test "returns for correct value" do 61 | assert Process.pid!(33) == pid(33) 62 | end 63 | 64 | test "raised for invalid value" do 65 | assert_raise ArgumentError, fn -> Process.pid!(nil) end 66 | end 67 | end 68 | 69 | describe "list" do 70 | setup do 71 | logger_pid = Process.pid(Logger) 72 | 73 | info = 74 | Process.list 75 | |> Enum.find(nil, fn x -> x.pid == logger_pid end) 76 | 77 | [logger: info] 78 | end 79 | 80 | test "returns pid", context do 81 | %{pid: pid} = context[:logger] 82 | 83 | assert pid == Process.pid(Logger) 84 | end 85 | 86 | test "returns name", context do 87 | %{name: name} = context[:logger] 88 | 89 | assert name == Logger 90 | end 91 | 92 | test "returns init", context do 93 | %{init: init} = context[:logger] 94 | 95 | assert init == "gen_event.init_it/6" 96 | end 97 | 98 | test "returns current", context do 99 | %{current: current} = context[:logger] 100 | 101 | assert current == "gen_event.fetch_msg/5" 102 | end 103 | 104 | test "returns memory", context do 105 | %{memory: memory} = context[:logger] 106 | 107 | assert memory > 0 108 | end 109 | 110 | test "returns reductions", context do 111 | %{reductions: reductions} = context[:logger] 112 | 113 | assert reductions > 0 114 | end 115 | 116 | test "returns message_queue_length", context do 117 | %{message_queue_length: message_queue_length} = context[:logger] 118 | 119 | assert message_queue_length == 0 120 | end 121 | end 122 | 123 | describe "info" do 124 | test "returns :error for invalid" do 125 | assert Process.info(nil) == :error 126 | end 127 | 128 | test "returns pid" do 129 | %{pid: pid} = Process.info(Logger) 130 | 131 | assert pid == Process.pid(Logger) 132 | end 133 | 134 | test "returns name" do 135 | %{registered_name: registered_name} = Process.info(Logger) 136 | 137 | assert registered_name == Logger 138 | end 139 | test "returns priority" do 140 | %{priority: priority} = Process.info(Logger) 141 | 142 | assert priority == :normal 143 | end 144 | 145 | test "returns trap_exit" do 146 | %{trap_exit: trap_exit} = Process.info(Logger) 147 | 148 | assert trap_exit 149 | end 150 | 151 | test "returns message_queue_len" do 152 | %{message_queue_len: message_queue_len} = Process.info(Logger) 153 | 154 | assert message_queue_len == 0 155 | end 156 | 157 | test "returns error_handler" do 158 | %{error_handler: error_handler} = Process.info(Logger) 159 | 160 | assert error_handler == :error_handler 161 | end 162 | 163 | test "returns meta" do 164 | %{meta: meta} = Process.info(Logger) 165 | 166 | assert meta 167 | end 168 | end 169 | 170 | describe "meta" do 171 | setup do 172 | info = 173 | Logger 174 | |> Process.pid 175 | |> Process.meta 176 | 177 | [logger: info] 178 | end 179 | 180 | test "returns init", context do 181 | %{init: init} = context[:logger] 182 | 183 | assert init == "gen_event.init_it/6" 184 | end 185 | 186 | test "returns current", context do 187 | %{current: current} = context[:logger] 188 | 189 | assert current == "gen_event.fetch_msg/5" 190 | end 191 | 192 | test "returns status", context do 193 | %{status: status} = context[:logger] 194 | 195 | assert status == :waiting 196 | end 197 | 198 | test "returns class", context do 199 | %{class: class} = context[:logger] 200 | 201 | assert class == :gen_event 202 | end 203 | end 204 | 205 | describe "edge cases" do 206 | test "init in dictionary" do 207 | assert Process.initial_call([ 208 | current: Logger, 209 | status: :testing, 210 | class: :mock, 211 | dictionary: [{:"$initial_call", Logger}] 212 | ]) == Logger 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /lib/wobserver/util/node/discovery.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Node.Discovery do 2 | @moduledoc ~S""" 3 | Helps discovering other nodes to connect to. 4 | 5 | The method used can be set in the config file by setting: 6 | ```elixir 7 | config :wobserver, 8 | discovery: :dns, 9 | ``` 10 | 11 | The following methods can be used: (default: `:none`) 12 | 13 | - `:none`, just returns the local node. 14 | - `:dns`, use DNS to search for other nodes. 15 | The option `discovery_search` needs to be set to filter entries. 16 | - `:custom`, a function as String. 17 | 18 | # Example config 19 | 20 | No discovery: (is default) 21 | ```elixir 22 | config :wobserver, 23 | port: 80, 24 | discovery: :none 25 | ``` 26 | 27 | Using dns as discovery service: 28 | ```elixir 29 | config :wobserver, 30 | port: 80, 31 | discovery: :dns, 32 | discovery_search: "google.nl" 33 | ``` 34 | 35 | Using a custom function: 36 | ```elixir 37 | config :wobserver, 38 | port: 80, 39 | discovery: :custom, 40 | discovery_search: &MyApp.CustomDiscovery.discover/0 41 | ``` 42 | 43 | Using a anonymous function: 44 | ```elixir 45 | config :wobserver, 46 | port: 80, 47 | discovery: :custom, 48 | discovery_search: fn -> [] end 49 | ``` 50 | """ 51 | 52 | alias Wobserver.Util.Node.Remote 53 | 54 | # Finding 55 | 56 | @doc ~S""" 57 | Searches for a node in the discovered nodes. 58 | 59 | The `search` will be used to filter through names, hosts and host:port combinations. 60 | 61 | The special values for search are: 62 | - `local`, will always return the local node. 63 | """ 64 | @spec find(String.t) :: 65 | :local 66 | | :unknown 67 | | {:remote, Remote.t} 68 | def find("local"), do: :local 69 | 70 | def find(search) do 71 | found_node = 72 | Enum.find(discover(), fn %{name: name, host: host, port: port} -> 73 | search == name 74 | || search == host 75 | || search == "#{host}:#{port}" 76 | end) 77 | 78 | cond do 79 | found_node == nil -> :unknown 80 | local?(found_node) -> :local 81 | true -> {:remote, found_node} 82 | end 83 | end 84 | 85 | @doc ~S""" 86 | Retuns the local node. 87 | """ 88 | def local do 89 | discover() 90 | |> Enum.find(fn %{local?: is_local} -> is_local end) 91 | end 92 | 93 | # Discoverying 94 | 95 | @doc ~S""" 96 | Discovers other nodes to connect to. 97 | 98 | The method used can be set in the config file by setting: 99 | config :wobserver, 100 | discovery: :dns, 101 | 102 | The following methods can be used: (default: `:none`) 103 | - `:none`, just returns the local node. 104 | - `:dns`, use DNS to search for other nodes. 105 | The option `discovery_search` needs to be set to filter entries. 106 | - `:custom`, a function as String. 107 | 108 | # Example config 109 | No discovery: (is default) 110 | config :wobserver, 111 | discovery: :none 112 | 113 | Using dns: 114 | config :wobserver, 115 | discovery: :dns, 116 | discovery_search: "google.nl" 117 | 118 | Using a custom function: 119 | config :wobserver, 120 | discovery: :custom, 121 | discovery_search: "&MyApp.CustomDiscovery.discover/0" 122 | """ 123 | @spec discover :: list(Remote.t) 124 | def discover do 125 | nodes = 126 | :wobserver 127 | |> Application.get_env(:discovery, :none) 128 | |> discovery_call 129 | 130 | case Enum.find(nodes, fn %{local?: is_local} -> is_local end) do 131 | nil -> discovery_call(:none) ++ nodes 132 | _ -> nodes 133 | end 134 | end 135 | 136 | @spec discovery_call(:dns) :: list(Remote.t) 137 | defp discovery_call(:dns), do: dns_discover Application.get_env(:wobserver, :discovery_search, nil) 138 | 139 | @spec discovery_call(:custom) :: list(Remote.t) 140 | defp discovery_call(:custom) do 141 | method = Application.get_env :wobserver, :discovery_search, fn -> [] end 142 | 143 | cond do 144 | is_binary(method) -> 145 | {call, []} = Code.eval_string method 146 | 147 | call.() 148 | is_function(method) -> 149 | method.() 150 | true -> 151 | [] 152 | end 153 | end 154 | 155 | @spec discovery_call(:none) :: list(Remote.t) 156 | defp discovery_call(:none) do 157 | [ 158 | %Remote{ 159 | name: get_local_ip(), 160 | host: get_local_ip(), 161 | port: Wobserver.Application.port, 162 | local?: true, 163 | } 164 | ] 165 | end 166 | 167 | @spec dns_discover(search :: String.t) :: list(Remote.t) 168 | defp dns_discover(search) when is_binary(search) do 169 | search 170 | |> String.to_charlist 171 | |> :inet_res.lookup(:in, :a) 172 | |> Enum.map(&dns_to_node/1) 173 | end 174 | 175 | defp dns_discover(_), do: [] 176 | 177 | # Helpers 178 | 179 | defp local?(%{name: "local"}), do: true 180 | defp local?(%{host: "127.0.0.1", port: port}), 181 | do: port == Wobserver.Application.port 182 | defp local?(%{host: ip, port: port}), 183 | do: ip == get_local_ip() && port == Wobserver.Application.port 184 | defp local?(_), do: false 185 | 186 | defp ip_to_string({ip1, ip2, ip3, ip4}), do: "#{ip1}.#{ip2}.#{ip3}.#{ip4}" 187 | 188 | defp dns_to_node(ip) do 189 | remote_ip = ip_to_string(ip) 190 | 191 | remote = %Remote{ 192 | name: remote_ip, 193 | host: remote_ip, 194 | port: Wobserver.Application.port, 195 | local?: false, 196 | } 197 | 198 | %Remote{remote | local?: local?(remote)} 199 | end 200 | 201 | defp get_local_ip do 202 | # with {:ok, ips} <- :inet.getif(), 203 | # {ip, _, _} <- List.first(ips), 204 | # do: ip_to_string(ip) 205 | {:ok, ips} = :inet.getif() 206 | {ip, _, _} = List.first(ips) 207 | ip_to_string(ip) 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /test/wobserver/web/router/metrics_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Web.Router.MetricsTest do 2 | use ExUnit.Case, async: false 3 | use Plug.Test 4 | 5 | alias Wobserver.Util.Metrics.Formatter 6 | alias Wobserver.Util.Node.Remote 7 | alias Wobserver.Web.Router.Metrics 8 | 9 | @opts Metrics.init([]) 10 | 11 | def test_generator do 12 | [ 13 | task_bunny_queue: { 14 | [normal: 5], 15 | :gauge, 16 | "The amount of task bunny queues used" 17 | } 18 | ] 19 | end 20 | 21 | describe "/" do 22 | test "returns 200" do 23 | conn = conn(:get, "/") 24 | 25 | conn = Metrics.call(conn, @opts) 26 | 27 | assert conn.state == :sent 28 | assert conn.status == 200 29 | end 30 | 31 | test "returns 500 with failed format" do 32 | :meck.new Formatter, [:passthrough] 33 | :meck.expect Formatter, :format_all, fn _ -> :error end 34 | 35 | on_exit(fn -> :meck.unload end) 36 | 37 | conn = conn(:get, "/memory") 38 | 39 | conn = Metrics.call(conn, @opts) 40 | 41 | assert conn.state == :sent 42 | assert conn.status == 500 43 | end 44 | 45 | test "returns 200 with custom metrics" do 46 | :meck.new Application, [:passthrough] 47 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 48 | case option do 49 | :metrics -> 50 | [additional: [ 51 | task_bunny_queue: { 52 | [normal: 5], 53 | :gauge, 54 | "The amount of task bunny queues used" 55 | } 56 | ]] 57 | :metric_format -> Wobserver.Util.Metrics.Prometheus 58 | :discovery -> :none 59 | :port -> 4001 60 | end 61 | end 62 | 63 | on_exit(fn -> :meck.unload end) 64 | 65 | conn = conn(:get, "/") 66 | 67 | conn = Metrics.call(conn, @opts) 68 | 69 | assert conn.state == :sent 70 | assert conn.status == 200 71 | end 72 | 73 | test "returns 200 with custom generator" do 74 | :meck.new Application, [:passthrough] 75 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 76 | case option do 77 | :metrics -> 78 | [generators: [&Wobserver.Web.Router.MetricsTest.test_generator/0]] 79 | :metric_format -> Wobserver.Util.Metrics.Prometheus 80 | :discovery -> :none 81 | :port -> 4001 82 | end 83 | end 84 | 85 | on_exit(fn -> :meck.unload end) 86 | 87 | conn = conn(:get, "/") 88 | 89 | conn = Metrics.call(conn, @opts) 90 | 91 | assert conn.state == :sent 92 | assert conn.status == 200 93 | end 94 | 95 | test "returns 200 with invalid custom metrics" do 96 | :meck.new Application, [:passthrough] 97 | :meck.expect Application, :get_env, fn (:wobserver, option, _) -> 98 | case option do 99 | :metrics -> 100 | [additional: [ 101 | task_bunny_queue: { 102 | 5, 103 | :gauge, 104 | "The amount of task bunny queues used" 105 | } 106 | ]] 107 | :metric_format -> Wobserver.Util.Metrics.Prometheus 108 | :discovery -> :none 109 | :port -> 4001 110 | end 111 | end 112 | 113 | on_exit(fn -> :meck.unload end) 114 | 115 | conn = conn(:get, "/") 116 | 117 | conn = Metrics.call(conn, @opts) 118 | 119 | assert conn.state == :sent 120 | assert conn.status == 200 121 | end 122 | 123 | test "returns 500 if metrics can not be generated" do 124 | :meck.new Formatter, [:passthrough] 125 | :meck.expect Formatter, :merge_metrics, fn (_) -> :error end 126 | 127 | on_exit(fn -> :meck.unload end) 128 | 129 | conn = conn(:get, "/") 130 | 131 | conn = Metrics.call(conn, @opts) 132 | 133 | assert conn.state == :sent 134 | assert conn.status == 500 135 | end 136 | end 137 | 138 | describe "remote nodes" do 139 | test "/n/local" do 140 | conn = conn(:get, "/n/local") 141 | 142 | conn = Metrics.call(conn, @opts) 143 | 144 | assert conn.state == :sent 145 | assert conn.status == 200 146 | end 147 | 148 | test "/n/unknown" do 149 | conn = conn(:get, "/n/unknown") 150 | 151 | conn = Metrics.call(conn, @opts) 152 | 153 | assert conn.state == :sent 154 | assert conn.status == 404 155 | end 156 | 157 | test "/n/remote without error" do 158 | :meck.new Application, [:passthrough] 159 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 160 | case field do 161 | :discovery -> :custom 162 | :discovery_search -> fn -> [%Remote{name: "remote", host: "127.0.0.5", port: 4001}] end 163 | :port -> 4001 164 | end 165 | end 166 | 167 | :meck.new Remote, [:passthrough] 168 | :meck.expect Remote, :metrics, fn _ -> "ok" end 169 | 170 | on_exit(fn -> :meck.unload end) 171 | 172 | conn = conn(:get, "/n/remote") 173 | 174 | conn = Metrics.call(conn, @opts) 175 | 176 | assert conn.state == :sent 177 | assert conn.status == 200 178 | end 179 | 180 | test "/n/remote with error" do 181 | :meck.new Application, [:passthrough] 182 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 183 | case field do 184 | :discovery -> :custom 185 | :discovery_search -> fn -> [%Remote{name: "remote", host: "127.0.0.5", port: 4001}] end 186 | :port -> 4001 187 | end 188 | end 189 | 190 | :meck.new Remote, [:passthrough] 191 | :meck.expect Remote, :metrics, fn _ -> :error end 192 | 193 | on_exit(fn -> :meck.unload end) 194 | 195 | conn = conn(:get, "/n/remote") 196 | 197 | conn = Metrics.call(conn, @opts) 198 | 199 | assert conn.state == :sent 200 | assert conn.status == 500 201 | end 202 | end 203 | 204 | test "/memory" do 205 | conn = conn(:get, "/memory") 206 | 207 | conn = Metrics.call(conn, @opts) 208 | 209 | assert conn.state == :sent 210 | assert conn.status == 200 211 | end 212 | 213 | test "/io" do 214 | conn = conn(:get, "/io") 215 | 216 | conn = Metrics.call(conn, @opts) 217 | 218 | assert conn.state == :sent 219 | assert conn.status == 200 220 | end 221 | 222 | 223 | test "unknown url returns 404" do 224 | conn = conn(:get, "/unknown") 225 | 226 | conn = Metrics.call(conn, @opts) 227 | 228 | assert conn.state == :sent 229 | assert conn.status == 404 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /lib/wobserver/page.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Page do 2 | @moduledoc """ 3 | Page management for custom commands and pages in api and wobserver. 4 | """ 5 | 6 | alias Wobserver.Page 7 | 8 | @pages_table :wobserver_pages 9 | 10 | @typedoc ~S""" 11 | Accepted page formats. 12 | """ 13 | @type data :: 14 | Page.t 15 | | map 16 | | {String.t, atom, fun} 17 | | {String.t, atom, fun, boolean} 18 | 19 | @typedoc ~S""" 20 | Page structure. 21 | 22 | Fields: 23 | - `title`, the name of the page. Is used for the web interface menu. 24 | - `command`, single atom to associate the page with. 25 | - `callback`, function to be evaluated, when the a api is called or page is viewd. 26 | The result is converted to JSON and displayed. 27 | - `options`, map containing options for the page. 28 | 29 | Options: 30 | - `api_only` (`boolean`), if set to true the page won't show up in the web interface, but will only be available as API. 31 | - `refresh` (`float`, 0-1), sets the refresh time factor. Used in the web interface to refresh the data on the page. Set to `0` for no refresh. 32 | """ 33 | @type t :: %__MODULE__{ 34 | title: String.t, 35 | command: atom, 36 | callback: fun, 37 | options: keyword, 38 | } 39 | 40 | defstruct [ 41 | :title, 42 | :command, 43 | :callback, 44 | options: %{ 45 | api_only: false, 46 | refresh: 1, 47 | }, 48 | ] 49 | 50 | @doc ~S""" 51 | List all registered pages. 52 | 53 | For every page the following information is given: 54 | - `title` 55 | - `command` 56 | - `api_only` 57 | - `refresh` 58 | """ 59 | @spec list :: list(map) 60 | def list do 61 | ensure_table() 62 | 63 | @pages_table 64 | |> :ets.match(:"$1") 65 | |> Enum.map(fn [{command, %Page{title: title, options: options}}] -> 66 | %{ 67 | title: title, 68 | command: command, 69 | api_only: options.api_only, 70 | refresh: options.refresh, 71 | } 72 | end) 73 | end 74 | 75 | @doc ~S""" 76 | Find the page for a given `command` 77 | 78 | Returns `:page_not_found`, if no page can be found. 79 | """ 80 | @spec find(command :: atom) :: Page.t 81 | def find(command) do 82 | ensure_table() 83 | 84 | case :ets.lookup(@pages_table, command) do 85 | [{^command, page}] -> page 86 | _ -> :page_not_found 87 | end 88 | end 89 | 90 | @doc ~S""" 91 | Calls the function associated with the `command`/page. 92 | 93 | Returns the result of the function or `:page_not_found`, if the page can not be found. 94 | """ 95 | @spec call(Page.t | atom) :: any 96 | def call(:page_not_found), do: :page_not_found 97 | def call(%Page{callback: callback}), do: callback.() 98 | def call(command) when is_atom(command), do: command |> find() |> call() 99 | def call(_), do: :page_not_found 100 | 101 | @doc ~S""" 102 | Registers a `page` with `:wobserver`. 103 | 104 | Returns true if succesfully added. (otherwise false) 105 | 106 | The following inputs are accepted: 107 | - `{title, command, callback}` 108 | - `{title, command, callback, options}` 109 | - a `map` with the following fields: 110 | - `title` 111 | - `command` 112 | - `callback` 113 | - `options` (optional) 114 | 115 | The fields are used as followed: 116 | - `title`, the name of the page. Is used for the web interface menu. 117 | - `command`, single atom to associate the page with. 118 | - `callback`, function to be evaluated, when the a api is called or page is viewd. 119 | The result is converted to JSON and displayed. 120 | - `options`, options for the page. 121 | 122 | The following options can be set: 123 | - `api_only` (`boolean`), if set to true the page won't show up in the web interface, but will only be available as API. 124 | - `refresh` (`float`, 0-1), sets the refresh time factor. Used in the web interface to refresh the data on the page. Set to `0` for no refresh. 125 | """ 126 | @spec register(page :: Page.data) :: boolean 127 | def register(page) 128 | 129 | def register({title, command, callback}), 130 | do: register(title, command, callback) 131 | def register({title, command, callback, options}), 132 | do: register(title, command, callback, options) 133 | 134 | def register(page = %Page{}) do 135 | ensure_table() 136 | :ets.insert @pages_table, {page.command, page} 137 | end 138 | 139 | def register(%{title: t, command: command, callback: call, options: options}), 140 | do: register(t, command, call, options) 141 | def register(%{title: title, command: command, callback: callback}), 142 | do: register(title, command, callback) 143 | def register(_), do: false 144 | 145 | @doc ~S""" 146 | Registers a `page` with `:wobserver`. 147 | 148 | The arguments are used as followed: 149 | - `title`, the name of the page. Is used for the web interface menu. 150 | - `command`, single atom to associate the page with. 151 | - `callback`, function to be evaluated, when the a api is called or page is viewd. 152 | The result is converted to JSON and displayed. 153 | - `options`, options for the page. 154 | 155 | The following options can be set: 156 | - `api_only` (`boolean`), if set to true the page won't show up in the web interface, but will only be available as API. 157 | - `refresh` (`float`, 0-1), sets the refresh time factor. Used in the web interface to refresh the data on the page. Set to `0` for no refresh. 158 | """ 159 | @spec register( 160 | title :: String.t, 161 | command :: atom, 162 | callback :: fun, 163 | options :: keyword 164 | ) :: boolean 165 | def register(title, command, callback, options \\ []) do 166 | register(%Page{ 167 | title: title, 168 | command: command, 169 | callback: callback, 170 | options: %{ 171 | api_only: Keyword.get(options, :api_only, false), 172 | refresh: Keyword.get(options, :refresh, 1.0) 173 | }, 174 | }) 175 | end 176 | 177 | @doc ~S""" 178 | Loads custom pages from configuration and adds them to `:wobserver`. 179 | 180 | To add custom pages set the `:pages` option. 181 | The `:pages` option must be a list of page data. 182 | 183 | The page data can be formatted as: 184 | - `{title, command, callback}` 185 | - `{title, command, callback, options}` 186 | - a `map` with the following fields: 187 | - `title` 188 | - `command` 189 | - `callback` 190 | - `options` (optional) 191 | 192 | For more information and types see: `Wobserver.Page.register/1`. 193 | 194 | Example: 195 | ```elixir 196 | config :wobserver, 197 | pages: [ 198 | {"Example", :example, fn -> %{x: 9} end} 199 | ] 200 | ``` 201 | """ 202 | @spec load_config :: [any] 203 | def load_config do 204 | ensure_table() 205 | 206 | :wobserver 207 | |> Application.get_env(:pages, []) 208 | |> Enum.map(®ister/1) 209 | end 210 | 211 | # Helpers 212 | 213 | defp ensure_table do 214 | case :ets.info(@pages_table) do 215 | :undefined -> 216 | :ets.new @pages_table, [:named_table, :public] 217 | true 218 | _ -> 219 | true 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /test/wobserver/util/node/discovery_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Node.DiscoveryTest do 2 | use ExUnit.Case 3 | 4 | alias Wobserver.Util.Node.Remote 5 | alias Wobserver.Util.Node.Discovery 6 | 7 | def custom_search do 8 | [ 9 | %Remote{ 10 | name: "Remote 1", 11 | host: "192.168.1.34", 12 | port: 4001 13 | }, 14 | %Remote{ 15 | name: "Remote 2", 16 | host: "84.23.12.175", 17 | port: 4001 18 | } 19 | ] 20 | end 21 | 22 | describe "find" do 23 | test "returns local for \"local\"" do 24 | assert Discovery.find("local") == :local 25 | end 26 | 27 | test "returns unknown for \"garbage\"" do 28 | assert Discovery.find("garbage") == :unknown 29 | end 30 | 31 | test "returns remote_note for \"Remote 1\"" do 32 | ip_address = 33 | with {:ok, ips} <- :inet.getif(), 34 | {ip, _, _} <- List.first(ips), 35 | {ip1, ip2, ip3, ip4} <- ip, 36 | do: "#{ip1}.#{ip2}.#{ip3}.#{ip4}" 37 | 38 | :meck.new Application, [:passthrough] 39 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 40 | case field do 41 | :discovery -> :custom 42 | :discovery_search -> fn -> [%Remote{name: ip_address, host: ip_address, port: 0}] end 43 | :port -> 4001 44 | end 45 | end 46 | 47 | on_exit(fn -> :meck.unload end) 48 | 49 | assert Discovery.find(ip_address) == :local 50 | end 51 | 52 | test "returns :local for machine ip" do 53 | :meck.new Application, [:passthrough] 54 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 55 | case field do 56 | :discovery -> :custom 57 | :discovery_search -> "&Wobserver.Util.Node.DiscoveryTest.custom_search/0" 58 | :port -> 4001 59 | end 60 | end 61 | 62 | on_exit(fn -> :meck.unload end) 63 | 64 | {:remote, node} = Discovery.find("Remote 1") 65 | 66 | assert node.host == "192.168.1.34" 67 | end 68 | 69 | test "returns :local for 127.0.0.1 and matching port" do 70 | :meck.new Application, [:passthrough] 71 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 72 | case field do 73 | :discovery -> :custom 74 | :discovery_search -> fn -> [%Remote{name: "Remote 1", host: "127.0.0.1", port: 4001}] end 75 | :port -> 4001 76 | end 77 | end 78 | 79 | on_exit(fn -> :meck.unload end) 80 | 81 | assert Discovery.find("Remote 1") == :local 82 | end 83 | end 84 | 85 | describe "discover" do 86 | test "returns local with no config" do 87 | [%{ 88 | name: name, 89 | host: host, 90 | port: port, 91 | }] = Discovery.discover 92 | 93 | assert is_binary(name) 94 | assert is_binary(host) 95 | assert port > 0 96 | end 97 | 98 | test "returns local with config set to none" do 99 | :meck.new Application, [:passthrough] 100 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 101 | case field do 102 | :discovery -> :none 103 | :port -> 4001 104 | end 105 | end 106 | 107 | on_exit(fn -> :meck.unload end) 108 | 109 | [%{ 110 | name: name, 111 | host: host, 112 | port: port, 113 | }] = Discovery.discover 114 | 115 | assert is_binary(name) 116 | assert is_binary(host) 117 | assert port > 0 118 | end 119 | 120 | test "returns local with config set to dns and localhost." do 121 | :meck.new Application, [:passthrough] 122 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 123 | case field do 124 | :discovery -> :dns 125 | :discovery_search -> "localhost." 126 | :port -> 4001 127 | end 128 | end 129 | 130 | on_exit(fn -> :meck.unload end) 131 | 132 | [%{ 133 | name: name, 134 | host: host, 135 | port: port, 136 | }] = Discovery.discover 137 | 138 | assert name == host 139 | assert is_binary(host) 140 | assert port > 0 141 | end 142 | 143 | test "returns info with config set to dns and non local host" do 144 | :meck.new Application, [:passthrough] 145 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 146 | case field do 147 | :discovery -> :dns 148 | :discovery_search -> "disovery.services.local." 149 | :port -> 4001 150 | end 151 | end 152 | 153 | :meck.new :inet_res, [:unstick] 154 | :meck.expect :inet_res, :lookup, fn (_, _, _) -> 155 | {:ok, ips} = :inet.getif() 156 | {ip, _, _} = List.first(ips) 157 | [ip] 158 | end 159 | 160 | on_exit(fn -> :meck.unload end) 161 | 162 | [%{ 163 | name: name, 164 | host: host, 165 | port: port, 166 | }] = Discovery.discover 167 | 168 | assert is_binary(name) 169 | assert is_binary(host) 170 | assert port > 0 171 | end 172 | 173 | test "returns nodes with config set to custom function as String" do 174 | :meck.new Application, [:passthrough] 175 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 176 | case field do 177 | :discovery -> :custom 178 | :discovery_search -> "&Wobserver.Util.Node.DiscoveryTest.custom_search/0" 179 | :port -> 4001 180 | end 181 | end 182 | 183 | on_exit(fn -> :meck.unload end) 184 | 185 | [_, remote, _] = Discovery.discover 186 | 187 | assert remote.name == "Remote 1" 188 | end 189 | 190 | test "returns nodes with config set to custom function" do 191 | :meck.new Application, [:passthrough] 192 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 193 | case field do 194 | :discovery -> :custom 195 | :discovery_search -> &Wobserver.Util.Node.DiscoveryTest.custom_search/0 196 | :port -> 4001 197 | end 198 | end 199 | 200 | on_exit(fn -> :meck.unload end) 201 | 202 | [_, remote, _] = Discovery.discover 203 | 204 | assert remote.name == "Remote 1" 205 | end 206 | 207 | test "returns nodes with config set to lambda function as String" do 208 | :meck.new Application, [:passthrough] 209 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 210 | case field do 211 | :discovery -> :custom 212 | :discovery_search -> "fn -> [%Wobserver.Util.Node.Remote{name: \"Remote 1\", host: nil, port: 0}] end" 213 | :port -> 4001 214 | end 215 | end 216 | 217 | on_exit(fn -> :meck.unload end) 218 | 219 | [_, remote] = Discovery.discover 220 | 221 | assert remote.name == "Remote 1" 222 | end 223 | 224 | test "returns nodes with config set to lambda function" do 225 | :meck.new Application, [:passthrough] 226 | :meck.expect Application, :get_env, fn (:wobserver, field, _) -> 227 | case field do 228 | :discovery -> :custom 229 | :discovery_search -> fn -> [%Remote{name: "Remote 1", host: nil, port: 0}] end 230 | :port -> 4001 231 | end 232 | end 233 | 234 | on_exit(fn -> :meck.unload end) 235 | 236 | [_, remote] = Discovery.discover 237 | 238 | assert remote.name == "Remote 1" 239 | end 240 | end 241 | end 242 | -------------------------------------------------------------------------------- /lib/wobserver/util/metrics/formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Metrics.Formatter do 2 | @moduledoc ~S""" 3 | Formatter. 4 | """ 5 | 6 | alias Wobserver.Util.Node.Discovery 7 | 8 | @doc ~S""" 9 | Format a set of `data` with a `label`. 10 | 11 | The `data` must be given as a `list` of tuples with the following format: `{value, labels}`, where `labels` is a keyword list with labels and their values. 12 | 13 | The following options can also be given: 14 | - `type`, the type of the metric. The following values are currently supported: `:gauge`, `:counter`. 15 | - `help`, a single line text description of the metric. 16 | """ 17 | @callback format_data( 18 | name :: String.t, 19 | data :: [{integer | float, keyword}], 20 | type :: :atom, 21 | help :: String.t 22 | ) :: String.t 23 | 24 | @doc ~S""" 25 | Combines formatted metrics together. 26 | 27 | Arguments: 28 | - `metrics`, a list of formatted metrics for one node. 29 | """ 30 | @callback combine_metrics( 31 | metrics :: list[String.t] 32 | ) :: String.t 33 | 34 | @doc ~S""" 35 | Merges formatted sets of metrics from different nodes together. 36 | 37 | The merge should prevent double declarations of help and type. 38 | 39 | Arguments: 40 | - `metrics`, a list of formatted sets metrics for multiple node. 41 | """ 42 | @callback merge_metrics( 43 | metrics :: list[String.t] 44 | ) :: String.t 45 | 46 | @doc ~S""" 47 | Format a set of `data` with a `label` for a metric parser/aggregater. 48 | 49 | The following options can also be given: 50 | - `type`, the type of the metric. The following values are currently supported: `:gauge`, `:counter`. 51 | - `help`, a single line text description of the metric. 52 | - `formatter`, a module implementing the `Formatter` behaviour to format metrics. 53 | """ 54 | @spec format( 55 | data :: any, 56 | label :: String.t, 57 | type :: :gauge | :counter | nil, 58 | help :: String.t | nil, 59 | formatter :: atom | nil 60 | ) :: String.t | :error 61 | def format(data, label, type \\ nil, help \\ nil, formatter \\ nil) 62 | 63 | def format(data, label, type, help, nil) do 64 | format(data, label, type, help, Application.get_env( 65 | :wobserver, 66 | :metric_format, 67 | Wobserver.Util.Metrics.Prometheus 68 | )) 69 | end 70 | 71 | def format(data, label, type, help, formatter) when is_binary(formatter) do 72 | {new_formatter, []} = Code.eval_string formatter 73 | 74 | format(data, label, type, help, new_formatter) 75 | end 76 | 77 | def format(data, label, type, help, formatter) 78 | when is_integer(data) or is_float(data) do 79 | format([{data, []}], label, type, help, formatter) 80 | end 81 | 82 | def format(data, label, type, help, formatter) when is_map(data) do 83 | data 84 | |> map_to_data() 85 | |> format(label, type, help, formatter) 86 | end 87 | 88 | def format(data, label, type, help, formatter) when is_binary(data) do 89 | {new_data, []} = Code.eval_string data 90 | 91 | format(new_data, label, type, help, formatter) 92 | catch 93 | :error, _ -> :error 94 | end 95 | 96 | def format(data, label, type, help, formatter) when is_function(data) do 97 | data.() 98 | |> format(label, type, help, formatter) 99 | end 100 | 101 | def format(data, label, type, help, formatter) do 102 | cond do 103 | Keyword.keyword?(data) -> 104 | data 105 | |> list_to_data() 106 | |> format(label, type, help, formatter) 107 | is_list(data) -> 108 | data = 109 | data 110 | |> Enum.map(fn {value, labels} -> 111 | {value, Keyword.merge([node: Discovery.local.name], labels)} 112 | end) 113 | 114 | formatter.format_data(label, data, type, help) 115 | true -> 116 | :error 117 | end 118 | end 119 | 120 | @doc ~S""" 121 | Formats a keyword list of metrics using a given `formatter`. 122 | 123 | **Metrics** 124 | 125 | The key is the name of the metric and the value can be given in the following formats: 126 | - `data` 127 | - `{data, type}` 128 | - `{data, type, help}` 129 | 130 | The different fields are: 131 | - `data`, the actual metrics information. 132 | - `type`, the type of the metric. 133 | Possible values: `:gauge`, `:counter`. 134 | - `help`, a one line text description of the metric. 135 | 136 | The `data` can be given in the following formats: 137 | - `integer` | `float`, just a single value. 138 | - `map`, where every key will be turned into a type value. 139 | - `keyword` list, where every key will be turned into a type value 140 | - `list` of tuples with the following format: `{value, labels}`, where `labels` is a keyword list with labels and their values. 141 | - `function` | `string`, a function or String that can be evaluated to a function, which, when called, returns one of the above data-types. 142 | 143 | Example: 144 | ```elixir 145 | iex> Wobserver.Util.Metrics.Formatter.format_all [simple: 5] 146 | "simple{node=\"10.74.181.35\"} 5\n" 147 | ``` 148 | 149 | ```elixir 150 | iex> Wobserver.Util.Metrics.Formatter.format_all [simple: {5, :gauge}] 151 | "# TYPE simple gauge\nsimple{node=\"10.74.181.35\"} 5\n" 152 | ``` 153 | 154 | ```elixir 155 | iex> Wobserver.Util.Metrics.Formatter.format_all [simple: {5, :gauge, "Example desc."}] 156 | "# HELP simple Example desc.\n 157 | # TYPE simple gauge\n 158 | simple{node=\"10.74.181.35\"} 5\n" 159 | ``` 160 | 161 | ```elixir 162 | iex> Wobserver.Util.Metrics.Formatter.format_all [simple: %{floor: 5, wall: 8}] 163 | "simple{node=\"10.74.181.35\",type=\"floor\"} 5\n 164 | simple{node=\"10.74.181.35\",type=\"wall\"} 8\n" 165 | ``` 166 | 167 | ```elixir 168 | iex> Wobserver.Util.Metrics.Formatter.format_all [simple: [floor: 5, wall: 8]] 169 | "simple{node=\"10.74.181.35\",type=\"floor\"} 5\n 170 | simple{node=\"10.74.181.35\",type=\"wall\"} 8\n" 171 | ``` 172 | 173 | ```elixir 174 | iex> Wobserver.Util.Metrics.Formatter.format_all [simple: [{5, [location: :floor]}, {8, [location: :wall]}]] 175 | "simple{node=\"10.74.181.35\",location=\"floor\"} 5\n 176 | simple{node=\"10.74.181.35\",location=\"wall\"} 8\n" 177 | ``` 178 | """ 179 | @spec format_all(data :: list, formatter :: atom) :: String.t | :error 180 | def format_all(data, formatter \\ nil) 181 | 182 | def format_all(data, nil) do 183 | format_all(data, Application.get_env( 184 | :wobserver, 185 | :metric_format, 186 | Wobserver.Util.Metrics.Prometheus 187 | )) 188 | end 189 | 190 | def format_all(data, formatter) do 191 | formatted = 192 | data 193 | |> Enum.map(&helper(&1, formatter)) 194 | 195 | case Enum.member?(formatted, :error) do 196 | true -> :error 197 | _ -> formatted |> formatter.combine_metrics 198 | end 199 | end 200 | 201 | @doc ~S""" 202 | Merges formatted sets of metrics from different nodes together using a given `formatter`. 203 | 204 | The merge should prevent double declarations of help and type. 205 | 206 | Arguments: 207 | - `metrics`, a list of formatted sets metrics for multiple node. 208 | """ 209 | @spec merge_metrics(metrics :: list(String.t), formatter :: atom) 210 | :: String.t | :error 211 | def merge_metrics(metrics, formatter \\ nil) 212 | 213 | def merge_metrics(metrics, nil) do 214 | merge_metrics(metrics, 215 | Application.get_env( 216 | :wobserver, 217 | :metric_format, 218 | Wobserver.Util.Metrics.Prometheus 219 | ) 220 | ) 221 | end 222 | 223 | def merge_metrics(metrics, formatter) do 224 | # Old way: One node error, means no metrics 225 | # 226 | # case Enum.member?(metrics, :error) do 227 | # true -> :error 228 | # false -> formatter.merge_metrics(metrics) 229 | # end 230 | metrics 231 | |> Enum.filter(fn m -> m != :error end) 232 | |> formatter.merge_metrics 233 | end 234 | 235 | # Helpers 236 | 237 | defp helper({key, {data, type, help}}, formatter) do 238 | format(data, Atom.to_string(key), type, help, formatter) 239 | end 240 | 241 | defp helper({key, {data, type}}, formatter) do 242 | format(data, Atom.to_string(key), type, nil, formatter) 243 | end 244 | 245 | defp helper({key, data}, formatter) do 246 | format(data, Atom.to_string(key), nil, nil, formatter) 247 | end 248 | 249 | defp map_to_data(map) do 250 | map 251 | |> Map.to_list 252 | |> Enum.filter(fn {a, _} -> a != :__struct__ && a != :total end) 253 | |> list_to_data() 254 | end 255 | 256 | defp list_to_data(list) do 257 | list 258 | |> Enum.map(fn {key, value} -> {value, [type: key]} end) 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /lib/wobserver/util/process.ex: -------------------------------------------------------------------------------- 1 | defmodule Wobserver.Util.Process do 2 | @moduledoc ~S""" 3 | Process and pid handling. 4 | """ 5 | 6 | import Wobserver.Util.Helper, only: [string_to_module: 1, format_function: 1] 7 | 8 | @process_summary [ 9 | :registered_name, 10 | :initial_call, 11 | :memory, 12 | :reductions, 13 | :current_function, 14 | :message_queue_len, 15 | :dictionary, 16 | ] 17 | 18 | @process_full [ 19 | :registered_name, 20 | :priority, 21 | :trap_exit, 22 | :initial_call, 23 | :current_function, 24 | :message_queue_len, 25 | :error_handler, 26 | :group_leader, 27 | :links, 28 | :memory, 29 | :total_heap_size, 30 | :heap_size, 31 | :stack_size, 32 | :min_heap_size, 33 | :garbage_collection, 34 | :status, 35 | :dictionary, 36 | ] 37 | 38 | @process_meta [ 39 | :initial_call, 40 | :current_function, 41 | :status, 42 | :dictionary, 43 | ] 44 | 45 | @doc ~S""" 46 | Turns the argument `pid` into a pid or if not possible returns `nil`. 47 | 48 | It will accept: 49 | - pids 50 | - atoms / module names (registered processes) 51 | - single integers 52 | - a list of 3 integers 53 | - a tuple of 3 integers 54 | - a charlist in the format: `'<0.0.0>'` 55 | - a String in the following formats: 56 | - `"#PID<0.0.0>"` 57 | - `"<0.0.0>"` 58 | - atom / module name 59 | 60 | Example: 61 | ```bash 62 | iex> Wobserver.Util.Process.pid pid(0, 33, 0) 63 | #PID<0.33.0> 64 | ``` 65 | ```bash 66 | iex> Wobserver.Util.Process.pid :cowboy_sup 67 | #PID<0.253.0> 68 | ``` 69 | ```bash 70 | iex> Wobserver.Util.Process.pid Logger 71 | #PID<0.213.0> 72 | ``` 73 | ```bash 74 | iex> Wobserver.Util.Process.pid 33 75 | #PID<0.33.0> 76 | ``` 77 | ```bash 78 | iex> Wobserver.Util.Process.pid [0, 33, 0] 79 | #PID<0.33.0> 80 | ``` 81 | ```bash 82 | iex> Wobserver.Util.Process.pid '<0.33.0>' 83 | #PID<0.33.0> 84 | ``` 85 | ```bash 86 | iex> Wobserver.Util.Process.pid {0, 33, 0} 87 | #PID<0.33.0> 88 | ``` 89 | ```bash 90 | iex> Wobserver.Util.Process.pid "#PID<0.33.0>" 91 | #PID<0.33.0> 92 | ``` 93 | ```bash 94 | iex> Wobserver.Util.Process.pid "<0.33.0>" 95 | #PID<0.33.0> 96 | ``` 97 | ```bash 98 | iex> Wobserver.Util.Process.pid "cowboy_sup" 99 | #PID<0.253.0> 100 | ``` 101 | ```bash 102 | iex> Wobserver.Util.Process.pid "Logger" 103 | #PID<0.213.0> 104 | ``` 105 | ```bash 106 | iex> Wobserver.Util.Process.pid 4.5 107 | nil 108 | ``` 109 | """ 110 | @spec pid( 111 | pid :: pid | atom | list | binary | integer | {integer, integer, integer} 112 | ) :: pid | nil 113 | def pid(pid) 114 | 115 | def pid(pid) when is_pid(pid), do: pid 116 | def pid(pid) when is_atom(pid), do: Process.whereis pid 117 | def pid(pid) when is_integer(pid), do: pid "<0.#{pid}.0>" 118 | def pid([a, b, c]), do: pid "<#{a}.#{b}.#{c}>" 119 | def pid(pid) when is_list(pid), do: :erlang.list_to_pid(pid) 120 | def pid({a, b, c}), do: pid "<#{a}.#{b}.#{c}>" 121 | def pid("#PID" <> pid), do: pid |> String.to_charlist |> pid() 122 | def pid(pid = ("<" <> _)), do: pid |> String.to_charlist |> pid() 123 | def pid(pid) when is_binary(pid), do: pid |> string_to_module() |> pid() 124 | def pid(_), do: nil 125 | 126 | @doc ~S""" 127 | Turns the argument `pid` into a pid or if not possible raises error. 128 | 129 | For example see: `Wobserver.Util.Process.pid/1`. 130 | """ 131 | @spec pid!( 132 | pid :: pid | list | binary | integer | {integer, integer, integer} 133 | ) :: pid 134 | def pid!(pid) do 135 | case pid(pid) do 136 | nil -> 137 | raise ArgumentError, message: "Can not convert #{inspect pid} to pid." 138 | p -> 139 | p 140 | end 141 | end 142 | 143 | @doc ~S""" 144 | Creates a complete overview of process stats based on the given `pid`. 145 | 146 | Including but not limited to: 147 | - `id`, the process pid 148 | - `name`, the registered name or `nil`. 149 | - `init`, initial function or name. 150 | - `current`, current function. 151 | - `memory`, the total amount of memory used by the process. 152 | - `reductions`, the amount of reductions. 153 | - `message_queue_length`, the amount of unprocessed messages for the process., 154 | """ 155 | @spec info( 156 | pid :: pid | list | binary | integer | {integer, integer, integer} 157 | ) :: :error | map 158 | def info(pid) do 159 | pid 160 | |> pid() 161 | |> process_info(@process_full, &structure_full/2) 162 | end 163 | 164 | @doc ~S""" 165 | Retreives a list of process summaries. 166 | 167 | Every summary contains: 168 | - `id`, the process pid. 169 | - `name`, the registered name or `nil`. 170 | - `init`, initial function or name. 171 | - `current`, current function. 172 | - `memory`, the total amount of memory used by the process. 173 | - `reductions`, the amount of reductions. 174 | - `message_queue_length`, the amount of unprocessed messages for the process. 175 | """ 176 | @spec list :: list(map) 177 | def list do 178 | :erlang.processes 179 | |> Enum.map(&summary/1) 180 | end 181 | 182 | @doc ~S""" 183 | Creates formatted meta information about the process based on the given `pid`. 184 | 185 | The information contains: 186 | - `init`, initial function or name. 187 | - `current`, current function. 188 | - `status`, process status. 189 | 190 | """ 191 | @spec meta(pid :: pid) :: map 192 | def meta(pid), 193 | do: pid |> process_info(@process_meta, &structure_meta/2) 194 | 195 | @doc ~S""" 196 | Creates formatted summary about the process based on the given `pid`. 197 | 198 | Every summary contains: 199 | - `id`, the process pid. 200 | - `name`, the registered name or `nil`. 201 | - `init`, initial function or name. 202 | - `current`, current function. 203 | - `memory`, the total amount of memory used by the process. 204 | - `reductions`, the amount of reductions. 205 | - `message_queue_length`, the amount of unprocessed messages for the process. 206 | 207 | """ 208 | @spec summary(pid :: pid) :: map 209 | def summary(pid), 210 | do: pid |> process_info(@process_summary, &structure_summary/2) 211 | 212 | # Helpers 213 | 214 | defp process_info(nil, _, _), do: :error 215 | 216 | defp process_info(pid, information, structurer) do 217 | case :erlang.process_info(pid, information) do 218 | :undefined -> :error 219 | data -> structurer.(data, pid) 220 | end 221 | end 222 | 223 | defp process_status_module(pid) do 224 | {:status, ^pid, {:module, class}, _} = :sys.get_status(pid, 100) 225 | class 226 | catch 227 | _, _ -> :unknown 228 | end 229 | 230 | defp state(pid) do 231 | :sys.get_state(pid, 100) 232 | catch 233 | _, _ -> :unknown 234 | end 235 | 236 | @doc false 237 | @spec initial_call(data :: keyword) :: {atom, atom, integer} | atom 238 | def initial_call(data) do 239 | dictionary_init = 240 | data 241 | |> Keyword.get(:dictionary, []) 242 | |> Keyword.get(:"$initial_call", nil) 243 | 244 | case dictionary_init do 245 | nil -> 246 | Keyword.get(data, :initial_call, nil) 247 | call -> 248 | call 249 | end 250 | end 251 | 252 | # Structurers 253 | 254 | defp structure_summary(data, pid) do 255 | process_name = 256 | case Keyword.get(data, :registered_name, []) do 257 | [] -> nil 258 | name -> name 259 | end 260 | 261 | %{ 262 | pid: pid, 263 | name: process_name, 264 | init: format_function(initial_call(data)), 265 | current: format_function(Keyword.get(data, :current_function, nil)), 266 | memory: Keyword.get(data, :memory, 0), 267 | reductions: Keyword.get(data, :reductions, 0), 268 | message_queue_length: Keyword.get(data, :message_queue_len, 0), 269 | } 270 | end 271 | 272 | defp structure_full(data, pid) do 273 | gc = Keyword.get(data, :garbage_collection, []) 274 | dictionary = Keyword.get(data, :dictionary) 275 | 276 | %{ 277 | pid: pid, 278 | registered_name: Keyword.get(data, :registered_name, nil), 279 | priority: Keyword.get(data, :priority, :normal), 280 | trap_exit: Keyword.get(data, :trap_exit, false), 281 | message_queue_len: Keyword.get(data, :message_queue_len, 0), 282 | error_handler: Keyword.get(data, :error_handler, :none), 283 | relations: %{ 284 | group_leader: Keyword.get(data, :group_leader, nil), 285 | ancestors: Keyword.get(dictionary, :"$ancestors", []), 286 | links: Keyword.get(data, :links, nil), 287 | }, 288 | memory: %{ 289 | total: Keyword.get(data, :memory, 0), 290 | stack_and_heap: Keyword.get(data, :total_heap_size, 0), 291 | heap_size: Keyword.get(data, :heap_size, 0), 292 | stack_size: Keyword.get(data, :stack_size, 0), 293 | gc_min_heap_size: Keyword.get(gc, :min_heap_size, 0), 294 | gc_full_sweep_after: Keyword.get(gc, :fullsweep_after, 0), 295 | }, 296 | meta: structure_meta(data, pid), 297 | state: to_string(:io_lib.format("~tp", [(state(pid))])), 298 | } 299 | end 300 | 301 | defp structure_meta(data, pid) do 302 | init = initial_call(data) 303 | 304 | class = 305 | case init do 306 | {:supervisor, _, _} -> :supervisor 307 | {:application_master, _, _} -> :application 308 | _ -> process_status_module(pid) 309 | end 310 | 311 | %{ 312 | init: format_function(init), 313 | current: format_function(Keyword.get(data, :current_function)), 314 | status: Keyword.get(data, :status), 315 | class: class, 316 | } 317 | end 318 | end 319 | --------------------------------------------------------------------------------