Initial commit: version 0.0.3
This commit is contained in:
commit
5d5725a7c1
19 changed files with 771 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
pkg/
|
||||||
|
._*
|
||||||
|
.sass-cache/
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
.*.swp
|
||||||
|
*.log
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2011 Kyle Kingsbury
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
50
README.markdown
Normal file
50
README.markdown
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
Installing
|
||||||
|
==========
|
||||||
|
|
||||||
|
gem install reimann-client
|
||||||
|
|
||||||
|
Use
|
||||||
|
===
|
||||||
|
|
||||||
|
``` ruby
|
||||||
|
require 'reimann/client'
|
||||||
|
|
||||||
|
# Create a client. Host and port are optional.
|
||||||
|
c = Reimann::Client.new host: 'localhost', port: 5555
|
||||||
|
|
||||||
|
# Send a simple event
|
||||||
|
c << {service: 'testing', metric: 2.5}
|
||||||
|
|
||||||
|
# Or a more complex one
|
||||||
|
c << {
|
||||||
|
host: 'web3',
|
||||||
|
service: 'api latency',
|
||||||
|
state: 'warn',
|
||||||
|
metric: 63.5
|
||||||
|
description: "63.5 milliseconds per request"
|
||||||
|
time: Time.now.to_i - 10
|
||||||
|
}
|
||||||
|
|
||||||
|
# :host defaults to gethostname(). :time defaults to current unix time. You
|
||||||
|
# can explicitly override host...
|
||||||
|
|
||||||
|
c << {host: nil, service: 'the cloud', state: 'nebulous'}
|
||||||
|
|
||||||
|
# Get all the states from the server
|
||||||
|
c['true']
|
||||||
|
|
||||||
|
# Or specific states matching a query
|
||||||
|
c['host =~ "%.dc1" and (state = "critical" or state = "warning")']
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Client state management
|
||||||
|
=======================
|
||||||
|
|
||||||
|
Reimann::Client provides some classes to make managing state updates easier.
|
||||||
|
|
||||||
|
Reimann::MetricThread starts a thread to poll a metric periodically, which can
|
||||||
|
be used to flush an accumulated value to ustate at regular intervals.
|
||||||
|
|
||||||
|
Reimann::AutoState bundles a state and a client together. Any changes to the
|
||||||
|
AutoState automatically send the new state to the client.
|
48
Rakefile.rb
Normal file
48
Rakefile.rb
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
$:.unshift(File.join(File.dirname(__FILE__), 'lib'))
|
||||||
|
|
||||||
|
require 'rubygems'
|
||||||
|
require 'rubygems/package_task'
|
||||||
|
require 'rdoc/task'
|
||||||
|
require 'reimann/dash/version'
|
||||||
|
require 'find'
|
||||||
|
|
||||||
|
# Don't include resource forks in tarballs on Mac OS X.
|
||||||
|
ENV['COPY_EXTENDED_ATTRIBUTES_DISABLE'] = 'true'
|
||||||
|
ENV['COPYFILE_DISABLE'] = 'true'
|
||||||
|
|
||||||
|
# Gemspec
|
||||||
|
gemspec = Gem::Specification.new do |s|
|
||||||
|
s.rubyforge_project = 'reimann-dash'
|
||||||
|
|
||||||
|
s.name = 'reimann-dash'
|
||||||
|
s.version = Reimann::Dash::VERSION
|
||||||
|
s.author = 'Kyle Kingsbury'
|
||||||
|
s.email = 'aphyr@aphyr.com'
|
||||||
|
s.homepage = 'https://github.com/aphyr/reimann-dash'
|
||||||
|
s.platform = Gem::Platform::RUBY
|
||||||
|
s.summary = 'HTTP dashboard for the distributed event system Reimann.'
|
||||||
|
|
||||||
|
s.add_dependency 'reimann-client', '>= 0.0.3'
|
||||||
|
s.add_dependency 'erubis', '>= 2.7.0'
|
||||||
|
s.add_dependency 'sinatra', '>= 1.3.2'
|
||||||
|
s.add_dependency 'sass', '>= 3.1.14'
|
||||||
|
s.add_dependency 'thin', '>= 1.3.1'
|
||||||
|
|
||||||
|
s.files = FileList['lib/**/*', 'bin/*', 'LICENSE', 'README.markdown'].to_a
|
||||||
|
s.executables << 'reimann-dash'
|
||||||
|
s.require_path = 'lib'
|
||||||
|
s.has_rdoc = true
|
||||||
|
|
||||||
|
s.required_ruby_version = '>= 1.9.1'
|
||||||
|
end
|
||||||
|
|
||||||
|
Gem::PackageTask.new gemspec do |p|
|
||||||
|
end
|
||||||
|
|
||||||
|
RDoc::Task.new do |rd|
|
||||||
|
rd.main = 'Reimann Dash'
|
||||||
|
rd.title = 'Reimann Dash'
|
||||||
|
rd.rdoc_dir = 'doc'
|
||||||
|
|
||||||
|
rd.rdoc_files.include('lib/**/*.rb')
|
||||||
|
end
|
7
bin/reimann-dash
Executable file
7
bin/reimann-dash
Executable file
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
$LOAD_PATH.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
|
||||||
|
require 'reimann/dash'
|
||||||
|
|
||||||
|
Reimann::Dash.load ARGV.first
|
||||||
|
Reimann::Dash.run!
|
14
example/config.rb
Normal file
14
example/config.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# Serve HTTP traffic on this port
|
||||||
|
set :port, 5000
|
||||||
|
|
||||||
|
# Talk to this Reimann server
|
||||||
|
config[:client][:host] = '123.45.67.8'
|
||||||
|
|
||||||
|
# Add custom controllers in controller/
|
||||||
|
config[:controllers] << 'controller'
|
||||||
|
|
||||||
|
# Use the local view directory instead of the default
|
||||||
|
config[:view] = 'view'
|
||||||
|
|
||||||
|
# Serve static files from this directory
|
||||||
|
public_dir 'public'
|
99
example/controller/graphs.rb
Normal file
99
example/controller/graphs.rb
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
class UState::Dash
|
||||||
|
PARAMS = [
|
||||||
|
'target',
|
||||||
|
'height',
|
||||||
|
'width',
|
||||||
|
'areaMode',
|
||||||
|
'from',
|
||||||
|
'title'
|
||||||
|
]
|
||||||
|
|
||||||
|
D = lambda do |x|
|
||||||
|
"scale(summarize(derivative(#{x}), \"1h\"), 24)"
|
||||||
|
end
|
||||||
|
|
||||||
|
TYPES = {
|
||||||
|
'api' =>
|
||||||
|
[
|
||||||
|
{'title' => 'Request Rate', 'target' => 'api.rate'},
|
||||||
|
{'title' => 'Request Latency', 'target' => [
|
||||||
|
'api.50',
|
||||||
|
'api.95',
|
||||||
|
'api.99'
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
'health' =>
|
||||||
|
[
|
||||||
|
{'title' => 'Memory', 'target' => '*.*.memory'},
|
||||||
|
{'title' => 'CPU', 'target' => '*.*.cpu'},
|
||||||
|
{'title' => 'Load', 'target' => '*.*.load'}
|
||||||
|
],
|
||||||
|
'riak' =>
|
||||||
|
[
|
||||||
|
{'title' => 'Gets', 'target' => '*.*.riak.node_gets', 'areaMode' => 'stacked'},
|
||||||
|
{'title' => 'Puts', 'target' => '*.*.riak.node_puts', 'areaMode' => 'stacked'},
|
||||||
|
{'title' => 'Get Latency', 'target' => 'riak.get.*'},
|
||||||
|
{'title' => 'Put Latency', 'target' => 'riak.put.*'},
|
||||||
|
{'title' => 'Disk', 'target' => '*.*.riak.disk'},
|
||||||
|
{'title' => 'Repairs', 'target' => '*.*.riak.read_repairs'},
|
||||||
|
{'title' => 'Keys', 'target' => '*.*.riak.keys'}
|
||||||
|
],
|
||||||
|
'ustate' =>
|
||||||
|
[
|
||||||
|
{'title' => 'Insert Rate', 'target' => '*.*.ustate.insert.rate'},
|
||||||
|
{'title' => 'Insert Latency', 'target' => [
|
||||||
|
'*.*.ustate.insert.50',
|
||||||
|
'*.*.ustate.insert.95',
|
||||||
|
'*.*.ustate.insert.99'
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def graphite(h = {})
|
||||||
|
"http://graphite/render" + '?' + graph_opts(h)
|
||||||
|
end
|
||||||
|
|
||||||
|
def graph_opts(h = {})
|
||||||
|
o = {
|
||||||
|
'hideLegend' => 'false'
|
||||||
|
}.merge h.select { |k, v|
|
||||||
|
PARAMS.include? k.to_s
|
||||||
|
}
|
||||||
|
|
||||||
|
o.inject([]) { |unpacked, pair|
|
||||||
|
case pair[1]
|
||||||
|
when Array
|
||||||
|
unpacked + pair[1].map { |e| [pair[0], e] }
|
||||||
|
else
|
||||||
|
unpacked << pair
|
||||||
|
end
|
||||||
|
}.map { |k, v|
|
||||||
|
"#{Rack::Utils.escape(k)}=#{Rack::Utils.escape(v)}"
|
||||||
|
}.join('&')
|
||||||
|
end
|
||||||
|
|
||||||
|
get '/graph' do
|
||||||
|
redirect graphite request.params
|
||||||
|
end
|
||||||
|
|
||||||
|
get '/graphs' do
|
||||||
|
redirect '/graphs/tablet'
|
||||||
|
end
|
||||||
|
|
||||||
|
get '/graphs/*' do |type|
|
||||||
|
@types = %w(health riak api ustate)
|
||||||
|
@type = @title = type
|
||||||
|
@graphs = case type
|
||||||
|
when 'all'
|
||||||
|
TYPES.values.inject(:|)
|
||||||
|
else
|
||||||
|
TYPES[type] or error 404
|
||||||
|
end
|
||||||
|
|
||||||
|
@graphs = @graphs.map do |g|
|
||||||
|
g.merge request.params
|
||||||
|
end
|
||||||
|
|
||||||
|
erubis :graphs, layout: :plain
|
||||||
|
end
|
||||||
|
end
|
1
example/public/placeholder
Normal file
1
example/public/placeholder
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Your custom files can go in this directory, and are served by Dash's rack middleware.
|
32
example/views/graphs.erubis
Normal file
32
example/views/graphs.erubis
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<% sizes = [[320, 200], [500, 300], [800, 400], [1000, 600]] %>
|
||||||
|
<% froms = %w(10m 1h 2h 4h 8h 1d 2d 4d 1w 2w 4w 8w 16w 32w 1y 2y) %>
|
||||||
|
|
||||||
|
<p>As of <%= Time.now %>.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<% @types.each do |t| %>
|
||||||
|
<a href="/graphs/<%=t%>?<%= graph_opts request.params %>"><%=t%></a>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<% sizes.each do |w, h| %>
|
||||||
|
<a href="/graphs/<%=@type%>?<%= graph_opts request.params.merge(
|
||||||
|
'width' => w, 'height' => h
|
||||||
|
) %>"><%=w%>x<%=h%></a>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<% froms.each do |f| %>
|
||||||
|
<a href="/graphs/<%=@type%>?<%= graph_opts request.params.merge(
|
||||||
|
'from' => "-#{f.gsub('m', 'minutes')}"
|
||||||
|
) %>"><%=f%></a>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<% @graphs.each do |g| %>
|
||||||
|
<img src="<%= graphite g %>" style="z-index: 100; position: relative" />
|
||||||
|
<% end %>
|
||||||
|
|
10
example/views/plain.erubis
Normal file
10
example/views/plain.erubis
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Dashboard</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<%= yield %>
|
||||||
|
</body>
|
||||||
|
</html>
|
126
lib/reimann/dash.rb
Normal file
126
lib/reimann/dash.rb
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
require 'reimann/client'
|
||||||
|
require 'sinatra/base'
|
||||||
|
|
||||||
|
module Reimann
|
||||||
|
class Dash < Sinatra::Base
|
||||||
|
# A little dashboard sinatra application.
|
||||||
|
|
||||||
|
require 'yaml'
|
||||||
|
require 'find'
|
||||||
|
require 'erubis'
|
||||||
|
require 'sass'
|
||||||
|
|
||||||
|
def self.config
|
||||||
|
@config ||= {
|
||||||
|
client: {},
|
||||||
|
age_scale: 60 * 30,
|
||||||
|
state_order: {
|
||||||
|
'critical' => 3,
|
||||||
|
'warning' => 2,
|
||||||
|
'ok' => 1
|
||||||
|
},
|
||||||
|
strftime: '%H:%M:%S',
|
||||||
|
controllers: [File.join(File.dirname(__FILE__), 'dash', 'controller')],
|
||||||
|
helpers: [File.join(File.dirname(__FILE__), 'dash', 'helper')],
|
||||||
|
views: File.join(File.dirname(__FILE__), 'dash', 'views')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.client
|
||||||
|
@client ||= Reimann::Client.new(config[:client])
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.load(filename)
|
||||||
|
unless load_config(filename || 'config.rb')
|
||||||
|
# Configuration failed; load a default view.
|
||||||
|
puts "No configuration loaded; using defaults."
|
||||||
|
end
|
||||||
|
|
||||||
|
config[:controllers].each { |d| load_controllers d }
|
||||||
|
config[:helpers].each { |d| load_helpers d }
|
||||||
|
set :views, File.expand_path(config[:views])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Executes the configuration file.
|
||||||
|
def self.load_config(filename)
|
||||||
|
begin
|
||||||
|
instance_eval File.read(filename)
|
||||||
|
true
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load controllers.
|
||||||
|
# Controllers can be regular old one-file-per-class, but if you prefer a little
|
||||||
|
# more modularity, this method will allow you to define all controller methods
|
||||||
|
# in their own files. For example, get "/posts/*/edit" can live in
|
||||||
|
# controller/posts/_/edit.rb. The sorting system provided here requires
|
||||||
|
# files in the correct order to handle wildcards appropriately.
|
||||||
|
def self.load_controllers(dir)
|
||||||
|
rbs = []
|
||||||
|
Find.find(
|
||||||
|
File.expand_path(dir)
|
||||||
|
) do |path|
|
||||||
|
rbs << path if path =~ /\.rb$/
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sort paths with _ last, becase those are wildcards.
|
||||||
|
rbs.sort! do |a, b|
|
||||||
|
as = a.split File::SEPARATOR
|
||||||
|
bs = b.split File::SEPARATOR
|
||||||
|
|
||||||
|
# Compare common subpaths
|
||||||
|
l = [as.size, bs.size].min
|
||||||
|
catch :x do
|
||||||
|
(0...l).each do |i|
|
||||||
|
a, b = as[i], bs[i]
|
||||||
|
if a[/^_/] and not b[/^_/]
|
||||||
|
throw :x, 1
|
||||||
|
elsif b[/^_/] and not a[/^_/]
|
||||||
|
throw :x, -1
|
||||||
|
elsif ord = (a <=> b) and ord != 0
|
||||||
|
throw :x, ord
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# All subpaths are identical; sort longest first
|
||||||
|
if as.size > bs.size
|
||||||
|
throw :x, -1
|
||||||
|
elsif as.size < bs.size
|
||||||
|
throw :x, -1
|
||||||
|
else
|
||||||
|
throw :x, 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
rbs.each do |r|
|
||||||
|
require r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load helpers
|
||||||
|
def self.load_helpers(dir)
|
||||||
|
Find.find(
|
||||||
|
File.expand_path(dir)
|
||||||
|
) do |path|
|
||||||
|
require path if path =~ /\.rb$/
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add an additional public directory.
|
||||||
|
def self.public_dir(dir)
|
||||||
|
require 'reimann/dash/rack/static'
|
||||||
|
use Reimann::Dash::Static, :root => dir
|
||||||
|
end
|
||||||
|
|
||||||
|
def client
|
||||||
|
self.class.client
|
||||||
|
end
|
||||||
|
|
||||||
|
def query(*a)
|
||||||
|
self.class.client.query(*a).events || []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
5
lib/reimann/dash/controller/css.rb
Normal file
5
lib/reimann/dash/controller/css.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class Reimann::Dash
|
||||||
|
get '/css' do
|
||||||
|
scss :css, :layout => false
|
||||||
|
end
|
||||||
|
end
|
5
lib/reimann/dash/controller/index.rb
Normal file
5
lib/reimann/dash/controller/index.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class Reimann::Dash
|
||||||
|
get '/' do
|
||||||
|
erb :index
|
||||||
|
end
|
||||||
|
end
|
258
lib/reimann/dash/helper/renderer.rb
Normal file
258
lib/reimann/dash/helper/renderer.rb
Normal file
|
@ -0,0 +1,258 @@
|
||||||
|
module Reimann
|
||||||
|
class Dash
|
||||||
|
helpers do
|
||||||
|
include ::Rack::Utils
|
||||||
|
|
||||||
|
alias_method :h, :escape_html
|
||||||
|
|
||||||
|
# Returns a scalar factor from 0.2 to 1, where 0.2 is "on the order of
|
||||||
|
# age_scale ago", and 1 is "very recent"
|
||||||
|
def age_fraction(time)
|
||||||
|
return 1 if time.nil?
|
||||||
|
|
||||||
|
x = 1 - ((Time.now.to_f - time) / Dash.config[:age_scale])
|
||||||
|
if x < 0.2
|
||||||
|
0.2
|
||||||
|
elsif x > 1
|
||||||
|
1
|
||||||
|
else
|
||||||
|
x
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds the longest common prefix of a list of strings.
|
||||||
|
# i.e. 'abc, 'ab', 'abdf' => 'ab'
|
||||||
|
def longest_common_prefix(strings, prefix = '')
|
||||||
|
return strings.first if strings.size <= 1
|
||||||
|
|
||||||
|
first = strings[0][0,1] or return prefix
|
||||||
|
tails = strings[1..-1].inject([strings[0][1..-1]]) do |tails, string|
|
||||||
|
if string[0,1] != first
|
||||||
|
return prefix
|
||||||
|
else
|
||||||
|
tails << string[1..-1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
longest_common_prefix(tails, prefix + first)
|
||||||
|
end
|
||||||
|
|
||||||
|
# An overview of states
|
||||||
|
def state_list(states)
|
||||||
|
ul(states.map { |s| state_short s })
|
||||||
|
end
|
||||||
|
|
||||||
|
def state_grid(states = Dash.client.query)
|
||||||
|
h2('States by Host') +
|
||||||
|
table(
|
||||||
|
*Event.partition(states, :host).map do |host, states|
|
||||||
|
tr(
|
||||||
|
th(host, class: 'host'),
|
||||||
|
*Event.sort(states, :service).map do |state|
|
||||||
|
state_short state
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renders a state as the given HTML tag with a % width corresponding to
|
||||||
|
# metric / max.
|
||||||
|
def state_bar(s, opts = {})
|
||||||
|
opts = {tag: 'div', max: 1}.merge opts
|
||||||
|
|
||||||
|
return '' unless s
|
||||||
|
x = s.metric
|
||||||
|
|
||||||
|
# Text
|
||||||
|
text = case x
|
||||||
|
when Float
|
||||||
|
'%.2f' % x
|
||||||
|
when Integer
|
||||||
|
x.to_s
|
||||||
|
else
|
||||||
|
'?'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Size
|
||||||
|
size = begin
|
||||||
|
(x || 0) * 100 / opts[:max]
|
||||||
|
rescue ZeroDivisionError
|
||||||
|
0
|
||||||
|
end
|
||||||
|
size = "%.2f" % size
|
||||||
|
|
||||||
|
tag opts[:tag], h(text),
|
||||||
|
:class => "state #{s.state}",
|
||||||
|
style: "opacity: #{age_fraction s.time}; width: #{size}%",
|
||||||
|
title: s.description
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renders a set of states in a chart. Each row is a given host, each
|
||||||
|
# service is a column. Each state is shown as a bar with an inferred
|
||||||
|
# maximum for the entire service, so you can readily compare multiple
|
||||||
|
# hosts.
|
||||||
|
#
|
||||||
|
# Takes a a set of states and options:
|
||||||
|
# title: the title of the chart. Inferred to be the longest common
|
||||||
|
# prefix of all services.
|
||||||
|
# maxima: maps each service to the maximum value used to display its
|
||||||
|
# bar.
|
||||||
|
# service_names: maps each service to a friendly name. Default service
|
||||||
|
# names have common prefixes removed.
|
||||||
|
# hosts: an array of hosts for rows. Default is every host present in
|
||||||
|
# states, sorted.
|
||||||
|
# transpose: Hosts go across, services go down. Enables :global_maxima.
|
||||||
|
# global_maximum: Compute default maxima for services globally,
|
||||||
|
# instead of a different maximum for each service.
|
||||||
|
def state_chart(states, opts = {})
|
||||||
|
o = {
|
||||||
|
:maxima => {},
|
||||||
|
:service_names => {}
|
||||||
|
}.merge opts
|
||||||
|
if o[:transpose] and not o.include?(:global_maximum)
|
||||||
|
o[:global_maximum] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get all services
|
||||||
|
services = states.map { |s| s.service }.compact.uniq.sort
|
||||||
|
|
||||||
|
# Figure out what name to use for each service.
|
||||||
|
prefix = longest_common_prefix services
|
||||||
|
service_names = services.inject({}) do |names, service|
|
||||||
|
names[service] = service[prefix.length..-1]
|
||||||
|
names
|
||||||
|
end.merge o[:service_names]
|
||||||
|
|
||||||
|
# Compute maximum for each service
|
||||||
|
maxima = if o[:global_maximum]
|
||||||
|
max = states.map(&:metric).compact.max
|
||||||
|
services.inject({}) do |m, s|
|
||||||
|
m[s] = max
|
||||||
|
m
|
||||||
|
end.merge o[:maxima]
|
||||||
|
else
|
||||||
|
states.inject(Hash.new(0)) do |m, s|
|
||||||
|
if s.metric
|
||||||
|
m[s.service] = [s.metric, m[s.service]].max
|
||||||
|
end
|
||||||
|
m
|
||||||
|
end.merge o[:maxima]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compute union of all hosts for these states, if no
|
||||||
|
# list of hosts explicitly given.
|
||||||
|
hosts = o[:hosts] || states.map do |state|
|
||||||
|
state.host
|
||||||
|
end
|
||||||
|
hosts = hosts.uniq.sort { |a, b|
|
||||||
|
if !a
|
||||||
|
-1
|
||||||
|
elsif !b
|
||||||
|
1
|
||||||
|
else
|
||||||
|
a <=> b
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
# Construct index
|
||||||
|
by = states.inject({}) do |index, s|
|
||||||
|
index[[s.host, s.service]] = s
|
||||||
|
index
|
||||||
|
end
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title = o[:title] || prefix.capitalize rescue 'Unknown'
|
||||||
|
|
||||||
|
if o[:transpose]
|
||||||
|
h2(title) +
|
||||||
|
table(
|
||||||
|
tr(
|
||||||
|
th,
|
||||||
|
*hosts.map do |host|
|
||||||
|
th host
|
||||||
|
end
|
||||||
|
),
|
||||||
|
*services.map do |service|
|
||||||
|
tr(
|
||||||
|
th(service_names[service]),
|
||||||
|
*hosts.map do |host|
|
||||||
|
s = by[[host, service]]
|
||||||
|
td(
|
||||||
|
s ? state_bar(s, max: maxima[service]) : nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end,
|
||||||
|
:class => 'chart'
|
||||||
|
)
|
||||||
|
else
|
||||||
|
h2(title) +
|
||||||
|
table(
|
||||||
|
tr(
|
||||||
|
th,
|
||||||
|
*services.map do |service|
|
||||||
|
th service_names[service]
|
||||||
|
end
|
||||||
|
),
|
||||||
|
*hosts.map do |host|
|
||||||
|
tr(
|
||||||
|
th(host),
|
||||||
|
*services.map do |service|
|
||||||
|
s = by[[host, service]]
|
||||||
|
td(
|
||||||
|
s ? state_bar(s, max: maxima[service]) : nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end,
|
||||||
|
:class => 'chart'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renders a state as a short tag.
|
||||||
|
def state_short(s, opts={tag: 'li'})
|
||||||
|
if s
|
||||||
|
"<#{opts[:tag]} class=\"state #{s.state}\" style=\"opacity: #{age_fraction s.time}\" title=\"#{h s.description}\">#{h s.host} #{h s.service}</#{opts[:tag]}>"
|
||||||
|
else
|
||||||
|
"<#{opts[:tag]} class=\"service\"></#{opts[:tag]}>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renders a time to an HTML tag.
|
||||||
|
def time(unix)
|
||||||
|
t = Time.at(unix)
|
||||||
|
"<time datetime=\"#{t.iso8601}\">#{t.strftime(Dash.config[:strftime])}</time>"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renders an HTML tag
|
||||||
|
def tag(tag, *a)
|
||||||
|
if Hash === a.last
|
||||||
|
opts = a.pop
|
||||||
|
else
|
||||||
|
opts = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs = opts.map do |k,v|
|
||||||
|
"#{k}=\"#{h v}\""
|
||||||
|
end.join ' '
|
||||||
|
|
||||||
|
content = if block_given?
|
||||||
|
a << yield
|
||||||
|
else
|
||||||
|
a
|
||||||
|
end.flatten.join("\n")
|
||||||
|
|
||||||
|
s = "<#{tag} #{attrs}>#{content}</#{tag}>"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Specific tag aliases
|
||||||
|
%w(div span h1 h2 h3 h4 h5 h6 ul ol li table th tr td u i b).each do |tag|
|
||||||
|
class_eval "def #{tag}(*a, &block)
|
||||||
|
tag #{tag.inspect}, *a, &block
|
||||||
|
end"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
16
lib/reimann/dash/rack/static.rb
Normal file
16
lib/reimann/dash/rack/static.rb
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
class Reimann::Dash::Static
|
||||||
|
def initialize(app, options = {})
|
||||||
|
@app = app
|
||||||
|
@root = options[:root] or raise ArgumentError, "no root"
|
||||||
|
@file_server = ::Rack::File.new(@root)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
r = @file_server.call env
|
||||||
|
if r[0] == 404
|
||||||
|
@app.call env
|
||||||
|
else
|
||||||
|
r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
4
lib/reimann/dash/version.rb
Normal file
4
lib/reimann/dash/version.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
module Reimann; end
|
||||||
|
class Reimann::Dash
|
||||||
|
VERSION = '0.0.3'
|
||||||
|
end
|
39
lib/reimann/dash/views/css.scss
Normal file
39
lib/reimann/dash/views/css.scss
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
html,table {
|
||||||
|
font-family: Helvetica Nueue, Helvetica, sans;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
float: left;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
background: #B8F1BC;
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
background: #F7D18E;
|
||||||
|
}
|
||||||
|
.critical {
|
||||||
|
background: #FF3C43;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 140px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
.chart td {
|
||||||
|
min-width: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chart th {
|
||||||
|
width: 1px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
8
lib/reimann/dash/views/index.erubis
Normal file
8
lib/reimann/dash/views/index.erubis
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<h2>Problems</h2>
|
||||||
|
<div class="box"><%= state_list query('state != "ok"') %></div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<%= state_chart query('service = "cpu" or service = "memory" or service =~ "disk%" or service = "load"'), title: "Health" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box"><%= state_chart query('true'), title: "Everything" %></div>
|
21
lib/reimann/dash/views/layout.erubis
Normal file
21
lib/reimann/dash/views/layout.erubis
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Dashboard</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/css" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
setTimeout(function() {
|
||||||
|
location.reload(true);
|
||||||
|
}, 10000);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body onload="refresh();">
|
||||||
|
<div>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<span style="position: absolute; top: 4px; right: 4px;">(as of <%= time Time.now.to_i %>)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= yield %>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue