Initial commit: version 0.0.3

This commit is contained in:
Aphyr 2012-02-18 15:22:24 -08:00
commit 5d5725a7c1
19 changed files with 771 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
pkg/
._*
.sass-cache/
*~
.DS_Store
.*.swp
*.log

21
LICENSE Normal file
View 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
View 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
View 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
View 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
View 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'

View 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

View file

@ -0,0 +1 @@
Your custom files can go in this directory, and are served by Dash's rack middleware.

View 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 %>

View 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
View 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

View file

@ -0,0 +1,5 @@
class Reimann::Dash
get '/css' do
scss :css, :layout => false
end
end

View file

@ -0,0 +1,5 @@
class Reimann::Dash
get '/' do
erb :index
end
end

View 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

View 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

View file

@ -0,0 +1,4 @@
module Reimann; end
class Reimann::Dash
VERSION = '0.0.3'
end

View 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;
}

View 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>

View 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>