├── .ruby-version ├── TODO ├── features ├── support │ ├── config │ │ ├── with_no_profiles │ │ │ └── .stub │ │ ├── stub │ │ │ ├── aaa1d80f3d8ce4fcaedd20eb4f88fa05.yaml │ │ │ ├── aaa901010d9fb72768159327de6dded9.yaml │ │ │ ├── fffe7aacef2d355851af1ee78d293e3b.yaml │ │ │ ├── ffffe7aacef2d355851af1ee78d293e3.yaml │ │ │ ├── fff7aacef2d355851af1ee78d293e3b6.yaml │ │ │ └── aaa7aacef2d355851af1ee78d293e3b6.yaml │ │ └── with_old_profile_yaml │ │ │ └── profiles.yaml │ ├── env.rb │ └── daemons.rb ├── step_definitions │ ├── result_steps.rb │ ├── webrat_steps.rb │ ├── site_steps.rb │ ├── cli_steps.rb │ ├── json_steps.rb │ └── profile_steps.rb ├── cli.feature ├── json.feature └── profiles.feature ├── lib ├── visage-app │ ├── assets │ │ └── coffeescripts │ │ │ ├── application.coffee │ │ │ ├── collections.coffee │ │ │ ├── models.coffee │ │ │ └── profiles.coffee │ ├── public │ │ ├── stylesheets │ │ │ ├── profiles.css │ │ │ ├── message.css │ │ │ └── LightFace.css │ │ ├── favicon.gif │ │ ├── images │ │ │ ├── add.png │ │ │ ├── ok.png │ │ │ ├── active.png │ │ │ ├── hosts.png │ │ │ ├── loader.gif │ │ │ ├── search.png │ │ │ ├── caution.png │ │ │ ├── metrics.png │ │ │ ├── LightFace │ │ │ │ ├── b.png │ │ │ │ ├── bl.png │ │ │ │ ├── br.png │ │ │ │ ├── tl.png │ │ │ │ ├── tr.png │ │ │ │ ├── button.png │ │ │ │ └── fbloader.gif │ │ │ └── questions.png │ │ └── javascripts │ │ │ ├── LightFace.Static.js │ │ │ ├── LightFace.IFrame.js │ │ │ ├── LightFace.Request.js │ │ │ ├── highcharts-mootools-adapter.js │ │ │ ├── LightFace.Image.js │ │ │ ├── backbone.handlebars.js │ │ │ ├── keyboard.js │ │ │ ├── highcharts-mootools-adapter.src.js │ │ │ ├── LightFace.js │ │ │ └── MooToolsAdapter.js │ ├── version.rb │ ├── config.ru │ ├── graph.rb │ ├── config.rb │ ├── views │ │ ├── profile.haml │ │ ├── layout.haml │ │ └── profiles.haml │ ├── upgrade.rb │ ├── patches.rb │ ├── data.rb │ ├── upgrade │ │ └── v3.rb │ ├── data │ │ ├── mock.rb │ │ └── rrd.rb │ ├── helpers.rb │ └── models │ │ └── profile.rb └── visage-app.rb ├── .travis.yml ├── .gitignore ├── Gemfile ├── AUTHORS ├── Guardfile ├── spec ├── spec_helper.rb ├── upgrade.rb └── models │ └── profile_spec.rb ├── CHANGELOG.md ├── LICENCE ├── visage-app.gemspec ├── man ├── visage-app.5.ronn ├── visage-app.5 ├── visage-api.5.ronn ├── visage-api.5 ├── visage-app.5.html └── visage-api.5.html ├── bin └── visage-app ├── Rakefile ├── Gemfile.lock └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 1.9.3-p125 2 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | change timeframe on profile page load 2 | -------------------------------------------------------------------------------- /features/support/config/with_no_profiles/.stub: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/visage-app/assets/coffeescripts/application.coffee: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/visage-app/public/stylesheets/profiles.css: -------------------------------------------------------------------------------- 1 | div.toggler { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "1.9.3" 4 | branches: 5 | only: 6 | - master 7 | -------------------------------------------------------------------------------- /lib/visage-app/version.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Visage 4 | VERSION = "2.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/visage-app/public/favicon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/favicon.gif -------------------------------------------------------------------------------- /lib/visage-app/public/images/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/add.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/ok.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/active.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/hosts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/hosts.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/loader.gif -------------------------------------------------------------------------------- /lib/visage-app/public/images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/search.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/caution.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/caution.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/metrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/metrics.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/LightFace/b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/LightFace/b.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/questions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/questions.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/LightFace/bl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/LightFace/bl.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/LightFace/br.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/LightFace/br.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/LightFace/tl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/LightFace/tl.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/LightFace/tr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/LightFace/tr.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/LightFace/button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/LightFace/button.png -------------------------------------------------------------------------------- /lib/visage-app/public/images/LightFace/fbloader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxesis/visage/HEAD/lib/visage-app/public/images/LightFace/fbloader.gif -------------------------------------------------------------------------------- /lib/visage-app/config.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'pathname' 4 | @root = Pathname.new(File.dirname(__FILE__)).parent.parent.expand_path 5 | $: << @root.to_s 6 | 7 | $0 = "visage" 8 | 9 | require 'lib/visage-app' 10 | use Visage::Profiles 11 | use Visage::JSON 12 | run Sinatra::Base 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | log/* 3 | tmp/* 4 | pkg/* 5 | *~ 6 | .#* 7 | webrat* 8 | lib/visage-app/config/profiles.yaml 9 | _site 10 | features/data/config/*/*.yaml 11 | vendor 12 | .bundle 13 | .rbenv-version 14 | lib/visage-app/public/images/screenshot.png 15 | features/support/config/tmp/* 16 | config/ 17 | -------------------------------------------------------------------------------- /features/support/config/stub/aaa1d80f3d8ce4fcaedd20eb4f88fa05.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | :anonymous: true 3 | :graphs: 4 | - host: localhost.localdomain 5 | plugin: cpu-0 6 | start: 1375268235 7 | :timeframe: "last 2 hours" 8 | :id: aaa1d80f3d8ce4fcaedd20eb4f88fa05 9 | :created_at: 2013-07-31 23:39:00.256278 +10:00 10 | -------------------------------------------------------------------------------- /features/support/config/stub/aaa901010d9fb72768159327de6dded9.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | :anonymous: true 3 | :graphs: 4 | - host: localhost.localdomain 5 | plugin: cpu-0 6 | start: 1375268235 7 | :timeframe: "last 2 hours" 8 | :id: aaa901010d9fb72768159327de6dded9 9 | :created_at: 2013-07-31 23:03:50.230810 +10:00 10 | -------------------------------------------------------------------------------- /features/support/config/stub/fffe7aacef2d355851af1ee78d293e3b.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | :anonymous: false 3 | :name: A memory graph 4 | :graphs: 5 | - host: foo 6 | plugin: memory 7 | start: 1375268235 8 | :timeframe: "last 2 hours" 9 | :id: fffe7aacef2d355851af1ee78d293e3b 10 | :created_at: 2013-07-31 23:39:19.208713 +10:00 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | source 'http://rubygems.org' 4 | 5 | gemspec 6 | 7 | group :development do 8 | gem 'rake' 9 | gem 'rack-test' 10 | gem 'rspec' 11 | gem 'cucumber' 12 | gem 'capybara' 13 | gem 'colorize' 14 | gem 'ronn' 15 | gem 'poltergeist' 16 | gem 'puma' 17 | gem 'guard' 18 | gem 'guard-rack' 19 | gem 'guard-ronn' 20 | gem 'guard-rake' 21 | gem 'delorean' 22 | end 23 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Visage was written by 2 | ===================== 3 | Lindsay Holmwood 4 | 5 | Contributors include 6 | -------------------- 7 | Jeffrey Lim 8 | Andrew Harvey 9 | Xavier Mehrenberger 10 | Jesse Reynolds 11 | 12 | Inspiration given by 13 | -------------------- 14 | Gareth Stokes 15 | Asdrubal Ibarra 16 | -------------------------------------------------------------------------------- /features/step_definitions/result_steps.rb: -------------------------------------------------------------------------------- 1 | Then /^I should see "(.*)"$/ do |text| 2 | page.body.to_s.should =~ /#{text}/m 3 | end 4 | 5 | Then /^I should not see "(.*)"$/ do |text| 6 | page.body.to_s.should_not =~ /#{text}/m 7 | end 8 | 9 | Then /^the (.*) ?request should succeed/ do |_| 10 | page.status_code.should < 400 11 | end 12 | 13 | Then /^the (.*) ?request should fail/ do |_| 14 | page.status_code.should >= 400 15 | end 16 | -------------------------------------------------------------------------------- /features/support/config/stub/ffffe7aacef2d355851af1ee78d293e3.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | :anonymous: false 3 | :name: Some CPU graphs 4 | :graphs: 5 | - host: foo 6 | plugin: cpu-0 7 | start: 1375268235 8 | - host: bar 9 | plugin: cpu-0 10 | start: 1375268235 11 | - host: baz 12 | plugin: cpu-0 13 | start: 1375268235 14 | :timeframe: "last 2 hours" 15 | :id: ffffe7aacef2d355851af1ee78d293e3 16 | :created_at: 2013-07-31 23:39:19.208713 +10:00 17 | -------------------------------------------------------------------------------- /features/support/config/stub/fff7aacef2d355851af1ee78d293e3b6.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | :anonymous: false 3 | :name: Load on foo, bar, baz 4 | :graphs: 5 | - host: foo 6 | plugin: load 7 | start: 1375268235 8 | - host: bar 9 | plugin: load 10 | start: 1375268235 11 | - host: baz 12 | plugin: load 13 | start: 1375268235 14 | :timeframe: "last 2 hours" 15 | :id: fff7aacef2d355851af1ee78d293e3b6 16 | :created_at: 2013-07-31 23:39:19.208713 +10:00 17 | -------------------------------------------------------------------------------- /features/support/config/stub/aaa7aacef2d355851af1ee78d293e3b6.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | :anonymous: true 3 | :graphs: 4 | - host: localhost.localdomain 5 | plugin: cpu-0 6 | start: 1375268235 7 | - host: ubuntu.localdomain 8 | plugin: cpu-0 9 | start: 1375268235 10 | - host: app-01.example.org 11 | plugin: cpu-0 12 | start: 1375268235 13 | :timeframe: "last 2 hours" 14 | :id: aaa7aacef2d355851af1ee78d293e3b6 15 | :created_at: 2013-07-31 23:39:19.208713 +10:00 16 | -------------------------------------------------------------------------------- /lib/visage-app/graph.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'digest/md5' 4 | 5 | module Visage 6 | class Graph 7 | 8 | attr_accessor :host, :plugin, :instances 9 | 10 | def initialize(opts={}) 11 | @host = opts[:host] 12 | @plugin = opts[:plugin] 13 | @instances = opts[:instances] 14 | end 15 | 16 | def id 17 | Digest::MD5.hexdigest("#{@host}-#{@plugin}-#{@instances}\n") 18 | end 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/visage-app/config.rb: -------------------------------------------------------------------------------- 1 | module Visage 2 | class Config 3 | class << self 4 | def use 5 | @configuration ||= {} 6 | yield @configuration 7 | nil 8 | end 9 | 10 | def method_missing(method, *args) 11 | if method.to_s[-1,1] == '=' 12 | @configuration[method.to_s.tr('=','')] = *args 13 | else 14 | @configuration[method.to_s] 15 | end 16 | end 17 | 18 | def to_hash 19 | @configuration 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV['VISAGE_DATA_BACKEND'] = 'Mock' 4 | 5 | ## Reload the development server 6 | #guard 'rack', :port => 9292, :config => 'lib/visage-app/config.ru', :server => 'puma', :force_run => true do 7 | # watch('Gemfile.lock') 8 | # watch(%r{^(lib)/.*}) 9 | #end 10 | 11 | ## Rebuild man pages 12 | #guard 'ronn' do 13 | # watch(%r{^man/.+\.ronn$}) 14 | #end 15 | 16 | # Recompile the coffeescript 17 | guard 'rake', :task => 'coffee:compile' do 18 | watch(%r{lib/visage-app/assets/coffeescripts/(.+\.coffee)}) 19 | end 20 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | require 'pathname' 5 | $: << Pathname.new(__FILE__).parent.parent.join('lib').to_s 6 | #require 'visage-app' 7 | 8 | ENV['RACK_ENV'] = 'test' 9 | 10 | RSpec.configure do |config| 11 | # Use color in STDOUT 12 | config.color_enabled = true 13 | 14 | # Use color not only in STDOUT but also in pagers and files 15 | config.tty = true 16 | 17 | # Use the specified formatter 18 | config.formatter = :documentation # :progress, :html, :textmate 19 | 20 | # Rspec 3 forward compatibility 21 | config.treat_symbols_as_metadata_keys_with_true_values = true 22 | end 23 | 24 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/LightFace.Static.js: -------------------------------------------------------------------------------- 1 | /* 2 | --- 3 | description: LightFace.Static 4 | 5 | authors: 6 | - David Walsh (http://davidwalsh.name) 7 | 8 | license: 9 | - MIT-style license 10 | 11 | requires: 12 | core/1.2.1: '*' 13 | 14 | provides: 15 | - LightFace.Static 16 | ... 17 | */ 18 | LightFace.Static = new Class({ 19 | Extends: LightFace, 20 | options: { 21 | offsets: { 22 | x: 20, 23 | y: 20 24 | } 25 | }, 26 | open: function(fast, x, y) { 27 | this.parent(fast); 28 | this._position(x, y); 29 | }, 30 | _position: function(x, y) { 31 | if(x == null) return; 32 | this.box.setStyles({ 33 | top: y - this.options.offsets.y, 34 | left: x - this.options.offsets.x 35 | }); 36 | } 37 | }); -------------------------------------------------------------------------------- /lib/visage-app/views/profile.haml: -------------------------------------------------------------------------------- 1 | - if @profile && !@profile.anonymous 2 | - page_title(@profile.name) 3 | - else 4 | - page_title('Explore Metrics') 5 | 6 | - require_js 'highcharts-mootools-adapter.src' 7 | - require_js 'highcharts.src' 8 | - require_js 'graph' 9 | - require_js 'mootools-more-1.4.0.1' 10 | - require_js 'raphael-min' 11 | - require_js 'application' 12 | - require_js 'LightFace' 13 | - require_js 'LightFace.Request' 14 | - require_css 'LightFace' 15 | 16 | %div#graphs 17 | 18 | %div#builder 19 | %div#hosts{:class => 'dimension'} 20 | %h2 1. Hosts 21 | %div#metrics{:class => 'dimension'} 22 | %h2 2. Metrics 23 | %div#display{:class => 'dimension'} 24 | %h2 3. Display 25 | 26 | %div#bottom_nav 27 | %a{:href => link_to("/profiles")} ← Back to profiles 28 | -------------------------------------------------------------------------------- /features/step_definitions/webrat_steps.rb: -------------------------------------------------------------------------------- 1 | # Commonly used webrat steps 2 | # http://github.com/brynary/webrat 3 | 4 | When /^I go to (.*)$/ do |path| 5 | visit path 6 | end 7 | 8 | When /^I press "(.*)"$/ do |button| 9 | click_button(button) 10 | end 11 | 12 | When /^I follow "(.*)"$/ do |link| 13 | click_link(link) 14 | end 15 | 16 | When /^I fill in "(.*)" with "(.*)"$/ do |field, value| 17 | fill_in(field, :with => value) 18 | end 19 | 20 | When /^I select "(.*)" from "(.*)"$/ do |value, field| 21 | select(value, :from => field) 22 | end 23 | 24 | When /^I check "(.*)"$/ do |field| 25 | check(field) 26 | end 27 | 28 | When /^I uncheck "(.*)"$/ do |field| 29 | uncheck(field) 30 | end 31 | 32 | When /^I choose "(.*)"$/ do |field| 33 | choose(field) 34 | end 35 | 36 | When /^I attach the file at "(.*)" to "(.*)" $/ do |path, field| 37 | attach_file(field, path) 38 | end 39 | 40 | Then /^show me the page$/ do 41 | # save_and_open_page 42 | # puts page.body 43 | 44 | root = Pathname.new(__FILE__).parent.parent.parent 45 | filename = root.join('lib/visage-app/public/images/screenshot.png') 46 | page.driver.render(filename.to_s) 47 | end 48 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/LightFace.IFrame.js: -------------------------------------------------------------------------------- 1 | /* 2 | --- 3 | description: LightFace.IFrame 4 | 5 | authors: 6 | - David Walsh (http://davidwalsh.name) 7 | 8 | license: 9 | - MIT-style license 10 | 11 | requires: 12 | core/1.2.1: "*" 13 | 14 | provides: 15 | - LightFace.IFrame 16 | ... 17 | */ 18 | LightFace.IFrame = new Class({ 19 | Extends: LightFace, 20 | options: { 21 | url: "" 22 | }, 23 | initialize: function(options) { 24 | this.parent(options); 25 | if(this.options.url) this.load(); 26 | }, 27 | load: function(url, title) { 28 | this.fade(); 29 | if(!this.iframe) { 30 | this.messageBox.set("html", ""); 31 | this.iframe = new IFrame({ 32 | styles: { 33 | width: "100%", 34 | height: "100%" 35 | }, 36 | events: { 37 | load: function() { 38 | this.unfade(); 39 | this.fireEvent("complete"); 40 | }.bind(this) 41 | }, 42 | border: 0 43 | }).inject(this.messageBox); 44 | this.messageBox.setStyles({ padding: 0, overflow: "hidden" }); 45 | } 46 | if(title) this.title.set("html", title); 47 | this.iframe.src = url || this.options.url; 48 | this.fireEvent("request"); 49 | return this; 50 | } 51 | }); -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2.1.0 - 2012/04/20 2 | - Add support for drawing 95th percentile lines on graphs 3 | Thanks to Jesse Reynolds for the feature addition! 4 | 5 | 2.0.5 - 2012/03/30 6 | - Fix a bug where metrics occasionally won't appear in the builder interface. 7 | Thanks to Jesse Reynolds for the patch! 8 | - Add documentation on how to do a release, for when I get hit by a bus. 9 | 10 | 2.0.4 - 2012/03/27 11 | - Add extra input validation in the builder, so invalid data can't be entered 12 | into `profiles.yaml`, per [issue 92](https://github.com/auxesis/visage/issues/92) 13 | 14 | 2.0.2 - 2012/03/26 15 | - Builder bugfix for [issue 92](https://github.com/auxesis/visage/issues/92), 16 | that stopped the builder from presenting the save profile dialog. 17 | 18 | 2.0.0 - 2012/03/25 19 | - New builder interface, optimised for metric discovery + investigatory work 20 | - Backwards incompatible profile.yaml file format change 21 | - Chart performance optimisations 22 | - Visual improvements to chart layout + design 23 | - Bump versions of dependencies 24 | - Improve the installation documentation for non-Ubuntu platforms 25 | 26 | 1.0.0 - 2011/06/11 27 | 28 | - 1.0.0 release! Production ready. 29 | 30 | -------------------------------------------------------------------------------- /spec/upgrade.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'visage-app/models/profile' 3 | require 'visage-app/upgrade' 4 | require 'tmpdir' 5 | 6 | describe "Upgrade" do 7 | 8 | before(:each) do 9 | # Create a temporary profile data directory 10 | Profile.config_path = Dir.mktmpdir 11 | end 12 | 13 | it "should indicate if there are upgrades to apply" do 14 | # Move a v2 profile.yaml in place 15 | source = File.join(File.dirname(__FILE__), 'data', 'profiles.yaml.2') 16 | destination = File.join(Profile.config_path, 'profiles.yaml') 17 | FileUtils.cp(source, destination) 18 | 19 | # Test 20 | Visage::Upgrade.pending?.should be_true 21 | end 22 | 23 | it "should run upgrades idempotently" do 24 | # Move a v2 profile.yaml in place 25 | source = File.join(File.dirname(__FILE__), 'data', 'profiles.yaml.2') 26 | destination = File.join(Profile.config_path, 'profiles.yaml') 27 | FileUtils.cp(source, destination) 28 | 29 | # Run the upgrade 30 | upgrades = Visage::Upgrade.run 31 | upgrades.size.should > 0 32 | 33 | # Second invocation of upgrade should run nothing 34 | upgrades = Visage::Upgrade.run 35 | upgrades.size.should == 0 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'pathname' 4 | 5 | require 'rspec' 6 | require 'capybara' 7 | require 'capybara/cucumber' 8 | require 'capybara/poltergeist' 9 | require 'delorean' 10 | 11 | # Application setup 12 | ENV['RACK_ENV'] = 'test' 13 | 14 | root = Pathname.new(File.dirname(__FILE__)).parent.parent.expand_path 15 | 16 | default_config_path = root.join('features/support/config/default') 17 | ENV['CONFIG_PATH'] = default_config_path.to_s 18 | # use a Mock backend, so tests don't depend on any specific backend (e.g. RRD) 19 | ENV['VISAGE_DATA_BACKEND'] = 'Mock' 20 | 21 | app_file = root.join('lib/visage-app').to_s 22 | require(app_file) 23 | 24 | # http://opensoul.org/blog/archives/2010/05/11/capybaras-eating-cucumbers/ 25 | Capybara.app = Rack::Builder.new do 26 | use Visage::Profiles 27 | use Visage::Builder 28 | use Visage::JSON 29 | 30 | run Sinatra::Application 31 | end.to_app 32 | 33 | Capybara.javascript_driver = :poltergeist 34 | 35 | Capybara.register_driver :poltergeist do |app| 36 | options = { 37 | #:debug => true, 38 | :js_errors => false, 39 | } 40 | Capybara::Poltergeist::Driver.new(app, options) 41 | end 42 | 43 | # Cucumber setup 44 | class SinatraWorld 45 | include Capybara::DSL 46 | end 47 | 48 | World do 49 | SinatraWorld.new 50 | end 51 | -------------------------------------------------------------------------------- /lib/visage-app/public/stylesheets/message.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | /* CSS Document */ 3 | 4 | /* Message Boxes */ 5 | .msgBox{ 6 | font-family:Arial, Helvetica, sans-serif; 7 | opacity: 0; 8 | position:absolute; 9 | top:-1000px; 10 | left:0px; 11 | max-width: 250px; 12 | min-width: 150px; 13 | color:#aaa; 14 | background: rgb(0, 0, 0); /* compatibility fallback */ 15 | background: rgba(0, 0, 0, 0.8); 16 | padding: 10px; 17 | border-radius: 15px; 18 | box-shadow: 2px 2px 6px #666; 19 | -moz-border-radius: 15px; 20 | -webkit-border-radius: 15px; 21 | -moz-box-shadow: 2px 2px 6px #666; 22 | -webkit-box-shadow: 2px 2px 5px #666; 23 | z-index:-1; 24 | } 25 | 26 | .msgBoxImage{ 27 | width: 40px; 28 | height: 40px; 29 | } 30 | 31 | .msgBoxIcon{ 32 | float:left; 33 | width: 40px; 34 | height: 40px; 35 | padding-right: 7px; 36 | } 37 | 38 | .msgBoxTitle{ 39 | float:left; 40 | color: #FFFFFF; 41 | } 42 | 43 | .msgBoxContent{ 44 | float:left; 45 | max-width: 80%; 46 | font-size:12px; 47 | } 48 | 49 | .msgBoxMessage{ float:left;} 50 | .msgBoxLink{ color:#6699CC;} 51 | .msgBoxLink:hover{ color:#FF9900;} 52 | 53 | .msgEditable{ 54 | font-family:Arial, Helvetica, sans-serif; 55 | font-size:12px; 56 | width:250px; 57 | background: rgb(0, 0, 0); /* compatibility fallback */ 58 | background: rgba(255, 255, 255, 0.1); 59 | border:#000; 60 | color:#FFF; 61 | } -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2010 Lindsay Holmwood 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | Visage is distributed with Highcharts. Torstein Hønsi has kindly granted 22 | permission to distribute Highcharts under the GPLv2 as part of Visage. 23 | 24 | If you ever need an excellent JavaScript charting library, please consider 25 | purchasing a [commercial license](http://highcharts.com/license) for 26 | Highcharts. 27 | 28 | -------------------------------------------------------------------------------- /visage-app.gemspec: -------------------------------------------------------------------------------- 1 | # 2 | # -*- encoding: utf-8 -*- 3 | $:.push File.expand_path("../lib", __FILE__) 4 | require "visage-app/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "visage-app" 8 | s.version = Visage::VERSION 9 | s.platform = Gem::Platform::RUBY 10 | s.authors = [ "Lindsay Holmwood" ] 11 | s.email = [ "lindsay@holmwood.id.au" ] 12 | s.homepage = "http://visage-app.com/" 13 | s.summary = %q{A web (interface | service) for viewing collectd statistics.} 14 | s.description = %q{Visage is a web interface for viewing collectd statistics. It also provides a JSON interface onto collectd's RRD data, giving you an easy way to mash up the data.} 15 | 16 | s.rubyforge_project = "visage-app" 17 | 18 | s.required_ruby_version = ">= 1.8.7" 19 | s.required_rubygems_version = ">= 1.3.6" 20 | 21 | s.files = `git ls-files`.split("\n") 22 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 23 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 24 | s.require_paths = ["lib"] 25 | 26 | s.add_runtime_dependency "sinatra", "= 1.4.3" 27 | s.add_runtime_dependency "sinatra-contrib", "= 1.4.0" 28 | s.add_runtime_dependency "haml", ">= 0" 29 | s.add_runtime_dependency "errand", "= 0.8.0" 30 | s.add_runtime_dependency "yajl-ruby", "= 1.1.0" 31 | s.add_runtime_dependency "activemodel", "~> 3.2.12" 32 | end 33 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/LightFace.Request.js: -------------------------------------------------------------------------------- 1 | /* 2 | --- 3 | description: LightFace.Request 4 | 5 | authors: 6 | - David Walsh (http://davidwalsh.name) 7 | 8 | license: 9 | - MIT-style license 10 | 11 | requires: 12 | core/1.2.1: "*" 13 | 14 | provides: 15 | - LightFace.Request 16 | ... 17 | */ 18 | LightFace.Request = new Class({ 19 | Extends: LightFace, 20 | options: { 21 | url: "", 22 | request: { 23 | url: false 24 | } 25 | }, 26 | initialize: function(options) { 27 | this.parent(options); 28 | if(this.options.url) this.load(); 29 | }, 30 | load: function(url, title) { 31 | var props = (Object.append || $extend)({ 32 | onRequest: function() { 33 | this.fade(); 34 | this.fireEvent("request"); 35 | }.bind(this), 36 | onSuccess: function(response) { 37 | this.messageBox.set("html", response); 38 | this.fireEvent("success"); 39 | }.bind(this), 40 | onFailure: function() { 41 | this.messageBox.set("html", this.options.errorMessage); 42 | this.fireEvent("failure"); 43 | }.bind(this), 44 | onComplete: function() { 45 | this._resize(); 46 | this._ie6Size(); 47 | this.messageBox.setStyle("opacity", 1); 48 | this.unfade(); 49 | this.fireEvent("complete"); 50 | }.bind(this) 51 | },this.options.request); 52 | 53 | if(title && this.title) this.title.set("html", title); 54 | if(!props.url) props.url = url || this.options.url; 55 | 56 | new Request(props).send(); 57 | return this; 58 | } 59 | }); -------------------------------------------------------------------------------- /lib/visage-app/upgrade.rb: -------------------------------------------------------------------------------- 1 | # Handle upgrading various persistent data when upgrading. 2 | # 3 | # Currently used for upgrading the profile data. 4 | module Visage 5 | class Upgrade 6 | class << self 7 | def run 8 | if pending? 9 | apply 10 | else 11 | [] 12 | end 13 | end 14 | 15 | # Determine the current profile storage format version 16 | def version 17 | config_path = Pathname.new(Profile.config_path) 18 | profiles_dot_yaml = config_path.join('profiles.yaml') 19 | 20 | if profiles_dot_yaml.exist? 21 | data = YAML.load(profiles_dot_yaml.read) 22 | if data.find_all {|profile, attrs| attrs.key?(:percentiles)}.empty? 23 | 1 24 | else 25 | 2 26 | end 27 | else 28 | 3 29 | end 30 | end 31 | 32 | def pending? 33 | # Build a list of upgrades that can be run 34 | upgrades = [] 35 | upgrade_files = Dir.glob(File.join(File.dirname(__FILE__), 'upgrade', '*')) 36 | upgrade_files.each do |f| 37 | require(f) 38 | upgrades << Visage::Upgrade::const_get(File.basename(f).split('.').first.upcase) 39 | end 40 | 41 | # Filter out upgrades that have been run 42 | @to_run = upgrades.reject {|u| u.version <= version} 43 | @to_run.size > 0 44 | end 45 | 46 | def apply 47 | # Run the remaining upgrades 48 | @to_run.each {|u| u.run} 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/visage-app/views/layout.haml: -------------------------------------------------------------------------------- 1 | !!! 2 | %html 3 | %head 4 | %title= include_page_title 5 | %link{:rel => "icon", :type => "image/gif", :href => "/favicon.gif"} 6 | %link{:rel => 'stylesheet', :href => '/stylesheets/screen.css', :type => 'text/css'} 7 | %link{:rel => 'stylesheet', :href => '/stylesheets/message.css', :type => 'text/css'} 8 | = include_required_css 9 | %script{:type => "text/javascript", :src => link_to("/javascripts/mootools-core-1.4.5.js")} 10 | %script{:type => "text/javascript", :src => link_to("/javascripts/MooToolsAdapter.js")} 11 | %script{:type => "text/javascript", :src => link_to("/javascripts/underscore-1.3.1.js")} 12 | %script{:type => "text/javascript", :src => link_to("/javascripts/backbone.js")} 13 | %script{:type => "text/javascript", :src => link_to("/javascripts/handlebars.js")} 14 | %script{:type => "text/javascript", :src => link_to("/javascripts/backbone.handlebars.js")} 15 | %script{:type => "text/javascript", :src => link_to("/javascripts/delorean.js")} 16 | = include_required_js 17 | 18 | %body 19 | %div#wrapper 20 | %div#header 21 | %div#nav 22 | %div#timeframe-toggler.toggler 23 | %label#timeframe-label last 1 hour 24 | %ul#timeframes 25 | %div#share-toggler.toggler 26 | %label#share-label Share 27 | 28 | %div#content 29 | = yield 30 | 31 | %div#footer 32 | Visage 33 | | 34 | %a{:href => link_to("/profiles/new")} Explore 35 | | 36 | %a{:href => link_to("/profiles")} Profiles 37 | 38 | 39 | -------------------------------------------------------------------------------- /lib/visage-app/views/profiles.haml: -------------------------------------------------------------------------------- 1 | - page_title 'Profiles' 2 | - require_css 'profiles' 3 | 4 | %div#named_profiles.profile_column 5 | %h2 Profiles 6 | / FIXME - disabling sort links as default is now sorted by profile name 7 | / and there's no ability to sort by created time (until it's saved with 8 | / profiles on creation) 9 | /%div#sort 10 | / Sort by: 11 | / %a{:href => link_to("/profiles?sort=name")} name 12 | / | 13 | / %a{:href => link_to("/profiles?sort=created")} created 14 | 15 | %ul 16 | - @profiles.each_with_index do |profile,index| 17 | %li 18 | %a{:href => link_to("/profiles/#{profile.id}")}= profile.name 19 | 20 | %p.create 21 | %a{:href => link_to("/profiles/new")} Create a profile 22 | 23 | %div#recent_profiles.profile_column 24 | %h2 Recently shared 25 | 26 | - if @anonymous.size > 0 27 | %ul 28 | - @anonymous.each_with_index do |profile,index| 29 | %li 30 | %a{:href => link_to("/profiles/#{profile.id}")} 31 | - description = profile.graphs.map {|prof| "#{prof['plugin']} on #{prof['host']}" }.join(', ') 32 | - count = profile.graphs.size 33 | = "#{count} graphs (#{truncate(description, :length => 46)})" 34 | %p.time{:title => "Shared at #{profile.created_at.strftime("%Y-%m-%d %H:%M")}"} 35 | = distance_of_time_in_words(profile.created_at) 36 | 37 | %p.create 38 | %a{:href => link_to("/profiles/new")} Create a profile 39 | 40 | - else 41 | %p 42 | No activity. 43 | %p 44 | Why not 45 | %a{:href => link_to("/profiles/new")} create a profile? 46 | 47 | 48 | -------------------------------------------------------------------------------- /features/cli.feature: -------------------------------------------------------------------------------- 1 | Feature: command line utility 2 | As a systems administrator 3 | Or a hard core developer 4 | I want to get Visage up and running 5 | With the least hassle possible 6 | 7 | Background: 8 | Given the visage server helper is not running 9 | 10 | @daemon 11 | Scenario: Booting the command line tool 12 | Given I am using a profile based on "default" 13 | When I start the visage server helper with "visage-app start" 14 | Then a visage web server should be running 15 | 16 | @daemon 17 | Scenario: Seeing where Visage is getting its data from 18 | Given I am using a profile based on "default" 19 | When I start the visage server helper with "visage-app start" 20 | Then I should see "Looking for RRDs in /.*collectd" on the terminal 21 | 22 | @daemon 23 | Scenario: Upgrading config from 2.0 to 3.0 24 | Given I am using a profile based on "profiles.yaml.2" 25 | When I start the visage server helper with "visage-app start" 26 | Then I should see "The Visage profile storage format has changed" on the terminal 27 | And I should see "Upgraded profile storage format from version 2 to 3" on the terminal 28 | 29 | @help 30 | Scenario Outline: Displaying the man page 31 | Given I am using a profile based on "default" 32 | When I start the visage server helper with "visage-app " 33 | Then I should see a man page 34 | And a visage web server should not be running 35 | 36 | Examples: 37 | | argument | 38 | | help | 39 | | --help | 40 | | start help | 41 | | help start | 42 | | --help start | 43 | | start --help | 44 | -------------------------------------------------------------------------------- /features/step_definitions/site_steps.rb: -------------------------------------------------------------------------------- 1 | When /^I visit the first recent profile$/ do 2 | visit "/profiles" 3 | 4 | doc = Nokogiri::HTML(page.body) 5 | profile = doc.search('div#recent_profiles ul a').first 6 | profile.should_not be_nil 7 | href = profile['href'] 8 | 9 | visit(href) 10 | end 11 | 12 | Then /^I should see a list of graphs$/ do 13 | begin 14 | follow_redirect! 15 | rescue Rack::Test::Error 16 | end 17 | doc = Nokogiri::HTML(page.body) 18 | doc.search('div#profile div.graph').size.should > 0 19 | end 20 | 21 | Then /^I should see a collection of graphs$/ do 22 | script = <<-SCRIPT 23 | $$('div#graphs li.graph').length 24 | SCRIPT 25 | 26 | result = page.evaluate_script(script) 27 | result.should > 0 28 | end 29 | 30 | Then /^I should see a list of profiles$/ do 31 | doc = Nokogiri::HTML(page.body) 32 | doc.search('div#profiles ul li').size.should > 1 33 | end 34 | 35 | Then /^I should see a list of profiles sorted alphabetically$/ do 36 | doc = Nokogiri::HTML(page.body) 37 | profiles = doc.search('div#named_profiles ul li') 38 | profiles.size.should > 1 39 | 40 | unsorted = profiles.map { |p| p.text.strip } 41 | sorted = profiles.map { |p| p.text.strip }.sort 42 | 43 | unsorted.should == sorted 44 | end 45 | 46 | Then /^I should see a list of recently shared profiles$/ do 47 | doc = Nokogiri::HTML(page.body) 48 | profiles = doc.search('div#recent_profiles ul li') 49 | profiles.size.should > 1 50 | end 51 | 52 | Then /^I should see a profile heading$/ do 53 | doc = Nokogiri::HTML(page.body) 54 | doc.search('div#profile h2#name').size.should == 1 55 | end 56 | 57 | Then /^show me the page source$/ do 58 | puts page.body 59 | end 60 | -------------------------------------------------------------------------------- /lib/visage-app/patches.rb: -------------------------------------------------------------------------------- 1 | # extracted from Extlib. 2 | # FIXME: what's the licensing here? 3 | class String 4 | def camel_case 5 | return self if self !~ /_/ && self =~ /[A-Z]+.*/ 6 | split('_').map{|e| e.capitalize}.join 7 | end 8 | 9 | def blank? 10 | strip.empty? 11 | end 12 | 13 | def to_bool 14 | return true if self == true || self =~ (/(true|t|yes|y|1)$/i) 15 | return false if self == false || self.blank? || self =~ (/(false|f|no|n|0)$/i) 16 | raise ArgumentError.new("invalid value for Boolean: \"#{self}\"") 17 | end 18 | end 19 | 20 | class NilClass 21 | def blank? 22 | true 23 | end 24 | end 25 | 26 | class Array 27 | def in_groups(number, fill_with = nil) 28 | raise "Error - in_groups of zero doesn't make sense" unless number > 0 29 | # size / number gives minor group size; 30 | # size % number gives how many objects need extra accomodation; 31 | # each group hold either division or division + 1 items. 32 | division = size / number 33 | modulo = size % number 34 | 35 | # create a new array avoiding dup 36 | groups = [] 37 | start = 0 38 | 39 | number.times do |index| 40 | length = division + (modulo > 0 && modulo > index ? 1 : 0) 41 | padding = fill_with != false && 42 | modulo > 0 && length == division ? 1 : 0 43 | groups << slice(start, length).concat([fill_with] * padding) 44 | start += length 45 | end 46 | 47 | if block_given? 48 | groups.each{|g| yield(g) } 49 | else 50 | groups 51 | end 52 | end 53 | 54 | def sum 55 | inject( nil ) { |sum,x| sum ? sum+x : x } 56 | end 57 | 58 | def mean 59 | if size > 0 60 | sum / size 61 | else 62 | nil 63 | end 64 | end 65 | 66 | end 67 | 68 | -------------------------------------------------------------------------------- /man/visage-app.5.ronn: -------------------------------------------------------------------------------- 1 | visage-app(5) -- Run a standalone instance of Visage 2 | ==================================================== 3 | 4 | ## SYNOPSIS 5 | 6 | `visage-app` 7 | 8 | ## DESCRIPTION 9 | 10 | Visage is a web interface for viewing collectd statistics. 11 | 12 | It also provides a JSON interface onto collectd's RRD data, giving you an 13 | easy way to mash up the data. 14 | 15 | ## OPTIONS 16 | 17 | * `start`: 18 | Start visage on `127.0.0.1:9292` 19 | 20 | * `genapache`: 21 | Generate a vhost suitable for Passenger + Apache. Will load Visage from 22 | the installed gem. 23 | 24 | * `help`: 25 | Display this man page. 26 | 27 | ## ENVIRONMENT 28 | 29 | * `CONFIG_PATH`: 30 | Where to look for configuration files. Added to the beginning of config 31 | load path, so files found in here take precedence over shipped config files. 32 | 33 | * `RRDDIR`: 34 | Where to look for collectd's RRDs. Must correspond with collectd's `rrdtool` 35 | plugin's `DataDir` value. 36 | 37 | * `COLLECTDSOCK`: 38 | Where to look for collectd's Unix socket. Used by Visage to issue `FLUSH` 39 | command to collectd to present more up to date statistics. 40 | 41 | * `RRDCACHEDSOCK`: 42 | Where to look for rrdcached's Unix socket. Used by Visage to issue `FLUSH` 43 | command to rrdcached to present more up to date statistics. 44 | 45 | * `VISAGE_DATA_BACKEND`: 46 | Specify which data backend Visage should use to retrieve statistics. An up 47 | to date list of backends can be found by looking in `lib/visage-app/data`. 48 | At the time of writing, the shipped backends are RRD and Mock. 49 | 50 | ## AUTHOR 51 | 52 | Lindsay Holmwood 53 | 54 | ## COPYRIGHT 55 | 56 | MIT Licensed. 57 | 58 | -------------------------------------------------------------------------------- /bin/visage-app: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'pathname' 4 | 5 | require 'socket' 6 | fqdn = Socket.gethostbyname(Socket.gethostname).first 7 | port = ENV.fetch('VISAGE_PORT', 9292) 8 | $stdout.sync = true 9 | @root = (Pathname.new(__FILE__).parent.parent + 'lib').expand_path 10 | $: << @root.to_s 11 | action = ARGV[0] 12 | 13 | def display_man_page 14 | man_path = @root.parent.join('man').join('visage-app.5') 15 | man_command = "man #{man_path}" 16 | exec(man_command) # replaces current process with invocation of man 17 | end 18 | 19 | # Display the man page if [--]help is specified anyhere in the arguments 20 | if ARGV.find {|arg| arg =~ /^(--)*help$/} 21 | display_man_page 22 | end 23 | 24 | case action 25 | when "start" 26 | require 'rubygems' 27 | require 'rack' 28 | require 'visage-app' 29 | 30 | puts ' _ ___' 31 | puts '| | / (_)________ _____ ____' 32 | puts '| | / / / ___/ __ `/ __ `/ _ \\' 33 | puts '| |/ / (__ ) /_/ / /_/ / __/' 34 | puts '|___/_/____/\__,_/\__, /\___/' 35 | puts ' /____/' 36 | puts 37 | puts "will be running at http://#{fqdn}:#{port}/" 38 | puts 39 | puts "Looking for RRDs in #{Visage::Config.rrddir}" 40 | puts 41 | 42 | config = (@root + 'visage-app/config.ru').to_s 43 | server = Rack::Server.new(:config => config, :Port => port, :server => "webrick") 44 | server.start 45 | when "genapache" 46 | require 'socket' 47 | public_dir = (@root + 'visage-app/public').to_s 48 | 49 | puts <<-CONFIG 50 | 51 | ServerName #{fqdn} 52 | ServerAdmin root@#{fqdn} 53 | 54 | DocumentRoot #{public_dir} 55 | 56 | 57 | Options FollowSymLinks Indexes 58 | AllowOverride None 59 | Order allow,deny 60 | Allow from all 61 | 62 | 63 | 64 | CONFIG 65 | else 66 | puts "Usage: visage " 67 | end 68 | 69 | -------------------------------------------------------------------------------- /man/visage-app.5: -------------------------------------------------------------------------------- 1 | .\" generated with Ronn/v0.7.3 2 | .\" http://github.com/rtomayko/ronn/tree/0.7.3 3 | . 4 | .TH "VISAGE\-APP" "5" "July 2013" "" "" 5 | . 6 | .SH "NAME" 7 | \fBvisage\-app\fR \- Run a standalone instance of Visage 8 | . 9 | .SH "SYNOPSIS" 10 | \fBvisage\-app\fR \fIstart|genapache\fR 11 | . 12 | .SH "DESCRIPTION" 13 | Visage is a web interface for viewing collectd statistics\. 14 | . 15 | .P 16 | It also provides a JSON interface onto collectd\'s RRD data, giving you an easy way to mash up the data\. 17 | . 18 | .SH "OPTIONS" 19 | . 20 | .TP 21 | \fBstart\fR 22 | Start visage on \fB127\.0\.0\.1:9292\fR 23 | . 24 | .TP 25 | \fBgenapache\fR 26 | Generate a vhost suitable for Passenger + Apache\. Will load Visage from the installed gem\. 27 | . 28 | .TP 29 | \fBhelp\fR 30 | Display this man page\. 31 | . 32 | .SH "ENVIRONMENT" 33 | . 34 | .TP 35 | \fBCONFIG_PATH\fR 36 | Where to look for configuration files\. Added to the beginning of config load path, so files found in here take precedence over shipped config files\. 37 | . 38 | .TP 39 | \fBRRDDIR\fR 40 | Where to look for collectd\'s RRDs\. Must correspond with collectd\'s \fBrrdtool\fR plugin\'s \fBDataDir\fR value\. 41 | . 42 | .TP 43 | \fBCOLLECTDSOCK\fR 44 | Where to look for collectd\'s Unix socket\. Used by Visage to issue \fBFLUSH\fR command to collectd to present more up to date statistics\. 45 | . 46 | .TP 47 | \fBRRDCACHEDSOCK\fR 48 | Where to look for rrdcached\'s Unix socket\. Used by Visage to issue \fBFLUSH\fR command to rrdcached to present more up to date statistics\. 49 | . 50 | .TP 51 | \fBVISAGE_DATA_BACKEND\fR 52 | Specify which data backend Visage should use to retrieve statistics\. An up to date list of backends can be found by looking in \fBlib/visage\-app/data\fR\. At the time of writing, the shipped backends are RRD and Mock\. 53 | . 54 | .SH "AUTHOR" 55 | Lindsay Holmwood \fIlindsay@holmwood\.id\.au\fR 56 | . 57 | .SH "COPYRIGHT" 58 | MIT Licensed\. 59 | -------------------------------------------------------------------------------- /lib/visage-app/data.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Proxy for the various Visage data backends. 4 | # 5 | # Mixes in a backend when you call `Visage::Data.backend`. 6 | # e.g. `Visage::Data.backend = 'RRD' 7 | # 8 | module Visage 9 | class Data 10 | attr_accessor :options 11 | 12 | def initialize(opts={}) 13 | @options = opts 14 | 15 | # Create shortcut instance variables for all of the options passed in. 16 | # 17 | # This makes looking up instance variables within each of the backends 18 | # significantly easier, e.g. rather than doing @options[:rrddir] in every 19 | # method, we create an @rrddir instance variable as a shortcut. 20 | @options.each_pair do |key, value| 21 | instance_variable_set("@#{key}", value) 22 | end 23 | end 24 | 25 | # Sets the data backend to use on subsequent requests. 26 | # 27 | # Currently supported backends are RRD and Mock. 28 | def self.backend=(backend) 29 | # Require the backend. 30 | # FIXME: test if the file exists. 31 | backend_filename = File.join(File.dirname(__FILE__), 'data', "#{backend.downcase}") 32 | require(backend_filename) 33 | 34 | # Determine the module name, and include it into Visage::Data. 35 | # 36 | # Uses method documented at http://ithaca.arpinum.org/2010/07/29/ruby-dynamic-includes.html 37 | module_name = Visage::Data.const_get(backend) 38 | self.send(:include, module_name) 39 | 40 | self.backend 41 | end 42 | 43 | def self.backend 44 | self.included_modules.find {|m| m.to_s =~ /^Visage::Data/} 45 | end 46 | 47 | private 48 | def parse_time(time, opts={}) 49 | case 50 | when time && time.index('.') 51 | time.split('.').first.to_i 52 | when time 53 | time.to_i 54 | else 55 | opts[:default] || Time.now.to_i 56 | end 57 | end 58 | end # class JSON 59 | end # module Visage 60 | -------------------------------------------------------------------------------- /man/visage-api.5.ronn: -------------------------------------------------------------------------------- 1 | visage-api(5) -- Reference documentation for the Visage JSON API 2 | ================================================================ 3 | 4 | ## DESCRIPTION 5 | 6 | Visage is a web interface for viewing collectd statistics. 7 | 8 | It also provides a JSON interface onto collectd's RRD data, giving you an 9 | easy way to mash up the data. 10 | 11 | This man page documents the different ways you can call the API. 12 | 13 | ## API methods 14 | 15 | Calls to the API follow the `/data///` pattern. 16 | 17 | * `/data` 18 | 19 | Display all the hosts metrics are available for. 20 | 21 | * `/data/[,hostname][,...][,hostname]` 22 | 23 | Display all metrics available for the specified hostnames. 24 | 25 | *Example: Single hostname:* 26 | 27 | `/data/localhost.localdomain` 28 | 29 | *Example: Multiple hostnames:* 30 | 31 | `/data/localhost.localdomain,foo-01.example.org,bar-23.example.org` 32 | 33 | * `/data//[,plugin][,...][,plugin]` 34 | 35 | Fetch all plugin data for the specified hostnames. 36 | 37 | *Example: Single host, single plugin:* 38 | 39 | `/data/localhost.localdomain/load` 40 | 41 | *Example: Multiple hosts, single plugin:* 42 | 43 | `/data/localhost.localdomain,foo-01.example.org,bar-23.example.org/swap` 44 | 45 | *Example: Multiple hosts, multiple plugins:* 46 | 47 | `/data/localhost.localdomain,foo-01.example.org,bar-23.example.org/swap,df` 48 | 49 | * `/data///[,instance][,...][,instance]` 50 | 51 | Fetch data for the specific plugin instance, for the specified hostnames. 52 | 53 | *Example: Single host, single instance* 54 | 55 | `/data/localhost.localdomain/memory/memory-free 56 | 57 | *Example: Multiple hosts, multiple instances* 58 | 59 | `/data/foo-01.example.org,bar-23.example.org/memory/memory-free,memory-used` 60 | 61 | ## AUTHOR 62 | 63 | Lindsay Holmwood 64 | 65 | ## COPYRIGHT 66 | 67 | MIT Licensed. 68 | 69 | -------------------------------------------------------------------------------- /lib/visage-app/upgrade/v3.rb: -------------------------------------------------------------------------------- 1 | module Visage 2 | class Upgrade 3 | class V3 4 | class << self 5 | def version 6 | 3 7 | end 8 | 9 | def run 10 | root = Pathname.new(Profile.config_path) 11 | profiles_dot_yaml = root.join('profiles.yaml') 12 | data = YAML.load(profiles_dot_yaml.read) 13 | 14 | data.each_pair do |url, attrs| 15 | graphs = [] 16 | 17 | @plugins = {} 18 | attrs[:metrics].each do |m| 19 | plugin, instance = m.split('/') 20 | @plugins[plugin] ||= [] 21 | @plugins[plugin] << instance 22 | end 23 | 24 | plugins = @plugins.map {|plugin, instances| "#{plugin}/#{instances.join(',')}"} 25 | plugins = @plugins.map {|plugin, instances| "#{plugin}"} 26 | 27 | attrs[:hosts].each do |host| 28 | plugins.each do |plugin| 29 | graphs << { 30 | :host => host, 31 | :plugin => plugin, 32 | :percentiles => attrs[:percentiles], 33 | } 34 | end 35 | end 36 | 37 | attributes = { 38 | :anonymous => false, 39 | :name => attrs[:profile_name], 40 | :graphs => graphs, 41 | } 42 | 43 | profile = Profile.new(attributes) 44 | if not profile.save 45 | puts "Could not upgrade profile #{profile.id}." 46 | puts "These were the error messages:" 47 | profile.errors.messages.each do |msg| 48 | puts " - #{msg.join(' ')}" 49 | end 50 | puts "Exiting!" 51 | exit 128 52 | end 53 | end 54 | 55 | # Move profiles.yaml out of the way 56 | backup = profiles_dot_yaml.dirname.join("profiles.yaml.#{Time.now.to_i.to_s}") 57 | profiles_dot_yaml.rename(backup) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | 64 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/highcharts-mootools-adapter.js: -------------------------------------------------------------------------------- 1 | /* 2 | Highcharts JS v2.2.1 (2012-03-15) 3 | MooTools adapter 4 | 5 | (c) 2010-2011 Torstein H?nsi 6 | 7 | License: www.highcharts.com/license 8 | */ 9 | (function(){var e=window,i=document,f=e.MooTools.version.substring(0,3),g=f==="1.2"||f==="1.1",j=g||f==="1.3",h=e.$extend||function(){return Object.append.apply(Object,arguments)};e.HighchartsAdapter={init:function(a){var b=Fx.prototype,c=b.start,d=Fx.Morph.prototype,e=d.compute;b.start=function(b,d){var e=this.element;if(b.d)this.paths=a.init(e,e.d,this.toD);c.apply(this,arguments);return this};d.compute=function(b,c,d){var f=this.paths;if(f)this.element.attr("d",a.step(f[0],f[1],d,this.toD));else return e.apply(this, 10 | arguments)}},getScript:function(a,b){var c=i.getElementsByTagName("head")[0],d=i.createElement("script");d.type="text/javascript";d.src=a;d.onload=b;c.appendChild(d)},animate:function(a,b,c){var d=a.attr,f=c&&c.complete;if(d&&!a.setStyle)a.getStyle=a.attr,a.setStyle=function(){var b=arguments;a.attr.call(a,b[0],b[1][0])},a.$family=function(){return!0};e.HighchartsAdapter.stop(a);c=new Fx.Morph(d?a:$(a),h({transition:Fx.Transitions.Quad.easeInOut},c));if(d)c.element=a;if(b.d)c.toD=b.d;f&&c.addEvent("complete", 11 | f);c.start(b);a.fx=c},each:function(a,b){return g?$each(a,b):Array.each(a,b)},map:function(a,b){return a.map(b)},grep:function(a,b){return a.filter(b)},merge:function(){var a=arguments,b=[{}],c=a.length;if(g)a=$merge.apply(null,a);else{for(;c--;)typeof a[c]!=="boolean"&&(b[c+1]=a[c]);a=Object.merge.apply(Object,b)}return a},offset:function(a){a=$(a).getOffsets();return{left:a.x,top:a.y}},extendWithEvents:function(a){a.addEvent||(a.nodeName?$(a):h(a,new Events))},addEvent:function(a,b,c){typeof b=== 12 | "string"&&(b==="unload"&&(b="beforeunload"),e.HighchartsAdapter.extendWithEvents(a),a.addEvent(b,c))},removeEvent:function(a,b,c){typeof a!=="string"&&(e.HighchartsAdapter.extendWithEvents(a),b?(b==="unload"&&(b="beforeunload"),c?a.removeEvent(b,c):a.removeEvents(b)):a.removeEvents())},fireEvent:function(a,b,c,d){b={type:b,target:a};b=j?new Event(b):new DOMEvent(b);b=h(b,c);b.preventDefault=function(){d=null};a.fireEvent&&a.fireEvent(b.type,b);d&&d(b)},stop:function(a){a.fx&&a.fx.cancel()}}})(); 13 | -------------------------------------------------------------------------------- /features/support/daemons.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'colorize' 4 | 5 | After('@daemon') do 6 | kill_lingering_daemons 7 | end 8 | 9 | class IO 10 | attr_accessor :command 11 | 12 | class << self 13 | alias :original_popen :popen 14 | 15 | def popen(*args) 16 | obj = original_popen(*args) 17 | obj.command = args 18 | obj 19 | end 20 | end 21 | end 22 | 23 | DAEMONS = [] 24 | 25 | def kill_lingering_daemons 26 | DAEMONS && DAEMONS.each do |daemon| 27 | begin 28 | puts "Killing process #{daemon.pid}".yellow if @debug 29 | Process.kill("KILL", daemon.pid) 30 | rescue Errno::ESRCH 31 | puts "Process #{daemon.pid} has already exited.".green 32 | end 33 | 34 | if @debug 35 | puts "Output from #{daemon.pid} #{daemon.command}\n".blue 36 | puts daemon.read + "\n" 37 | end 38 | end 39 | puts "Done killing processes".yellow if @debug 40 | end 41 | 42 | def spawn_daemon(command, opts={}) 43 | puts "Running: #{command}".yellow if @debug 44 | daemon = IO.popen(command) 45 | DAEMONS << daemon 46 | sleep 2 47 | 48 | begin 49 | Process.kill(0, daemon.pid) 50 | rescue 51 | puts "Daemon #{$?.pid}: (#{command}) is stopped!".red 52 | puts "Output from #{daemon.pid} #{daemon.command}\n".red 53 | puts daemon.read_nonblock + "\n" 54 | puts "Aborting!" 55 | exit 3 56 | end 57 | daemon 58 | end 59 | 60 | # Testing daemons with Ruby backticks blocks indefinitely, because the 61 | # backtick method waits for the program to exit. We use the select() system 62 | # call to read from a pipe connected to a daemon, and return if no data is 63 | # read within the specified timeout. 64 | # 65 | # http://weblog.jamisbuck.org/assets/2006/9/25/gdb.rb 66 | def read_until_timeout(pipe, timeout=1, verbose=false) 67 | @output ||= [] 68 | line = "" 69 | while data = IO.select([pipe], nil, nil, timeout) do 70 | next if data.empty? 71 | char = pipe.read(1) 72 | break if char.nil? 73 | 74 | line << char 75 | if line[-1] == ?\n 76 | puts line if verbose 77 | @output << line 78 | line = "" 79 | end 80 | end 81 | 82 | @output 83 | end 84 | 85 | at_exit do 86 | kill_lingering_daemons 87 | end 88 | -------------------------------------------------------------------------------- /man/visage-api.5: -------------------------------------------------------------------------------- 1 | .\" generated with Ronn/v0.7.3 2 | .\" http://github.com/rtomayko/ronn/tree/0.7.3 3 | . 4 | .TH "VISAGE\-API" "5" "July 2013" "" "" 5 | . 6 | .SH "NAME" 7 | \fBvisage\-api\fR \- Reference documentation for the Visage JSON API 8 | . 9 | .SH "DESCRIPTION" 10 | Visage is a web interface for viewing collectd statistics\. 11 | . 12 | .P 13 | It also provides a JSON interface onto collectd\'s RRD data, giving you an easy way to mash up the data\. 14 | . 15 | .P 16 | This man page documents the different ways you can call the API\. 17 | . 18 | .SH "API methods" 19 | Calls to the API follow the \fB/data///\fR pattern\. 20 | . 21 | .IP "\(bu" 4 22 | \fB/data\fR 23 | . 24 | .IP 25 | Display all the hosts metrics are available for\. 26 | . 27 | .IP "\(bu" 4 28 | \fB/data/[,hostname][,\.\.\.][,hostname]\fR 29 | . 30 | .IP 31 | Display all metrics available for the specified hostnames\. 32 | . 33 | .IP 34 | \fIExample: Single hostname:\fR 35 | . 36 | .IP 37 | \fB/data/localhost\.localdomain\fR 38 | . 39 | .IP 40 | \fIExample: Multiple hostnames:\fR 41 | . 42 | .IP 43 | \fB/data/localhost\.localdomain,foo\-01\.example\.org,bar\-23\.example\.org\fR 44 | . 45 | .IP "\(bu" 4 46 | \fB/data//[,plugin][,\.\.\.][,plugin]\fR 47 | . 48 | .IP 49 | Fetch all plugin data for the specified hostnames\. 50 | . 51 | .IP 52 | \fIExample: Single host, single plugin:\fR 53 | . 54 | .IP 55 | \fB/data/localhost\.localdomain/load\fR 56 | . 57 | .IP 58 | \fIExample: Multiple hosts, single plugin:\fR 59 | . 60 | .IP 61 | \fB/data/localhost\.localdomain,foo\-01\.example\.org,bar\-23\.example\.org/swap\fR 62 | . 63 | .IP 64 | \fIExample: Multiple hosts, multiple plugins:\fR 65 | . 66 | .IP 67 | \fB/data/localhost\.localdomain,foo\-01\.example\.org,bar\-23\.example\.org/swap,df\fR 68 | . 69 | .IP "\(bu" 4 70 | \fB/data///[,instance][,\.\.\.][,instance]\fR 71 | . 72 | .IP 73 | Fetch data for the specific plugin instance, for the specified hostnames\. 74 | . 75 | .IP 76 | \fIExample: Single host, single instance\fR 77 | . 78 | .IP 79 | `/data/localhost\.localdomain/memory/memory\-free 80 | . 81 | .IP 82 | \fIExample: Multiple hosts, multiple instances\fR 83 | . 84 | .IP 85 | \fB/data/foo\-01\.example\.org,bar\-23\.example\.org/memory/memory\-free,memory\-used\fR 86 | . 87 | .IP "" 0 88 | . 89 | .SH "AUTHOR" 90 | Lindsay Holmwood \fIlindsay@holmwood\.id\.au\fR 91 | . 92 | .SH "COPYRIGHT" 93 | MIT Licensed\. 94 | -------------------------------------------------------------------------------- /lib/visage-app/assets/coffeescripts/collections.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Collections 3 | # 4 | HostCollection = Backbone.Collection.extend({ 5 | url: '/data', 6 | model: Host, 7 | parse: (response) -> 8 | attrs = response.hosts.map((host) -> 9 | { id: host } 10 | ) 11 | # FIXME: Refactor into common class 12 | filter: (term) -> 13 | this.each((item) -> 14 | try 15 | match = !!item.get('id').match(term) 16 | catch error 17 | if not error instanceof SyntaxError 18 | throw error 19 | 20 | item.set('display', match) 21 | ) 22 | # FIXME: Refactor into common class 23 | selected: () -> 24 | this.models.filter((model) -> model.get('checked') == true) 25 | for_api: () -> 26 | this.selected().map((host) -> host.get('id')).unique() 27 | }) 28 | 29 | MetricCollection = Backbone.Collection.extend({ 30 | url: () -> 31 | '/data/' + this.getConditions().join(',') 32 | 33 | model: Metric, 34 | parse: (response) -> 35 | # TODO(auxesis): add support for nesting instances under plugins 36 | attrs = response.metrics.map((metric) -> 37 | { id: metric } 38 | ) 39 | _.sortBy(attrs, (attr) -> attr.id) 40 | 41 | # FIXME: Refactor into common class 42 | filter: (term) -> 43 | this.each((item) -> 44 | try 45 | match = !!item.get('id').match(term) 46 | catch error 47 | if not error instanceof SyntaxError 48 | throw error 49 | 50 | item.set('display', match) 51 | ) 52 | 53 | # FIXME: Refactor into common class 54 | selected: () -> 55 | this.models.filter((model) -> model.get('checked') == true) 56 | for_api: () -> 57 | selected = {} 58 | selected_metrics = [] 59 | 60 | this.selected().each((metric) -> 61 | id = metric.get('id') 62 | [ plugin, instance ] = id.split('/') 63 | selected[plugin] ||= [] 64 | selected[plugin].include(instance) 65 | ) 66 | Object.each(selected, (item, key, object) -> 67 | selected_metrics.include("#{key}/#{item.join(',')}") 68 | ) 69 | 70 | selected_metrics 71 | 72 | initialize: () -> 73 | this.conditions = [] 74 | setConditions: (conditions) -> 75 | this.conditions = conditions 76 | getConditions: () -> 77 | this.conditions 78 | 79 | }) 80 | 81 | GraphCollection = Backbone.Collection.extend({ 82 | model: Graph 83 | }) 84 | 85 | TimeframeCollection = Backbone.Collection.extend({ 86 | model: Timeframe 87 | }) 88 | 89 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/LightFace.Image.js: -------------------------------------------------------------------------------- 1 | /* 2 | --- 3 | description: LightFace.Image 4 | 5 | authors: 6 | - David Walsh (http://davidwalsh.name) 7 | 8 | license: 9 | - MIT-style license 10 | 11 | requires: 12 | core/1.2.1: "*" 13 | 14 | provides: 15 | - LightFace.Image 16 | ... 17 | */ 18 | LightFace.Image = new Class({ 19 | Extends: LightFace, 20 | options: { 21 | constrain: true, 22 | url: "" 23 | }, 24 | initialize: function(options) { 25 | this.parent(options); 26 | this.url = ""; 27 | this.resizeOnOpen = false; 28 | if(this.options.url) this.load(); 29 | }, 30 | _resize: function() { 31 | //get the largest possible height 32 | var maxHeight = window.getSize().y - this.options.pad; 33 | 34 | //get the image size 35 | var imageDimensions = document.id(this.image).retrieve("dimensions"); 36 | 37 | //if image is taller than window... 38 | if(imageDimensions.y > maxHeight) { 39 | this.image.height = maxHeight; 40 | this.image.width = (imageDimensions.x * (maxHeight / imageDimensions.y)); 41 | this.image.setStyles({ 42 | height: maxHeight, 43 | width: (imageDimensions.x * (maxHeight / imageDimensions.y)).toInt() 44 | }); 45 | } 46 | 47 | //get rid of styles 48 | this.messageBox.setStyles({ height: "", width: "" }); 49 | 50 | //position the box 51 | this._position(); 52 | }, 53 | load: function(url, title) { 54 | //keep current height/width 55 | var currentDimensions = { x: "", y: "" }; 56 | if(this.image) currentDimensions = this.image.getSize(); 57 | ///empty the content, show the indicator 58 | this.messageBox.set("html", "").addClass("lightFaceMessageBoxImage").setStyles({ 59 | width: currentDimensions.x, 60 | height: currentDimensions.y 61 | }); 62 | this._position(); 63 | this.fade(); 64 | this.image = new Element("img", { 65 | events: { 66 | load: function() { 67 | (function() { 68 | var setSize = function() { 69 | this.image.inject(this.messageBox).store("dimensions", this.image.getSize()); 70 | }.bind(this); 71 | setSize(); 72 | this._resize(); 73 | setSize(); //stupid ie 74 | this.unfade(); 75 | this.fireEvent("complete"); 76 | }).bind(this).delay(10); 77 | }.bind(this), 78 | error: function() { 79 | this.fireEvent("error"); 80 | this.image.destroy(); 81 | delete this.image; 82 | this.messageBox.set("html", this.options.errorMessage).removeClass("lightFaceMessageBoxImage"); 83 | }.bind(this), 84 | click: function() { 85 | this.close(); 86 | }.bind(this) 87 | }, 88 | styles: { 89 | width: "auto", 90 | height: "auto" 91 | } 92 | }); 93 | this.image.src = url || this.options.url; 94 | if(title && this.title) this.title.set("html", title); 95 | return this; 96 | } 97 | }); -------------------------------------------------------------------------------- /features/step_definitions/cli_steps.rb: -------------------------------------------------------------------------------- 1 | Given /^the visage server helper is not running$/ do 2 | require 'socket' 3 | 4 | lambda { 5 | fqdn = Socket.gethostbyname(Socket.gethostname).first 6 | socket = TCPSocket.new(fqdn, 9292) 7 | }.should raise_error, 'Visage server is running on 9292' 8 | end 9 | 10 | When /^I start the visage server helper with "([^"]*)"$/ do |cmd| 11 | @root = Pathname.new(File.dirname(__FILE__)).parent.parent.expand_path 12 | command = "#{@root.join('bin')}/#{cmd}" 13 | 14 | @pipe = spawn_daemon(command) 15 | end 16 | 17 | Then /^a visage web server should be running$/ do 18 | `ps -eo cmd |grep ^visage`.size.should > 0 19 | end 20 | 21 | Then /^a visage web server should not be running$/ do 22 | `ps -eo cmd |grep ^visage`.size.should == 0 23 | end 24 | 25 | Then /^I should see a man page$/ do 26 | output = read_until_timeout(@pipe) 27 | 28 | headings = %w(VISAGE-APP NAME DESCRIPTION SYNOPSIS COPYRIGHT) 29 | 30 | headings.each do |heading| 31 | output.find {|line| line =~ /^#{heading}/}.should_not be_nil 32 | end 33 | end 34 | 35 | Then /^I should see "([^"]*)" on the terminal$/ do |string| 36 | output = read_until_timeout(@pipe).join('') 37 | output.should =~ /#{string}/ 38 | end 39 | 40 | Given /^there is no file at "([^"]*)"$/ do |filename| 41 | FileUtils.rm_f(filename).should be_true 42 | end 43 | 44 | When /^I start the visage server helper with "([^"]*)" and the following variables:$/ do |cmd, table| 45 | table.hashes.each do |hash| 46 | hash.each_pair do |variable, value| 47 | ENV[variable] = value 48 | end 49 | end 50 | step %(I start the visage server helper with "#{cmd}") 51 | end 52 | 53 | Then /^I should see a file at "([^"]*)"$/ do |filename| 54 | File.exists?(filename).should be_true 55 | end 56 | 57 | Then /^show me the output$/ do 58 | puts @pipe.read(350) 59 | end 60 | 61 | Given /^I am using a profile based on "(.*?)"$/ do |directory| 62 | root = Pathname.new(__FILE__).parent.parent.join('support').join('config') 63 | source = root.join(directory).to_s 64 | destination = root.join('tmp').to_s 65 | 66 | File.directory?(source).should be_true 67 | 68 | FileUtils.rm_rf(destination) 69 | FileUtils.mkdir_p(destination) 70 | FileUtils.cp_r(Dir.glob("#{source}/*.yaml"), destination) 71 | 72 | ENV['CONFIG_PATH'] = destination 73 | end 74 | 75 | Given /^a profile file doesn't exist$/ do 76 | root = Pathname.new(__FILE__).parent.parent.join('support/config') 77 | destination = root.join('tmp').join('profiles.yaml') 78 | 79 | FileUtils.rm(destination) if destination.exist? 80 | ENV['CONFIG_PATH'] = destination.parent.to_s 81 | end 82 | 83 | Then /^I should see a profile file has been created$/ do 84 | root = Pathname.new(__FILE__).parent.parent.join('support/config') 85 | destination = root.join('tmp').join('profiles.yaml') 86 | 87 | destination.exist?.should be_true 88 | end 89 | -------------------------------------------------------------------------------- /features/support/config/with_old_profile_yaml/profiles.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | zend+tail+on+ubuntu: 3 | :metrics: tail*/* 4 | :hosts: ubuntu.localdomain 5 | :url: zend+tail+on+ubuntu 6 | :profile_name: zend tail on ubuntu 7 | load+on+ubuntu+localdomain: 8 | :metrics: load/* 9 | :hosts: ubuntu.localdomain 10 | :url: load+on+ubuntu+localdomain 11 | :profile_name: load on ubuntu.localdomain 12 | apache+on+blah: 13 | :metrics: apache/* 14 | :hosts: blah 15 | :url: apache+on+blah 16 | :profile_name: apache on blah 17 | interfaces+on+blah: 18 | :metrics: interface/* 19 | :hosts: blah 20 | :url: interfaces+on+blah 21 | :profile_name: interfaces on blah 22 | all+on+all: 23 | :metrics: "*" 24 | :hosts: "*" 25 | :url: all+on+all 26 | :profile_name: all on all 27 | object+space+on+ree: 28 | :metrics: curl_json-object_space/* 29 | :hosts: ubuntu* 30 | :url: object+space+on+ree 31 | :profile_name: Object space on REE 32 | disk+sda+on+ubuntu: 33 | :metrics: disk-sda/* 34 | :hosts: ubuntu.localdomain 35 | :url: disk+sda+on+ubuntu 36 | :profile_name: disk-sda on ubuntu 37 | gc+on+ree: 38 | :metrics: curl_json-gc/* 39 | :hosts: ubuntu* 40 | :url: gc+on+ree 41 | :profile_name: GC on REE 42 | gateway+ping+from+blah: 43 | :metrics: ping/* 44 | :hosts: blah 45 | :url: gateway+ping+from+blah 46 | :profile_name: gateway ping from blah 47 | tcpconns+on+ubuntu: 48 | :metrics: tcpconns*/* 49 | :hosts: ubuntu* 50 | :url: tcpconns+on+ubuntu 51 | :profile_name: tcpconns on ubuntu 52 | entropy+on+blah: 53 | :metrics: entropy/* 54 | :hosts: blah 55 | :url: entropy+on+blah 56 | :profile_name: entropy on blah 57 | memory+on+ubunttu: 58 | :metrics: memory/* 59 | :hosts: ubuntu* 60 | :url: memory+on+ubunttu 61 | :profile_name: memory on ubunttu 62 | processes+on+blah: 63 | :metrics: processes/* 64 | :hosts: blah 65 | :url: processes+on+blah 66 | :profile_name: processes on blah 67 | ruby+gc+on+ubuntu+localdomain: 68 | :metrics: curl_json-gc/* 69 | :hosts: ubuntu* 70 | :url: ruby+gc+on+ubuntu+localdomain 71 | :profile_name: Ruby GC on ubuntu.localdomain 72 | cpu+statistics+for+ubuntu+localdomain: 73 | :metrics: cpu*/* 74 | :hosts: ubuntu* 75 | :url: cpu+statistics+for+ubuntu+localdomain 76 | :profile_name: CPU statistics for ubuntu.localdomain 77 | swap+on+blah: 78 | :metrics: swap/* 79 | :hosts: blah 80 | :url: swap+on+blah 81 | :profile_name: swap on blah 82 | cpu+and+load+on+ubuntu: 83 | :metrics: cpu*/*,load/* 84 | :hosts: ubuntu* 85 | :url: cpu+and+load+on+ubuntu 86 | :profile_name: cpu and load on ubuntu 87 | cpu+on+flapjack+workers: 88 | :metrics: cpu*/* 89 | :hosts: flapjack-* 90 | :url: cpu+on+flapjack+workers 91 | :profile_name: cpu on flapjack workers 92 | memory+on+flapjack+workers+and+blah: 93 | :metrics: memory/* 94 | :hosts: flapjack-worker*,blah 95 | :url: memory+on+flapjack+workers+and+blah 96 | :profile_name: memory on flapjack workers and blah 97 | uptime+on+blah: 98 | :metrics: uptime/* 99 | :hosts: blah 100 | :url: uptime+on+blah 101 | :profile_name: uptime on blah 102 | users+on+blah: 103 | :metrics: users/* 104 | :hosts: blah 105 | :url: users+on+blah 106 | :profile_name: users on blah 107 | irqs+on+blah: 108 | :metrics: irq/* 109 | :hosts: blah 110 | :url: irqs+on+blah 111 | :profile_name: irqs on blah 112 | vmem+on+blah: 113 | :metrics: vmem/* 114 | :hosts: blah 115 | :url: vmem+on+blah 116 | :profile_name: vmem on blah 117 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | require 'cucumber' 6 | require 'cucumber/rake/task' 7 | require 'rspec/core/rake_task' 8 | require 'colorize' 9 | require 'pathname' 10 | $: << Pathname.new(__FILE__).join('lib').expand_path.to_s 11 | require 'visage-app/version' 12 | 13 | Cucumber::Rake::Task.new(:features) do |t| 14 | t.cucumber_opts = "features --format pretty" 15 | end 16 | 17 | RSpec::Core::RakeTask.new(:spec) 18 | 19 | desc "build man pages" 20 | task :man do 21 | man_glob = Pathname.new(__FILE__).parent.join('man').join('*.ronn').to_s 22 | Dir.glob(man_glob) do |man_page| 23 | sh "ronn --roff #{man_page}" 24 | end 25 | end 26 | 27 | desc "build gem" 28 | task :build => [:man, :verify] do 29 | build_output = `gem build visage-app.gemspec` 30 | puts build_output 31 | 32 | gem_filename = build_output[/File: (.*)/,1] 33 | pkg_path = "pkg" 34 | FileUtils.mkdir_p(pkg_path) 35 | FileUtils.mv(gem_filename, pkg_path) 36 | 37 | puts "Gem built in #{pkg_path}/#{gem_filename}".green 38 | end 39 | 40 | desc "push gem" 41 | task :push do 42 | filenames = Dir.glob("pkg/*.gem") 43 | filenames_with_times = filenames.map do |filename| 44 | [filename, File.mtime(filename)] 45 | end 46 | 47 | newest = filenames_with_times.sort_by { |tuple| tuple.last }.last 48 | newest_filename = newest.first 49 | 50 | command = "gem push #{newest_filename}" 51 | system(command) 52 | end 53 | 54 | desc "clean up various generated files" 55 | task :clean do 56 | [ "webrat.log", "pkg/", "_site/"].each do |filename| 57 | puts "Removing #{filename}" 58 | FileUtils.rm_rf(filename) 59 | end 60 | end 61 | 62 | namespace :verify do 63 | desc "perform lintian checks on the JavaScript about to be shipped" 64 | task :lintian do 65 | @count = 0 66 | require 'pathname' 67 | @root = Pathname.new(File.dirname(__FILE__)).expand_path 68 | javascripts_path = @root.join('lib/visage-app/public/javascripts') 69 | 70 | javascripts = Dir.glob("#{javascripts_path + "*"}.js").reject {|f| f =~ /highcharts|mootools|src\.js/ } 71 | javascripts.each do |filename| 72 | puts "Checking #{filename}".green 73 | count = `grep -c 'console.log' #{filename}`.strip.to_i 74 | if count > 0 75 | puts "#{count} instances of console.log found in #{File.basename(filename)}".red 76 | @count += 1 77 | end 78 | end 79 | 80 | abort if @count > 0 81 | end 82 | 83 | task :changelog do 84 | changelog_filename = "CHANGELOG.md" 85 | version = Visage::VERSION 86 | 87 | if not system("grep ^#{version} #{changelog_filename} 2>&1 >/dev/null") 88 | puts "#{changelog_filename} doesn't have an entry for the version you are about to build.".red 89 | exit 1 90 | end 91 | end 92 | 93 | task :uncommitted do 94 | uncommitted = `git ls-files -m`.split("\n") 95 | if uncommitted.size > 0 96 | puts "The following files are uncommitted:".red 97 | uncommitted.each do |filename| 98 | puts " - #{filename}".red 99 | end 100 | exit 1 101 | end 102 | end 103 | 104 | task :all => [ :lintian, :changelog, :uncommitted ] 105 | end 106 | 107 | task :verify => 'verify:all' 108 | 109 | task :default => [:spec,:features] 110 | 111 | 112 | namespace :coffee do 113 | task :compile do 114 | cmd = %w(coffee) 115 | cmd << "--output lib/visage-app/public/javascripts/" 116 | cmd << "--join" 117 | 118 | # TODO(auxesis): add ability to pull in other arbitrary coffeescript 119 | %w(application models collections views profiles).each do |filename| 120 | cmd << "lib/visage-app/assets/coffeescripts/#{filename}.coffee" 121 | end 122 | 123 | command = cmd.join(' ') 124 | 125 | sh(command) 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/visage-app/data/mock.rb: -------------------------------------------------------------------------------- 1 | module Visage 2 | class Data 3 | module Mock 4 | DATA = { 5 | :hosts => %w(localhost.localdomain ubuntu.localdomain app-01.example.org), 6 | :metrics => { 7 | 'cpu-0' => %w(cpu-user cpu-idle cpu-steal cpu-system cpu-wait), 8 | 'cpu-1' => %w(cpu-user cpu-idle cpu-steal cpu-system cpu-wait), 9 | 'df' => %w(df-root df-dev-shm), 10 | 'entropy' => %w(entropy), 11 | 'load' => %w(shortterm midterm longterm), 12 | 'memory' => %w(memory-free memory-used memory-cached memory-buffered), 13 | 'swap' => %w(swap-cached swap-free swap-used), 14 | } 15 | } 16 | 17 | def self.included(base) 18 | base.extend(ClassMethods) 19 | end 20 | 21 | def json(opts={}) 22 | # setup variables 23 | hosts = build_host_list(opts[:host]) 24 | plugins = build_plugin_list(opts[:plugin]) 25 | instances = build_instances_list(opts[:instances]) 26 | source = 'value' 27 | 28 | finish = parse_time(opts[:finish]) 29 | start = parse_time(opts[:start], :default => (finish - 3600 || (Time.now - 3600).to_i)) 30 | 31 | structure = {} 32 | functions = [:sin, :cos, :cbrt,] * 10 33 | 34 | # Build data structures 35 | hosts.each do |host| 36 | structure[host] ||= {} 37 | 38 | plugins.each do |plugin| 39 | structure[host][plugin] ||= {} 40 | 41 | instances = DATA[:metrics][plugin] if instances == '*' 42 | instances.each_with_index do |instance, index| 43 | function = functions[index] 44 | metric = (0..360).step(20).to_a.map {|i| Math.send(function, i) * 10 * (index + 1) }[index ** 2..-1] 45 | metric = metric * ((finish - start) / 3600.0).ceil 46 | 47 | structure[host][plugin][instance] ||= {} 48 | structure[host][plugin][instance][source] ||= {} 49 | structure[host][plugin][instance][source][:start] ||= start 50 | structure[host][plugin][instance][source][:finish] ||= finish 51 | structure[host][plugin][instance][source][:data] ||= metric 52 | structure[host][plugin][instance][source][:percentile_95] ||= 95 53 | structure[host][plugin][instance][source][:percentile_50] ||= 50 54 | structure[host][plugin][instance][source][:percentile_5] ||= 5 55 | end 56 | end 57 | end 58 | 59 | encoder = Yajl::Encoder.new 60 | encoder.encode(structure) 61 | end 62 | 63 | private 64 | 65 | def build_host_list(hosts) 66 | hosts.split(',').map! do |host| 67 | if host =~ /\*/ 68 | DATA[:hosts].find_all {|k| k =~ /#{host.gsub('*', '.*')}/ } 69 | else 70 | host 71 | end 72 | end.flatten 73 | end 74 | 75 | def build_plugin_list(plugins) 76 | plugins.split(',').map! do |plugin| 77 | if plugin =~ /\*/ 78 | DATA[:metrics].keys.find_all {|k| k =~ /#{plugin.gsub('*', '.*')}/ } 79 | else 80 | plugin 81 | end 82 | end.flatten 83 | end 84 | 85 | def build_instances_list(instances) 86 | instances[1..-1] && instances[1..-1].split(',') || '*' 87 | end 88 | 89 | module ClassMethods 90 | def hosts(opts={}) 91 | DATA[:hosts] 92 | end 93 | 94 | def metrics(opts={}) 95 | DATA[:metrics].map { |plugin, instances| 96 | instances.map { |instance| 97 | "#{plugin}/#{instance}" 98 | } 99 | }.flatten 100 | end 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /features/json.feature: -------------------------------------------------------------------------------- 1 | Feature: JSON API 2 | To perform analysis 3 | A user 4 | Must be able to extract data 5 | From the application 6 | 7 | Scenario: Retreive a list of hosts 8 | When I go to /data 9 | Then the request should succeed 10 | Then I should receive valid JSON 11 | And the JSON should have a list of hosts 12 | 13 | Scenario: Get a list of available metrics on a host 14 | When I visit the first available host 15 | Then the request should succeed 16 | Then I should receive valid JSON 17 | And the JSON should have a list of plugins 18 | 19 | Scenario: Get a list of available metrics across many hosts 20 | When I visit the first two available hosts 21 | Then the request should succeed 22 | Then I should receive valid JSON 23 | And the JSON should have a list of plugins 24 | 25 | Scenario: Retrieve single plugin instance 26 | Given a list of hosts exist 27 | When I visit "memory/memory-free" on the first available host 28 | Then the request should succeed 29 | Then I should receive valid JSON 30 | And the JSON should have a plugin instance named "memory-free" 31 | 32 | Scenario: Retrieve multiple plugin instances 33 | Given a list of hosts exist 34 | When I visit "memory" on the first available host 35 | Then the request should succeed 36 | Then I should receive valid JSON 37 | And the JSON should have a plugin named "memory" 38 | And the JSON should have multiple plugin instances under the "memory" plugin 39 | 40 | Scenario: Make cross-domain requests 41 | Given a list of hosts exist 42 | When I visit "cpu-0?callback=foobar" on the first available host 43 | Then I should receive JSON wrapped in a callback named "foobar" 44 | 45 | Scenario: Retrieve multiple plugins through a glob 46 | Given a list of hosts exist 47 | When I visit "cpu*/cpu-user" on the first available host 48 | Then the request should succeed 49 | Then I should receive valid JSON 50 | And I should see multiple plugins 51 | 52 | Scenario: Retrieve multple hosts through a glob 53 | When I go to /data/*/memory 54 | Then the request should succeed 55 | Then I should receive valid JSON 56 | And I should see multiple hosts 57 | 58 | Scenario: Retrieve data with a defined start time 59 | Given a list of hosts exist 60 | When I visit "cpu*/cpu-user" on the first available host with the following query parameters: 61 | | parameter | value | 62 | | start | 1321769692 | 63 | | finish | 1321773292 | 64 | Then the request should succeed 65 | Then I should receive valid JSON 66 | And I should see the following parameters for each plugin instance: 67 | | parameter | value | 68 | | start | 1321769692 | 69 | | finish | 1321773292 | 70 | 71 | Scenario: Retrieve data with a defined finish time 72 | Given a list of hosts exist 73 | When I visit "cpu*/cpu-system" on the first available host with the following query parameters: 74 | | parameter | value | 75 | | finish | 1321773292 | 76 | Then the request should succeed 77 | Then I should receive valid JSON 78 | And I should see the following parameters for each plugin instance: 79 | | parameter | value | 80 | | start | 1321769692 | 81 | | finish | 1321773292 | 82 | 83 | Scenario: Retrieve data for a defined period including 95th percentile calculations 84 | Given a list of hosts exist 85 | When I visit "cpu*/cpu-idle" on the first available host with the following query parameters: 86 | | parameter | value | 87 | | start | 1 hour ago | 88 | | finish | now | 89 | | percentiles | 95 | 90 | Then the request should succeed 91 | Then I should receive valid JSON 92 | And I should see a 95th percentile value for each plugin instance 93 | -------------------------------------------------------------------------------- /lib/visage-app/helpers.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'sinatra/base' 4 | 5 | module Sinatra 6 | module LinkToHelper 7 | # from http://gist.github.com/98310 8 | def link_to(url_fragment, mode=:path_only) 9 | case mode 10 | when :path_only 11 | base = request.script_name 12 | when :full_url 13 | if (request.scheme == 'http' && request.port == 80 || 14 | request.scheme == 'https' && request.port == 443) 15 | port = "" 16 | else 17 | port = ":#{request.port}" 18 | end 19 | base = "#{request.scheme}://#{request.host}#{port}#{request.script_name}" 20 | else 21 | raise "Unknown script_url mode #{mode}" 22 | end 23 | "#{base}#{url_fragment}" 24 | end 25 | end 26 | 27 | module PageTitleHelper 28 | def page_title(string) 29 | @page_title = string 30 | end 31 | 32 | def include_page_title 33 | @page_title ? "#{@page_title} | Visage" : "Visage" 34 | end 35 | end 36 | 37 | module RequireJSHelper 38 | def require_js(filename) 39 | @js_filenames ||= [] 40 | @js_filenames << filename 41 | end 42 | 43 | def include_required_js 44 | if @js_filenames 45 | @js_filenames.map { |filename| 46 | "" 47 | }.join("\n") 48 | else 49 | "" 50 | end 51 | end 52 | end 53 | 54 | module RequireCSSHelper 55 | def require_css(filename) 56 | @css_filenames ||= [] 57 | @css_filenames << filename 58 | end 59 | 60 | def include_required_css 61 | if @css_filenames 62 | @css_filenames.map { |filename| 63 | %() 64 | }.join("\n") 65 | else 66 | "" 67 | end 68 | end 69 | end 70 | 71 | module FormatHelper 72 | def distance_of_time_in_words(from_time, to_time = Time.now.to_i, include_seconds = false) 73 | from_time = from_time.to_i 74 | distance_in_minutes = (((to_time - from_time).abs)/60).round 75 | distance_in_seconds = ((to_time - from_time).abs).round 76 | 77 | case distance_in_minutes 78 | when 0..1 79 | return (distance_in_minutes==0) ? 'less than a minute ago' : '1 minute ago' unless include_seconds 80 | case distance_in_seconds 81 | when 0..5 then 'less than 5 seconds ago' 82 | when 6..10 then 'less than 10 seconds ago' 83 | when 11..20 then 'less than 20 seconds ago' 84 | when 21..40 then 'half a minute ago' 85 | when 41..59 then 'less than a minute ago' 86 | else '1 minute ago' 87 | end 88 | 89 | when 2..45 then "#{distance_in_minutes} minutes ago" 90 | when 46..90 then 'about 1 hour ago' 91 | when 90..1440 then "about #{(distance_in_minutes / 60).round} hours ago" 92 | when 1441..2880 then '1 day ago' 93 | when 2881..43220 then "#{(distance_in_minutes / 1440).round} days ago" 94 | when 43201..86400 then 'about 1 month ago' 95 | when 86401..525960 then "#{(distance_in_minutes / 43200).round} months ago" 96 | when 525961..1051920 then 'about 1 year ago' 97 | else "over #{(distance_in_minutes / 525600).round} years ago" 98 | end 99 | end 100 | 101 | 102 | def truncate(text, opts={}) 103 | options = { :length => 30, :omission => "..."}.merge(opts) 104 | if text 105 | len = options[:length] - options[:omission].length 106 | chars = text 107 | (chars.length > options[:length] ? chars[0...len] + options[:omission] : text).to_s 108 | end 109 | end 110 | 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | visage-app (2.1.0) 5 | activemodel (~> 3.2.12) 6 | errand (= 0.8.0) 7 | haml 8 | sinatra (= 1.4.3) 9 | sinatra-contrib (= 1.4.0) 10 | yajl-ruby (= 1.1.0) 11 | 12 | GEM 13 | remote: http://rubygems.org/ 14 | specs: 15 | activemodel (3.2.12) 16 | activesupport (= 3.2.12) 17 | builder (~> 3.0.0) 18 | activesupport (3.2.12) 19 | i18n (~> 0.6) 20 | multi_json (~> 1.0) 21 | backports (3.3.3) 22 | builder (3.0.4) 23 | capybara (2.0.2) 24 | mime-types (>= 1.16) 25 | nokogiri (>= 1.3.3) 26 | rack (>= 1.0.0) 27 | rack-test (>= 0.5.4) 28 | selenium-webdriver (~> 2.0) 29 | xpath (~> 1.0.0) 30 | childprocess (0.3.8) 31 | ffi (~> 1.0, >= 1.0.11) 32 | chronic (0.10.1) 33 | coderay (1.0.9) 34 | colorize (0.5.8) 35 | cucumber (1.3.2) 36 | builder (>= 2.1.2) 37 | diff-lcs (>= 1.1.3) 38 | gherkin (~> 2.12.0) 39 | multi_json (~> 1.3) 40 | delorean (2.1.0) 41 | chronic 42 | diff-lcs (1.2.4) 43 | errand (0.8.0) 44 | rrd-ffi (= 0.2.13) 45 | eventmachine (1.0.0) 46 | faye-websocket (0.4.7) 47 | eventmachine (>= 0.12.0) 48 | ffi (1.4.0) 49 | formatador (0.2.4) 50 | gherkin (2.12.0) 51 | multi_json (~> 1.3) 52 | growl (1.0.3) 53 | guard (1.8.1) 54 | formatador (>= 0.2.4) 55 | listen (>= 1.0.0) 56 | lumberjack (>= 1.0.2) 57 | pry (>= 0.9.10) 58 | thor (>= 0.14.6) 59 | guard-rack (1.3.0) 60 | growl (~> 1.0.3) 61 | guard (~> 1.1) 62 | rb-fsevent (>= 0.3.9) 63 | spoon (~> 0.0.1) 64 | guard-rake (0.0.9) 65 | guard 66 | rake 67 | guard-ronn (1.0) 68 | guard (~> 1.8) 69 | ronn (~> 0.7) 70 | haml (3.1.4) 71 | hpricot (0.8.6) 72 | http_parser.rb (0.5.3) 73 | i18n (0.6.1) 74 | listen (1.2.2) 75 | rb-fsevent (>= 0.9.3) 76 | rb-inotify (>= 0.9) 77 | rb-kqueue (>= 0.2) 78 | lumberjack (1.0.4) 79 | method_source (0.8.2) 80 | mime-types (1.21) 81 | multi_json (1.6.1) 82 | mustache (0.99.4) 83 | nokogiri (1.5.6) 84 | poltergeist (1.1.0) 85 | capybara (~> 2.0, >= 2.0.1) 86 | faye-websocket (~> 0.4, >= 0.4.4) 87 | http_parser.rb (~> 0.5.3) 88 | pry (0.9.12.2) 89 | coderay (~> 1.0.5) 90 | method_source (~> 0.8) 91 | slop (~> 3.4) 92 | puma (2.4.0) 93 | rack (>= 1.1, < 2.0) 94 | rack (1.5.2) 95 | rack-protection (1.5.0) 96 | rack 97 | rack-test (0.6.2) 98 | rack (>= 1.0) 99 | rake (10.0.3) 100 | rb-fsevent (0.9.3) 101 | rb-inotify (0.9.0) 102 | ffi (>= 0.5.0) 103 | rb-kqueue (0.2.0) 104 | ffi (>= 0.5.0) 105 | rdiscount (2.0.7) 106 | ronn (0.7.3) 107 | hpricot (>= 0.8.2) 108 | mustache (>= 0.7.0) 109 | rdiscount (>= 1.5.8) 110 | rrd-ffi (0.2.13) 111 | activesupport 112 | ffi 113 | rspec (2.13.0) 114 | rspec-core (~> 2.13.0) 115 | rspec-expectations (~> 2.13.0) 116 | rspec-mocks (~> 2.13.0) 117 | rspec-core (2.13.0) 118 | rspec-expectations (2.13.0) 119 | diff-lcs (>= 1.1.3, < 2.0) 120 | rspec-mocks (2.13.0) 121 | rubyzip (0.9.9) 122 | selenium-webdriver (2.30.0) 123 | childprocess (>= 0.2.5) 124 | multi_json (~> 1.0) 125 | rubyzip 126 | websocket (~> 1.0.4) 127 | sinatra (1.4.3) 128 | rack (~> 1.4) 129 | rack-protection (~> 1.4) 130 | tilt (~> 1.3, >= 1.3.4) 131 | sinatra-contrib (1.4.0) 132 | backports (>= 2.0) 133 | eventmachine 134 | rack-protection 135 | rack-test 136 | sinatra (~> 1.4.2) 137 | tilt (~> 1.3) 138 | slop (3.4.6) 139 | spoon (0.0.4) 140 | ffi 141 | thor (0.18.1) 142 | tilt (1.4.1) 143 | websocket (1.0.7) 144 | xpath (1.0.0) 145 | nokogiri (~> 1.3) 146 | yajl-ruby (1.1.0) 147 | 148 | PLATFORMS 149 | ruby 150 | 151 | DEPENDENCIES 152 | capybara 153 | colorize 154 | cucumber 155 | delorean 156 | guard 157 | guard-rack 158 | guard-rake 159 | guard-ronn 160 | poltergeist 161 | puma 162 | rack-test 163 | rake 164 | ronn 165 | rspec 166 | visage-app! 167 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/backbone.handlebars.js: -------------------------------------------------------------------------------- 1 | // backbone.handlebars v0.2.0 2 | // 3 | // Copyright (c) 2013 Loïc Frering 4 | // Distributed under the MIT license 5 | 6 | (function() { 7 | 8 | var originalNameLookup = Handlebars.JavaScriptCompiler.prototype.nameLookup; 9 | 10 | Handlebars.JavaScriptCompiler.prototype.nameLookup = function(parent, name, type) { 11 | statement = '(function() {' 12 | statement += 'if (typeof ' + parent + '.' + name + ' === "function" && "' + parent + '" != "helpers" ) { return ' + parent + '.' + name + '() } ' 13 | statement += 'else if (' + parent + '.get) { return ' + parent + '.get("' + name + '") } ' 14 | statement += 'else { return ' + originalNameLookup.apply(this, arguments) + '}})()' 15 | //console.log(statement) 16 | return statement 17 | 18 | //return parent + ".get ? " + parent + ".get('" + name + "') : " + originalNameLookup.apply(this, arguments); 19 | }; 20 | 21 | var HandlebarsView = Backbone.View.extend({ 22 | render: function() { 23 | var context = this.context(); 24 | 25 | if (_.isString(this.template)) { 26 | this.template = Handlebars.compile(this.template, {knownHelpersOnly: true}); 27 | } 28 | 29 | if (_.isFunction(this.template)) { 30 | var html = this.template(context, {data: {view: this}}); 31 | this.$el.html(html); 32 | } 33 | this.renderNestedViews(); 34 | return this; 35 | }, 36 | 37 | renderNestedViews: function() { 38 | _.each(this.nestedViews, function(nestedView, id) { 39 | this.resolveViewClass(nestedView.name, _.bind(function(viewClass) { 40 | this.renderNestedView(id, viewClass, nestedView.options); 41 | }, this)); 42 | }, this); 43 | }, 44 | 45 | renderNestedView: function(id, viewClass, options) { 46 | var $el = this.$('#' + id); 47 | if ($el.size() === 1) { 48 | var view = new viewClass(options); 49 | $el.replaceWith(view.$el); 50 | view.render(); 51 | } 52 | }, 53 | 54 | resolveViewClass: function(name, callback) { 55 | if (_.isFunction(name)) { 56 | return callback(name); 57 | } else if (_.isString(name)) { 58 | var parts, i, len, obj; 59 | parts = name.split("."); 60 | for (i = 0, len = parts.length, obj = window; i < len; ++i) { 61 | obj = obj[parts[i]]; 62 | } 63 | if (obj) { 64 | return callback(obj); 65 | } else if (typeof require !== 'undefined') { 66 | return require([name], callback); 67 | } 68 | } 69 | throw new Error('Cannot resolve view "' + name + '"'); 70 | }, 71 | 72 | context: function() { 73 | return this.model || this.collection || {}; 74 | } 75 | }); 76 | 77 | Handlebars.registerHelper('each', function(context, options) { 78 | var fn = options.fn, inverse = options.inverse; 79 | var i = 0, ret = "", data; 80 | var current; 81 | 82 | if (options.data) { 83 | data = Handlebars.createFrame(options.data); 84 | } 85 | 86 | if (context && typeof context === 'object') { 87 | if (context instanceof Array || context instanceof Backbone.Collection) { 88 | for (var j = context.length; i'); 127 | }); 128 | 129 | Backbone.HandlebarsView = HandlebarsView; 130 | })(); 131 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/keyboard.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var mask; 4 | 5 | function checkForInput(e) { 6 | var element; 7 | 8 | if (e.target) { 9 | element = e.target; 10 | } 11 | else if (e.srcElement) { 12 | element = e.srcElement; 13 | } 14 | 15 | // 3 == TEXT_NODE 16 | if (element.nodeType == 3) { 17 | element = element.parentNode; 18 | } 19 | 20 | if (element.tagName == 'INPUT' || element.tagName == 'TEXTAREA') { 21 | return true; 22 | } 23 | 24 | return false; 25 | } 26 | 27 | 28 | function gotoBuilder(e) { 29 | if (checkForInput(e)) { 30 | return; 31 | } 32 | 33 | var uri = new URI('/builder'); 34 | uri.go(); 35 | } 36 | 37 | function gotoProfiles(e) { 38 | if (checkForInput(e)) { 39 | return; 40 | } 41 | var uri = new URI('/profiles'); 42 | uri.go(); 43 | } 44 | 45 | function moveUp(e) { 46 | if (checkForInput(e)) { 47 | return; 48 | } 49 | if ( $('navigation') ) { 50 | var active = $('navigation').getElement('.active'); 51 | var previous = active.getPrevious('.shortcut'); 52 | 53 | if (previous) { 54 | var previousY = previous.getPosition().y, 55 | windowY = window.getSize().y; 56 | 57 | if (previousY > windowY) { 58 | var scroller = new Fx.Scroll(window, {duration: 10}); 59 | scroller.toElement(previous); 60 | } 61 | 62 | active.toggleClass('active'); 63 | previous.toggleClass('active'); 64 | } 65 | } 66 | } 67 | 68 | function moveDown(e) { 69 | if (checkForInput(e)) { 70 | return; 71 | } 72 | if ( $('navigation') ) { 73 | var active = $('navigation').getElement('.active'); 74 | var next = active.getNext('.shortcut'); 75 | if (next) { 76 | var nextY = next.getPosition().y, 77 | windowY = window.getSize().y; 78 | 79 | if (nextY > windowY) { 80 | var scroller = new Fx.Scroll(window, {duration: 10}); 81 | scroller.toElement(next); 82 | } 83 | 84 | active.toggleClass('active'); 85 | next.toggleClass('active'); 86 | } 87 | } 88 | } 89 | 90 | function select(e) { 91 | if (checkForInput(e)) { 92 | return; 93 | } 94 | if ( $('navigation') ) { 95 | var active = $('navigation').getElement('.active'); 96 | var destination = active.getElement('a').get('href'); 97 | var uri = new URI(destination); 98 | uri.go(); 99 | } 100 | } 101 | 102 | function back(e) { 103 | if (checkForInput(e)) { 104 | return; 105 | } 106 | history.go(-1); 107 | } 108 | 109 | function help(e) { 110 | if (checkForInput(e)) { 111 | return; 112 | } 113 | mask.toggle();; 114 | } 115 | 116 | window.addEvent('domready', function() { 117 | /* bindings */ 118 | var vKeyboard = new Keyboard({defaultEventType: 'keydown'}); 119 | vKeyboard.addShortcuts({ 120 | 'builder': { 121 | 'keys': 'c', 122 | 'description': 'Go to the builder', 123 | 'handler': gotoBuilder 124 | }, 125 | 'profiles': { 126 | 'keys': 'g+i', 127 | 'description': 'Go to the list of profiles', 128 | 'handler': gotoProfiles 129 | }, 130 | 'up': { 131 | 'keys': 'k', 132 | 'description': 'Move cursor up', 133 | 'handler': moveUp 134 | }, 135 | 'down': { 136 | 'keys': 'j', 137 | 'description': 'Move cursor down', 138 | 'handler': moveDown 139 | }, 140 | 'select': { 141 | 'keys': 'enter', 142 | 'description': 'Select the element', 143 | 'handler': select 144 | }, 145 | 'back': { 146 | 'keys': 'u', 147 | 'description': 'Go to previous location', 148 | 'handler': back 149 | }, 150 | 'help': { 151 | 'keys': 'h', 152 | 'description': 'Display a list of shortcuts', 153 | 'handler': help 154 | } 155 | }); 156 | 157 | /* Shortcuts cheat sheet */ 158 | mask = new Mask('shortcuts', { 159 | hideOnClick: true, 160 | onHide: function() { 161 | shortcutsContainer.hide() 162 | }, 163 | onShow: function() { 164 | var offset = document.body.getScroll().y + 100; 165 | shortcutsContainer.setStyle('top', offset); 166 | shortcutsContainer.show(); 167 | } 168 | }); 169 | 170 | var shortcutsContainer = new Element('div', { 171 | 'id': 'shortcuts' 172 | }); 173 | 174 | var rows = []; 175 | vKeyboard.getShortcuts().each(function(shortcut) { 176 | rows.push([shortcut.keys, shortcut.description]); 177 | }); 178 | var table = new HtmlTable({ 179 | headers: ['Keys', 'Description'], 180 | rows: rows 181 | }); 182 | 183 | shortcutsContainer.hide(); 184 | shortcutsContainer.grab(table); 185 | document.body.grab(shortcutsContainer, 'top'); 186 | 187 | var shortcutsLink = new Element('a', { 188 | 'html': 'Shortcuts', 189 | 'href': '#', 190 | 'events': { 191 | 'click': function() { 192 | help() 193 | } 194 | } 195 | }); 196 | var footer = $('footer'); 197 | footer.set('html', footer.get('html') + ' | '); 198 | $('footer').grab(shortcutsLink); 199 | 200 | }); 201 | -------------------------------------------------------------------------------- /lib/visage-app/models/profile.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | root = Pathname.new(File.dirname(__FILE__)).parent.parent 4 | $: << root.join('lib').to_s 5 | require 'visage-app/graph' 6 | require 'visage-app/patches' 7 | require 'digest/md5' 8 | require 'active_model' 9 | require 'active_support/core_ext/file/atomic' 10 | require 'active_support/core_ext/string/conversions' 11 | 12 | class Profile 13 | include ActiveModel::AttributeMethods 14 | include ActiveModel::Serializers::JSON 15 | include ActiveModel::Validations 16 | include ActiveModel::Dirty 17 | 18 | # Don't emit a single root node named after the object type with all the 19 | # attributes under that - make the JSON representation of the profile just 20 | # the attributes. 21 | # 22 | # http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html#method-i-as_json 23 | # 24 | self.include_root_in_json = false 25 | 26 | # Class methods 27 | class << self 28 | # Retrieve previously saved profiles. 29 | # 30 | # Provides filters for common tasks, such as: 31 | # 32 | # - Returning anonymous or named profiles 33 | # - Sorting by a particular key 34 | # - Ordering the results 35 | # 36 | def all(opts={}) 37 | sort = opts[:sort] 38 | order = opts[:order] 39 | tags = opts[:tags] 40 | # Anonymous profiles don't have names, so if we see the sort key is the 41 | # profile name, we automatically filter out anonymous profiles. 42 | anonymous = sort == :name ? false : opts[:anonymous] 43 | 44 | # Load up all profiles 45 | result = Dir.glob(File.join(self.config_path, '*.yaml')) 46 | result = result.map {|r| self.get(File.basename(r, '.yaml'))} 47 | 48 | # Filter profiles based on options 49 | result = result.find_all {|r| r.anonymous == anonymous} unless anonymous.nil? 50 | result = result.sort_by {|r| r.send(sort)} if sort 51 | result = result.reverse if order == :descending 52 | result = result.find_all {|r| r.tags && r.tags.include?(tags) } if tags 53 | 54 | result 55 | end 56 | 57 | def get(id) 58 | path = File.join(self.config_path, "#{id}.yaml") 59 | if File.exists?(path) 60 | contents = File.open(path, 'r') 61 | attributes = YAML::load(contents) 62 | self.new(attributes) 63 | else 64 | nil 65 | end 66 | end 67 | 68 | def config_path 69 | # FIXME(auxesis): this is an enormous hack 70 | # 71 | # Invalidate @config_path if the CONFIG_PATH environment variable changes. 72 | if @env_config_path != ENV['CONFIG_PATH'] 73 | @env_config_path = ENV['CONFIG_PATH'] 74 | @config_path = ENV['CONFIG_PATH'] 75 | end 76 | 77 | # FIXME(auxesis): look at not doing this lookup + mkdir every time 78 | @config_path ||= File.expand_path(File.join(__FILE__, '..', '..', '..', '..', 'config')) 79 | FileUtils.mkdir_p(@config_path) 80 | 81 | @config_path 82 | end 83 | 84 | def config_path=(path) 85 | @config_path = path 86 | end 87 | 88 | end 89 | 90 | attr_accessor :attributes 91 | 92 | attribute_method_suffix '=' # attr_writers 93 | define_attribute_methods [ :id, :name, :graphs, :anonymous, :created_at, :timeframe, :tags ] 94 | 95 | validates_presence_of :id, :graphs 96 | validate :name_validations 97 | 98 | # Instance methods 99 | def initialize(attributes) 100 | default_attributes = { 101 | :anonymous => true 102 | } 103 | @attributes = default_attributes.merge(attributes) 104 | end 105 | 106 | # Persist the Profile record. 107 | def save 108 | if new_record? 109 | self.id = SecureRandom.hex 110 | self.created_at = Time.now 111 | end 112 | 113 | # Fix up the record 114 | # created_at in submitted JSON is converted to a String. Convert to Time. 115 | if @attributes[:created_at].class == String 116 | @attributes[:created_at] = @attributes[:created_at].to_time(:local) 117 | end 118 | 119 | if not [true,false].include?(@attributes[:anonymous]) 120 | @attributes[:anonymous] = @attributes[:anonymous].to_bool 121 | end 122 | 123 | if valid? 124 | File.atomic_write(self.path) do |file| 125 | file.write(@attributes.to_yaml) 126 | end 127 | true 128 | else 129 | false 130 | end 131 | end 132 | 133 | def update_attributes(attributes) 134 | @attributes.merge!(attributes) 135 | save 136 | end 137 | 138 | def destroy 139 | begin 140 | File.delete(self.path) 141 | true 142 | rescue 143 | false 144 | end 145 | end 146 | 147 | def path 148 | File.join(self.class.config_path, "#{self.id}.yaml") 149 | end 150 | 151 | def ==(other) 152 | self.attributes == other.attributes 153 | end 154 | 155 | private 156 | # Determine if the record we are operating on is newly created. 157 | # 158 | # Useful for adding additional data to records when they're being created. 159 | def new_record? 160 | !self.id 161 | end 162 | 163 | def name_validations 164 | self.errors.add 'name', "can't be blank" if not anonymous and name.blank? 165 | end 166 | 167 | # http://stackoverflow.com/questions/7613574/activemodel-fields-not-mapped-to-accessors 168 | # 169 | # Simulate attribute writers from method_missing 170 | def attribute=(attr, value) 171 | @attributes[attr.to_sym] = value 172 | end 173 | 174 | # Simulate attribute readers from method_missing 175 | def attribute(attr) 176 | @attributes[attr.to_sym] 177 | end 178 | 179 | # Used by ActiveModel to lookup attributes during validations. 180 | def read_attribute_for_validation(attr) 181 | @attributes[attr] 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /man/visage-app.5.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | visage-app(5) - Run a standalone instance of Visage 7 | 44 | 45 | 52 | 53 |
54 | 55 | 64 | 65 |
    66 |
  1. visage-app(5)
  2. 67 |
  3. 68 |
  4. visage-app(5)
  5. 69 |
70 | 71 |

NAME

72 |

73 | visage-app - Run a standalone instance of Visage 74 |

75 | 76 |

SYNOPSIS

77 | 78 |

visage-app start|genapache

79 | 80 |

DESCRIPTION

81 | 82 |

Visage is a web interface for viewing collectd statistics.

83 | 84 |

It also provides a JSON interface onto collectd's RRD data, giving you an 85 | easy way to mash up the data.

86 | 87 |

OPTIONS

88 | 89 |
90 |
start

Start visage on 127.0.0.1:9292

91 |
genapache

Generate a vhost suitable for Passenger + Apache. Will load Visage from 92 | the installed gem.

93 |
help

Display this man page.

94 |
95 | 96 | 97 |

ENVIRONMENT

98 | 99 |
100 |
CONFIG_PATH

Where to look for configuration files. Added to the beginning of config 101 | load path, so files found in here take precedence over shipped config files.

102 |
RRDDIR

Where to look for collectd's RRDs. Must correspond with collectd's rrdtool 103 | plugin's DataDir value.

104 |
COLLECTDSOCK

Where to look for collectd's Unix socket. Used by Visage to issue FLUSH 105 | command to collectd to present more up to date statistics.

106 |
RRDCACHEDSOCK

Where to look for rrdcached's Unix socket. Used by Visage to issue FLUSH 107 | command to rrdcached to present more up to date statistics.

108 |
VISAGE_DATA_BACKEND

Specify which data backend Visage should use to retrieve statistics. An up 109 | to date list of backends can be found by looking in lib/visage-app/data. 110 | At the time of writing, the shipped backends are RRD and Mock.

111 |
112 | 113 | 114 |

AUTHOR

115 | 116 |

Lindsay Holmwood lindsay@holmwood.id.au

117 | 118 | 119 | 120 |

MIT Licensed.

121 | 122 | 123 |
    124 |
  1. 125 |
  2. July 2013
  3. 126 |
  4. visage-app(5)
  5. 127 |
128 | 129 |
130 | 131 | 132 | -------------------------------------------------------------------------------- /man/visage-api.5.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | visage-api(5) - Reference documentation for the Visage JSON API 7 | 44 | 45 | 52 | 53 |
54 | 55 | 62 | 63 |
    64 |
  1. visage-api(5)
  2. 65 |
  3. 66 |
  4. visage-api(5)
  5. 67 |
68 | 69 |

NAME

70 |

71 | visage-api - Reference documentation for the Visage JSON API 72 |

73 | 74 |

DESCRIPTION

75 | 76 |

Visage is a web interface for viewing collectd statistics.

77 | 78 |

It also provides a JSON interface onto collectd's RRD data, giving you an 79 | easy way to mash up the data.

80 | 81 |

This man page documents the different ways you can call the API.

82 | 83 |

API methods

84 | 85 |

Calls to the API follow the /data/<hostnames>/<plugins>/<instances> pattern.

86 | 87 |
    88 |
  • /data

    89 | 90 |

    Display all the hosts metrics are available for.

  • 91 |
  • /data/<hostname>[,hostname][,...][,hostname]

    92 | 93 |

    Display all metrics available for the specified hostnames.

    94 | 95 |

    Example: Single hostname:

    96 | 97 |

    /data/localhost.localdomain

    98 | 99 |

    Example: Multiple hostnames:

    100 | 101 |

    /data/localhost.localdomain,foo-01.example.org,bar-23.example.org

  • 102 |
  • /data/<hostnames>/<plugin>[,plugin][,...][,plugin]

    103 | 104 |

    Fetch all plugin data for the specified hostnames.

    105 | 106 |

    Example: Single host, single plugin:

    107 | 108 |

    /data/localhost.localdomain/load

    109 | 110 |

    Example: Multiple hosts, single plugin:

    111 | 112 |

    /data/localhost.localdomain,foo-01.example.org,bar-23.example.org/swap

    113 | 114 |

    Example: Multiple hosts, multiple plugins:

    115 | 116 |

    /data/localhost.localdomain,foo-01.example.org,bar-23.example.org/swap,df

  • 117 |
  • /data/<hostnames>/<plugins>/<instance>[,instance][,...][,instance]

    118 | 119 |

    Fetch data for the specific plugin instance, for the specified hostnames.

    120 | 121 |

    Example: Single host, single instance

    122 | 123 |

    `/data/localhost.localdomain/memory/memory-free

    124 | 125 |

    Example: Multiple hosts, multiple instances

    126 | 127 |

    /data/foo-01.example.org,bar-23.example.org/memory/memory-free,memory-used

  • 128 |
129 | 130 | 131 |

AUTHOR

132 | 133 |

Lindsay Holmwood lindsay@holmwood.id.au

134 | 135 | 136 | 137 |

MIT Licensed.

138 | 139 | 140 |
    141 |
  1. 142 |
  2. July 2013
  3. 143 |
  4. visage-api(5)
  5. 144 |
145 | 146 |
147 | 148 | 149 | -------------------------------------------------------------------------------- /features/step_definitions/json_steps.rb: -------------------------------------------------------------------------------- 1 | Then /^I should receive valid JSON$/ do 2 | yajl = Yajl::Parser.new 3 | lambda { 4 | @response = yajl.parse(page.body) 5 | }.should_not raise_error 6 | 7 | case 8 | when @response.class == Array 9 | next 10 | when @response.keys.first == "hosts" 11 | @response["hosts"].should respond_to(:size) 12 | when @response[@response.keys.first].respond_to?(:size) 13 | host = @response.keys.first 14 | plugins = @response[host] 15 | plugins.size.should > 0 16 | else 17 | host = @response.keys.first 18 | plugin = @response[host].keys.first 19 | metric = @response[host][plugin].keys.first 20 | 21 | host.should_not be_nil 22 | plugin.should_not be_nil 23 | metric.should_not be_nil 24 | 25 | data = @response[host][plugin][metric]["data"] 26 | end 27 | 28 | end 29 | 30 | Then /^I should receive JSON wrapped in a callback named "([^\"]*)"$/ do |callback| 31 | page.body.should =~ /^#{callback}\(.+\)$/ 32 | end 33 | 34 | Then /^the JSON should have a plugin instance named "([^\"]*)"$/ do |plugin_instance| 35 | yajl = Yajl::Parser.new 36 | data = yajl.parse(page.body) 37 | 38 | data.values.map { |k,v| k.values }.map {|k,v| k.keys }.flatten.include?(plugin_instance).should be_true 39 | end 40 | 41 | Then /^the JSON should have a plugin named "([^\"]*)"$/ do |plugin| 42 | yajl = Yajl::Parser.new 43 | data = yajl.parse(page.body) 44 | 45 | data.values.map { |k,v| k.keys }.flatten.include?(plugin).should be_true 46 | end 47 | 48 | Then /^the JSON should have multiple plugin instances under the "([^\"]*)" plugin$/ do |arg1| 49 | yajl = Yajl::Parser.new 50 | data = yajl.parse(page.body) 51 | 52 | data.values.map { |k,v| k[arg1] }.map { |k,v| k.keys }.flatten.size.should > 1 53 | end 54 | 55 | Then /^each plugin instance should have a different color$/ do 56 | yajl = Yajl::Parser.new 57 | data = yajl.parse(page.body) 58 | 59 | @colours = [] 60 | data.values.map { |k,v| k.values }.map {|k,v| k.values }.map {|k,v| k.values }.map do |a| 61 | a.each do |b| 62 | string = b["color"] 63 | string.should =~ /^#[0-9a-fA-F]+$/ 64 | @colours << string 65 | end 66 | end 67 | 68 | @colours.uniq.size.should == @colours.size 69 | 70 | end 71 | 72 | Then /^the plugin instance should have a color$/ do 73 | Then "each plugin instance should have a different color" 74 | end 75 | 76 | 77 | Given /^I have the "([^\"]*)" plugin collecting data on multiple ports$/ do |plugin| 78 | Dir.glob("/var/lib/collectd/rrd/theodor/tcpconns*").size.should > 1 79 | end 80 | 81 | Then /^I should see multiple plugins$/ do 82 | @response.should_not be_nil 83 | host = @response.keys.first 84 | @response[host].keys.size.should > 1 85 | end 86 | 87 | Then /^I should see multiple hosts$/ do 88 | @response.should_not be_nil 89 | @response.keys.size.should > 1 90 | end 91 | 92 | Then /^the JSON should have a list of hosts$/ do 93 | @response["hosts"].size.should > 0 94 | end 95 | 96 | Given /^a list of hosts exist$/ do 97 | step 'I go to /data' 98 | step 'the request should succeed' 99 | step 'I should receive valid JSON' 100 | step 'the JSON should have a list of hosts' 101 | end 102 | 103 | When /^I visit "([^"]*)" on the first available host$/ do |glob| 104 | host = @response["hosts"].first 105 | url = "/data/#{host}/#{glob}" 106 | step "I go to #{url}" 107 | end 108 | 109 | 110 | When /^I visit the first available host$/ do 111 | step 'I go to /data' 112 | step 'the request should succeed' 113 | step 'I should receive valid JSON' 114 | step 'the JSON should have a list of hosts' 115 | 116 | host = @response["hosts"].first 117 | url = "/data/#{host}" 118 | step "I go to #{url}" 119 | end 120 | 121 | When /^I visit the first two available hosts$/ do 122 | step 'I go to /data' 123 | step 'the request should succeed' 124 | step 'I should receive valid JSON' 125 | step 'the JSON should have a list of hosts' 126 | 127 | host = @response["hosts"][0..1].join(',') 128 | url = "/data/#{host}" 129 | step "I go to #{url}" 130 | end 131 | 132 | Then /^the JSON should have a list of plugins$/ do 133 | host = @response.keys.first 134 | plugins = @response[host] 135 | plugins.size.should > 0 136 | end 137 | 138 | Then /^the JSON should have a list of types$/ do 139 | @response.size.should > 0 140 | @response.each do |type| 141 | %w(dataset datasource type min max).each do |attr| 142 | type[attr].should_not be_nil 143 | end 144 | end 145 | end 146 | 147 | 148 | When /^I visit "([^"]*)" on the first available host with the following query parameters:$/ do |glob, table| 149 | host = @response["hosts"].first 150 | url = "/data/#{host}/#{glob}" 151 | 152 | params = Hash[table.hashes.map { |hash| [hash["parameter"], hash["value"]] }] 153 | params.each do |key, value| 154 | if value == "1 hour ago" 155 | params[key] = (Time.now() - 3600).to_i.to_s 156 | end 157 | if value == "now" 158 | params[key] = Time.now().to_i.to_s 159 | end 160 | end 161 | query = params.map{|k,v| "#{CGI.escape(k)}=#{CGI.escape(v)}"}.join("&") 162 | url += "?#{query}" 163 | 164 | step "I go to #{url}" 165 | end 166 | 167 | Then /^I should see the following parameters for each plugin instance:$/ do |table| 168 | params = Hash[table.hashes.map { |hash| [hash["parameter"], hash["value"]] }] 169 | 170 | @response.should_not be_nil 171 | 172 | @response.each_pair do |host, plugin| 173 | plugin.each_pair do |instance, metric| 174 | metric.each_pair do |k, series| 175 | series.each_pair do |k, data| 176 | params.each do |key, value| 177 | data[key].should == value.to_i 178 | end 179 | end 180 | end 181 | end 182 | end 183 | 184 | end 185 | 186 | Then /^I should see a 95th percentile value for each plugin instance$/ do 187 | @response.should_not be_nil 188 | 189 | @response.each_pair do |host, plugin| 190 | plugin.each_pair do |instance, metric| 191 | metric.each_pair do |k, series| 192 | series.each_pair do |k, data| 193 | data['percentile_95'].should >= 0 194 | end 195 | end 196 | end 197 | end 198 | 199 | end 200 | 201 | -------------------------------------------------------------------------------- /lib/visage-app/assets/coffeescripts/models.coffee: -------------------------------------------------------------------------------- 1 | # Built roughly following pattern detailed here: 2 | # 3 | # http://weblog.bocoup.com/backbone-live-collections/ 4 | 5 | # 6 | # Models 7 | # 8 | Dimension = Backbone.Model.extend({ 9 | defaults: { 10 | checked: false, 11 | display: true, 12 | }, 13 | }) 14 | 15 | Host = Dimension.extend({}) 16 | Metric = Dimension.extend({}) 17 | Graph = Backbone.Model.extend({ 18 | url: () -> 19 | that = this 20 | host = that.get('host') 21 | plugin = that.get('plugin') 22 | start = that.get('start') 23 | finish = that.get('finish') 24 | query = {} 25 | query.start = start if start 26 | query.finish = finish if finish 27 | query = if Object.getLength(query) > 0 then '?' + Object.toQueryString(query) else '' 28 | 29 | "/data/#{host}/#{plugin}#{query}" 30 | 31 | parse: (response) -> 32 | that = this 33 | host = response.host || that.get('host') 34 | [plugin, instance] = (response.plugin || that.get('plugin')).split('/') 35 | data = response 36 | 37 | obj = {} 38 | obj.data = data 39 | obj.series = [] 40 | 41 | Object.each(data[host][plugin], (instance, instanceName) -> 42 | Object.each(instance, (metric, metricName) -> 43 | start = obj.start = metric.start 44 | finish = obj.finish = metric.finish 45 | interval = (finish - start) / metric.data.length 46 | 47 | data = metric.data.map((value, index) -> 48 | x = (start + index * interval) * 1000 49 | y = value 50 | [ x, y ] 51 | ) 52 | 53 | set = { 54 | name: formatSeriesLabel([ host, plugin, instanceName, metricName ]) 55 | data: data 56 | percentile95: metric.percentile_95 57 | } 58 | 59 | obj.series.push(set) 60 | ) 61 | ) 62 | 63 | obj.series = obj.series.sort((a,b) -> 64 | return -1 if a.name < b.name 65 | return 1 if a.name > b.name 66 | return 0 67 | ) 68 | 69 | return obj 70 | }) 71 | 72 | Timeframe = Backbone.Model.extend({ 73 | currentUnixTime: () -> 74 | date = new Date 75 | parseInt(date.getTime() / 1000) 76 | relativeUnixTimeTo: (value) -> 77 | that = this 78 | that.currentUnixTime() - (Math.abs(value) * 3600) 79 | roundedUnixTimeTo: (value) -> 80 | if (value < 0) 81 | return new Date().decrement('month', Math.abs(value)).set('date', 1).clearTime().getTime() / 1000 82 | 83 | if (value > 0) 84 | return new Date().increment('month', value).set('date', 1).clearTime().getTime() / 1000 85 | 86 | return new Date().set('date', 1).clearTime().getTime() / 1000; 87 | 88 | toTimeAttributes: () -> 89 | that = this 90 | unit = that.get('unit') 91 | start = that.get('start') 92 | finish = that.get('finish') 93 | attrs = {} 94 | 95 | if unit == 'hours' 96 | attrs.start = that.relativeUnixTimeTo(start) if start 97 | attrs.finish = that.relativeUnixTimeTo(finish) if finish 98 | else if unit == 'months' 99 | attrs.start = that.relativeUnixTimeTo(start) if start 100 | attrs.finish = that.relativeUnixTimeTo(finish) if finish 101 | 102 | attrs.label = that.get('label') 103 | attrs 104 | }) 105 | 106 | Profile = Backbone.Model.extend({ 107 | permalink: () -> 108 | window.location.protocol + '//' + 109 | window.location.host + 110 | "/profiles/#{this.id}" 111 | 112 | url: (options) -> 113 | id = this.id 114 | if options and options.json == false 115 | if id 116 | "/profiles/#{id}" 117 | else 118 | '/profiles' 119 | else 120 | if id 121 | "/profiles/#{id}.json" 122 | else 123 | '/profiles' 124 | 125 | change: (event) -> 126 | # Ignore change events triggered by fetch() 127 | if !event.success and !event.error 128 | # Mark the profile as dirty if graphs have been updated 129 | this.dirty(true) if event.changes.graphs 130 | 131 | # Dirty means out of sync with the server. 132 | # 133 | # This is used for determining if we need to create a new profile when a user 134 | # re-shares an existing profile. 135 | dirty: (status) -> 136 | this.is_dirty = status if status 137 | !!this.is_dirty 138 | 139 | isAnonymous: () -> 140 | !!this.get('anonymous') 141 | 142 | isNotAnonymous: () -> 143 | !this.isAnonymous() 144 | 145 | isAbsolute: () -> 146 | this.get('timeframe') == 'absolute' 147 | 148 | isRelative: () -> 149 | !this.isAbsolute() 150 | 151 | initialize: () -> 152 | id = document.location.pathname.split('/')[2] 153 | if id == 'new' 154 | # Stub out the graphs if this is truly a new profile 155 | this.set('graphs', []) 156 | this.set('timeframe', 'absolute') 157 | else 158 | # Set the object id 159 | this.set('id', id) 160 | 161 | sync: (method, original_model, options) -> 162 | # http://stackoverflow.com/questions/5096549/how-to-override-backbone-sync 163 | # console.log('arguments', method, original_model, options) 164 | 165 | # If we're updating the profile, strip down the graphs we're sending 166 | if ['create', 'update'].contains(method) 167 | # Backbone's clone() copies the id attribute of the, which mucks up the 168 | # url() method, causing the existing profile to be updated - the opposite 169 | # of what we're trying to acheive here. 170 | # 171 | # Instead, use MooTools' Object.clone. 172 | model = Object.clone(original_model) 173 | 174 | # FIXME(auxesis): I don't think we need this super dodgy 175 | # JSON.parse/stringify now we're using MooTools' Object.clone. 176 | graphs = JSON.parse(JSON.stringify(original_model.get('graphs'))) # deep clone 177 | if graphs.length > 0 # FIXME(auxesis): we can probably remove this after putting in some validations 178 | simplified_graphs = graphs.map((attrs) -> 179 | Object.subset(attrs , ['host', 'plugin', 'start', 'finish']) 180 | ) 181 | model.set('graphs', simplified_graphs) 182 | else 183 | model = original_model 184 | 185 | Backbone.sync.apply(this, [method, model, options]); 186 | }) 187 | 188 | -------------------------------------------------------------------------------- /lib/visage-app/assets/coffeescripts/profiles.coffee: -------------------------------------------------------------------------------- 1 | window.addEvent('domready', () -> 2 | 3 | Workspace = Backbone.Router.extend({ 4 | routes: { 5 | 'profile/new': 'profile', 6 | 'profile/:id': 'profile' 7 | }, 8 | }); 9 | # FIXME(auxesis): use of global variable window - is this the best pattern? 10 | window.Application = new Workspace() 11 | 12 | Backbone.history.start({pushState: true}) 13 | 14 | # 15 | # Instantiate everything 16 | # 17 | hostsContainer = $('hosts') 18 | hosts = new HostCollection 19 | hostsView = new DimensionCollectionView({ 20 | collection: hosts, 21 | container: hostsContainer 22 | }) 23 | hosts.fetch({ 24 | success: (collection) -> 25 | list = hostsView.render().el 26 | hostsContainer.grab(list) 27 | }) 28 | 29 | metricsContainer = $('metrics') 30 | metrics = new MetricCollection 31 | metricsView = new DimensionCollectionView({ 32 | collection: metrics 33 | container: metricsContainer 34 | linked: hosts 35 | }) 36 | 37 | # FIXME(auxesis): use of global variable window - is this the best pattern? 38 | window.graphsContainer = $('graphs') 39 | window.graphs = new GraphCollection 40 | window.graphsView = new GraphCollectionView({ 41 | el: window.graphsContainer 42 | collection: window.graphs 43 | }) 44 | 45 | # If we're working with an existing profile, fetch the details and render 46 | # the graphs 47 | window.profile = new Profile() 48 | if not window.profile.isNew() 49 | window.profile.fetch({ 50 | success: (model) -> 51 | timeframe = model.get('timeframe') 52 | timeframesView.collection.each((timeframe) -> timeframe.set('selected', false)) 53 | 54 | if timeframe == 'absolute' 55 | timeframesView.collection.add({ 56 | label: 'As specified by profile', 57 | selected: true 58 | }, {at: 0}) 59 | timeframesView.render() 60 | else 61 | selected = timeframesView.collection.find((entry) -> 62 | entry.get('label') == timeframe 63 | ) 64 | selected.set('selected', true) 65 | timeframesView.render() 66 | time_attributes = selected.toTimeAttributes() 67 | 68 | 69 | model.get('graphs').each((attributes) -> 70 | if timeframe != 'absolute' 71 | attributes.start = time_attributes.start 72 | attributes.finish = time_attributes.finish 73 | 74 | graph = new Graph(attributes) 75 | graph.fetch({ 76 | success: (model, response) -> 77 | # FIXME(auxesis): use of global variable window - is this the best pattern? 78 | window.graphs.add(graph) 79 | window.graphsView.render().el 80 | }) 81 | ) 82 | }) 83 | 84 | button = new Element('input', { 85 | 'type': 'button', 86 | 'value': 'Add graphs', 87 | 'class': 'button', 88 | 'styles': { 89 | 'font-size': '80%', 90 | 'padding': '4px 8px', 91 | }, 92 | 'events': { 93 | 'click': (event) -> 94 | hosts.for_api().each((host) -> 95 | metrics.for_api().each((metric) -> 96 | 97 | attributes = { 98 | host: host 99 | plugin: metric 100 | } 101 | timeframe = JSON.decode(Cookie.read('timeframe')) 102 | attributes = Object.merge(attributes, timeframe) 103 | 104 | graph = new Graph(attributes) 105 | graph.fetch({ 106 | success: (model, response, options) -> 107 | # FIXME(auxesis): use of global variable window - is this the best pattern? 108 | # FIXME(auxesis): this displays graphs in the reverse order of how they're stored in the data structure 109 | graphs = JSON.parse(JSON.stringify(window.profile.get('graphs'))) 110 | graphs.push(graph.attributes) 111 | window.profile.set('graphs', graphs) 112 | 113 | window.graphs.add(graph) 114 | window.graphsView.render().el 115 | error: (model, response, options) -> 116 | console.log('error', model, response, options) 117 | }) 118 | ) 119 | 120 | builder = $('builder') 121 | builder.tween('padding-top', 24).get('tween').chain(() -> 122 | builder.setStyle('border-top', '1px dotted #aaa') 123 | ) 124 | 125 | 126 | ) 127 | } 128 | }) 129 | $('display').grab(button) 130 | 131 | 132 | timeframes = new TimeframeCollection 133 | timeframes.add([ 134 | { label: 'last 1 hour', start: -1, finish: 0, unit: 'hours', 'default': true } 135 | { label: 'last 2 hours', start: -2, finish: 0, unit: 'hours' } 136 | { label: 'last 6 hours', start: -6, finish: 0, unit: 'hours' } 137 | { label: 'last 12 hours', start: -12, finish: 0, unit: 'hours' } 138 | { label: 'last 24 hours', start: -24, finish: 0, unit: 'hours' } 139 | { label: 'last 3 days', start: -72, finish: 0, unit: 'hours' } 140 | { label: 'last 7 days', start: -168, finish: 0, unit: 'hours' } 141 | { label: 'last 2 weeks', start: -336, finish: 0, unit: 'hours' } 142 | { label: 'last 1 month', start: -774, finish: 0, unit: 'hours' } 143 | { label: 'last 3 months', start: -2322, finish: 0, unit: 'hours' } 144 | { label: 'last 6 months', start: -4368, finish: 0, unit: 'hours' } 145 | { label: 'last 1 year', start: -8760, finish: 0, unit: 'hours' } 146 | { label: 'last 2 years', start: -17520, finish: 0, unit: 'hours' } 147 | { label: 'current month', start: 0, finish: 1, unit: 'months' } 148 | { label: 'previous month', start: -1, finish: 0, unit: 'months' } 149 | { label: 'two months ago', start: -2, finish: -1, unit: 'months' } 150 | { label: 'three months ago', start: -3, finish: -2, unit: 'months' } 151 | ]) 152 | 153 | if !Cookie.read('timeframe') 154 | attributes = timeframes.find((model) -> model.get('default')).toTimeAttributes() 155 | Cookie.write('timeframe', JSON.encode(attributes)) 156 | 157 | timeframesView = new TimeframeCollectionView({ 158 | collection: timeframes, 159 | el: $('timeframes') 160 | }) 161 | timeframesView.render() 162 | ) 163 | -------------------------------------------------------------------------------- /lib/visage-app/public/stylesheets/LightFace.css: -------------------------------------------------------------------------------- 1 | .lightface { 2 | margin: 0; 3 | padding: 0; 4 | border-collapse: collapse; 5 | position: absolute; 6 | top: -9000px; 7 | left: -9000px; 8 | } 9 | 10 | .lightface:focus { 11 | outline: none; 12 | } 13 | 14 | .lightface td { 15 | padding: 0; 16 | margin: 0; 17 | background-color: transparent; 18 | vertical-align: top; 19 | font-family: 'Verdana'; 20 | font-size: 11px; 21 | } 22 | 23 | .lightface .centerLeft, .lightface .centerRight { 24 | width: 10px; 25 | height: auto; 26 | background-image: url(../images/LightFace/b.png); 27 | background-repeat: repeat-y; 28 | } 29 | 30 | .lightface .topLeft, .lightface .topRight, .lightface .bottomLeft, .lightface .bottomRight { 31 | width: 10px; 32 | height: 10px; 33 | background-repeat: no-repeat; 34 | } 35 | 36 | .lightface .topLeft { 37 | background-image: url(../images/LightFace/tl.png); 38 | background-position: top left; 39 | } 40 | 41 | .lightface .topRight { 42 | background-image: url(../images/LightFace/tr.png); 43 | background-position: top right; 44 | } 45 | 46 | .lightface .bottomLeft { 47 | background-image: url(../images/LightFace/bl.png); 48 | background-position: bottom left; 49 | } 50 | 51 | .lightface .bottomRight { 52 | background-image: url(../images/LightFace/br.png); 53 | background-position: bottom right; 54 | } 55 | 56 | .lightface .topCenter, .lightface .bottomCenter { 57 | width: auto; 58 | height: 10px; 59 | background-image: url(../images/LightFace/b.png); 60 | background-repeat: repeat-x; 61 | } 62 | 63 | .lightface .lightfaceContent { 64 | background-color: #fff; 65 | border: 1px solid #555; 66 | position: relative; 67 | } 68 | 69 | .lightface .loading { 70 | display: block; 71 | margin: 10px auto; 72 | } 73 | 74 | .lightface .lightfaceContent .lightfaceTitle { 75 | font-size: 14px; 76 | color: #fff; 77 | //*background-color: #6d84b4;*/ 78 | background: -moz-linear-gradient(100% 100% 90deg, #244671, #4C94F0); 79 | background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#4C94F0), to(#244671)); 80 | border: 1px solid #3b5998; 81 | font-weight: bold; 82 | margin: -1px; 83 | margin-bottom: 0; 84 | padding: 5px 10px; 85 | } 86 | 87 | .lightface .lightfaceContent .lightfaceDraggable { 88 | cursor:move; 89 | } 90 | 91 | .lightface .lightfaceContent .lightfaceMessage { 92 | overflow: auto; 93 | margin: 0; 94 | position: relative; 95 | padding: 5px 10px; 96 | border: 1px solid #fff; 97 | } 98 | 99 | .lightface .lightfaceContent .lightfaceMessage h3, 100 | .lightface .lightfaceContent .lightfaceMessage h4, 101 | .lightface .lightfaceContent .lightfaceMessage h5, 102 | .lightface .lightfaceContent .lightfaceMessage h6 { 103 | margin-top: 6px; 104 | } 105 | 106 | .lightface .lightfaceContent .lightfaceFooter { 107 | background-color: #f2f2f2; 108 | border-top: 1px solid #ccc; 109 | padding: 6px 10px; 110 | text-align: right; 111 | } 112 | 113 | .lightface .lightfaceFooter input[type='button'] { 114 | border: 1px solid #666; 115 | border-top-color: #e7e7e7; 116 | border-left-color: #e7e7e7; 117 | /* 118 | background-color: #f0f0f0; 119 | */ 120 | padding: 1px 0 2px; 121 | line-height: 16px; 122 | vertical-align: middle; 123 | cursor: pointer; 124 | font-size: 13px; 125 | display: inline-block; 126 | font-family: 'Lucida Grande', Tahoma, Verdana, Arial, sans-serif; 127 | font-weight: bold; 128 | white-space: nowrap; 129 | border:2px outset buttonface; 130 | border-style: inset; 131 | text-align:center; 132 | } 133 | 134 | .lightface .lightfaceFooter input[type='button'].lightfaceBlue { 135 | border-color: #0e1f5b; 136 | border-top-color: #d9dfea; 137 | border-left-color: #d9dfea; 138 | background-color: #3b598a; 139 | color: #fff; 140 | } 141 | 142 | .lightface .lightfaceFooter label { 143 | font-size: 13px; 144 | border-style: solid; 145 | background-image:url(../images/LightFace/button.png); 146 | cursor:pointer; 147 | font-weight:bold; 148 | padding:2px 6px 2px 6px; 149 | text-align:center; 150 | vertical-align:top; 151 | white-space:nowrap; 152 | border-width:1px; 153 | margin-left:3px; 154 | background-position:0 0; 155 | border-color:#999; 156 | line-height:normal !important; 157 | display:inline-block; 158 | } 159 | 160 | .lightface .lightfaceFooter label input { 161 | background:none; 162 | border:0 !important; 163 | cursor:pointer; 164 | font-family: 'Lucida Grande', Tahoma, Verdana, Arial, sans-serif; 165 | font-weight:bold; 166 | margin:0; 167 | padding:1px 0 2px; 168 | white-space:nowrap; 169 | text-align:center; 170 | color:#fff; 171 | font-size:13px; 172 | border: 2px outset buttonface; 173 | text-indent:0; 174 | text-shadow:none; 175 | display:inline-block; 176 | color:#444; 177 | font-size:13px; 178 | } 179 | /* ie6 hacks */ 180 | * html .lightface .lightfaceFooter label input { border:0; } 181 | 182 | .lightface .lightfaceFooter label.lightfaceblue { 183 | border-color: #29447E #29447E #1A356E; 184 | background-position:0 -48px; 185 | outline: none; 186 | } 187 | 188 | .lightface .lightfaceFooter label.lightfaceblue.lightfacefocusblue { 189 | background-color: #5b74a8; 190 | background-image:none; 191 | } 192 | 193 | .lightface .lightfaceFooter label.lightfacered { 194 | border-color: #74412B; 195 | background-position:0 -288px; 196 | } 197 | 198 | .lightface .lightfaceFooter label.lightfacered.lightfacefocusred { 199 | background-color: #74412B; background-image:none; 200 | } 201 | 202 | .lightface .lightfaceFooter label.lightfacegreen { 203 | border-color: #3B6E22 #3B6E22 #2C5115; 204 | background-position:0 -96px; 205 | } 206 | 207 | .lightface .lightfaceFooter label.lightfacegreen.lightfacefocusgreen { 208 | background-color: #69A74E; background-image:none; 209 | } 210 | 211 | .lightface .lightfaceFooter label.lightfaceblue input, 212 | .lightface .lightfaceFooter label.lightfacered input, 213 | .lightface .lightfaceFooter label.lightfacegreen input { 214 | color:#fff; 215 | } 216 | 217 | 218 | .lightface .hiddenButton { 219 | visibility: hidden; 220 | } 221 | 222 | .lightface .lightfaceOverlay { 223 | position: absolute; 224 | left: 0; 225 | top: 0; 226 | bottom: 0; 227 | right: 0; 228 | /* 229 | background-image: url(../images/LightFace/fbloader.gif); 230 | background-position: center center; 231 | background-repeat: no-repeat; 232 | background-color: #fff; 233 | */ 234 | background:url(../images/LightFace/fbloader.gif) center center no-repeat #fff; 235 | } 236 | 237 | .lightface .lightfaceMessageBox { 238 | overflow: auto; 239 | min-height: 20px; 240 | position:relative; 241 | } 242 | 243 | .lightface .lightFaceMessageBoxImage { 244 | overflow: hidden; 245 | padding: 0; 246 | background:url(../images/LightFace/fbloader.gif) center center no-repeat #fff; 247 | } 248 | 249 | .lightface .lightFaceMessageBoxImage img { 250 | display: block; 251 | } 252 | -------------------------------------------------------------------------------- /features/profiles.feature: -------------------------------------------------------------------------------- 1 | Feature: Viewing data 2 | To find out how systems are performing 3 | A user 4 | Must be able to visualise the data 5 | 6 | Background: 7 | Given I am using a profile based on "stub" 8 | 9 | Scenario: List named profiles 10 | When I go to /profiles 11 | Then I should see a list of profiles sorted alphabetically 12 | 13 | Scenario: List recently shared profiles 14 | When I go to /profiles 15 | Then I should see a list of recently shared profiles 16 | 17 | @javascript 18 | Scenario: Show recent profile 19 | When I go to /profiles 20 | And I visit the first recent profile 21 | Then I should see a collection of graphs 22 | And I should see "Back to profiles" 23 | 24 | @javascript 25 | Scenario: Navigate profiles 26 | When I go to /profiles 27 | And I visit the first recent profile 28 | Then I should see a collection of graphs 29 | When I go to /profiles 30 | Then I should see a list of recently shared profiles 31 | 32 | @javascript @anonymous 33 | Scenario: Create an anonymous profile 34 | When I go to /profiles/new 35 | And I add a graph 36 | And I share the profile 37 | Then I should see a permalink for the profile 38 | 39 | @javascript @anonymous 40 | Scenario: Update an anonymous profile 41 | When I create an anonymous profile 42 | And I visit that anonymous profile 43 | And I add a graph 44 | And I share the profile 45 | Then I should see a new permalink for the profile 46 | 47 | @javascript @anonymous 48 | Scenario: Delete an anonymous profile 49 | When I create an anonymous profile 50 | And I visit that anonymous profile 51 | And I activate the share modal 52 | And I delete the profile 53 | Then I should be at /profiles 54 | 55 | @javascript @named 56 | Scenario: Create a named profile 57 | When I go to /profiles/new 58 | And I add a graph 59 | And I share the profile with the name "Collection of graphs" 60 | Then I should see a permalink for the profile 61 | When I go to /profiles 62 | Then I should see a profile named "Collection of graphs" 63 | When I visit a profile named "Collection of graphs" 64 | Then I should see "Collection of graphs" in the page title 65 | 66 | @javascript @named 67 | Scenario: Update a named profile 68 | When I create a profile named "Collection of graphs" 69 | And I visit a profile named "Collection of graphs" 70 | And I add a graph 71 | And I share the profile with the name "A different collection of graphs" 72 | Then I should see a permalink for the profile 73 | When I go to /profiles 74 | Then I should see a profile named "A different collection of graphs" 75 | Then I should not see a profile named "Collection of graphs" 76 | 77 | @javascript @named 78 | Scenario: Delete a named profile 79 | When I create a profile named "Graphs to delete" 80 | And I visit a profile named "Graphs to delete" 81 | And I activate the share modal 82 | And I delete the profile 83 | Then I should be at /profiles 84 | Then I should not see a profile named "Graphs to delete" 85 | 86 | @javascript @timeframe 87 | Scenario: Default timeframe 88 | When I go to /profiles/new 89 | Then the timeframe should be "last 1 hour" 90 | And I add a graph 91 | Then the graphs should have data for the last 1 hour 92 | 93 | @javascript @timeframe 94 | Scenario: Timeframe specifier 95 | When I go to /profiles/new 96 | And I set the timeframe to "last 6 hours" 97 | And I add a graph 98 | Then the graphs should have data for the last 6 hours 99 | 100 | @javascript @timeframe 101 | Scenario: Use the existing timeframe 102 | When I go to /profiles/new 103 | And I set the timeframe to "last 2 hours" 104 | When I go to /profiles/new 105 | And I add a graph 106 | Then the graphs should have data for the last 2 hours 107 | 108 | @javascript @timeframe 109 | Scenario: Remember a timeframe on a profile 110 | When I go to /profiles/new 111 | And I set the timeframe to "last 12 hours" 112 | And I add 3 graphs 113 | And I remember the timeframe when sharing the profile named "Remember the timeframe" 114 | And I reset the timeframe 115 | And I visit a profile named "Remember the timeframe" 116 | Then the graphs should have data for the last 12 hours 117 | Then the timeframe should be "As specified by profile" 118 | 119 | @javascript @timeframe 120 | Scenario: Switch between timeframes on a remembered profile 121 | When I go to /profiles/new 122 | And I set the timeframe to "last 12 hours" 123 | And I add 4 graphs 124 | And I remember the timeframe when sharing the profile named "Remember the timeframe" 125 | And I reset the timeframe 126 | And I visit a profile named "Remember the timeframe" 127 | Then the graphs should have data for the last 12 hours 128 | Then the timeframe should be "As specified by profile" 129 | When I set the timeframe to "last 2 hours" 130 | And I wait 5 seconds 131 | #And I go 15 minutes into the future 132 | Then the graphs should have data for the last 12 hours 133 | Then the graphs should have data for the last 2 hours 134 | When I set the timeframe to "As specified by profile" 135 | Then the graphs should have data for the last 12 hours 136 | 137 | @javascript @timeframe 138 | Scenario: Store absolute timeframes 139 | When I go to /profiles/new 140 | And I set the timeframe to "last 6 hours" 141 | And I add 3 graphs 142 | And I share the profile 143 | And I remember the timeframe absolutely 144 | And I set the profile name to "Store absolute timeframe" 145 | And I save the profile 146 | And I reset the timeframe 147 | And I go 15 minutes into the future 148 | And I visit a profile named "Store absolute timeframe" 149 | Then the timeframe should be "As specified by profile" 150 | Then the graphs should have data for exactly 6 hours 151 | 152 | @javascript @timeframe 153 | Scenario: Store relative timeframes 154 | When I go to /profiles/new 155 | And I set the timeframe to "last 12 hours" 156 | And I add 1 graph 157 | And I share the profile 158 | And I remember the timeframe relatively 159 | When I set the profile name to "Store relative timeframe" 160 | And I save the profile 161 | And I reset the timeframe 162 | And I visit a profile named "Store relative timeframe" 163 | Then the graphs should have data for the last 12 hours 164 | 165 | Scenario: 95e on graphs 166 | Scenario: Store tags on profile 167 | Scenario: Filter profiles by tags 168 | 169 | @javascript @validation 170 | Scenario: Create a profile without any graphs 171 | When I go to /profiles/new 172 | And I share the profile 173 | Then I should not see a permalink for the profile 174 | And I should see a modal prompting me to add graphs 175 | And I should only see a button to close the dialog 176 | -------------------------------------------------------------------------------- /lib/visage-app.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'pathname' 4 | @root = Pathname.new(File.dirname(__FILE__)).expand_path 5 | $: << @root.to_s 6 | 7 | require 'sinatra/base' 8 | require 'sinatra/reloader' 9 | require 'haml' 10 | require 'yajl/json_gem' 11 | require 'visage-app/models/profile' 12 | require 'visage-app/helpers' 13 | require 'visage-app/config' 14 | require 'visage-app/data' 15 | require 'visage-app/upgrade' 16 | 17 | module Sinatra 18 | module PutOrPost 19 | def put_or_post(path,options={},&block) 20 | put(path,options,&block) 21 | post(path,options,&block) 22 | end 23 | end 24 | 25 | register PutOrPost 26 | end 27 | 28 | module Visage 29 | class Application < Sinatra::Base 30 | @root = Pathname.new(File.dirname(__FILE__)).expand_path 31 | set :public_folder, @root.join('visage-app/public') 32 | set :views, @root.join('visage-app/views') 33 | 34 | enable :logging 35 | 36 | helpers Sinatra::LinkToHelper 37 | helpers Sinatra::PageTitleHelper 38 | helpers Sinatra::RequireJSHelper 39 | helpers Sinatra::RequireCSSHelper 40 | helpers Sinatra::FormatHelper 41 | 42 | register Sinatra::PutOrPost 43 | 44 | configure do 45 | Visage::Config.use do |c| 46 | # FIXME: make this configurable through a YAML config file 47 | c['data_backend'] = ENV['VISAGE_DATA_BACKEND'] || 'RRD' 48 | 49 | # RRD specific config options 50 | # FIXME: make this configurable through a YAML config file 51 | c['rrddir'] = ENV["RRDDIR"] ? Pathname.new(ENV["RRDDIR"]).expand_path : Pathname.new("/var/lib/collectd/rrd").expand_path 52 | c['collectdsock'] = ENV["COLLECTDSOCK"] 53 | c['rrdcachedsock'] = ENV["RRDCACHEDSOCK"] 54 | end 55 | 56 | # Upgrade the profile if we're running an older version 57 | if Visage::Upgrade.pending? 58 | puts "The Visage profile storage format has changed." 59 | upgrades = Visage::Upgrade.run 60 | first = upgrades.first.version - 1 61 | last = upgrades.last.version 62 | puts "Upgraded profile storage format from version #{first} to #{last}" 63 | end 64 | 65 | # Set the data backend to use in Visage::JSON 66 | Visage::Data.backend = Visage::Config.data_backend 67 | end 68 | 69 | configure :development do 70 | register Sinatra::Reloader 71 | end 72 | 73 | configure :test do 74 | disable :logging 75 | end 76 | end 77 | 78 | class Profiles < Application 79 | get '/' do 80 | redirect '/profiles' 81 | end 82 | 83 | get '/profiles/new' do 84 | haml :profile 85 | end 86 | 87 | get '/profiles/share/:id' do 88 | @profile = Profile.get(params[:id]) 89 | raise Sinatra::NotFound unless @profile 90 | 91 | haml :share, :layout => false 92 | end 93 | 94 | # Viewing a single profile 95 | get %r{/profiles/([^/\.]+).?([^/]+)?} do 96 | id = params[:captures][0] 97 | format = params[:captures][1] 98 | 99 | @profile = Profile.get(id) 100 | raise Sinatra::NotFound unless @profile 101 | 102 | if format == 'json' 103 | @profile.to_json 104 | else 105 | haml :profile 106 | end 107 | end 108 | 109 | # Viewing all profiles 110 | get %r{/profiles/*} do 111 | named_options = { 112 | :anonymous => false, 113 | :sort => :name, 114 | } 115 | @profiles = Profile.all(named_options) 116 | 117 | anonymous_options = { 118 | :anonymous => true, 119 | :sort => :created_at, 120 | :order => :descending, 121 | } 122 | @anonymous = Profile.all(anonymous_options) 123 | 124 | haml :profiles 125 | end 126 | 127 | # Creating a new profile 128 | post '/profiles' do 129 | attributes = ::JSON.parse(request.body.read).symbolize_keys 130 | filter_parameters!(attributes) 131 | @profile = Profile.new(attributes) 132 | 133 | if @profile.save 134 | {'status' => 'ok', 'id' => @profile.id}.to_json 135 | else 136 | status 400 # Bad Request 137 | {'status' => 'error', 'errors' => @profile.errors}.to_json 138 | end 139 | end 140 | 141 | # Updating an existing profile 142 | put_or_post %r{/profiles/([^/\.]+).?([^/]+)?} do 143 | id = params[:captures][0] 144 | format = params[:captures][1] 145 | 146 | @profile = Profile.get(id) 147 | raise Sinatra::NotFound unless @profile 148 | 149 | if format == 'json' 150 | attributes = ::JSON.parse(request.body.read).symbolize_keys 151 | else 152 | attributes = params['profile'].symbolize_keys 153 | end 154 | filter_parameters!(attributes) 155 | 156 | if @profile.update_attributes(attributes) 157 | {'status' => 'ok', 'id' => @profile.id}.to_json 158 | else 159 | status 400 # Bad Request 160 | {'status' => 'error', 'errors' => @profile.errors}.to_json 161 | end 162 | end 163 | 164 | delete %r{/profiles/([^/\.]+).?([^/]+)?} do 165 | id = params[:captures][0] 166 | format = params[:captures][1] 167 | 168 | @profile = Profile.get(id) 169 | raise Sinatra::NotFound unless @profile 170 | 171 | if @profile.destroy 172 | {'status' => 'ok', 'id' => @profile.id}.to_json 173 | else 174 | status 400 # Bad Request 175 | {'status' => 'error', 'errors' => @profile.errors}.to_json 176 | end 177 | end 178 | 179 | private 180 | def filter_parameters!(attributes) 181 | allowed = [ :id, :name, :graphs, :anonymous, :created_at, :timeframe, :tags ] 182 | attributes.reject! {|k,v| !allowed.include?(k) } 183 | end 184 | end 185 | 186 | 187 | class Builder < Application 188 | end 189 | 190 | class JSON < Application 191 | 192 | # JSON data backend 193 | mime_type :json, "application/json" 194 | mime_type :jsonp, "text/javascript" 195 | 196 | before do 197 | content_type :jsonp 198 | end 199 | 200 | # /data/:host/:plugin/:optional_plugin_instance 201 | get %r{/data/([^/]+)/([^/]+)((/[^/]+)*)} do 202 | content_type :json if headers["Content-Type"] =~ /text/ 203 | 204 | host = params[:captures][0].gsub("\0", "") 205 | plugin = params[:captures][1].gsub("\0", "") 206 | instances = params[:captures][2].gsub("\0", "") 207 | start = params[:start] 208 | finish = params[:finish] 209 | percentiles = params[:percentiles] ||= "false" 210 | resolution = params[:resolution] 211 | 212 | options = { 213 | :rrddir => Visage::Config.rrddir, 214 | :collectdsock => Visage::Config.collectdsock, 215 | :rrdcachedsock => Visage::Config.rrdcachedsock 216 | } 217 | data = Visage::Data.new(options) 218 | 219 | query = { 220 | :host => host, 221 | :plugin => plugin, 222 | :instances => instances, 223 | :start => start, 224 | :finish => finish, 225 | :percentiles => percentiles, 226 | :resolution => resolution, 227 | } 228 | json = data.json(query) 229 | 230 | # If the request is cross-domain, we need to serve JSON-P. 231 | maybe_wrap_with_callback(json) 232 | end 233 | 234 | get %r{/data/([^/]+)} do 235 | content_type :json if headers["Content-Type"] =~ /text/ 236 | 237 | query = { 238 | :hosts => params[:captures][0].gsub("\0", "") 239 | } 240 | metrics = Visage::Data.metrics(query) 241 | json = { :metrics => metrics }.to_json 242 | 243 | maybe_wrap_with_callback(json) 244 | end 245 | 246 | get %r{/data(/)*} do 247 | content_type :json if headers["Content-Type"] =~ /text/ 248 | 249 | hosts = Visage::Data.hosts 250 | json = { :hosts => hosts }.to_json 251 | 252 | maybe_wrap_with_callback(json) 253 | end 254 | 255 | # Wraps json with a callback method that JSON-P clients can call. 256 | def maybe_wrap_with_callback(json) 257 | params[:callback] ? params[:callback] + '(' + json + ')' : json 258 | end 259 | 260 | end 261 | 262 | end 263 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Visage 2 | ====== 3 | 4 | Visage is a web interface for viewing [collectd](http://collectd.org) statistics. 5 | 6 | It also provides a [JSON](http://json.org) interface onto `collectd`'s RRD data, 7 | giving you an easy way to mash up the data. 8 | 9 | Features 10 | -------- 11 | 12 | * Renders graphs in the browser with SVG, and retrieves data asynchronously 13 | * Easy interface for building, ordering, and sharing collections of graphs 14 | * Interactive graph elements - toggle line visibility, inspect exact point-in-time data 15 | * Drop-down or mouse selection of timeframes 16 | * JSON interface onto collectd RRDs 17 | * Support for FLUSH using either collectd's rrdtool plugin, or rrdcached 18 | 19 | Here, have a graph: 20 | 21 | ![Something I prepared earlier - Visage 3.0 graph.](http://farm9.staticflickr.com/8234/8526570663_1d2479407f_c.jpg) 22 | 23 | Installing 24 | ---------- 25 | 26 | N.B: Visage must be deployed on a machine where `collectd` stores its stats in RRD. 27 | 28 | ### Ubuntu ### 29 | 30 | On Ubuntu, to install dependencies run: 31 | 32 | ``` bash 33 | sudo apt-get install -y build-essential librrd-ruby ruby ruby-dev rubygems collectd 34 | ``` 35 | 36 | Then install the app with: 37 | 38 | ``` bash 39 | gem install visage-app 40 | ``` 41 | 42 | ### CentOS/RHEL ### 43 | 44 | #### CentOS/RHEL 5 #### 45 | Visage uses [yajl-ruby](https://github.com/brianmario/yajl-ruby) to work with 46 | JSON, which requires Ruby >= 1.8.6. CentOS/RHEL 5 ship with Ruby 1.8.5, so you 47 | will need to use [Ruby Enterprise Edition](http://www.rubyenterpriseedition.com/). 48 | 49 | [Endpoint](http://endpoint.com) provide packages for REE and a [Yum repository](https://packages.endpoint.com/) 50 | to ease installation. 51 | 52 | Follow the above instructions for installing REE, and then run: 53 | 54 | ``` bash 55 | sudo yum install -y librrd-dev ruby rubygems collectd 56 | gem install librrd 57 | ``` 58 | 59 | Then install the app with: 60 | 61 | ``` bash 62 | gem install visage-app 63 | ``` 64 | 65 | #### CentOS/RHEL 6+ #### 66 | 67 | On CentOS 6, to install dependencies run: 68 | 69 | ``` bash 70 | sudo yum install -y ruby-RRDtool ruby ruby-devel rubygems collectd 71 | ``` 72 | 73 | Then install the app with: 74 | 75 | ``` bash 76 | gem install visage-app 77 | ``` 78 | 79 | #### openSUSE 12.3+ #### 80 | 81 | On openSUSE 12.3+, to install dependencies run: 82 | 83 | ``` bash 84 | sudo zypper install -y ruby-rrdtool ruby ruby-devel collectd 85 | ``` 86 | Then install the app with: 87 | 88 | ``` bash 89 | gem install visage-app 90 | ``` 91 | 92 | Before running visage-app set the environment variable to "RRDDIR=/var/lib/collectd" 93 | 94 | ### Mac OS X ### 95 | 96 | Visage is not supported on Mac OS X, as RRDtool is a pain in the arse on that 97 | platform. It's highly recommended you use [Vagrant](http://vagrantup.com/) to 98 | fire up an Ubuntu box to run Visage. 99 | 100 | 101 | Running 102 | ------- 103 | 104 | You can try out Visage quickly with: 105 | 106 | ``` bash 107 | visage-app start 108 | ``` 109 | 110 | Then paste the URL from the output into your browser. 111 | 112 | If you get a `command not found` when running the above command (RubyGems likely 113 | isn't on your PATH), try this instead: 114 | 115 | ``` bash 116 | $(dirname $(dirname $(gem which visage-app)))/bin/visage-app start 117 | ``` 118 | 119 | Deploying 120 | --------- 121 | 122 | Visage can be deployed easily on Apache with Passenger: 123 | 124 | ``` bash 125 | sudo apt-get install libapache2-mod-passenger 126 | ``` 127 | 128 | Visage can attempt to generate an Apache vhost config for use with Passenger: 129 | 130 | ``` bash 131 | $ visage-app genapache 132 | 133 | ServerName ubuntu.localdomain 134 | ServerAdmin root@ubuntu.localdomain 135 | 136 | DocumentRoot /home/user/.gem/ruby/1.8/gems/visage-app-0.1.0/lib/visage-app/public 137 | 138 | 139 | Options FollowSymLinks Indexes 140 | AllowOverride None 141 | Order allow,deny 142 | Allow from all 143 | 144 | 145 | ``` 146 | 147 | Copypasta this into your system's Apache config structure and tune to taste. 148 | 149 | To do this on Debian/Ubuntu: 150 | 151 | ``` bash 152 | sudo -s 153 | visage-app genapache > /etc/apache2/sites-available/visage 154 | a2ensite visage 155 | a2dissite default 156 | service apache2 reload 157 | ``` 158 | 159 | Then visit your Apache instance in a browser, and Visage will be up and running. 160 | 161 | Configuring 162 | ----------- 163 | 164 | Visage looks for some environment variables when starting up: 165 | 166 | * `CONFIG_PATH`, an entry on the configuration file search path. 167 | * `RRDDIR`, the location of collectd's RRDs. 168 | * `COLLECTDSOCK`, the location of collectd's Unix socket. 169 | * `RRDCACHEDSOCK`, the location of rrdcached's Unix socket. 170 | * `VISAGE_DATA_BACKEND`, which storage backend to retrieve data from. 171 | 172 | Visage has a configuration search path which can be used for overriding 173 | individual files. By default it has one entry: `$VISAGE_ROOT/lib/visage/config/`. 174 | You can set the `CONFIG_PATH` environment variable to add another directory to 175 | the config load path. This directory will be searched when loading up 176 | configuration files: 177 | 178 | ``` bash 179 | CONFIG_PATH=/var/lib/visage visage-app start 180 | ``` 181 | 182 | This is especially useful when you want to deploy + run Visage from an installed 183 | gem with Passenger. e.g. 184 | 185 | ``` 186 | 187 | ServerName monitoring.example.org 188 | ServerAdmin me@example.org 189 | 190 | SetEnv CONFIG_PATH /var/lib/visage 191 | SetEnv RRDDIR /opt/collectd/var/lib/collectd 192 | 193 | DocumentRoot /var/lib/gems/1.8/gems/visage-app-0.3.0/lib/visage/public 194 | 195 | Options FollowSymLinks 196 | AllowOverride None 197 | 198 | 199 | LogFormat "%h %l %u %t \"%r\" %>s %b" common 200 | CustomLog /var/log/apache2/access.log common 201 | 202 | ``` 203 | 204 | Also to keep in mind when deploying with Passenger, the `CONFIG_PATH` directory 205 | and its files need to have the correct ownership: 206 | 207 | ``` bash 208 | chown nobody:nogroup -R /var/lib/visage 209 | ``` 210 | 211 | Developing + testing 212 | -------------------- 213 | 214 | Check out the code: 215 | 216 | ``` bash 217 | git clone git://github.com/auxesis/visage.git 218 | ``` 219 | 220 | Install the development dependencies: 221 | 222 | ``` bash 223 | bundle 224 | ``` 225 | 226 | Run all the cukes: 227 | 228 | ``` bash 229 | rake 230 | ``` 231 | 232 | Visage tests should pass every time. [Travis](https://travis-ci.org/auxesis/visage) says the current Visage is ![build status](https://travis-ci.org/auxesis/visage.png?branch=master). 233 | 234 | Run the app with: 235 | 236 | ``` bash 237 | VISAGE_DATA_BACKEND=Mock bundle exec shotgun lib/visage-app/config.ru -p 9292 -o 0.0.0.0 --server thin 238 | ``` 239 | 240 | Visage ships a Mock data backend, so you can test without needing a real instance of collectd writing data with the RRDtool plugin. Per the above example, you can enable it by specifying the `VISAGE_DATA_BACKEND=Mock` environment variable on the command line. 241 | 242 | To create and install a new gem from the current source tree: 243 | 244 | ``` bash 245 | rake build 246 | ``` 247 | 248 | Releasing 249 | --------- 250 | 251 | 1. Bump the version in `lib/visage-app/version.rb` 252 | 2. Add an entry to `CHANGELOG.md` 253 | 3. `git commit` everything. 254 | 4. Build the gem with `rake build` 255 | 5. Push the gem to RubyGems.org with `rake push` 256 | 257 | Licencing 258 | --------- 259 | 260 | Visage is MIT licensed. 261 | 262 | Visage is distributed with Highcharts. Torstein Hønsi has kindly granted 263 | permission to distribute Highcharts under the GPLv2 as part of Visage. 264 | 265 | If you ever need an excellent JavaScript charting library, please consider 266 | purchasing a [commercial license](http://highcharts.com/license) for 267 | Highcharts. 268 | 269 | Support 270 | ------- 271 | 272 | * Post to [the mailing list](https://groups.google.com/forum/?fromgroups=#!forum/visage-app). 273 | * Ping [@auxesis](https://twitter.com/auxesis) on Twitter. 274 | * Check [issues on GitHub](https://github.com/auxesis/visage/issues). 275 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/highcharts-mootools-adapter.src.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Highcharts JS v2.2.1 (2012-03-15) 3 | * MooTools adapter 4 | * 5 | * (c) 2010-2011 Torstein Hønsi 6 | * 7 | * License: www.highcharts.com/license 8 | */ 9 | 10 | // JSLint options: 11 | /*global Fx, $, $extend, $each, $merge, Events, Event, DOMEvent */ 12 | 13 | (function () { 14 | 15 | var win = window, 16 | doc = document, 17 | mooVersion = win.MooTools.version.substring(0, 3), // Get the first three characters of the version number 18 | legacy = mooVersion === '1.2' || mooVersion === '1.1', // 1.1 && 1.2 considered legacy, 1.3 is not. 19 | legacyEvent = legacy || mooVersion === '1.3', // In versions 1.1 - 1.3 the event class is named Event, in newer versions it is named DOMEvent. 20 | $extend = win.$extend || function () { 21 | return Object.append.apply(Object, arguments); 22 | }; 23 | 24 | win.HighchartsAdapter = { 25 | /** 26 | * Initialize the adapter. This is run once as Highcharts is first run. 27 | * @param {Object} pathAnim The helper object to do animations across adapters. 28 | */ 29 | init: function (pathAnim) { 30 | var fxProto = Fx.prototype, 31 | fxStart = fxProto.start, 32 | morphProto = Fx.Morph.prototype, 33 | morphCompute = morphProto.compute; 34 | 35 | // override Fx.start to allow animation of SVG element wrappers 36 | /*jslint unparam: true*//* allow unused parameters in fx functions */ 37 | fxProto.start = function (from, to) { 38 | var fx = this, 39 | elem = fx.element; 40 | 41 | // special for animating paths 42 | if (from.d) { 43 | //this.fromD = this.element.d.split(' '); 44 | fx.paths = pathAnim.init( 45 | elem, 46 | elem.d, 47 | fx.toD 48 | ); 49 | } 50 | fxStart.apply(fx, arguments); 51 | 52 | return this; // chainable 53 | }; 54 | 55 | // override Fx.step to allow animation of SVG element wrappers 56 | morphProto.compute = function (from, to, delta) { 57 | var fx = this, 58 | paths = fx.paths; 59 | 60 | if (paths) { 61 | fx.element.attr( 62 | 'd', 63 | pathAnim.step(paths[0], paths[1], delta, fx.toD) 64 | ); 65 | } else { 66 | return morphCompute.apply(fx, arguments); 67 | } 68 | }; 69 | /*jslint unparam: false*/ 70 | }, 71 | 72 | /** 73 | * Downloads a script and executes a callback when done. 74 | * @param {String} scriptLocation 75 | * @param {Function} callback 76 | */ 77 | getScript: function (scriptLocation, callback) { 78 | // We cannot assume that Assets class from mootools-more is available so instead insert a script tag to download script. 79 | var head = doc.getElementsByTagName('head')[0]; 80 | var script = doc.createElement('script'); 81 | 82 | script.type = 'text/javascript'; 83 | script.src = scriptLocation; 84 | script.onload = callback; 85 | 86 | head.appendChild(script); 87 | }, 88 | 89 | /** 90 | * Animate a HTML element or SVG element wrapper 91 | * @param {Object} el 92 | * @param {Object} params 93 | * @param {Object} options jQuery-like animation options: duration, easing, callback 94 | */ 95 | animate: function (el, params, options) { 96 | var isSVGElement = el.attr, 97 | effect, 98 | complete = options && options.complete; 99 | 100 | if (isSVGElement && !el.setStyle) { 101 | // add setStyle and getStyle methods for internal use in Moo 102 | el.getStyle = el.attr; 103 | el.setStyle = function () { // property value is given as array in Moo - break it down 104 | var args = arguments; 105 | el.attr.call(el, args[0], args[1][0]); 106 | }; 107 | // dirty hack to trick Moo into handling el as an element wrapper 108 | el.$family = function () { return true; }; 109 | } 110 | 111 | // stop running animations 112 | win.HighchartsAdapter.stop(el); 113 | 114 | // define and run the effect 115 | effect = new Fx.Morph( 116 | isSVGElement ? el : $(el), 117 | $extend({ 118 | transition: Fx.Transitions.Quad.easeInOut 119 | }, options) 120 | ); 121 | 122 | // Make sure that the element reference is set when animating svg elements 123 | if (isSVGElement) { 124 | effect.element = el; 125 | } 126 | 127 | // special treatment for paths 128 | if (params.d) { 129 | effect.toD = params.d; 130 | } 131 | 132 | // jQuery-like events 133 | if (complete) { 134 | effect.addEvent('complete', complete); 135 | } 136 | 137 | // run 138 | effect.start(params); 139 | 140 | // record for use in stop method 141 | el.fx = effect; 142 | }, 143 | 144 | /** 145 | * MooTool's each function 146 | * 147 | */ 148 | each: function (arr, fn) { 149 | return legacy ? 150 | $each(arr, fn) : 151 | Array.each(arr, fn); 152 | }, 153 | 154 | /** 155 | * Map an array 156 | * @param {Array} arr 157 | * @param {Function} fn 158 | */ 159 | map: function (arr, fn) { 160 | return arr.map(fn); 161 | }, 162 | 163 | /** 164 | * Grep or filter an array 165 | * @param {Array} arr 166 | * @param {Function} fn 167 | */ 168 | grep: function (arr, fn) { 169 | return arr.filter(fn); 170 | }, 171 | 172 | /** 173 | * Deep merge two objects and return a third 174 | */ 175 | merge: function () { 176 | var args = arguments, 177 | args13 = [{}], // MooTools 1.3+ 178 | i = args.length, 179 | ret; 180 | 181 | if (legacy) { 182 | ret = $merge.apply(null, args); 183 | } else { 184 | while (i--) { 185 | // Boolean argumens should not be merged. 186 | // JQuery explicitly skips this, so we do it here as well. 187 | if (typeof args[i] !== 'boolean') { 188 | args13[i + 1] = args[i]; 189 | } 190 | } 191 | ret = Object.merge.apply(Object, args13); 192 | } 193 | 194 | return ret; 195 | }, 196 | 197 | /** 198 | * Get the offset of an element relative to the top left corner of the web page 199 | */ 200 | offset: function (el) { 201 | var offsets = $(el).getOffsets(); 202 | return { 203 | left: offsets.x, 204 | top: offsets.y 205 | }; 206 | }, 207 | 208 | /** 209 | * Extends an object with Events, if its not done 210 | */ 211 | extendWithEvents: function (el) { 212 | // if the addEvent method is not defined, el is a custom Highcharts object 213 | // like series or point 214 | if (!el.addEvent) { 215 | if (el.nodeName) { 216 | el = $(el); // a dynamically generated node 217 | } else { 218 | $extend(el, new Events()); // a custom object 219 | } 220 | } 221 | }, 222 | 223 | /** 224 | * Add an event listener 225 | * @param {Object} el HTML element or custom object 226 | * @param {String} type Event type 227 | * @param {Function} fn Event handler 228 | */ 229 | addEvent: function (el, type, fn) { 230 | if (typeof type === 'string') { // chart broke due to el being string, type function 231 | 232 | if (type === 'unload') { // Moo self destructs before custom unload events 233 | type = 'beforeunload'; 234 | } 235 | 236 | win.HighchartsAdapter.extendWithEvents(el); 237 | 238 | el.addEvent(type, fn); 239 | } 240 | }, 241 | 242 | removeEvent: function (el, type, fn) { 243 | if (typeof el === 'string') { 244 | // el.removeEvents below apperantly calls this method again. Do not quite understand why, so for now just bail out. 245 | return; 246 | } 247 | win.HighchartsAdapter.extendWithEvents(el); 248 | if (type) { 249 | if (type === 'unload') { // Moo self destructs before custom unload events 250 | type = 'beforeunload'; 251 | } 252 | 253 | if (fn) { 254 | el.removeEvent(type, fn); 255 | } else { 256 | el.removeEvents(type); 257 | } 258 | } else { 259 | el.removeEvents(); 260 | } 261 | }, 262 | 263 | fireEvent: function (el, event, eventArguments, defaultFunction) { 264 | var eventArgs = { 265 | type: event, 266 | target: el 267 | }; 268 | // create an event object that keeps all functions 269 | event = legacyEvent ? new Event(eventArgs) : new DOMEvent(eventArgs); 270 | event = $extend(event, eventArguments); 271 | // override the preventDefault function to be able to use 272 | // this for custom events 273 | event.preventDefault = function () { 274 | defaultFunction = null; 275 | }; 276 | // if fireEvent is not available on the object, there hasn't been added 277 | // any events to it above 278 | if (el.fireEvent) { 279 | el.fireEvent(event.type, event); 280 | } 281 | 282 | // fire the default if it is passed and it is not prevented above 283 | if (defaultFunction) { 284 | defaultFunction(event); 285 | } 286 | }, 287 | 288 | /** 289 | * Stop running animations on the object 290 | */ 291 | stop: function (el) { 292 | if (el.fx) { 293 | el.fx.cancel(); 294 | } 295 | } 296 | }; 297 | 298 | }()); 299 | -------------------------------------------------------------------------------- /lib/visage-app/data/rrd.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'errand' 4 | require 'yajl' 5 | 6 | # Exposes RRDs as JSON. 7 | # 8 | # A loose shim onto RRDtool/Errand, with some extra logic to normalise the data. 9 | module Visage 10 | class Data 11 | module RRD 12 | # http://www.railstips.org/blog/archives/2009/05/15/include-vs-extend-in-ruby/ 13 | def self.included(base) 14 | base.extend(ClassMethods) 15 | end 16 | 17 | # Entry point. 18 | def json(opts={}) 19 | host = opts[:host] 20 | plugin = opts[:plugin] 21 | instances = opts[:instances][/\w.*/] 22 | instances = instances.blank? ? '*' : '{' + instances.split('/').join(',') + '}' 23 | percentiles = opts[:percentiles] !~ /^$|^false$/ ? true : false 24 | resolution = opts[:resolution] || "" 25 | rrdglob = "#{@rrddir}/#{host}/#{plugin}/#{instances}.rrd" 26 | finish = parse_time(opts[:finish]) 27 | start = parse_time(opts[:start], :default => (finish - 3600 || (Time.now - 3600).to_i)) 28 | data = [] 29 | 30 | Dir.glob(rrdglob).map do |rrdname| 31 | parts = rrdname.gsub(/#{@rrddir}\//, '').split('/') 32 | host_name = parts[0] 33 | plugin_name = parts[1] 34 | instance_name = File.basename(parts[2], '.rrd') 35 | 36 | if @collectdsock then 37 | socket = UNIXSocket.new(@collectdsock) 38 | socket.puts "FLUSH \"#{host_name}/#{plugin_name}/#{instance_name}\"" 39 | socket.gets 40 | socket.close 41 | end 42 | 43 | if @rrdcachedsock then 44 | socket = UNIXSocket.new(@rrdcachedsock) 45 | socket.puts "FLUSH #{rrdname}" 46 | socket.gets 47 | socket.close 48 | end 49 | 50 | rrd = Errand.new(:filename => rrdname) 51 | 52 | data << { :plugin => plugin_name, :instance => instance_name, 53 | :host => host_name, 54 | :start => start, 55 | :finish => finish, 56 | :rrd => rrd, 57 | :percentiles => percentiles, 58 | :resolution => resolution} 59 | 60 | end 61 | 62 | encode(data) 63 | end 64 | 65 | private 66 | def percentile_of_array(samples, percentage) 67 | if samples 68 | samples.sort[ (samples.length.to_f * ( percentage.to_f / 100.to_f ) ).to_i - 1 ] 69 | else 70 | raise "I can't work out percentiles on a nil sample set" 71 | end 72 | end 73 | 74 | def downsample_array(samples, old_resolution, new_resolution) 75 | return samples unless samples.length > 0 76 | timer_start = Time.now 77 | new_samples = [] 78 | if (new_resolution > 0) and (old_resolution > 0) and (new_resolution % old_resolution == 0) 79 | groups_of = samples.length / (new_resolution / old_resolution) 80 | return samples unless groups_of > 0 81 | samples.in_groups(groups_of, false) {|group| 82 | new_samples << group.compact.mean 83 | } 84 | else 85 | raise "downsample_array: cowardly refusing to downsample as old_resolution (#{old_resolution.to_s}) doesn't go into new_resolution (#{new_resolution.to_s}) evenly, or new_resolution or old_resolution are zero." 86 | end 87 | timer = Time.now - timer_start 88 | 89 | new_samples 90 | end 91 | 92 | # Attempt to structure the JSON reasonably sanely, so the consumer (i.e. a 93 | # browser) doesn't have to do a lot of computationally expensive work. 94 | def encode(datas) 95 | 96 | structure = {} 97 | datas.each do |data| 98 | 99 | start = data[:start].to_i 100 | finish = data[:finish].to_i 101 | resolution = data[:resolution].to_i || 0 102 | 103 | fetch = data[:rrd].fetch(:function => "AVERAGE", 104 | :start => start.to_s, 105 | :finish => finish.to_s) 106 | 107 | rrd_data = fetch[:data] 108 | percentiles = data[:percentiles] 109 | 110 | # A single rrd can have multiple data sets (multiple metrics within 111 | # the same file). Separate the metrics. 112 | rrd_data.each_pair do |source, metric| 113 | 114 | # Filter out NaNs and weirdly massive values so yajl doesn't choke 115 | metric = metric.map do |datapoint| 116 | if datapoint && datapoint.nan? 117 | @last_valid 118 | else 119 | @last_valid = datapoint 120 | end 121 | end 122 | 123 | # Last value is always wack. Remove it. 124 | metric = metric[0...metric.length-1] 125 | host = data[:host] 126 | plugin = data[:plugin] 127 | instance = data[:instance] 128 | 129 | # only calculate percentiles if requested 130 | if percentiles 131 | timeperiod = finish.to_f - start.to_f 132 | interval = (timeperiod / metric.length.to_f).round 133 | resolution = 300 134 | if (interval < resolution) and (resolution > 0) 135 | metric_for_percentiles = downsample_array(metric, interval, resolution) 136 | else 137 | metric_for_percentiles = metric 138 | end 139 | metric_for_percentiles.compact! 140 | percentiles = false unless metric_for_percentiles.length > 0 141 | end 142 | 143 | if metric.length > 2000 144 | metric = downsample_array(metric, 1, metric.length / 1000) 145 | end 146 | 147 | structure[host] ||= {} 148 | structure[host][plugin] ||= {} 149 | structure[host][plugin][instance] ||= {} 150 | structure[host][plugin][instance][source] ||= {} 151 | structure[host][plugin][instance][source][:start] ||= start 152 | structure[host][plugin][instance][source][:finish] ||= finish 153 | structure[host][plugin][instance][source][:data] ||= metric 154 | if percentiles 155 | structure[host][plugin][instance][source][:percentile_95] ||= percentile_of_array(metric_for_percentiles, 95).round 156 | structure[host][plugin][instance][source][:percentile_50] ||= percentile_of_array(metric_for_percentiles, 50).round 157 | structure[host][plugin][instance][source][:percentile_5] ||= percentile_of_array(metric_for_percentiles, 5).round 158 | end 159 | 160 | end 161 | end 162 | 163 | encoder = Yajl::Encoder.new 164 | encoder.encode(structure) 165 | end 166 | 167 | module ClassMethods 168 | attr_writer :rrddir 169 | 170 | def rrddir 171 | @rrddir ||= Visage::Config.rrddir 172 | end 173 | 174 | def types 175 | @types ||= Visage::Config.types 176 | end 177 | 178 | def collectdsock 179 | @collectdsock ||= Visage::Config.collectdsock 180 | end 181 | 182 | def rrdcachedsock 183 | @rrdcachedsock ||= Visage::Config.rrdcachedsock 184 | end 185 | 186 | # Returns a list of hosts that match the supplied glob, or array of names. 187 | def hosts(opts={}) 188 | hosts = opts[:hosts] 189 | case hosts 190 | when String 191 | glob = "{#{hosts}}" 192 | when Array 193 | glob = "{#{opts[:hosts].join(',')}}" 194 | else 195 | glob = "*" 196 | end 197 | 198 | Dir.glob("#{rrddir}/#{glob}").map {|e| e.split('/').last }.sort.uniq 199 | end 200 | 201 | def metrics(opts={}) 202 | selected_hosts = hosts(opts) 203 | 204 | metrics = opts[:metrics] 205 | case metrics 206 | when String && /,/ 207 | metric_glob = "{#{metrics}}" 208 | when Array 209 | metric_glob = "{#{opts[:metrics].join(',')}}" 210 | else 211 | metric_glob = "*/*" 212 | end 213 | 214 | dametrics = selected_hosts.map { |host| 215 | Dir.glob("#{rrddir}/#{host}/#{metric_glob}.rrd").map {|filename| 216 | filename[/#{rrddir}\/#{host}\/(.*)\.rrd/, 1] 217 | } 218 | } 219 | if (dametrics.length) == 1 220 | dametrics.first 221 | else 222 | dametrics.reduce(:|) 223 | end 224 | end 225 | end # module ClassMethods 226 | end # module RRD 227 | end # class Data 228 | end # module Visage 229 | -------------------------------------------------------------------------------- /spec/models/profile_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'visage-app/models/profile' 3 | require 'tmpdir' 4 | 5 | describe "Profile" do 6 | 7 | before(:each) do 8 | # Create a temporary profile data directory 9 | Profile.config_path = Dir.mktmpdir 10 | 11 | # Create stub graphs 12 | graphs = [ 13 | { :plugin => 'memory', :host => 'foo', :start => Time.now.to_i }, 14 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 15 | ] 16 | # Create stub profiles 17 | [ 18 | {:anonymous => true, :id => 'zzz', :created_at => Time.now - 90, :graphs => graphs}, 19 | {:anonymous => true, :id => 'aaa', :created_at => Time.now - 10, :graphs => graphs, :tags => %w(memory aaa) }, 20 | {:anonymous => false, :id => 'yyy', :name => 'Carol', :created_at => Time.now - 80, :graphs => graphs}, 21 | {:anonymous => false, :id => 'bbb', :name => 'Bob', :created_at => Time.now - 20, :graphs => graphs}, 22 | 23 | ].each do |attributes| 24 | filename = File.join(Profile.config_path, "#{attributes[:id]}.yaml") 25 | File.open(filename, 'w') {|f| f << attributes.to_yaml} 26 | end 27 | end 28 | 29 | describe "lookup" do 30 | it "should provide filtering for anonymous profiles" do 31 | profiles = Profile.all(:anonymous => true) 32 | profiles.size.should > 0 33 | profiles.each do |profile| 34 | profile.anonymous.should be_true 35 | end 36 | end 37 | 38 | it "should provide filtering for named profiles" do 39 | profiles = Profile.all(:anonymous => false) 40 | profiles.size.should > 0 41 | profiles.each do |profile| 42 | profile.anonymous.should be_false 43 | end 44 | end 45 | 46 | it "should return all records on unscoped lookups" do 47 | anonymous = Profile.all(:anonymous => true).size 48 | named = Profile.all(:anonymous => false).size 49 | all = Profile.all.size 50 | 51 | (anonymous + named).should == all 52 | end 53 | 54 | it "should allow sorting on name" do 55 | default_sorting = Profile.all 56 | custom_sorting = Profile.all(:sort => :name) 57 | 58 | default_sorting.should_not == custom_sorting 59 | end 60 | 61 | it "should allow sorting on created_at" do 62 | default_sorting = Profile.all 63 | custom_sorting = Profile.all(:sort => :created_at) 64 | 65 | default_sorting.should_not == custom_sorting 66 | end 67 | 68 | it "should allow ordering of records ascending or descending" do 69 | ascending = Profile.all(:sort => :id, :order => :ascending) 70 | descending = Profile.all(:sort => :id, :order => :descending) 71 | 72 | ascending.reverse.should == descending 73 | end 74 | 75 | it "should allow filtering by tags" do 76 | profiles = Profile.all(:tags => 'memory') 77 | profiles.size.should == 1 78 | profiles.each do |profile| 79 | profile.tags.should include('memory') 80 | end 81 | end 82 | 83 | end 84 | 85 | describe "records" do 86 | it "should be saveable" do 87 | attributes = { 88 | :anonymous => true, 89 | :name => 'An example profile', 90 | :graphs => [ 91 | { :plugin => 'memory', :host => 'foo', :start => Time.now.to_i }, 92 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 93 | ] 94 | } 95 | 96 | profile = Profile.new(attributes) 97 | profile.save.should be_true 98 | end 99 | 100 | it "should convert boolean fields on save" do 101 | attributes = { 102 | :name => 'An example profile', 103 | :graphs => [ 104 | { :plugin => 'memory', :host => 'foo', :start => Time.now.to_i }, 105 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 106 | ] 107 | } 108 | 109 | profile = Profile.new(attributes.merge({:anonymous => 'true'})) 110 | profile.save.should be_true 111 | profile.anonymous.class.should be(TrueClass) 112 | 113 | profile = Profile.new(attributes.merge({:anonymous => 'false'})) 114 | profile.save.should be_true 115 | profile.anonymous.class.should be(FalseClass) 116 | end 117 | 118 | it "should be fetchable by id" do 119 | attributes = { 120 | :anonymous => true, 121 | :name => 'An example profile', 122 | :graphs => [ 123 | { :plugin => 'memory', :host => 'foo', :start => Time.now.to_i }, 124 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 125 | ] 126 | } 127 | profile = Profile.new(attributes) 128 | profile.save.should be_true 129 | 130 | profile = Profile.get(profile.id) 131 | profile.should_not be_nil 132 | end 133 | 134 | it "should automatically generate an id for a profile on save" do 135 | attributes = { 136 | :anonymous => true, 137 | :name => 'Another example profile', 138 | :graphs => [ 139 | { :plugin => 'memory', :host => 'foo', :start => Time.now.to_i }, 140 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 141 | ] 142 | } 143 | 144 | profile = Profile.new(attributes) 145 | profile.save.should be_true 146 | profile.id.should_not be_nil 147 | end 148 | 149 | it "should be representable as JSON" do 150 | profile = Profile.all.first 151 | profile.to_json.should_not be_nil 152 | 153 | keys = profile.as_json.keys 154 | keys.size.should_not be(1) 155 | keys.should_not include('profile') 156 | end 157 | 158 | it "should be updateable" do 159 | attributes = { 160 | :name => 'Foo bar baz', 161 | :graphs => [ 162 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 163 | ] 164 | } 165 | 166 | profile = Profile.all.first 167 | id = profile.id 168 | 169 | profile.update_attributes(attributes).should be_true 170 | 171 | profile = Profile.get(id) 172 | profile.name.should == attributes[:name] 173 | profile.graphs.should == attributes[:graphs] 174 | end 175 | 176 | it "should be deletable" do 177 | Profile.all.each do |profile| 178 | profile.destroy.should be_true 179 | end 180 | end 181 | end 182 | 183 | describe "validations and callbacks" do 184 | it "should confirm presence of name when not anonymous" do 185 | # Anonymous profiles should not validate the presence of a name 186 | attributes = {} 187 | profile = Profile.new(attributes) 188 | profile.save.should_not be_true 189 | profile.errors.keys.should_not include(:name) 190 | 191 | # Named profiles should validate the presence of a name 192 | attributes = {:anonymous => false} 193 | profile = Profile.new(attributes) 194 | profile.save.should_not be_true 195 | profile.errors.keys.should include(:name) 196 | end 197 | 198 | it "should confirm presence of graphs" do 199 | attributes = {} 200 | profile = Profile.new(attributes) 201 | profile.save.should_not be_true 202 | profile.errors.should_not be_nil 203 | end 204 | 205 | it "should automatically set created_at" do 206 | attributes = { 207 | :anonymous => true, 208 | :name => 'Another example profile', 209 | :graphs => [ 210 | { :plugin => 'memory', :host => 'foo', :start => Time.now.to_i }, 211 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 212 | ] 213 | } 214 | 215 | profile = Profile.new(attributes) 216 | profile.save.should be_true 217 | profile.created_at.should_not be_nil 218 | end 219 | end 220 | 221 | describe "serialisation" do 222 | it "should save each profile in a separate file" do 223 | attributes = { 224 | :anonymous => true, 225 | :name => 'Another example profile', 226 | :graphs => [ 227 | { :plugin => 'memory', :host => 'foo', :start => Time.now.to_i }, 228 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 229 | ] 230 | } 231 | 232 | profile = Profile.new(attributes) 233 | profile.save.should be_true 234 | 235 | profile.path.should == "#{Profile.config_path}/#{profile.id}.yaml" 236 | File.exists?(profile.path).should be_true 237 | 238 | loaded_profile = Profile.get(profile.id) 239 | loaded_profile.should == profile 240 | end 241 | 242 | it "should read a profile from a file" do 243 | attributes = { 244 | :anonymous => true, 245 | :name => 'externally created profile', 246 | :graphs => [ 247 | { :plugin => 'memory', :host => 'foo', :start => Time.now.to_i }, 248 | { :plugin => 'memory', :host => 'bar', :start => Time.now.to_i }, 249 | ] 250 | } 251 | 252 | # Save the file without using the Profile class 253 | filename = File.join(Profile.config_path, "#{Time.now.to_i}.yaml") 254 | File.open(filename, 'w') {|f| f << attributes.to_yaml} 255 | 256 | # Read the profile using the Profile class 257 | id = File.basename(filename, '.yaml') 258 | profile = Profile.get(id) 259 | profile.name.should == 'externally created profile' 260 | profile.created_at.should be_nil 261 | end 262 | 263 | it "should return nil if the profile doesn't exist" do 264 | profile = Profile.get(Time.now.to_i) 265 | profile.should be_nil 266 | end 267 | end 268 | end 269 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/LightFace.js: -------------------------------------------------------------------------------- 1 | /* 2 | --- 3 | description: LightFace 4 | 5 | license: MIT-style 6 | 7 | authors: 8 | - David Walsh (http://davidwalsh.name) 9 | 10 | requires: 11 | - core/1.2.1: "*" 12 | 13 | provides: [LightFace] 14 | 15 | ... 16 | */ 17 | 18 | var LightFace = new Class({ 19 | 20 | Implements: [Options,Events], 21 | 22 | options: { 23 | width: "auto", 24 | height: "auto", 25 | draggable: false, 26 | title: "", 27 | buttons: [], 28 | fadeDelay: 400, 29 | fadeDuration: 400, 30 | keys: { 31 | esc: function() { this.close(); } 32 | }, 33 | content: "

Message not specified.

", 34 | zIndex: 9001, 35 | pad: 100, 36 | overlayAll: false, 37 | constrain: false, 38 | resetOnScroll: true, 39 | baseClass: "lightface", 40 | errorMessage: "

The requested file could not be found.

"/*, 41 | onOpen: $empty, 42 | onClose: $empty, 43 | onFade: $empty, 44 | onUnfade: $empty, 45 | onComplete: $empty, 46 | onRequest: $empty, 47 | onSuccess: $empty, 48 | onFailure: $empty 49 | */ 50 | }, 51 | 52 | 53 | initialize: function(options) { 54 | this.setOptions(options); 55 | this.state = false; 56 | this.buttons = {}; 57 | this.resizeOnOpen = true; 58 | this.ie6 = typeof document.body.style.maxHeight == "undefined"; 59 | this.draw(); 60 | }, 61 | 62 | draw: function() { 63 | 64 | //create main box 65 | this.box = new Element("table",{ 66 | "class": this.options.baseClass, 67 | styles: { 68 | "z-index": this.options.zIndex, 69 | opacity: 0 70 | }, 71 | tween: { 72 | duration: this.options.fadeDuration, 73 | onComplete: function() { 74 | if(this.box.getStyle("opacity") == 0) { 75 | this.box.setStyles({ top: -9000, left: -9000 }); 76 | } 77 | }.bind(this) 78 | } 79 | }).inject(document.body,"bottom"); 80 | 81 | //draw rows and cells; use native JS to avoid IE7 and I6 offsetWidth and offsetHeight issues 82 | var verts = ["top","center","bottom"], hors = ["Left","Center","Right"], len = verts.length; 83 | for(var x = 0; x < len; x++) { 84 | var row = this.box.insertRow(x); 85 | for(var y = 0; y < len; y++) { 86 | var cssClass = verts[x] + hors[y], cell = row.insertCell(y); 87 | cell.className = cssClass; 88 | if (cssClass == "centerCenter") { 89 | this.contentBox = new Element("div",{ 90 | "class": "lightfaceContent", 91 | styles: { 92 | width: this.options.width 93 | } 94 | }); 95 | cell.appendChild(this.contentBox); 96 | } 97 | else { 98 | document.id(cell).setStyle("opacity", 0.4); 99 | } 100 | } 101 | } 102 | 103 | //draw title 104 | 105 | this.title = new Element("h2",{ 106 | "class": "lightfaceTitle", 107 | html: this.options.title 108 | }).inject(this.contentBox); 109 | 110 | if(this.options.draggable && window["Drag"] != null) { 111 | this.draggable = true; 112 | new Drag(this.box, { handle: this.title }); 113 | this.title.addClass("lightfaceDraggable"); 114 | } 115 | 116 | //draw message box 117 | this.messageBox = new Element("div", { 118 | "class": "lightfaceMessageBox", 119 | html: this.options.content || "", 120 | styles: { 121 | height: this.options.height 122 | } 123 | }).inject(this.contentBox); 124 | 125 | //button container 126 | this.footer = new Element("div", { 127 | "class": "lightfaceFooter", 128 | styles: { 129 | display: "none" 130 | } 131 | }).inject(this.contentBox); 132 | 133 | //draw overlay 134 | this.overlay = new Element("div", { 135 | html: " ", 136 | styles: { 137 | opacity: 0, 138 | visibility: "hidden" 139 | }, 140 | "class": "lightfaceOverlay", 141 | tween: { 142 | link: "chain", 143 | duration: this.options.fadeDuration, 144 | onComplete: function() { 145 | if(this.overlay.getStyle("opacity") == 0) this.box.focus(); 146 | }.bind(this) 147 | } 148 | }).inject(this.contentBox); 149 | if(!this.options.overlayAll) { 150 | this.overlay.setStyle("top", (this.title ? this.title.getSize().y - 1: 0)); 151 | } 152 | 153 | //create initial buttons 154 | this.buttons = []; 155 | if(this.options.buttons.length) { 156 | this.options.buttons.each(function(button) { 157 | this.addButton(button.title, button.event, button.color); 158 | },this); 159 | } 160 | 161 | //focus node 162 | this.focusNode = this.box; 163 | 164 | return this; 165 | }, 166 | 167 | // Manage buttons 168 | addButton: function(title,clickEvent,color) { 169 | this.footer.setStyle("display", "block"); 170 | var focusClass = "lightfacefocus" + color; 171 | var label = new Element("label", { 172 | "class": color ? "lightface" + color : "", 173 | events: { 174 | mousedown: function() { 175 | if(color) { 176 | label.addClass(focusClass); 177 | var ev = function() { 178 | label.removeClass(focusClass); 179 | document.id(document.body).removeEvent("mouseup", ev); 180 | }; 181 | document.id(document.body).addEvent("mouseup", ev); 182 | } 183 | } 184 | } 185 | }); 186 | this.buttons[title] = (new Element("input", { 187 | type: "button", 188 | value: title, 189 | events: { 190 | click: (clickEvent || this.close).bind(this) 191 | } 192 | }).inject(label)); 193 | label.inject(this.footer); 194 | return this; 195 | }, 196 | showButton: function(title) { 197 | if(this.buttons[title]) this.buttons[title].removeClass("hiddenButton"); 198 | return this.buttons[title]; 199 | }, 200 | hideButton: function(title) { 201 | if(this.buttons[title]) this.buttons[title].addClass("hiddenButton"); 202 | return this.buttons[title]; 203 | }, 204 | 205 | // Open and close box 206 | close: function(fast) { 207 | if(this.isOpen) { 208 | this.box[fast ? "setStyles" : "tween"]("opacity", 0); 209 | this.fireEvent("close"); 210 | this._detachEvents(); 211 | this.isOpen = false; 212 | } 213 | return this; 214 | }, 215 | 216 | open: function(fast) { 217 | if(!this.isOpen) { 218 | this.box[fast ? "setStyles" : "tween"]("opacity", 1); 219 | if(this.resizeOnOpen) this._resize(); 220 | this.fireEvent("open"); 221 | this._attachEvents(); 222 | (function() { 223 | this._setFocus(); 224 | }).bind(this).delay(this.options.fadeDuration + 10); 225 | this.isOpen = true; 226 | } 227 | return this; 228 | }, 229 | 230 | _setFocus: function() { 231 | this.focusNode.setAttribute("tabIndex", 0); 232 | this.focusNode.focus(); 233 | }, 234 | 235 | // Show and hide overlay 236 | fade: function(fade, delay) { 237 | this._ie6Size(); 238 | (function() { 239 | this.overlay.setStyle("opacity", fade || 1); 240 | }.bind(this)).delay(delay || 0); 241 | this.fireEvent("fade"); 242 | return this; 243 | }, 244 | unfade: function(delay) { 245 | (function() { 246 | this.overlay.fade(0); 247 | }.bind(this)).delay(delay || this.options.fadeDelay); 248 | this.fireEvent("unfade"); 249 | return this; 250 | }, 251 | _ie6Size: function() { 252 | if(this.ie6) { 253 | var size = this.contentBox.getSize(); 254 | var titleHeight = (this.options.overlayAll || !this.title) ? 0 : this.title.getSize().y; 255 | this.overlay.setStyles({ 256 | height: size.y - titleHeight, 257 | width: size.x 258 | }); 259 | } 260 | }, 261 | 262 | // Loads content 263 | load: function(content, title) { 264 | if(content) this.messageBox.set("html", content); 265 | title = title || this.options.title; 266 | if(title) this.title.set("html", title).setStyle("display", "block"); 267 | else this.title.setStyle("display", "none"); 268 | this.fireEvent("complete"); 269 | return this; 270 | }, 271 | 272 | // Attaches events when opened 273 | _attachEvents: function() { 274 | this.keyEvent = function(e){ 275 | if(this.options.keys[e.key]) this.options.keys[e.key].call(this); 276 | }.bind(this); 277 | this.focusNode.addEvent("keyup", this.keyEvent); 278 | 279 | this.resizeEvent = this.options.constrain ? function(e) { 280 | this._resize(); 281 | }.bind(this) : function() { 282 | this._position(); 283 | }.bind(this); 284 | window.addEvent("resize", this.resizeEvent); 285 | 286 | if(this.options.resetOnScroll) { 287 | this.scrollEvent = function() { 288 | this._position(); 289 | }.bind(this); 290 | window.addEvent("scroll", this.scrollEvent); 291 | } 292 | 293 | return this; 294 | }, 295 | 296 | // Detaches events upon close 297 | _detachEvents: function() { 298 | this.focusNode.removeEvent("keyup", this.keyEvent); 299 | window.removeEvent("resize", this.resizeEvent); 300 | if(this.scrollEvent) window.removeEvent("scroll", this.scrollEvent); 301 | return this; 302 | }, 303 | 304 | // Repositions the box 305 | _position: function() { 306 | var windowSize = window.getSize(), 307 | scrollSize = window.getScroll(), 308 | boxSize = this.box.getSize(); 309 | this.box.setStyles({ 310 | left: scrollSize.x + ((windowSize.x - boxSize.x) / 2), 311 | top: scrollSize.y + ((windowSize.y - boxSize.y) / 2) 312 | }); 313 | this._ie6Size(); 314 | return this; 315 | }, 316 | 317 | // Resizes the box, then positions it 318 | _resize: function() { 319 | var height = this.options.height; 320 | if(height == "auto") { 321 | //get the height of the content box 322 | var max = window.getSize().y - this.options.pad; 323 | if(this.contentBox.getSize().y > max) height = max; 324 | } 325 | this.messageBox.setStyle("height", height); 326 | this._position(); 327 | }, 328 | 329 | // Expose message box 330 | toElement: function () { 331 | return this.messageBox; 332 | }, 333 | 334 | // Expose entire modal box 335 | getBox: function() { 336 | return this.box; 337 | }, 338 | 339 | // Cleanup 340 | destroy: function() { 341 | this._detachEvents(); 342 | this.buttons.each(function(button) { 343 | button.removeEvents("click"); 344 | }); 345 | this.box.dispose(); 346 | delete this.box; 347 | } 348 | }); -------------------------------------------------------------------------------- /features/step_definitions/profile_steps.rb: -------------------------------------------------------------------------------- 1 | When /^I add a graph$/ do 2 | script = <<-SCRIPT 3 | // Reset all the checkboxes 4 | $$('div#hosts input.checkbox').each(function(checkbox) { if (checkbox.checked) { checkbox.click() }}) 5 | // Select a random checkbox 6 | $$('div#hosts input.checkbox').shuffle()[0].click(); 7 | SCRIPT 8 | execute_script(script) # so metrics can be fetched 9 | 10 | script = <<-SCRIPT 11 | $$('div#metrics input.checkbox')[0].click(); 12 | $$('div#display input.button')[0].click(); 13 | SCRIPT 14 | execute_script(script) 15 | end 16 | 17 | When(/^I add (\d+) graphs*$/) do |count| 18 | count = count.to_i 19 | count.times { step 'I add a graph' } 20 | 21 | script = <<-SCRIPT 22 | window.graphs.length 23 | SCRIPT 24 | page.evaluate_script(script).should == count 25 | end 26 | 27 | When /^I share the profile$/ do 28 | script = <<-SCRIPT 29 | $('share-toggler').fireEvent('click'); 30 | SCRIPT 31 | execute_script(script) 32 | end 33 | 34 | Then /^I should see a permalink for the profile$/ do 35 | page.current_path.should_not match(/\/profiles\/new$/) 36 | page.current_path.should match(/\/profiles\/[0-9a-f]+$/) 37 | end 38 | 39 | When(/^I create an anonymous profile$/) do 40 | step "I go to /profiles/new" 41 | step "I add a graph" 42 | step "I share the profile" 43 | step "I should see a permalink for the profile" 44 | @anonymous_profile_url = page.current_path 45 | end 46 | 47 | When(/^I visit that anonymous profile$/) do 48 | step "I visit the first recent profile" 49 | step "I should see a collection of graphs" 50 | end 51 | 52 | Then(/^I should see a new permalink for the profile$/) do 53 | page.current_path.should_not == @anonymous_profile_url 54 | end 55 | 56 | When(/^I share the profile with the name "(.*?)"$/) do |name| 57 | script = <<-SCRIPT 58 | $('share-toggler').fireEvent('click'); 59 | SCRIPT 60 | execute_script(script) 61 | 62 | script = <<-SCRIPT 63 | $('profile-anonymous').checked = true; 64 | $('profile-anonymous').fireEvent('click'); 65 | SCRIPT 66 | execute_script(script) 67 | 68 | script = <<-SCRIPT 69 | $('profile-name').set('value', '#{name}'); 70 | $('share-save').fireEvent('click'); 71 | SCRIPT 72 | execute_script(script, :wait => 3) 73 | end 74 | 75 | Then(/^I should see a profile named "(.*?)"$/) do |name| 76 | doc = Nokogiri::HTML(page.body) 77 | profiles = doc.search('div#named_profiles ul li') 78 | profiles.size.should > 0 79 | 80 | match = profiles.find {|profile| profile.text =~ /#{name}/} 81 | match.should_not be_nil 82 | end 83 | 84 | Then(/^I should not see a profile named "(.*?)"$/) do |name| 85 | doc = Nokogiri::HTML(page.body) 86 | profiles = doc.search('div#named_profiles ul li') 87 | profiles.size.should > 0 88 | 89 | match = profiles.find {|profile| profile.text =~ /#{name}/} 90 | match.should be_nil 91 | end 92 | 93 | When(/^I create a profile named "(.*?)"$/) do |name| 94 | step %(I go to /profiles/new) 95 | step %(I add a graph) 96 | step %(I share the profile with the name "#{name}") 97 | step %(I should see a permalink for the profile) 98 | step %(I go to /profiles) 99 | step %(I should see a profile named "#{name}") 100 | end 101 | 102 | When(/^I visit a profile named "(.*?)"$/) do |name| 103 | step 'I go to /profiles' 104 | step %(I follow "#{name}") 105 | end 106 | 107 | When(/^I activate the share modal$/) do 108 | step 'I share the profile' 109 | end 110 | 111 | When(/^I delete the profile$/) do 112 | script = <<-SCRIPT 113 | $('share-delete').fireEvent('click'); 114 | SCRIPT 115 | execute_script(script, :wait => 3) 116 | end 117 | 118 | Then(/^I should be at (.*)$/) do |path| 119 | page.current_path.should == path 120 | end 121 | 122 | Then(/^I should see "(.*?)" in the page title$/) do |name| 123 | doc = Nokogiri::HTML(page.body) 124 | title = doc.search('head title') 125 | title.text.should match(/#{name}/) 126 | end 127 | 128 | Then(/^I should not see a permalink for the profile$/) do 129 | page.current_path.should match(/\/profiles\/new$/) 130 | page.current_path.should_not match(/\/profiles\/[0-9a-f]+$/) 131 | end 132 | 133 | Then(/^I should see a modal prompting me to add graphs$/) do 134 | script = <<-SCRIPT 135 | $('errors') == null 136 | SCRIPT 137 | page.evaluate_script(script).should be_false 138 | 139 | script = <<-SCRIPT 140 | $$('div#errors div.error').length 141 | SCRIPT 142 | page.evaluate_script(script).should > 0 143 | end 144 | 145 | Then(/^I should only see a button to close the dialog$/) do 146 | script = <<-SCRIPT 147 | $$('div.lightfaceFooter input.action').length 148 | SCRIPT 149 | page.evaluate_script(script).should == 1 150 | end 151 | 152 | When(/^I set the timeframe to "(.*?)"$/) do |timeframe| 153 | script = <<-SCRIPT 154 | $('timeframe-toggler').fireEvent('click'); 155 | 156 | var timeframes = $$('ul#timeframes li.timeframe'); 157 | // we use filter instead of find, as we're operating on a collection of elements 158 | var match = timeframes.filter(function(item, index) { 159 | return item.get('html') == '#{timeframe}' 160 | })[0]; 161 | 162 | match.fireEvent('click'); 163 | SCRIPT 164 | execute_script(script) 165 | end 166 | 167 | Then(/^the graphs should have data for the last (\d+) hours*$/) do |hours| 168 | step 'show me the page' 169 | 170 | script = <<-SCRIPT 171 | window.profile.get('graphs').map(function(graph) { return graph.finish }); 172 | SCRIPT 173 | finish_times = page.evaluate_script(script) 174 | finish_times.size.should > 0 175 | 176 | script = <<-SCRIPT 177 | window.profile.get('graphs').map(function(graph) { return graph.start }) 178 | SCRIPT 179 | start_times = page.evaluate_script(script) 180 | start_times.size.should > 0 181 | start_times.uniq.size.should == 1 182 | start_time = start_times.first 183 | 184 | ############# start debugging 185 | now = Time.now 186 | js_now = page.evaluate_script("(new Date).getTime() / 1000").to_i 187 | 188 | n_hours_ago = (now - (hours.to_i * 3600)).to_i 189 | fuzzy_start = n_hours_ago - 30 190 | fuzzy_finish = n_hours_ago + 30 191 | 192 | debug = { 193 | :fuzzy_start => fuzzy_start, 194 | :start_time => start_time, 195 | :ruby_now => now.to_i, 196 | :js_now => js_now, 197 | :fuzzy_finish => fuzzy_finish, 198 | } 199 | print_hash(debug) 200 | ############# end debugging 201 | 202 | Time.at(start_time).should be_between(Time.at(fuzzy_start), Time.at(fuzzy_finish)) 203 | end 204 | 205 | def print_hash(hash) 206 | max = hash.keys.sort_by {|k| k.size}.last.size 207 | 208 | hash.each_pair do |key, value| 209 | puts "#{key.to_s.ljust(max)} | #{value} | #{Time.at(value)}" 210 | end 211 | end 212 | 213 | When(/^I wait (\d+) seconds$/) do |arg1| 214 | sleep(arg1.to_i) 215 | end 216 | 217 | Then(/^the graphs should have data for exactly (\d+) hours$/) do |hours| 218 | hours = hours.to_i 219 | seconds = hours * 60 * 60 220 | fuzzy_start = seconds - 30 221 | fuzzy_finish = seconds + 30 222 | 223 | script = <<-SCRIPT 224 | window.profile.get('graphs').map(function(graph) { return graph.start }) 225 | SCRIPT 226 | start_times = page.evaluate_script(script).compact 227 | start_times.size.should > 0 228 | start_times.uniq.size.should eq(1), 'more than one start time' 229 | 230 | script = <<-SCRIPT 231 | window.profile.get('graphs').map(function(graph) { return graph.finish }) 232 | SCRIPT 233 | finish_times = page.evaluate_script(script).compact 234 | finish_times.size.should > 0 235 | 236 | start_times.zip(finish_times).each do |start, finish| 237 | (finish - start).should be_between(fuzzy_start, fuzzy_finish) 238 | end 239 | end 240 | 241 | When(/^I set the profile name to "(.*?)"$/) do |name| 242 | script = <<-SCRIPT 243 | $('profile-anonymous').checked = true; 244 | $('profile-anonymous').fireEvent('click'); 245 | $('profile-name').set('value', '#{name}'); 246 | SCRIPT 247 | execute_script(script) 248 | end 249 | 250 | When(/^I save the profile$/) do 251 | script = <<-SCRIPT 252 | $('share-save').fireEvent('click'); 253 | SCRIPT 254 | execute_script(script, :wait => 3) 255 | end 256 | 257 | When(/^I check the "Remember the timeframe" option$/) do 258 | pending 259 | end 260 | 261 | When(/^I remember the timeframe absolutely$/) do 262 | script = <<-SCRIPT 263 | $('profile-timeframe-absolute').checked = true; 264 | $('profile-timeframe-absolute').fireEvent('click'); 265 | SCRIPT 266 | execute_script(script) 267 | end 268 | 269 | When(/^I remember the timeframe relatively$/) do 270 | script = <<-SCRIPT 271 | $('profile-timeframe-relative').checked = true; 272 | $('profile-timeframe-relative').fireEvent('click'); 273 | SCRIPT 274 | execute_script(script) 275 | end 276 | 277 | When(/^I remember the timeframe when sharing the profile named "(.*?)"$/) do |name| 278 | step %(I activate the share modal) 279 | step %(I set the profile name to "#{name}") 280 | step %(I remember the timeframe absolutely) 281 | step %(I save the profile) 282 | end 283 | 284 | When(/^I reset the timeframe$/) do 285 | step %(I go to /profiles/new) 286 | step %(I set the timeframe to "last 6 hours") 287 | end 288 | 289 | When(/^I go (\d+) minutes into the future$/) do |n| 290 | minutes = n.to_i 291 | 292 | # Ruby time manipulation 293 | Delorean.time_travel_to("#{minutes} minutes from now") 294 | 295 | script = "new Date" 296 | p page.evaluate_script(script) 297 | 298 | # JavaScript time manipulation 299 | @javascript_time_offset = minutes * 60 * 1000 300 | script = <<-SCRIPT 301 | DeLorean.globalApi(true); 302 | DeLorean.advance(#{@javascript_time_offset}); 303 | SCRIPT 304 | execute_script(script) 305 | 306 | script = "new Date" 307 | p page.evaluate_script(script) 308 | 309 | end 310 | 311 | Then(/^the timeframe should be "(.*?)"$/) do |timeframe| 312 | script = <<-SCRIPT 313 | $('timeframe-label').get('html') 314 | SCRIPT 315 | 316 | page.evaluate_script(script).should == timeframe 317 | end 318 | 319 | Then(/^show me the timeframe cookie$/) do 320 | script = <<-SCRIPT 321 | Cookie.read('timeframe') 322 | SCRIPT 323 | p page.evaluate_script(script) 324 | end 325 | 326 | def execute_script(script, opts={}) 327 | options = { 328 | :wait => 1, 329 | :screenshot => false 330 | }.merge!(opts) 331 | 332 | page.execute_script(script) 333 | sleep(options[:wait]) 334 | 335 | step 'show me the page' if options[:screenshot] 336 | end 337 | 338 | 339 | -------------------------------------------------------------------------------- /lib/visage-app/public/javascripts/MooToolsAdapter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MooToolsAdapter 0.1 3 | * For all details and documentation: 4 | * http://github.com/inkling/backbone-mootools 5 | * 6 | * Copyright 2011 Inkling Systems, Inc. 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | */ 20 | 21 | /* 22 | * This file provides a basic jQuery to MooTools Adapter. It allows us to run Backbone.js 23 | * with minimal modifications. 24 | */ 25 | (function(window){ 26 | var MooToolsAdapter = new Class({ 27 | initialize: function(elements){ 28 | for (var i = 0; i < elements.length; i++){ 29 | this[i] = elements[i]; 30 | } 31 | this.length = elements.length; 32 | }, 33 | 34 | /** 35 | * Hide the elements defined by the MooToolsAdapter from the screen. 36 | */ 37 | hide: function(){ 38 | for (var i = 0; i < this.length; i++){ 39 | this[i].setStyle('display', 'none'); 40 | } 41 | return this; 42 | }, 43 | 44 | /** 45 | * Append the frst element in the MooToolsAdapter to the elements found by the passed in 46 | * selector. If the selector selects more than one element, a clone of the first element is 47 | * put into every selected element except the first. The first selected element always 48 | * adopts the real element. 49 | * 50 | * @param selector A CSS3 selector. 51 | */ 52 | appendTo: function(selector){ 53 | var elements = window.getElements(selector); 54 | 55 | for (var i = 0; i < elements.length; i++){ 56 | if (i > 0){ 57 | elements[i].adopt(Object.clone(this[0])); 58 | } else { 59 | elements[i].adopt(this[0]); 60 | } 61 | } 62 | 63 | return this; 64 | }, 65 | 66 | /** 67 | * Set the attributes of the element defined by the MooToolsAdapter. 68 | * 69 | * @param map:Object literal map definining the attributes and the values to which 70 | * they should be set. 71 | * 72 | * @return MooToolsAdapter The object on which this method was called. 73 | */ 74 | attr: function(map){ 75 | for (var i = 0; i < this.length; i++){ 76 | this[i].set(map); 77 | } 78 | return this; 79 | }, 80 | 81 | /** 82 | * Set the HTML contents of the elements contained by the MooToolsAdapter. 83 | * 84 | * @param htmlString:String A string of HTML text. 85 | * 86 | * @return MooToolsAdapter The object the method was called on. 87 | */ 88 | html: function(htmlString){ 89 | if (typeof htmlString === 'undefined') { 90 | return this[0].get('html'); 91 | } else { 92 | for (var i = 0; i < this.length; i++){ 93 | this[i].set('html', htmlString); 94 | } 95 | } 96 | return this; 97 | }, 98 | 99 | /** 100 | * Remove an event namespace from an eventName. 101 | * For Example: Transform click.mootools -> click 102 | * 103 | * @param eventName:String A string representing an event name. 104 | * 105 | * @return String A string representing the event name passed without any namespacing. 106 | */ 107 | removeNamespace_: function(eventName){ 108 | var dotIndex = eventName.indexOf('.'); 109 | 110 | if (dotIndex != '-1'){ 111 | eventName = eventName.substr(0, dotIndex); 112 | } 113 | 114 | return eventName; 115 | }, 116 | 117 | /** 118 | * Delegate an event that is fired on the elements defined by the selector to trigger the 119 | * passed in callback. 120 | * 121 | * @param selector:String A CSS3 selector defining which elements should be listenining to 122 | * the event. 123 | * @param eventName:String The name of the event. 124 | * @param method:Function The callback to call when the event is fired on the proper 125 | * element. 126 | * 127 | * @return MooToolsAdapter The object the method was called on. 128 | */ 129 | delegate: function(selector, eventName, method){ 130 | // Remove namespacing because it's not supported in MooTools. 131 | eventName = this.removeNamespace_(eventName); 132 | 133 | // Note: MooTools Delegation does not support delegating on blur and focus yet. 134 | for (var i = 0; i < this.length; i++){ 135 | this[i].addEvent(eventName + ':relay(' + selector + ')', method); 136 | } 137 | return this; 138 | }, 139 | 140 | /** 141 | * Bind the elements on the MooToolsAdapter to call the specific method for the specific 142 | * event. 143 | * 144 | * @param eventName:String The name of the event. 145 | * @param method:Function The callback to apply when the event is fired. 146 | * 147 | * @return MooToolsAdapter The object the method was called on. 148 | */ 149 | bind: function(eventName, method){ 150 | // Remove namespacing because it's not supported in MooTools. 151 | eventName = this.removeNamespace_(eventName); 152 | 153 | // Bind the events. 154 | for (var i = 0; i < this.length; i++){ 155 | if (eventName == 'popstate' || eventName == 'hashchange'){ 156 | this[i].addEventListener(eventName, method); 157 | } else { 158 | this[i].addEvent(eventName, method); 159 | } 160 | } 161 | return this; 162 | }, 163 | 164 | /** 165 | * Unbind the bound events for the element. 166 | */ 167 | unbind: function(eventName){ 168 | // Remove namespacing because it's not supported in MooTools. 169 | eventName = this.removeNamespace_(eventName); 170 | 171 | for (var i = 0; i < this.length; i++){ 172 | if (eventName !== ""){ 173 | this[i].removeEvent(eventName); 174 | } else { 175 | this[i].removeEvents(); 176 | } 177 | } 178 | return this; 179 | }, 180 | 181 | /** 182 | * Return the element at the specified index on the MooToolsAdapter. 183 | * Equivalent to MooToolsAdapter[index]. 184 | * 185 | * @param index:Number a numerical index. 186 | * 187 | * @return HTMLElement An HTML element from the MooToolsAdapter. Returns undefined 188 | * if an element at that index does not exist. 189 | */ 190 | get: function(index){ 191 | return this[index]; 192 | }, 193 | 194 | /** 195 | * Removes from the DOM all the elements selected by the MooToolsAdapter. 196 | */ 197 | remove: function(){ 198 | for (var i = 0; i < this.length; i++){ 199 | this[i].dispose(); 200 | } 201 | return this; 202 | }, 203 | 204 | /** 205 | * Add a callback for when the document is ready. 206 | */ 207 | ready: function(callback){ 208 | for (var i = 0; i < this.length; i++){ 209 | window.addEvent('domready', callback); 210 | } 211 | }, 212 | 213 | /** 214 | * Return the text content of all the elements selected by the MooToolsAdapter. 215 | * The text of the different elements is seperated by a space. 216 | * 217 | * @return String The text contents of all the elements selected by the MooToolsAdapter. 218 | */ 219 | text: function(){ 220 | var text = []; 221 | for (var i = 0; i < this.length; i++){ 222 | text.push(this[i].get('text')); 223 | } 224 | return text.join(' '); 225 | }, 226 | 227 | /** 228 | * Fire a specific event on the elements selected by the MooToolsAdapter. 229 | * 230 | * @param trigger: 231 | */ 232 | trigger: function(eventName){ 233 | for (var i = 0; i < this.length; i++){ 234 | this[i].fireEvent(eventName); 235 | } 236 | return this; 237 | }, 238 | 239 | /** 240 | * Find all elements that match a given selector which are descendants of the 241 | * elements selected the MooToolsAdapter. 242 | * 243 | * @param selector:String - A css3 selector; 244 | * 245 | * @return MooToolsAdapter A MooToolsAdapter containing the selected 246 | * elements. 247 | */ 248 | find: function(selector){ 249 | var elements = new Elements(); 250 | for (var i = 0; i < this.length; i++){ 251 | var result = this[i].getElements(selector); 252 | elements = elements.concat(result); 253 | } 254 | return new MooToolsAdapter(elements); 255 | } 256 | }); 257 | 258 | /** 259 | * JQuery Selector Methods 260 | * 261 | * jQuery(html) - Returns an HTML element wrapped in a MooToolsAdapter. 262 | * jQuery(expression) - Returns a MooToolsAdapter containing an element set corresponding the 263 | * elements selected by the expression. 264 | * jQuery(expression, context) - Returns a MooToolsAdapter containing an element set corresponding 265 | * to applying the expression in the specified context. 266 | * jQuery(element) - Wraps the provided element in a MooToolsAdapter and returns it. 267 | * 268 | * @return MooToolsAdapter an adapter element containing the selected/constructed 269 | * elements. 270 | */ 271 | window.jQuery = function(expression, context){ 272 | var elements; 273 | 274 | // Handle jQuery(html). 275 | if (typeof expression === 'string' && !context){ 276 | if (expression.charAt(0) === '<' && expression.charAt(expression.length - 1) === '>'){ 277 | elements = [new Element('div', { 278 | html: expression 279 | }).getFirst()]; 280 | return new MooToolsAdapter(elements); 281 | } 282 | } else if (typeof expression == 'object'){ 283 | if (instanceOf(expression, MooToolsAdapter)){ 284 | // Handle jQuery(MooToolsAdapter) 285 | return expression; 286 | } else { 287 | // Handle jQuery(element). 288 | return new MooToolsAdapter([expression]); 289 | } 290 | } 291 | 292 | // Handle jQuery(expression) and jQuery(expression, context). 293 | context = context || document; 294 | elements = context.getElements(expression); 295 | return new MooToolsAdapter(elements); 296 | }; 297 | 298 | /* 299 | * jQuery.ajax 300 | * 301 | * Maps a jQuery ajax request to a MooTools Request and sends it. 302 | */ 303 | window.jQuery.ajax = function(params){ 304 | var emulation = false; 305 | var data = params.data; 306 | if (Backbone.emulateJSON){ 307 | emulation = true; 308 | data = data ? { model: data } : {}; 309 | } 310 | 311 | var parameters = { 312 | url: params.url, 313 | method: params.type, 314 | data: data, 315 | emulation: emulation, 316 | onSuccess: function(responseText){ 317 | params.success(JSON.parse(responseText)); 318 | }, 319 | onFailure: params.error, 320 | headers: { 'Content-Type': params.contentType } 321 | }; 322 | 323 | new Request(parameters).send(); 324 | }; 325 | 326 | })(window); 327 | --------------------------------------------------------------------------------