├── 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 |
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 |
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 |
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: ''
81 | +'<% for (var i=0; i'
82 | +'- '
83 | +'\">'
84 | +'<% if (datasets[i].label) { %><%= datasets[i].label %><% } %>'
85 | +'
'
86 | +'<% } %>'
87 | +'
'
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 |
--------------------------------------------------------------------------------