commit 5d5725a7c136b591062c8848f95a27ea0981962b Author: Aphyr Date: Sat Feb 18 15:22:24 2012 -0800 Initial commit: version 0.0.3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e778ede --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +pkg/ +._* +.sass-cache/ +*~ +.DS_Store +.*.swp +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..57f0087 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..d10846e --- /dev/null +++ b/README.markdown @@ -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. diff --git a/Rakefile.rb b/Rakefile.rb new file mode 100644 index 0000000..d247602 --- /dev/null +++ b/Rakefile.rb @@ -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 diff --git a/bin/reimann-dash b/bin/reimann-dash new file mode 100755 index 0000000..78b74fc --- /dev/null +++ b/bin/reimann-dash @@ -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! diff --git a/example/config.rb b/example/config.rb new file mode 100644 index 0000000..1e0ce9a --- /dev/null +++ b/example/config.rb @@ -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' diff --git a/example/controller/graphs.rb b/example/controller/graphs.rb new file mode 100644 index 0000000..d659b31 --- /dev/null +++ b/example/controller/graphs.rb @@ -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 diff --git a/example/public/placeholder b/example/public/placeholder new file mode 100644 index 0000000..459cb96 --- /dev/null +++ b/example/public/placeholder @@ -0,0 +1 @@ +Your custom files can go in this directory, and are served by Dash's rack middleware. diff --git a/example/views/graphs.erubis b/example/views/graphs.erubis new file mode 100644 index 0000000..29363ba --- /dev/null +++ b/example/views/graphs.erubis @@ -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) %> + +

As of <%= Time.now %>.

+ +

+<% @types.each do |t| %> + <%=t%> +<% end %> +

+ +

+<% sizes.each do |w, h| %> + <%=w%>x<%=h%> +<% end %> +

+ +

+<% froms.each do |f| %> + "><%=f%> +<% end %> +

+ + +<% @graphs.each do |g| %> + +<% end %> + diff --git a/example/views/plain.erubis b/example/views/plain.erubis new file mode 100644 index 0000000..88fad01 --- /dev/null +++ b/example/views/plain.erubis @@ -0,0 +1,10 @@ + + + Dashboard + + + + + <%= yield %> + + diff --git a/lib/reimann/dash.rb b/lib/reimann/dash.rb new file mode 100644 index 0000000..a558f6d --- /dev/null +++ b/lib/reimann/dash.rb @@ -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 diff --git a/lib/reimann/dash/controller/css.rb b/lib/reimann/dash/controller/css.rb new file mode 100644 index 0000000..01b1276 --- /dev/null +++ b/lib/reimann/dash/controller/css.rb @@ -0,0 +1,5 @@ +class Reimann::Dash + get '/css' do + scss :css, :layout => false + end +end diff --git a/lib/reimann/dash/controller/index.rb b/lib/reimann/dash/controller/index.rb new file mode 100644 index 0000000..91d12ab --- /dev/null +++ b/lib/reimann/dash/controller/index.rb @@ -0,0 +1,5 @@ +class Reimann::Dash + get '/' do + erb :index + end +end diff --git a/lib/reimann/dash/helper/renderer.rb b/lib/reimann/dash/helper/renderer.rb new file mode 100644 index 0000000..1655176 --- /dev/null +++ b/lib/reimann/dash/helper/renderer.rb @@ -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}" + else + "<#{opts[:tag]} class=\"service\">" + end + end + + # Renders a time to an HTML tag. + def time(unix) + t = Time.at(unix) + "" + 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}" + 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 diff --git a/lib/reimann/dash/rack/static.rb b/lib/reimann/dash/rack/static.rb new file mode 100644 index 0000000..4632d05 --- /dev/null +++ b/lib/reimann/dash/rack/static.rb @@ -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 diff --git a/lib/reimann/dash/version.rb b/lib/reimann/dash/version.rb new file mode 100644 index 0000000..ddefdff --- /dev/null +++ b/lib/reimann/dash/version.rb @@ -0,0 +1,4 @@ +module Reimann; end +class Reimann::Dash + VERSION = '0.0.3' +end diff --git a/lib/reimann/dash/views/css.scss b/lib/reimann/dash/views/css.scss new file mode 100644 index 0000000..10f047b --- /dev/null +++ b/lib/reimann/dash/views/css.scss @@ -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; +} diff --git a/lib/reimann/dash/views/index.erubis b/lib/reimann/dash/views/index.erubis new file mode 100644 index 0000000..c9332ab --- /dev/null +++ b/lib/reimann/dash/views/index.erubis @@ -0,0 +1,8 @@ +

Problems

+
<%= state_list query('state != "ok"') %>
+ +
+<%= state_chart query('service = "cpu" or service = "memory" or service =~ "disk%" or service = "load"'), title: "Health" %> +
+ +
<%= state_chart query('true'), title: "Everything" %>
diff --git a/lib/reimann/dash/views/layout.erubis b/lib/reimann/dash/views/layout.erubis new file mode 100644 index 0000000..b0dd535 --- /dev/null +++ b/lib/reimann/dash/views/layout.erubis @@ -0,0 +1,21 @@ + + + Dashboard + + + + + + +
+

Dashboard

+ (as of <%= time Time.now.to_i %>) +
+ + <%= yield %> + +