diff --git a/.gitignore b/.gitignore index 23deb6d..256cb7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ config.rb dump.rdb webrat.log -*.json +import +coverage vendor/bundle .bundle -Gemfile.lock .idea diff --git a/Gemfile b/Gemfile index f88b840..4d6bbe7 100644 --- a/Gemfile +++ b/Gemfile @@ -4,15 +4,19 @@ source 'https://rubygems.org' gem 'unicorn' gem 'rake' -gem 'mongo' -gem 'bson_ext' gem 'sinatra' gem 'nokogiri', '~> 1.5.10' gem 'yajl-ruby' gem 'activesupport' +gem 'elasticsearch' +gem 'patron' +gem 'hashie' group :development do gem 'rack-test' + gem 'simplecov' gem 'webrat' gem 'shotgun' + gem 'elasticsearch-extensions' + gem 'timecop' end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..24c5a1a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,89 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (4.1.5) + i18n (~> 0.6, >= 0.6.9) + json (~> 1.7, >= 1.7.7) + minitest (~> 5.1) + thread_safe (~> 0.1) + tzinfo (~> 1.1) + ansi (1.5.0) + docile (1.1.5) + elasticsearch (2.0.0) + elasticsearch-api (= 2.0.0) + elasticsearch-transport (= 2.0.0) + elasticsearch-api (2.0.0) + multi_json + elasticsearch-extensions (0.0.22) + ansi + ruby-prof + elasticsearch-transport (2.0.0) + faraday + multi_json + faraday (0.9.2) + multipart-post (>= 1.2, < 3) + hashie (2.1.2) + i18n (0.6.11) + json (1.8.1) + kgio (2.9.2) + minitest (5.4.0) + multi_json (1.12.1) + multipart-post (2.0.0) + nokogiri (1.5.11) + patron (0.4.18) + rack (1.5.2) + rack-protection (1.5.3) + rack + rack-test (0.6.2) + rack (>= 1.0) + raindrops (0.13.0) + rake (10.3.2) + ruby-prof (0.16.2) + shotgun (0.9) + rack (>= 1.0) + simplecov (0.9.0) + docile (~> 1.1.0) + multi_json + simplecov-html (~> 0.8.0) + simplecov-html (0.8.0) + sinatra (1.4.5) + rack (~> 1.4) + rack-protection (~> 1.4) + tilt (~> 1.3, >= 1.3.4) + thread_safe (0.3.4) + tilt (1.4.1) + timecop (0.7.1) + tzinfo (1.2.2) + thread_safe (~> 0.1) + unicorn (4.8.3) + kgio (~> 2.6) + rack + raindrops (~> 0.7) + webrat (0.7.3) + nokogiri (>= 1.2.0) + rack (>= 1.0) + rack-test (>= 0.5.3) + yajl-ruby (1.2.1) + +PLATFORMS + ruby + +DEPENDENCIES + activesupport + elasticsearch + elasticsearch-extensions + hashie + nokogiri (~> 1.5.10) + patron + rack-test + rake + shotgun + simplecov + sinatra + timecop + unicorn + webrat + yajl-ruby + +BUNDLED WITH + 1.10.6 diff --git a/Rakefile b/Rakefile index 0173426..cfbe8ee 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,6 @@ +$LOAD_PATH.unshift '.' $LOAD_PATH.unshift 'lib' -require 'tools' +require 'utils' task :default => :test @@ -12,10 +13,25 @@ end desc "Remove a single exception with all occurrences completely" task :remove_exception do uber_key = ENV['KEY'] - Exceptionist::Remover.run(uber_key) + Utils::Remover.run(uber_key) end -desc "Create MongoDB indexes" -task :create_indexes do - Exceptionist::IndexCreator.run +desc "Export occurrences in json" +task :export do + Utils::Exporter.run +end + +desc "Import occurrences from json file" +task :import do + Utils::Importer.run +end + +desc "Clear DB and create index with mapping" +task :cleardb do + Utils::ClearDB.run +end + +desc "Print mapping" +task :mapping do + Utils::Mapping.run end diff --git a/config.rb.example b/config.rb.example index b619584..b04668f 100644 --- a/config.rb.example +++ b/config.rb.example @@ -11,8 +11,8 @@ require 'app' # ========================== # -# Configure your MongoDB server and port -Exceptionist.mongo = 'localhost:27017' +# Configure your ES server and port +Exceptionist.elasticsearch_host = 'localhost:9200' # Credentials for accessing the web app, remove if you do auth in another way Exceptionist.enable_authentication('username', 'password') diff --git a/lib/app.rb b/lib/app.rb index 425dfe0..897b12b 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -3,13 +3,18 @@ require 'sinatra/base' require 'net/smtp' require 'stringio' -require 'pp' class ExceptionistApp < Sinatra::Base dir = File.join(File.dirname(__FILE__), '..') set :views, "#{dir}/views" set :public_folder, "#{dir}/public" + configure :test do + set :raise_errors, true + set :dump_errors, false + set :show_exceptions, false + end + before do protected! if request.path_info !~ /^\/notifier_api\/v2/ end @@ -22,7 +27,7 @@ class ExceptionistApp < Sinatra::Base end get '/river' do - @occurrences = Occurrence.find_all + @occurrences = Occurrence.find @title = "River" erb :river @@ -31,37 +36,49 @@ class ExceptionistApp < Sinatra::Base get '/projects/:project' do @projects = Project.all @current_project = Project.new(params[:project]) + @start = params[:start] ? params[:start].to_i : 0 + @category = { category: params[:category] } if params[:category] + @terms =[{ project_name: @current_project.name }] << @category + @exceptions_count = UberException.count(terms: @terms) + if params[:sort_by] && params[:sort_by] == 'frequent' - @uber_exceptions = @current_project.most_frequest_exceptions(@start) + @uber_exceptions = UberException.find_sorted_by_occurrences_count(terms: @terms, from: @start) else - @uber_exceptions = @current_project.latest_exceptions(@start) + @uber_exceptions = UberException.find(terms: @terms, from: @start) end - @title = "Latest Exceptions for #{@current_project.name}" erb :index end - get '/projects/:project/river' do + get '/projects/:project/since_last_deploy' do + @projects = Project.all @current_project = Project.new(params[:project]) - @occurrences = Occurrence.find_all(@current_project.name) - @title = "Latest Occurrences for #{@current_project.name}" - erb :river - end + @deploy = @current_project.last_deploy + raise ArgumentError, "There is no deploy for project #{@current_project.name}" unless @deploy - get '/projects/:project/new_on/:day' do - @day = Time.parse(params[:day]) - @current_project = Project.new(params[:project]) - @uber_exceptions = @current_project.new_exceptions_on(@day) + @category = { category: params[:category] } if params[:category] + @terms =[{ project_name: @current_project.name }] << @category + @exceptions_count = UberException.count(terms: @terms, filters: [ { range: { 'last_occurrence.occurred_at' => { gte: Helper.es_time(@deploy.occurred_at) } } } ]) - message_body = erb(:new_exceptions, :layout => false) - - if params[:mail_to] - Mailer.deliver_new_exceptions(@current_project, @day, params[:mail_to], message_body) + @start = params[:start] ? params[:start].to_i : 0 + if params[:sort_by] && params[:sort_by] == 'frequent' + @uber_exceptions = UberException.find_since_last_deploy_ordered_by_occurrences_count(project: @current_project.name, category: params[:category], from: @start) + else + @category = { category: params[:category] } if params[:category] + @uber_exceptions = UberException.find_since_last_deploy(project: @current_project.name, terms: [@category], from: @start) end + @title = "Exceptions since last deploy (#{format_time(@deploy.occurred_at)}) for project #{@current_project.name}" + erb :index + end - message_body + get '/projects/:project/river' do + @current_project = Project.new(params[:project]) + @occurrences = Occurrence.find( filters: { term: { project_name: @current_project.name } } ) + + @title = "Latest Occurrences for #{@current_project.name}" + erb :river end post '/projects/:project/forget_exceptions' do @@ -73,15 +90,10 @@ class ExceptionistApp < Sinatra::Base get '/exceptions/:id' do @projects = Project.all - @uber_exception = UberException.find(params[:id]) + @uber_exception = UberException.get(params[:id]) @occurrence_position = @uber_exception.occurrences_count @occurrence = @uber_exception.current_occurrence(@occurrence_position) - if @occurrence.nil? - @uber_exception.update_occurrence_count - redirect request.url - end - @current_project = @occurrence.project @backlink = true @@ -90,7 +102,7 @@ class ExceptionistApp < Sinatra::Base end get '/exceptions/:id/occurrences/:occurrence_position' do - @uber_exception = UberException.find(params[:id]) + @uber_exception = UberException.get(params[:id]) @occurrence_position = params[:occurrence_position].to_i @occurrence = @uber_exception.current_occurrence(@occurrence_position) @@ -98,16 +110,34 @@ class ExceptionistApp < Sinatra::Base end post '/exceptions/:id/close' do - @uber_exceptions = UberException.find(params[:id]) + @uber_exceptions = UberException.get(params[:id]) @uber_exceptions.close! + Exceptionist.esclient.refresh redirect to("/projects/#{@uber_exceptions.project_name}?#{Rack::Utils.unescape(params[:backparams])}") end + post '/exceptions/category' do + @uber_exceptions = UberException.get(params[:id]) + @uber_exceptions.update( category: params[:category] ) + Exceptionist.esclient.refresh + + halt 200, 'category changed' + end + + get '/deploys/:project' do + @deploys = Deploy.find_by_project(params[:project]) + @current_project = Project.new(params[:project]) + + @title = "All deploys for Project #{@current_project.name}" + + erb :deploy + end + post '/notifier_api/v2/notices/?' do occurrence = Occurrence.from_xml(params[:data] || request.body.read) - project = Project.find_by_key(occurrence.api_key) + if project occurrence.project_name = project.name occurrence.save @@ -120,6 +150,20 @@ class ExceptionistApp < Sinatra::Base end end + post '/notifier_api/v2/deploy/?' do + deploy = Deploy.from_json(params[:data] || request.body.read) + project = Project.find_by_key(deploy.api_key) + if project + deploy.project_name = project.name + deploy.save + + "#{deploy.id}" + else + status 401 + 'Invalid API Key' + end + end + helpers do include Rack::Utils diff --git a/lib/boot.rb b/lib/boot.rb index 8a4be74..2e2077a 100644 --- a/lib/boot.rb +++ b/lib/boot.rb @@ -3,13 +3,20 @@ require 'digest' require 'active_support/ordered_hash' -require 'mongo' +require 'elasticsearch' require 'yajl' require 'nokogiri' +require 'yaml' +require 'json' +require 'pp' require 'models/project' require 'models/uber_exception' require 'models/occurrence' +require 'models/deploy' require 'models/mailer' +require 'mapping/mapping_helper' + require 'exceptionist' +require 'helper' diff --git a/lib/es_client.rb b/lib/es_client.rb new file mode 100644 index 0000000..571d497 --- /dev/null +++ b/lib/es_client.rb @@ -0,0 +1,143 @@ +class ESClient + + attr_accessor :es, :host, :port + + ES_INDEX = 'exceptionist' + + def initialize(endpoint) + @host, @port = endpoint.split(':') + @es = Elasticsearch::Client.new(host: endpoint) + end + + def search(type: '', filters: [], terms: [], sort: {}, from: 0, size: 25) + raise ArgumentError, 'from has to be >= 0' if from < 0 + + filters = merge(filters, terms) + + query = create_search_query(filters, sort, from, size) + response = @es.search(index: ES_INDEX, type: type, body: query) + Hashie::Mash.new(response) + end + + def aggregation(type: '', filters: [], aggregation: '') + # size set 0 for Integer.MAX_VALUE + query = { query: wrap_filters(filters), aggs: { exceptions: { terms: { field: aggregation, size: 0 } } } } + response = @es.search(index: ES_INDEX, type: type, body: query, search_type: 'count') + hash = Hashie::Mash.new(response) + hash.aggregations.exceptions.buckets + end + + def mget(type: '', ids: []) + response = @es.mget( index: ES_INDEX, type: type, body: { ids: ids } ) + hash = Hashie::Mash.new(response) + hash.docs + end + + def index(type: '', body: {}) + response = @es.index(index: ES_INDEX, type: type, body: body) + Hashie::Mash.new(response) + end + + def update(type: '', id: -1, body: {}) + @es.update(index: ES_INDEX, type: type, id: id, body: body) + end + + def count(type: '', filters: [], terms: []) + filters = merge(filters, terms) + @es.count(index: ES_INDEX, type: type, body: { query: wrap_filters(filters) } )['count'] + end + + def find_all(type, query) + # Open the "view" of the index with the `scan` search_type + r = @es.search(index: ES_INDEX, type: type, body: query, search_type: 'scan', scroll: '1m', size: 50) + + ids = [] + + # Call the `scroll` API until empty results are returned + while r = @es.scroll(scroll_id: r['_scroll_id'], scroll: '1m') and not r['hits']['hits'].empty? do + ids += r['hits']['hits'].map { |d| d['_id'] } + end + + ids + end + + def bulk_delete(type, ids) + body = ids.map { |id| {delete: {_index: ES_INDEX, _type: type, _id: id}} } + @es.bulk(body: body) + end + + def delete(type: '', id: -1) + @es.delete(index: ES_INDEX, type: type, id: id) + end + + def delete_indices(index) + @es.indices.delete(index: index) + end + + def create_indices(index, body={}) + @es.indices.create(index: index, body: body) + end + + def get_mapping(type) + @es.indices.get_mapping(index: ES_INDEX, type: type) + end + + def get(type: '', id: id) + @es.get(index: ES_INDEX, type: type, id: id) + end + + def refresh + @es.indices.refresh + end + + def export(type) + export = [] + + # Open the view + result = @es.search(index: ES_INDEX, search_type: 'scan', scroll: '5m', body: { query: { match: { _type: type } } }, size: 10) + result = Hashie::Mash.new(result) + + while result = Hashie::Mash.new(@es.scroll(scroll_id: result._scroll_id, scroll: '5m')) and not result.hits.hits.empty? do + result.hits.hits.each do |object| + export << Occurrence.new(Helper.transform(object)).create_es_hash + end + end + + export + end + + private + def create_search_query(filters, sort, from, size) + { query: wrap_filters(filters), sort: wrap_sort(sort), from: from, size: size } + end + + def wrap_sort(sort) + wrap(sort).each { | field | add_ignore_unmapped(field) } + end + + def wrap_filters(filters) + { filtered: { filter: { bool: { must: wrap(filters) } } } } + end + + def add_ignore_unmapped(hash) + hash.each { | field, ordering | ordering[:ignore_unmapped] = true } + end + + def wrap(args) + return [] unless args + args.is_a?(Array) ? args : [args] + end + + def merge(filters, terms) + filters = wrap(filters) + terms = wrap(terms) + + terms = transform_terms(terms) + terms.push(*filters) + end + + def transform_terms(terms) + terms = terms.map { |term| { term: term } if term } + terms.compact + end +end diff --git a/lib/exceptionist.rb b/lib/exceptionist.rb index 0067f8e..fb66dfe 100644 --- a/lib/exceptionist.rb +++ b/lib/exceptionist.rb @@ -1,10 +1,19 @@ +require 'hashie' +require 'multi_json' +require 'faraday' +require 'elasticsearch' +require 'elasticsearch/api' +require 'es_client' + module Exceptionist - def self.mongo - @mongo ||= Mongo::Connection.new(@host, @port).db('exceptionist') + attr_accessor :esclient + + def self.esclient + @esclient ||= ESClient.new(@elasticsearch_host) end - def self.mongo=(server) - @host, @port = server.split(':') + def self.elasticsearch_host=(elasticsearch_host) + @elasticsearch_host = elasticsearch_host end def self.config diff --git a/lib/helper.rb b/lib/helper.rb new file mode 100644 index 0000000..d6ba016 --- /dev/null +++ b/lib/helper.rb @@ -0,0 +1,39 @@ +module Helper + + def self.get_day_ago(days) + today = Time.now + today - (3600 * 24 * (days - 1)) # `days` days ago + end + + def self.last_n_days(days) + start = Helper.get_day_ago(days) + today = Time.now + + n_days = [] + begin + n_days << Time.utc(start.year, start.month, start.day) + end while (start += 86_400) <= today + + n_days + end + + def self.symbolize_keys(hash) + hash.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo} + end + + def self.es_time(date) + date.strftime("%Y-%m-%dT%H:%M:%S.%L%z") + end + + def self.day_range(date) + plain_day = Time.new(date.year, date.month, date.day) + { gte: es_time(plain_day), lte: es_time(plain_day + 60*60*24 - 1) } + end + + def self.transform(attr) + attr.merge!(attr['_source']).delete('_source') + attr = Helper.symbolize_keys(attr) + attr[:id] = attr.delete :_id + attr + end +end diff --git a/lib/mapping/deploys.yaml b/lib/mapping/deploys.yaml new file mode 100644 index 0000000..8a9b1c9 --- /dev/null +++ b/lib/mapping/deploys.yaml @@ -0,0 +1,12 @@ +properties: + project_name: + type: 'string' + index: 'not_analyzed' + version: + type: 'string' + index: 'not_analyzed' + occurred_at: + type: 'date' + changelog_link: + type: 'string' + index: 'not_analyzed' diff --git a/lib/mapping/exceptions.yaml b/lib/mapping/exceptions.yaml new file mode 100644 index 0000000..290b2cb --- /dev/null +++ b/lib/mapping/exceptions.yaml @@ -0,0 +1,13 @@ +properties: + project_name: + type: 'string' + index: 'not_analyzed' + closed: + type: 'boolean' + first_occurred_at: + type: 'date' + occurrences_count: + type: 'long' + category: + type: 'string' + index: 'not_analyzed' diff --git a/lib/mapping/mapping_helper.rb b/lib/mapping/mapping_helper.rb new file mode 100644 index 0000000..e22477d --- /dev/null +++ b/lib/mapping/mapping_helper.rb @@ -0,0 +1,19 @@ +class MappingHelper + def self.get_mapping + occurrence = YAML.load(File.read('lib/mapping/occurrences.yaml')) + exception = YAML.load(File.read('lib/mapping/exceptions.yaml')) + deploy = YAML.load(File.read('lib/mapping/deploys.yaml')) + + exception['properties']['last_occurrence'] = occurrence + + { + 'mappings' => { + '_default_' => { + 'dynamic' => 'false'}, + 'occurrences' => occurrence, + 'exceptions' => exception, + 'deploys' => deploy + } + } + end +end diff --git a/lib/mapping/occurrences.yaml b/lib/mapping/occurrences.yaml new file mode 100644 index 0000000..3dae164 --- /dev/null +++ b/lib/mapping/occurrences.yaml @@ -0,0 +1,24 @@ +properties: + action_name: + type: 'string' + index: 'not_analyzed' + controller_name: + type: 'string' + index: 'not_analyzed' + project_name: + type: 'string' + index: 'not_analyzed' + uber_key: + type: 'string' + index: 'not_analyzed' + exception_class: + type: 'string' + index: 'not_analyzed' + occurred_at: + type: 'date' + request_id: + type: 'string' + index: 'not_analyzed' + ip_address: + type: 'string' + index: 'not_analyzed' diff --git a/lib/models/deploy.rb b/lib/models/deploy.rb new file mode 100644 index 0000000..7b7dc25 --- /dev/null +++ b/lib/models/deploy.rb @@ -0,0 +1,60 @@ +class Deploy + + attr_accessor :id, :project_name, :api_key, :version, :changelog_link, :occurred_at + + ES_TYPE = 'deploys' + + def initialize(attributes = {}) + attributes.each do |key, value| + instance_variable_set("@#{key}", value) + end + + @occurred_at = Time.parse(occurred_at) if occurred_at.is_a? String + end + + def self.find_by_project_since(project, date) + find( filters: [{ term: { project_name: project } }, { range: { occurred_at: { gte: Helper.es_time(date) } } }] ) + end + + def self.find_by_project(project) + find( filters: { term: { project_name: project } } ) + end + + def self.find_last_deploy(project) + find( filters: { term: { project_name: project } }, from: 0, size: 1).first + end + + def self.find(filters: {}, sort: { occurred_at: { order: 'desc' } }, from: 0, size: 25) + hash = Exceptionist.esclient.search(type: ES_TYPE, filters: filters, sort: sort, from: from, size: size) + hash.hits.hits.map { |doc| new(Helper.transform(doc)) } + end + + def self.from_json(json) + attr = Helper.symbolize_keys(JSON.parse(json)) + attr['occurred_at'] = Time.now if attr['occurred_at'].nil? + + new(attr) + end + + def save + deploy = Exceptionist.esclient.index(type: ES_TYPE, body: create_es_hash) + @id = deploy._id + self + end + + def ==(other) + id == other.id + end + + def inspect + "(Deploy id=#{id} project_name=#{project_name})" + end + + def create_es_hash + self.instance_variables.each_with_object({}) do |var, hash| + value = self.instance_variable_get(var); + value = Helper.es_time(value) if value.is_a?(Time) + hash[var.to_s.delete("@")] = value + end + end +end diff --git a/lib/models/mailer.rb b/lib/models/mailer.rb index b1c9417..6757539 100644 --- a/lib/models/mailer.rb +++ b/lib/models/mailer.rb @@ -8,7 +8,7 @@ def self.deliver_new_exceptions(project, day, to_address, body) To: Exceptionist MIME-Version: 1.0 Content-type: text/html -Subject: [Exceptionist][#{project.name}] Summary for #{day.strftime('%Y-%m-%d')} +Subject: [Exceptionist][#{project.name}] Summary for #{Helper.es_day(day)} #{body} MESSAGE_END diff --git a/lib/models/occurrence.rb b/lib/models/occurrence.rb index 61a0d51..0faa4c1 100644 --- a/lib/models/occurrence.rb +++ b/lib/models/occurrence.rb @@ -1,27 +1,67 @@ class Occurrence + attr_accessor :url, :controller_name, :action_name, :exception_class, :exception_message, :exception_backtrace, :parameters, :session, :cgi_data, :environment, - :project_name, :occurred_at, :occurred_at_day, :'_id', :uber_key, :api_key + :project_name, :occurred_at, :id, :uber_key, :api_key, :sort, + :ip_address, :request_id + ES_TYPE = 'occurrences' - def initialize(attributes={}) + def initialize(attributes = {}) attributes.each do |key, value| - send("#{key}=", value) + instance_variable_set("@#{key}", value) end - self.occurred_at ||= attributes['occurred_at'] || Time.now - self.uber_key ||= generate_uber_key + @occurred_at = Time.parse(occurred_at) if occurred_at.is_a? String + @uber_key ||= generate_uber_key end - def inspect - "(Occurrence: id: #{_id}, title: '#{title}')" + def uber_exception + UberException.get(uber_key) end - def ==(other) - _id == other._id + def self.delete_all_for(uber_key) + ids = Exceptionist.esclient.find_all(ES_TYPE, query: { term: { uber_key: uber_key } }) + Exceptionist.esclient.bulk_delete(ES_TYPE, ids) + end + + def self.find_first_for(uber_key) + occurrences = Occurrence.find(filters: { term: { uber_key: uber_key } }, sort: { occurred_at: { order: 'asc' } }, size: 1) + occurrences.any? ? occurrences.first : nil + end + + def self.find_last_for(uber_key) + occurrences = Occurrence.find(filters: { term: { uber_key: uber_key } }, size: 1) + occurrences.any? ? occurrences.first : nil + end + + def self.find_next(uber_key, date) + find(filters: [{ range: { occurred_at: { gte: Helper.es_time(date) } } }, + { term: { uber_key: uber_key } }], sort: { occurred_at: { order: 'asc' } }, size: 1).first + end + + def self.find(filters: {}, sort: { occurred_at: { order: 'desc' } }, from: 0, size: 25) + hash = Exceptionist.esclient.search(type: ES_TYPE, filters: filters, sort: sort, from: from, size: size) + hash.hits.hits.map { |doc| new(Helper.transform(doc)) } end + def self.count_since(uber_key, date) + count(filters: [{ range: { occurred_at: { gte: Helper.es_time(date) } } }, { term: { uber_key: uber_key } }] ) + end + + def self.count(filters: {}) + Exceptionist.esclient.count(type: ES_TYPE, filters: filters) + end + + def self.aggregation(filters: {}, aggregation: '') + Exceptionist.esclient.aggregation(type: ES_TYPE, filters: filters, aggregation: aggregation) + end + + # + # accessors + # + def title case exception_class when 'Mysql::Error', 'RuntimeError', 'Timeout::Error', 'SystemExit' @@ -43,75 +83,33 @@ def user_agent cgi_data ? cgi_data['HTTP_USER_AGENT'] : nil end - def occurred_at - @occurred_at.is_a?(String) ? Time.parse(@occurred_at) : @occurred_at - end - def project Project.new(project_name) end - def uber_exception - UberException.find(uber_key) - end - - def self.delete_all_for(uber_key) - Exceptionist.mongo['occurrences'].remove({:uber_key => uber_key}, :w => 1) - end - - def self.find_first_for(uber_key) - new(Exceptionist.mongo['occurrences'].find({:uber_key => uber_key}, :sort => [:occurred_at, :asc], :limit => 1).first) - end - - def self.find_last_for(uber_key) - new(Exceptionist.mongo['occurrences'].find({:uber_key => uber_key}, :sort => [:occurred_at, :desc], :limit => 1).first) - end - - def self.count_all_on(project, day) - Exceptionist.mongo['occurrences'].find({:project_name => project, :occurred_at_day => day.strftime('%Y-%m-%d')}).count - end - - def self.find_all(project=nil, limit=50) - find_options = {} - find_options[:project_name] = project if project - - occurrences = Exceptionist.mongo['occurrences'].find(find_options, :sort => [:occurred_at, :desc], :limit => limit) - occurrences.map { |doc| new(doc) } - end - # # serialization # def save - Exceptionist.mongo['occurrences'].insert(to_hash) - + occurrence = Exceptionist.esclient.index(type: ES_TYPE, body: create_es_hash) + @id = occurrence._id self end - def self.create(attributes = {}) - new(attributes).save - end - - def to_hash - { :exception_message => exception_message, - :session => session, - :action_name => action_name, - :parameters => parameters, - :cgi_data => cgi_data, - :url => url, - :occurred_at => occurred_at, - :occurred_at_day => occurred_at.strftime('%Y-%m-%d'), - :exception_backtrace => exception_backtrace, - :controller_name => controller_name, - :environment => environment, - :exception_class => exception_class, - :project_name => project_name, - :uber_key => uber_key } + def create_es_hash + self.instance_variables.each_with_object({}) do |var, hash| + value = self.instance_variable_get(var); + value = Helper.es_time(value) if value.is_a?(Time) + hash[var.to_s.delete("@")] = value + end end def self.from_xml(xml_text) - new(parse_xml(xml_text)) + attr = parse_xml(xml_text) + attr['occurred_at'] = Time.now if attr['occurred_at'].nil? + + new(attr) end def self.parse_xml(xml_text) @@ -135,6 +133,11 @@ def self.parse_xml(xml_text) hash[:parameters] = parse_vars(doc.xpath('/notice/request/params')) hash[:session] = parse_vars(doc.xpath('/notice/request/session')) hash[:cgi_data] = parse_vars(doc.xpath('/notice/request/cgi-data'), :skip_internal => true) + + if hash[:cgi_data] + hash[:request_id] = hash[:cgi_data]["HTTP_X_PODIO_REQUEST_ID"] + hash[:ip_address] = hash[:cgi_data]["HTTP_X_FORWARDED_FOR"] + end end hash @@ -143,7 +146,7 @@ def self.parse_xml(xml_text) def self.parse_vars(node, options = {}) node.children.inject({}) do |hash, child| key = child['key'] - value = self.node_to_hash(child, options) unless (options[:skip_internal] && key.include?('.')) + value = node_to_hash(child, options) unless (options[:skip_internal] && key.include?('.')) hash[key] = value unless value.nil? hash end @@ -153,7 +156,7 @@ def self.node_to_hash(node, options = {}) if node.children.size > 1 node.children.inject({}) do |hash, child| key = child['key'] - hash[key] = self.node_to_hash(child, options) unless (options[:skip_internal] && key.include?('.')) + hash[key] = node_to_hash(child, options) unless (options[:skip_internal] && key.include?('.')) hash end elsif node.children.size == 1 && node.children.first.keys.include?('key') @@ -169,19 +172,27 @@ def self.parse_optional_element(doc, xpath) element ? element.content : nil end -private + def ==(other) + id == other.id + end + + def inspect + "(Occurrence id=#{id} uber_key=#{uber_key})" + end + + private def generate_uber_key key = case exception_class - when *Exceptionist.global_exception_classes - "#{exception_class}:#{exception_message}" - when *Exceptionist.timeout_exception_classes - first_non_lib_line = exception_backtrace.detect { |line| line =~ /\[PROJECT_ROOT\]/ } - "#{exception_class}:#{exception_message}:#{first_non_lib_line}" - else - backtrace = exception_backtrace ? exception_backtrace.first : '' - "#{controller_name}:#{action_name}:#{exception_class}:#{backtrace}" - end + when *Exceptionist.global_exception_classes + "#{exception_class}:#{exception_message}" + when *Exceptionist.timeout_exception_classes + first_non_lib_line = exception_backtrace.detect { |line| line =~ /\[PROJECT_ROOT\]/ } + "#{exception_class}:#{exception_message}:#{first_non_lib_line}" + else + backtrace = exception_backtrace ? exception_backtrace.first : '' + "#{controller_name}:#{action_name}:#{exception_class}:#{backtrace}" + end Digest::SHA1.hexdigest("#{project_name}:#{key}") end diff --git a/lib/models/project.rb b/lib/models/project.rb index 3672d96..f6ca860 100644 --- a/lib/models/project.rb +++ b/lib/models/project.rb @@ -1,8 +1,9 @@ class Project + attr_accessor :name def initialize(name) - self.name = name + @name = name end def exceptions_count @@ -10,51 +11,32 @@ def exceptions_count end def last_thirty_days - Project.last_n_days(30).map { |day| [day, occurrence_count_on(day)] } - end - - def self.last_n_days(days) - today = Time.now - start = today - (3600 * 24 * (days - 1)) # `days` days ago - - n_days = [] - begin - n_days << Time.utc(start.year, start.month, start.day) - end while (start += 86400) <= today - - n_days + Helper.last_n_days(30).map { |day| [day, Occurrence.count(filters: [{ term: { project_name: name } }, { range: { occurred_at: Helper.day_range(day) } }] )] } end - def occurrence_count_on(date) - Exceptionist.mongo['occurrences'].find({:project_name => name, :occurred_at_day => date.strftime('%Y-%m-%d')}).count + def last_deploy + Deploy.find_last_deploy(name) end - def latest_exceptions(start, limit = 25) - UberException.find_all_sorted_by_time(name, start, limit) + def deploys_last_thirty_days + since = Helper.get_day_ago(30) + Deploy.find_by_project_since(@name, since) end - def most_frequest_exceptions(start, limit = 25) - UberException.find_all_sorted_by_occurrence_count(name, start, limit) - end - - def new_exceptions_on(day) - UberException.find_new_on(name, day) + def self.find_by_key(api_key) + project = Exceptionist.projects.find { |name, project_key| project_key == api_key } + project ? Project.new(project.first) : nil end - def total_count_on(day) - Occurrence.count_all_on(name, day) + def self.all + Exceptionist.projects.map { |name, api_key| Project.new(name) } end def ==(other) name == other.name end - def self.find_by_key(api_key) - project = Exceptionist.projects.find { |name, project_key| project_key == api_key } - project ? Project.new(project.first) : nil - end - - def self.all - Exceptionist.projects.map { |name, api_key| Project.new(name) } + def inspect + "(Project name=#{name})" end end diff --git a/lib/models/uber_exception.rb b/lib/models/uber_exception.rb index 8f9abd5..b499f69 100644 --- a/lib/models/uber_exception.rb +++ b/lib/models/uber_exception.rb @@ -1,65 +1,120 @@ class UberException - attr_accessor :id, :project_name, :occurrences_count - def initialize(attributes) - @id = attributes['_id'] - @project_name = attributes['project_name'] - @occurrences_count = attributes['occurrence_count'] + attr_accessor :id, :project_name, :_occurrences_count, :closed, :last_occurrence, :first_occurred_at, :category + + ES_TYPE = 'exceptions' + + def initialize(attributes = {}) + attributes.each do |key, value| + instance_variable_set("@#{key}", value) + end + @_occurrences_count = nil + @last_occurrence = Occurrence.new(attributes[:last_occurrence]) + @first_occurred_at = Time.parse(first_occurred_at) if first_occurred_at.is_a? String end def self.count_all(project) - Exceptionist.mongo['exceptions'].find({:project_name => project, :closed => false}).count + Exceptionist.esclient.count(type: ES_TYPE, terms: { project_name: project } ) end - def self.find(uber_key) - new(Exceptionist.mongo['exceptions'].find_one({:_id => uber_key})) + def self.count(terms: [], filters: []) + Exceptionist.esclient.count(type: ES_TYPE, filters: filters, terms: terms) end - def self.find_all(project) - uber_exceptions = Exceptionist.mongo['exceptions'].find({:project_name => project, :closed => false}) - uber_exceptions.map { |doc| new(doc) } + def self.get(uber_key) + new(Helper.transform(Exceptionist.esclient.get(type: ES_TYPE, id: uber_key))) end - def self.find_all_sorted_by_time(project, start, limit) - uber_exceptions = Exceptionist.mongo['exceptions'].find({:project_name => project, :closed => false}, :skip => start, :limit => limit, :sort => [:occurred_at, :desc]) - uber_exceptions.map { |doc| new(doc) } + def self.find_sorted_by_occurrences_count(terms: [], from: 0, size: 25) + uber_list = Occurrence.aggregation(filters: terms.select{|t| ! t.nil? }.map{|t| {term: t}}, aggregation: "uber_key") + uber_list.map{|raw_uber| get(raw_uber['key'])} + end + + def self.find_since_last_deploy(project: '', terms: [], from: 0, size: 25) + aggregation, ids = aggregation_since_last_deploy(project) + + exceptions = find(terms: terms.compact << { closed: false }, filters: [{ ids: { type: ES_TYPE, values: ids } }], from: from, size: size) + merge(exceptions, aggregation) end - def self.find_all_sorted_by_occurrence_count(project, start, limit) - uber_exceptions = Exceptionist.mongo['exceptions'].find({:project_name => project, :closed => false}, :skip => start, :limit => limit, :sort => [:occurrence_count, :desc]) - uber_exceptions.map { |doc| new(doc) } + def self.find_since_last_deploy_ordered_by_occurrences_count(project: '', category: nil, from: 0, size: 25) + aggregation_exceptions, ids = aggregation_since_last_deploy(project) + + # to preserve ordering and filtering category at the same time, filtering has to be done in ruby, not on db-level + exceptions = Exceptionist.esclient.mget(type: ES_TYPE, ids: ids).map { |doc| new(Helper.transform(doc)) } + exceptions.select!{ |exception| exception.category == category && !exception.closed } unless category.nil? + merge(exceptions.slice(from, size), aggregation_exceptions) end - def self.find_new_on(project, day) - next_day = day + 86400 - uber_keys = Exceptionist.mongo['occurrences'].distinct(:uber_key, {:occurred_at_day => day.strftime('%Y-%m-%d')}) - uber_exceptions = Exceptionist.mongo['exceptions'].find({:_id => {'$in' => uber_keys}}).map { |doc| new(doc) } + def self.aggregation_since_last_deploy(project) + deploy = Deploy.find_last_deploy(project) + raise 'There is no deploy' if deploy.nil? + + filters_occurrence = [{ term: { project_name: project } }, { range: { occurred_at: { gte: Helper.es_time(deploy.occurred_at) } } }] + agg_exceptions = Occurrence.aggregation(filters: filters_occurrence, aggregation: 'uber_key') + ids = [] + agg_exceptions.each { |occurrence| ids << occurrence['key'] } + + return agg_exceptions, ids + end - uber_exceptions.select { |uber_exp| uber_exp.first_occurred_at >= day && uber_exp.first_occurred_at < next_day } + def self.merge(exceptions, aggregation) + # used by _since_last_deploy's to correct the occurences_count + exceptions.each do |exception| + aggregation.each do |occurrence| + if occurrence['key'] == exception.id + exception._occurrences_count = occurrence['doc_count'] + aggregation.delete(occurrence) + break + end + end + end + exceptions + end + + def self.find(terms: [], filters: [], sort: { 'last_occurrence.occurred_at' => { order: 'desc'} }, from: 0, size: 25) + terms << { closed: false } + + hash = Exceptionist.esclient.search(type: ES_TYPE, filters: filters, terms: terms, sort: sort, from: from, size: size) + hash.hits.hits.map { |doc| new(Helper.transform(doc)) } end def self.occurred(occurrence) - # upsert the UberException - Exceptionist.mongo['exceptions'].update( - {:_id => occurrence.uber_key}, - { - "$set" => {:project_name => occurrence.project_name, :occurred_at => occurrence.occurred_at, :closed => false}, - "$inc" => {:occurrence_count => 1} - }, - :upsert => true, :w => 1 - ) + first_timestamp = occurrence.occurred_at + + #TODO maybe remove when events arrive sorted + begin + exec = get(occurrence.uber_key) + occurrence = exec.last_occurrence.occurred_at < first_timestamp ? occurrence : exec.last_occurrence + first_timestamp = first_timestamp < exec.first_occurred_at ? first_timestamp : exec.first_occurred_at + rescue Elasticsearch::Transport::Transport::Errors::NotFound + # get throws NotFound exception when there is no exception with this uber_key + # we could also search with a query but then we have to handle the null value and it would be slower + end - # return the UberException - new('_id' => occurrence.uber_key) + occurrence_hash = occurrence.create_es_hash + occurrence_hash[:id] = occurrence.id + + script = 'ctx._source.occurrences_count += 1; ctx._source.closed=false; ctx._source.last_occurrence=var_occurrence; ctx._source.first_occurred_at=var_timestamp' + body = { project_name: occurrence.project_name, last_occurrence: occurrence_hash, first_occurred_at: Helper.es_time(first_timestamp), closed: false, occurrences_count: 1, category: 'no-category'} + Exceptionist.esclient.update(type: ES_TYPE, id: occurrence.uber_key, + body: { + script: script, + params: { var_occurrence: occurrence_hash, var_timestamp: Helper.es_time(first_timestamp)}, + upsert: body + } + ) + get(occurrence.uber_key) end - def self.forget_old_exceptions(project, days) + def self.forget_old_exceptions(project, days=0) since_date = Time.now - (86400 * days) deleted = 0 - uber_exceptions = Exceptionist.mongo['exceptions'].find({:project_name => project, :occurred_at => {'$lt' => since_date}}) - uber_exceptions.each do |doc| - UberException.new(doc).forget! + exceptions = find(filters: [{ term: { project_name: project } }, range: { 'last_occurrence.occurred_at' => { lte: Helper.es_time(since_date) } }]) + + exceptions.each do |exception| + exception.forget! deleted += 1 end @@ -68,41 +123,37 @@ def self.forget_old_exceptions(project, days) def forget! Occurrence.delete_all_for(id) - Exceptionist.mongo['exceptions'].remove({:_id => id}, :w => 1) - end - def close! - Exceptionist.mongo['exceptions'].update({:_id => id}, {'$set' => {'closed' => true}}) + Exceptionist.esclient.delete(type: ES_TYPE, id: id) end - def last_occurrence - @last_occurrence ||= Occurrence.find_last_for(id) + def close! + Exceptionist.esclient.update(type: ES_TYPE, id: @id, body: { doc: { closed: true } }) end - def first_occurrence - @first_occurrence ||= Occurrence.find_first_for(id) + def update(doc) + Exceptionist.esclient.update(type: ES_TYPE, id: @id, body: { doc: doc }) end def current_occurrence(position) - if occurrence = Exceptionist.mongo['occurrences'].find({:uber_key => id}, :sort => [:occurred_at, :asc], :skip => position - 1, :limit => 1).first - Occurrence.new(occurrence) - else - nil - end + occurrences = Occurrence.find(filters: { term: { uber_key: id } }, sort: { occurred_at: { order: 'asc'} }, from: position - 1, size: 1) + occurrences.any? ? occurrences.first : nil end - def update_occurrence_count - @occurrences_count = Exceptionist.mongo['occurrences'].find({:uber_key => id}).count - Exceptionist.mongo['exceptions'].update({:_id => id}, {'$set' => {'occurrence_count' => @occurrences_count}}) + def occurrences_count + return @_occurrences_count unless @_occurrences_count.nil? + d = Occurrence.aggregation(filters: [{ term: {uber_key: @id} }], aggregation: "uber_key") + d[0]["doc_count"] end - def first_occurred_at - first_occurrence.occurred_at + def new_since_last_deploy + deploy = Deploy.find_last_deploy(@project_name) + deploy.nil? ? true : deploy.occurred_at < @first_occurred_at end - def last_occurred_at - last_occurrence.occurred_at - end + # + # accessors + # def title last_occurrence.title @@ -112,12 +163,12 @@ def url last_occurrence.url end - def occurrence_count_on(date) - Exceptionist.mongo['occurrences'].find({:uber_key => id, :occurred_at_day => date.strftime('%Y-%m-%d')}).count + def occurrences_count_on(date) + Occurrence.count(filters: [{ term: { uber_key: @id } }, { range: { occurred_at: Helper.day_range(date) } }]) end def last_thirty_days - Project.last_n_days(30).map { |day| [day, occurrence_count_on(day)] } + Helper.last_n_days(30).map { |day| [day, occurrences_count_on(day)] } end def ==(other) @@ -125,6 +176,6 @@ def ==(other) end def inspect - "(UberException: id: #{id})" + "(UberException id=#{id})" end end diff --git a/lib/tools.rb b/lib/tools.rb deleted file mode 100644 index a7a5b2d..0000000 --- a/lib/tools.rb +++ /dev/null @@ -1,24 +0,0 @@ -require 'boot' -require './config' - -module Exceptionist - class Remover - def self.run(uber_key) - UberException.find(uber_key).forget! - end - end - - class IndexCreator - def self.run - Exceptionist.mongo['exceptions'].ensure_index(:project_name) - - Exceptionist.mongo['occurrences'].ensure_index(:uber_key) - Exceptionist.mongo['occurrences'].ensure_index([[:project_name, Mongo::ASCENDING], [:occurred_at_day, Mongo::ASCENDING]]) - Exceptionist.mongo['occurrences'].ensure_index([[:uber_key, Mongo::ASCENDING], [:occurred_at, Mongo::ASCENDING]]) - Exceptionist.mongo['occurrences'].ensure_index([[:uber_key, Mongo::ASCENDING], [:occurred_at_day, Mongo::ASCENDING]]) - - # River view - Exceptionist.mongo['occurrences'].ensure_index([[:occurred_at, Mongo::DESCENDING]]) - end - end -end diff --git a/lib/utils.rb b/lib/utils.rb new file mode 100644 index 0000000..649adfc --- /dev/null +++ b/lib/utils.rb @@ -0,0 +1,75 @@ +require 'boot' +require 'config' +require 'elasticsearch' + + +module Utils + class Remover + def self.run(uber_key) + UberException.get(uber_key).forget! + end + end + + class Exporter + def self.run + + puts "exporting deploys" + deploys = Exceptionist.esclient.export('deploys') + + File.open('deploys_export.json', 'w') do |file| + file.write(Yajl::Encoder.encode(deploys)) + end + + puts "exporting occurrences" + occurrences = Exceptionist.esclient.export('occurrences') + + File.open('occurrences_export.json', 'w') do |file| + file.write(Yajl::Encoder.encode(occurrences)) + end + end + end + + class Importer + def self.run + puts "importing deploys" + Yajl::Parser.parse(File.read('import/deploys_export.json')).each { |hash| Deploy.new(hash).save} + + files = Dir.glob('import/occurrences_export*').sort + files.each do |file| + puts "importing #{file}" + + occurrences = Yajl::Parser.parse(File.read(file)) + occurrences.each do |occurrence_hash| + + occurrence_hash.delete('uber_key') + occurrence_hash.delete('id') + + occurrence = Occurrence.new(occurrence_hash) + occurrence.save + + UberException.occurred(occurrence) + end + end + end + end + + class ClearDB + def self.run + begin + Exceptionist.esclient.delete_indices('exceptionist') + rescue Elasticsearch::Transport::Transport::Errors::NotFound + end + + Exceptionist.esclient.create_indices('exceptionist', MappingHelper.get_mapping) + Exceptionist.esclient.refresh + end + end + + class Mapping + def self.run + pp Exceptionist.esclient.get_mapping 'deploys' + pp Exceptionist.esclient.get_mapping 'exceptions' + pp Exceptionist.esclient.get_mapping 'occurrences' + end + end +end diff --git a/public/javascripts/jquery-1.4.1.min.js b/public/javascripts/jquery-1.4.1.min.js deleted file mode 100644 index 0c7294c..0000000 --- a/public/javascripts/jquery-1.4.1.min.js +++ /dev/null @@ -1,152 +0,0 @@ -/*! - * jQuery JavaScript Library v1.4.1 - * http://jquery.com/ - * - * Copyright 2010, John Resig - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * Includes Sizzle.js - * http://sizzlejs.com/ - * Copyright 2010, The Dojo Foundation - * Released under the MIT, BSD, and GPL Licenses. - * - * Date: Mon Jan 25 19:43:33 2010 -0500 - */ -(function(z,v){function la(){if(!c.isReady){try{r.documentElement.doScroll("left")}catch(a){setTimeout(la,1);return}c.ready()}}function Ma(a,b){b.src?c.ajax({url:b.src,async:false,dataType:"script"}):c.globalEval(b.text||b.textContent||b.innerHTML||"");b.parentNode&&b.parentNode.removeChild(b)}function X(a,b,d,f,e,i){var j=a.length;if(typeof b==="object"){for(var n in b)X(a,n,b[n],f,e,d);return a}if(d!==v){f=!i&&f&&c.isFunction(d);for(n=0;n-1){i=j.data;i.beforeFilter&&i.beforeFilter[a.type]&&!i.beforeFilter[a.type](a)||f.push(j.selector)}else delete x[o]}i=c(a.target).closest(f, -a.currentTarget);m=0;for(s=i.length;m)[^>]*$|^#([\w-]+)$/,Qa=/^.[^:#\[\.,]*$/,Ra=/\S/,Sa=/^(\s|\u00A0)+|(\s|\u00A0)+$/g,Ta=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,O=navigator.userAgent, -va=false,P=[],L,$=Object.prototype.toString,aa=Object.prototype.hasOwnProperty,ba=Array.prototype.push,Q=Array.prototype.slice,wa=Array.prototype.indexOf;c.fn=c.prototype={init:function(a,b){var d,f;if(!a)return this;if(a.nodeType){this.context=this[0]=a;this.length=1;return this}if(typeof a==="string")if((d=Pa.exec(a))&&(d[1]||!b))if(d[1]){f=b?b.ownerDocument||b:r;if(a=Ta.exec(a))if(c.isPlainObject(b)){a=[r.createElement(a[1])];c.fn.attr.call(a,b,true)}else a=[f.createElement(a[1])];else{a=ra([d[1]], -[f]);a=(a.cacheable?a.fragment.cloneNode(true):a.fragment).childNodes}}else{if(b=r.getElementById(d[2])){if(b.id!==d[2])return S.find(a);this.length=1;this[0]=b}this.context=r;this.selector=a;return this}else if(!b&&/^\w+$/.test(a)){this.selector=a;this.context=r;a=r.getElementsByTagName(a)}else return!b||b.jquery?(b||S).find(a):c(b).find(a);else if(c.isFunction(a))return S.ready(a);if(a.selector!==v){this.selector=a.selector;this.context=a.context}return c.isArray(a)?this.setArray(a):c.makeArray(a, -this)},selector:"",jquery:"1.4.1",length:0,size:function(){return this.length},toArray:function(){return Q.call(this,0)},get:function(a){return a==null?this.toArray():a<0?this.slice(a)[0]:this[a]},pushStack:function(a,b,d){a=c(a||null);a.prevObject=this;a.context=this.context;if(b==="find")a.selector=this.selector+(this.selector?" ":"")+d;else if(b)a.selector=this.selector+"."+b+"("+d+")";return a},setArray:function(a){this.length=0;ba.apply(this,a);return this},each:function(a,b){return c.each(this, -a,b)},ready:function(a){c.bindReady();if(c.isReady)a.call(r,c);else P&&P.push(a);return this},eq:function(a){return a===-1?this.slice(a):this.slice(a,+a+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(Q.apply(this,arguments),"slice",Q.call(arguments).join(","))},map:function(a){return this.pushStack(c.map(this,function(b,d){return a.call(b,d,b)}))},end:function(){return this.prevObject||c(null)},push:ba,sort:[].sort,splice:[].splice}; -c.fn.init.prototype=c.fn;c.extend=c.fn.extend=function(){var a=arguments[0]||{},b=1,d=arguments.length,f=false,e,i,j,n;if(typeof a==="boolean"){f=a;a=arguments[1]||{};b=2}if(typeof a!=="object"&&!c.isFunction(a))a={};if(d===b){a=this;--b}for(;b
a";var e=d.getElementsByTagName("*"),i=d.getElementsByTagName("a")[0];if(!(!e||!e.length||!i)){c.support= -{leadingWhitespace:d.firstChild.nodeType===3,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/red/.test(i.getAttribute("style")),hrefNormalized:i.getAttribute("href")==="/a",opacity:/^0.55$/.test(i.style.opacity),cssFloat:!!i.style.cssFloat,checkOn:d.getElementsByTagName("input")[0].value==="on",optSelected:r.createElement("select").appendChild(r.createElement("option")).selected,checkClone:false,scriptEval:false,noCloneEvent:true,boxModel:null}; -b.type="text/javascript";try{b.appendChild(r.createTextNode("window."+f+"=1;"))}catch(j){}a.insertBefore(b,a.firstChild);if(z[f]){c.support.scriptEval=true;delete z[f]}a.removeChild(b);if(d.attachEvent&&d.fireEvent){d.attachEvent("onclick",function n(){c.support.noCloneEvent=false;d.detachEvent("onclick",n)});d.cloneNode(true).fireEvent("onclick")}d=r.createElement("div");d.innerHTML="";a=r.createDocumentFragment();a.appendChild(d.firstChild); -c.support.checkClone=a.cloneNode(true).cloneNode(true).lastChild.checked;c(function(){var n=r.createElement("div");n.style.width=n.style.paddingLeft="1px";r.body.appendChild(n);c.boxModel=c.support.boxModel=n.offsetWidth===2;r.body.removeChild(n).style.display="none"});a=function(n){var o=r.createElement("div");n="on"+n;var m=n in o;if(!m){o.setAttribute(n,"return;");m=typeof o[n]==="function"}return m};c.support.submitBubbles=a("submit");c.support.changeBubbles=a("change");a=b=d=e=i=null}})();c.props= -{"for":"htmlFor","class":"className",readonly:"readOnly",maxlength:"maxLength",cellspacing:"cellSpacing",rowspan:"rowSpan",colspan:"colSpan",tabindex:"tabIndex",usemap:"useMap",frameborder:"frameBorder"};var G="jQuery"+J(),Ua=0,xa={},Va={};c.extend({cache:{},expando:G,noData:{embed:true,object:true,applet:true},data:function(a,b,d){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==z?xa:a;var f=a[G],e=c.cache;if(!b&&!f)return null;f||(f=++Ua);if(typeof b==="object"){a[G]=f;e=e[f]=c.extend(true, -{},b)}else e=e[f]?e[f]:typeof d==="undefined"?Va:(e[f]={});if(d!==v){a[G]=f;e[b]=d}return typeof b==="string"?e[b]:e}},removeData:function(a,b){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==z?xa:a;var d=a[G],f=c.cache,e=f[d];if(b){if(e){delete e[b];c.isEmptyObject(e)&&c.removeData(a)}}else{try{delete a[G]}catch(i){a.removeAttribute&&a.removeAttribute(G)}delete f[d]}}}});c.fn.extend({data:function(a,b){if(typeof a==="undefined"&&this.length)return c.data(this[0]);else if(typeof a==="object")return this.each(function(){c.data(this, -a)});var d=a.split(".");d[1]=d[1]?"."+d[1]:"";if(b===v){var f=this.triggerHandler("getData"+d[1]+"!",[d[0]]);if(f===v&&this.length)f=c.data(this[0],a);return f===v&&d[1]?this.data(d[0]):f}else return this.trigger("setData"+d[1]+"!",[d[0],b]).each(function(){c.data(this,a,b)})},removeData:function(a){return this.each(function(){c.removeData(this,a)})}});c.extend({queue:function(a,b,d){if(a){b=(b||"fx")+"queue";var f=c.data(a,b);if(!d)return f||[];if(!f||c.isArray(d))f=c.data(a,b,c.makeArray(d));else f.push(d); -return f}},dequeue:function(a,b){b=b||"fx";var d=c.queue(a,b),f=d.shift();if(f==="inprogress")f=d.shift();if(f){b==="fx"&&d.unshift("inprogress");f.call(a,function(){c.dequeue(a,b)})}}});c.fn.extend({queue:function(a,b){if(typeof a!=="string"){b=a;a="fx"}if(b===v)return c.queue(this[0],a);return this.each(function(){var d=c.queue(this,a,b);a==="fx"&&d[0]!=="inprogress"&&c.dequeue(this,a)})},dequeue:function(a){return this.each(function(){c.dequeue(this,a)})},delay:function(a,b){a=c.fx?c.fx.speeds[a]|| -a:a;b=b||"fx";return this.queue(b,function(){var d=this;setTimeout(function(){c.dequeue(d,b)},a)})},clearQueue:function(a){return this.queue(a||"fx",[])}});var ya=/[\n\t]/g,ca=/\s+/,Wa=/\r/g,Xa=/href|src|style/,Ya=/(button|input)/i,Za=/(button|input|object|select|textarea)/i,$a=/^(a|area)$/i,za=/radio|checkbox/;c.fn.extend({attr:function(a,b){return X(this,a,b,true,c.attr)},removeAttr:function(a){return this.each(function(){c.attr(this,a,"");this.nodeType===1&&this.removeAttribute(a)})},addClass:function(a){if(c.isFunction(a))return this.each(function(o){var m= -c(this);m.addClass(a.call(this,o,m.attr("class")))});if(a&&typeof a==="string")for(var b=(a||"").split(ca),d=0,f=this.length;d-1)return true;return false},val:function(a){if(a===v){var b=this[0];if(b){if(c.nodeName(b,"option"))return(b.attributes.value|| -{}).specified?b.value:b.text;if(c.nodeName(b,"select")){var d=b.selectedIndex,f=[],e=b.options;b=b.type==="select-one";if(d<0)return null;var i=b?d:0;for(d=b?d+1:e.length;i=0;else if(c.nodeName(this,"select")){var x=c.makeArray(s);c("option",this).each(function(){this.selected=c.inArray(c(this).val(),x)>=0});if(!x.length)this.selectedIndex=-1}else this.value=s}})}});c.extend({attrFn:{val:true,css:true,html:true,text:true,data:true,width:true,height:true,offset:true},attr:function(a,b,d,f){if(!a||a.nodeType===3||a.nodeType===8)return v;if(f&&b in c.attrFn)return c(a)[b](d); -f=a.nodeType!==1||!c.isXMLDoc(a);var e=d!==v;b=f&&c.props[b]||b;if(a.nodeType===1){var i=Xa.test(b);if(b in a&&f&&!i){if(e){b==="type"&&Ya.test(a.nodeName)&&a.parentNode&&c.error("type property can't be changed");a[b]=d}if(c.nodeName(a,"form")&&a.getAttributeNode(b))return a.getAttributeNode(b).nodeValue;if(b==="tabIndex")return(b=a.getAttributeNode("tabIndex"))&&b.specified?b.value:Za.test(a.nodeName)||$a.test(a.nodeName)&&a.href?0:v;return a[b]}if(!c.support.style&&f&&b==="style"){if(e)a.style.cssText= -""+d;return a.style.cssText}e&&a.setAttribute(b,""+d);a=!c.support.hrefNormalized&&f&&i?a.getAttribute(b,2):a.getAttribute(b);return a===null?v:a}return c.style(a,b,d)}});var ab=function(a){return a.replace(/[^\w\s\.\|`]/g,function(b){return"\\"+b})};c.event={add:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){if(a.setInterval&&a!==z&&!a.frameElement)a=z;if(!d.guid)d.guid=c.guid++;if(f!==v){d=c.proxy(d);d.data=f}var e=c.data(a,"events")||c.data(a,"events",{}),i=c.data(a,"handle"),j;if(!i){j= -function(){return typeof c!=="undefined"&&!c.event.triggered?c.event.handle.apply(j.elem,arguments):v};i=c.data(a,"handle",j)}if(i){i.elem=a;b=b.split(/\s+/);for(var n,o=0;n=b[o++];){var m=n.split(".");n=m.shift();if(o>1){d=c.proxy(d);if(f!==v)d.data=f}d.type=m.slice(0).sort().join(".");var s=e[n],x=this.special[n]||{};if(!s){s=e[n]={};if(!x.setup||x.setup.call(a,f,m,d)===false)if(a.addEventListener)a.addEventListener(n,i,false);else a.attachEvent&&a.attachEvent("on"+n,i)}if(x.add)if((m=x.add.call(a, -d,f,m,s))&&c.isFunction(m)){m.guid=m.guid||d.guid;m.data=m.data||d.data;m.type=m.type||d.type;d=m}s[d.guid]=d;this.global[n]=true}a=null}}},global:{},remove:function(a,b,d){if(!(a.nodeType===3||a.nodeType===8)){var f=c.data(a,"events"),e,i,j;if(f){if(b===v||typeof b==="string"&&b.charAt(0)===".")for(i in f)this.remove(a,i+(b||""));else{if(b.type){d=b.handler;b=b.type}b=b.split(/\s+/);for(var n=0;i=b[n++];){var o=i.split(".");i=o.shift();var m=!o.length,s=c.map(o.slice(0).sort(),ab);s=new RegExp("(^|\\.)"+ -s.join("\\.(?:.*\\.)?")+"(\\.|$)");var x=this.special[i]||{};if(f[i]){if(d){j=f[i][d.guid];delete f[i][d.guid]}else for(var A in f[i])if(m||s.test(f[i][A].type))delete f[i][A];x.remove&&x.remove.call(a,o,j);for(e in f[i])break;if(!e){if(!x.teardown||x.teardown.call(a,o)===false)if(a.removeEventListener)a.removeEventListener(i,c.data(a,"handle"),false);else a.detachEvent&&a.detachEvent("on"+i,c.data(a,"handle"));e=null;delete f[i]}}}}for(e in f)break;if(!e){if(A=c.data(a,"handle"))A.elem=null;c.removeData(a, -"events");c.removeData(a,"handle")}}}},trigger:function(a,b,d,f){var e=a.type||a;if(!f){a=typeof a==="object"?a[G]?a:c.extend(c.Event(e),a):c.Event(e);if(e.indexOf("!")>=0){a.type=e=e.slice(0,-1);a.exclusive=true}if(!d){a.stopPropagation();this.global[e]&&c.each(c.cache,function(){this.events&&this.events[e]&&c.event.trigger(a,b,this.handle.elem)})}if(!d||d.nodeType===3||d.nodeType===8)return v;a.result=v;a.target=d;b=c.makeArray(b);b.unshift(a)}a.currentTarget=d;(f=c.data(d,"handle"))&&f.apply(d, -b);f=d.parentNode||d.ownerDocument;try{if(!(d&&d.nodeName&&c.noData[d.nodeName.toLowerCase()]))if(d["on"+e]&&d["on"+e].apply(d,b)===false)a.result=false}catch(i){}if(!a.isPropagationStopped()&&f)c.event.trigger(a,b,f,true);else if(!a.isDefaultPrevented()){d=a.target;var j;if(!(c.nodeName(d,"a")&&e==="click")&&!(d&&d.nodeName&&c.noData[d.nodeName.toLowerCase()])){try{if(d[e]){if(j=d["on"+e])d["on"+e]=null;this.triggered=true;d[e]()}}catch(n){}if(j)d["on"+e]=j;this.triggered=false}}},handle:function(a){var b, -d;a=arguments[0]=c.event.fix(a||z.event);a.currentTarget=this;d=a.type.split(".");a.type=d.shift();b=!d.length&&!a.exclusive;var f=new RegExp("(^|\\.)"+d.slice(0).sort().join("\\.(?:.*\\.)?")+"(\\.|$)");d=(c.data(this,"events")||{})[a.type];for(var e in d){var i=d[e];if(b||f.test(i.type)){a.handler=i;a.data=i.data;i=i.apply(this,arguments);if(i!==v){a.result=i;if(i===false){a.preventDefault();a.stopPropagation()}}if(a.isImmediatePropagationStopped())break}}return a.result},props:"altKey attrChange attrName bubbles button cancelable charCode clientX clientY ctrlKey currentTarget data detail eventPhase fromElement handler keyCode layerX layerY metaKey newValue offsetX offsetY originalTarget pageX pageY prevValue relatedNode relatedTarget screenX screenY shiftKey srcElement target toElement view wheelDelta which".split(" "), -fix:function(a){if(a[G])return a;var b=a;a=c.Event(b);for(var d=this.props.length,f;d;){f=this.props[--d];a[f]=b[f]}if(!a.target)a.target=a.srcElement||r;if(a.target.nodeType===3)a.target=a.target.parentNode;if(!a.relatedTarget&&a.fromElement)a.relatedTarget=a.fromElement===a.target?a.toElement:a.fromElement;if(a.pageX==null&&a.clientX!=null){b=r.documentElement;d=r.body;a.pageX=a.clientX+(b&&b.scrollLeft||d&&d.scrollLeft||0)-(b&&b.clientLeft||d&&d.clientLeft||0);a.pageY=a.clientY+(b&&b.scrollTop|| -d&&d.scrollTop||0)-(b&&b.clientTop||d&&d.clientTop||0)}if(!a.which&&(a.charCode||a.charCode===0?a.charCode:a.keyCode))a.which=a.charCode||a.keyCode;if(!a.metaKey&&a.ctrlKey)a.metaKey=a.ctrlKey;if(!a.which&&a.button!==v)a.which=a.button&1?1:a.button&2?3:a.button&4?2:0;return a},guid:1E8,proxy:c.proxy,special:{ready:{setup:c.bindReady,teardown:c.noop},live:{add:function(a,b){c.extend(a,b||{});a.guid+=b.selector+b.live;b.liveProxy=a;c.event.add(this,b.live,na,b)},remove:function(a){if(a.length){var b= -0,d=new RegExp("(^|\\.)"+a[0]+"(\\.|$)");c.each(c.data(this,"events").live||{},function(){d.test(this.type)&&b++});b<1&&c.event.remove(this,a[0],na)}},special:{}},beforeunload:{setup:function(a,b,d){if(this.setInterval)this.onbeforeunload=d;return false},teardown:function(a,b){if(this.onbeforeunload===b)this.onbeforeunload=null}}}};c.Event=function(a){if(!this.preventDefault)return new c.Event(a);if(a&&a.type){this.originalEvent=a;this.type=a.type}else this.type=a;this.timeStamp=J();this[G]=true}; -c.Event.prototype={preventDefault:function(){this.isDefaultPrevented=Z;var a=this.originalEvent;if(a){a.preventDefault&&a.preventDefault();a.returnValue=false}},stopPropagation:function(){this.isPropagationStopped=Z;var a=this.originalEvent;if(a){a.stopPropagation&&a.stopPropagation();a.cancelBubble=true}},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=Z;this.stopPropagation()},isDefaultPrevented:Y,isPropagationStopped:Y,isImmediatePropagationStopped:Y};var Aa=function(a){for(var b= -a.relatedTarget;b&&b!==this;)try{b=b.parentNode}catch(d){break}if(b!==this){a.type=a.data;c.event.handle.apply(this,arguments)}},Ba=function(a){a.type=a.data;c.event.handle.apply(this,arguments)};c.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(a,b){c.event.special[a]={setup:function(d){c.event.add(this,b,d&&d.selector?Ba:Aa,a)},teardown:function(d){c.event.remove(this,b,d&&d.selector?Ba:Aa)}}});if(!c.support.submitBubbles)c.event.special.submit={setup:function(a,b,d){if(this.nodeName.toLowerCase()!== -"form"){c.event.add(this,"click.specialSubmit."+d.guid,function(f){var e=f.target,i=e.type;if((i==="submit"||i==="image")&&c(e).closest("form").length)return ma("submit",this,arguments)});c.event.add(this,"keypress.specialSubmit."+d.guid,function(f){var e=f.target,i=e.type;if((i==="text"||i==="password")&&c(e).closest("form").length&&f.keyCode===13)return ma("submit",this,arguments)})}else return false},remove:function(a,b){c.event.remove(this,"click.specialSubmit"+(b?"."+b.guid:""));c.event.remove(this, -"keypress.specialSubmit"+(b?"."+b.guid:""))}};if(!c.support.changeBubbles){var da=/textarea|input|select/i;function Ca(a){var b=a.type,d=a.value;if(b==="radio"||b==="checkbox")d=a.checked;else if(b==="select-multiple")d=a.selectedIndex>-1?c.map(a.options,function(f){return f.selected}).join("-"):"";else if(a.nodeName.toLowerCase()==="select")d=a.selectedIndex;return d}function ea(a,b){var d=a.target,f,e;if(!(!da.test(d.nodeName)||d.readOnly)){f=c.data(d,"_change_data");e=Ca(d);if(a.type!=="focusout"|| -d.type!=="radio")c.data(d,"_change_data",e);if(!(f===v||e===f))if(f!=null||e){a.type="change";return c.event.trigger(a,b,d)}}}c.event.special.change={filters:{focusout:ea,click:function(a){var b=a.target,d=b.type;if(d==="radio"||d==="checkbox"||b.nodeName.toLowerCase()==="select")return ea.call(this,a)},keydown:function(a){var b=a.target,d=b.type;if(a.keyCode===13&&b.nodeName.toLowerCase()!=="textarea"||a.keyCode===32&&(d==="checkbox"||d==="radio")||d==="select-multiple")return ea.call(this,a)},beforeactivate:function(a){a= -a.target;a.nodeName.toLowerCase()==="input"&&a.type==="radio"&&c.data(a,"_change_data",Ca(a))}},setup:function(a,b,d){for(var f in T)c.event.add(this,f+".specialChange."+d.guid,T[f]);return da.test(this.nodeName)},remove:function(a,b){for(var d in T)c.event.remove(this,d+".specialChange"+(b?"."+b.guid:""),T[d]);return da.test(this.nodeName)}};var T=c.event.special.change.filters}r.addEventListener&&c.each({focus:"focusin",blur:"focusout"},function(a,b){function d(f){f=c.event.fix(f);f.type=b;return c.event.handle.call(this, -f)}c.event.special[b]={setup:function(){this.addEventListener(a,d,true)},teardown:function(){this.removeEventListener(a,d,true)}}});c.each(["bind","one"],function(a,b){c.fn[b]=function(d,f,e){if(typeof d==="object"){for(var i in d)this[b](i,f,d[i],e);return this}if(c.isFunction(f)){e=f;f=v}var j=b==="one"?c.proxy(e,function(n){c(this).unbind(n,j);return e.apply(this,arguments)}):e;return d==="unload"&&b!=="one"?this.one(d,f,e):this.each(function(){c.event.add(this,d,j,f)})}});c.fn.extend({unbind:function(a, -b){if(typeof a==="object"&&!a.preventDefault){for(var d in a)this.unbind(d,a[d]);return this}return this.each(function(){c.event.remove(this,a,b)})},trigger:function(a,b){return this.each(function(){c.event.trigger(a,b,this)})},triggerHandler:function(a,b){if(this[0]){a=c.Event(a);a.preventDefault();a.stopPropagation();c.event.trigger(a,b,this[0]);return a.result}},toggle:function(a){for(var b=arguments,d=1;d0){y=t;break}}t=t[g]}l[q]=y}}}var f=/((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^[\]]*\]|['"][^'"]*['"]|[^[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g,e=0,i=Object.prototype.toString,j=false,n=true;[0,0].sort(function(){n=false;return 0});var o=function(g,h,k,l){k=k||[];var q=h=h||r;if(h.nodeType!==1&&h.nodeType!==9)return[];if(!g|| -typeof g!=="string")return k;for(var p=[],u,t,y,R,H=true,M=w(h),I=g;(f.exec(""),u=f.exec(I))!==null;){I=u[3];p.push(u[1]);if(u[2]){R=u[3];break}}if(p.length>1&&s.exec(g))if(p.length===2&&m.relative[p[0]])t=fa(p[0]+p[1],h);else for(t=m.relative[p[0]]?[h]:o(p.shift(),h);p.length;){g=p.shift();if(m.relative[g])g+=p.shift();t=fa(g,t)}else{if(!l&&p.length>1&&h.nodeType===9&&!M&&m.match.ID.test(p[0])&&!m.match.ID.test(p[p.length-1])){u=o.find(p.shift(),h,M);h=u.expr?o.filter(u.expr,u.set)[0]:u.set[0]}if(h){u= -l?{expr:p.pop(),set:A(l)}:o.find(p.pop(),p.length===1&&(p[0]==="~"||p[0]==="+")&&h.parentNode?h.parentNode:h,M);t=u.expr?o.filter(u.expr,u.set):u.set;if(p.length>0)y=A(t);else H=false;for(;p.length;){var D=p.pop();u=D;if(m.relative[D])u=p.pop();else D="";if(u==null)u=h;m.relative[D](y,u,M)}}else y=[]}y||(y=t);y||o.error(D||g);if(i.call(y)==="[object Array]")if(H)if(h&&h.nodeType===1)for(g=0;y[g]!=null;g++){if(y[g]&&(y[g]===true||y[g].nodeType===1&&E(h,y[g])))k.push(t[g])}else for(g=0;y[g]!=null;g++)y[g]&& -y[g].nodeType===1&&k.push(t[g]);else k.push.apply(k,y);else A(y,k);if(R){o(R,q,k,l);o.uniqueSort(k)}return k};o.uniqueSort=function(g){if(C){j=n;g.sort(C);if(j)for(var h=1;h":function(g,h){var k=typeof h==="string";if(k&&!/\W/.test(h)){h=h.toLowerCase();for(var l=0,q=g.length;l=0))k||l.push(u);else if(k)h[p]=false;return false},ID:function(g){return g[1].replace(/\\/g,"")},TAG:function(g){return g[1].toLowerCase()},CHILD:function(g){if(g[1]==="nth"){var h=/(-?)(\d*)n((?:\+|-)?\d*)/.exec(g[2]==="even"&&"2n"||g[2]==="odd"&& -"2n+1"||!/\D/.test(g[2])&&"0n+"+g[2]||g[2]);g[2]=h[1]+(h[2]||1)-0;g[3]=h[3]-0}g[0]=e++;return g},ATTR:function(g,h,k,l,q,p){h=g[1].replace(/\\/g,"");if(!p&&m.attrMap[h])g[1]=m.attrMap[h];if(g[2]==="~=")g[4]=" "+g[4]+" ";return g},PSEUDO:function(g,h,k,l,q){if(g[1]==="not")if((f.exec(g[3])||"").length>1||/^\w/.test(g[3]))g[3]=o(g[3],null,null,h);else{g=o.filter(g[3],h,k,true^q);k||l.push.apply(l,g);return false}else if(m.match.POS.test(g[0])||m.match.CHILD.test(g[0]))return true;return g},POS:function(g){g.unshift(true); -return g}},filters:{enabled:function(g){return g.disabled===false&&g.type!=="hidden"},disabled:function(g){return g.disabled===true},checked:function(g){return g.checked===true},selected:function(g){return g.selected===true},parent:function(g){return!!g.firstChild},empty:function(g){return!g.firstChild},has:function(g,h,k){return!!o(k[3],g).length},header:function(g){return/h\d/i.test(g.nodeName)},text:function(g){return"text"===g.type},radio:function(g){return"radio"===g.type},checkbox:function(g){return"checkbox"=== -g.type},file:function(g){return"file"===g.type},password:function(g){return"password"===g.type},submit:function(g){return"submit"===g.type},image:function(g){return"image"===g.type},reset:function(g){return"reset"===g.type},button:function(g){return"button"===g.type||g.nodeName.toLowerCase()==="button"},input:function(g){return/input|select|textarea|button/i.test(g.nodeName)}},setFilters:{first:function(g,h){return h===0},last:function(g,h,k,l){return h===l.length-1},even:function(g,h){return h%2=== -0},odd:function(g,h){return h%2===1},lt:function(g,h,k){return hk[3]-0},nth:function(g,h,k){return k[3]-0===h},eq:function(g,h,k){return k[3]-0===h}},filter:{PSEUDO:function(g,h,k,l){var q=h[1],p=m.filters[q];if(p)return p(g,k,h,l);else if(q==="contains")return(g.textContent||g.innerText||a([g])||"").indexOf(h[3])>=0;else if(q==="not"){h=h[3];k=0;for(l=h.length;k= -0}},ID:function(g,h){return g.nodeType===1&&g.getAttribute("id")===h},TAG:function(g,h){return h==="*"&&g.nodeType===1||g.nodeName.toLowerCase()===h},CLASS:function(g,h){return(" "+(g.className||g.getAttribute("class"))+" ").indexOf(h)>-1},ATTR:function(g,h){var k=h[1];g=m.attrHandle[k]?m.attrHandle[k](g):g[k]!=null?g[k]:g.getAttribute(k);k=g+"";var l=h[2];h=h[4];return g==null?l==="!=":l==="="?k===h:l==="*="?k.indexOf(h)>=0:l==="~="?(" "+k+" ").indexOf(h)>=0:!h?k&&g!==false:l==="!="?k!==h:l==="^="? -k.indexOf(h)===0:l==="$="?k.substr(k.length-h.length)===h:l==="|="?k===h||k.substr(0,h.length+1)===h+"-":false},POS:function(g,h,k,l){var q=m.setFilters[h[2]];if(q)return q(g,k,h,l)}}},s=m.match.POS;for(var x in m.match){m.match[x]=new RegExp(m.match[x].source+/(?![^\[]*\])(?![^\(]*\))/.source);m.leftMatch[x]=new RegExp(/(^(?:.|\r|\n)*?)/.source+m.match[x].source.replace(/\\(\d+)/g,function(g,h){return"\\"+(h-0+1)}))}var A=function(g,h){g=Array.prototype.slice.call(g,0);if(h){h.push.apply(h,g);return h}return g}; -try{Array.prototype.slice.call(r.documentElement.childNodes,0)}catch(B){A=function(g,h){h=h||[];if(i.call(g)==="[object Array]")Array.prototype.push.apply(h,g);else if(typeof g.length==="number")for(var k=0,l=g.length;k";var k=r.documentElement;k.insertBefore(g,k.firstChild);if(r.getElementById(h)){m.find.ID=function(l,q,p){if(typeof q.getElementById!=="undefined"&&!p)return(q=q.getElementById(l[1]))?q.id===l[1]||typeof q.getAttributeNode!=="undefined"&&q.getAttributeNode("id").nodeValue===l[1]?[q]:v:[]};m.filter.ID=function(l,q){var p=typeof l.getAttributeNode!=="undefined"&&l.getAttributeNode("id"); -return l.nodeType===1&&p&&p.nodeValue===q}}k.removeChild(g);k=g=null})();(function(){var g=r.createElement("div");g.appendChild(r.createComment(""));if(g.getElementsByTagName("*").length>0)m.find.TAG=function(h,k){k=k.getElementsByTagName(h[1]);if(h[1]==="*"){h=[];for(var l=0;k[l];l++)k[l].nodeType===1&&h.push(k[l]);k=h}return k};g.innerHTML="";if(g.firstChild&&typeof g.firstChild.getAttribute!=="undefined"&&g.firstChild.getAttribute("href")!=="#")m.attrHandle.href=function(h){return h.getAttribute("href", -2)};g=null})();r.querySelectorAll&&function(){var g=o,h=r.createElement("div");h.innerHTML="

";if(!(h.querySelectorAll&&h.querySelectorAll(".TEST").length===0)){o=function(l,q,p,u){q=q||r;if(!u&&q.nodeType===9&&!w(q))try{return A(q.querySelectorAll(l),p)}catch(t){}return g(l,q,p,u)};for(var k in g)o[k]=g[k];h=null}}();(function(){var g=r.createElement("div");g.innerHTML="
";if(!(!g.getElementsByClassName||g.getElementsByClassName("e").length=== -0)){g.lastChild.className="e";if(g.getElementsByClassName("e").length!==1){m.order.splice(1,0,"CLASS");m.find.CLASS=function(h,k,l){if(typeof k.getElementsByClassName!=="undefined"&&!l)return k.getElementsByClassName(h[1])};g=null}}})();var E=r.compareDocumentPosition?function(g,h){return g.compareDocumentPosition(h)&16}:function(g,h){return g!==h&&(g.contains?g.contains(h):true)},w=function(g){return(g=(g?g.ownerDocument||g:0).documentElement)?g.nodeName!=="HTML":false},fa=function(g,h){var k=[], -l="",q;for(h=h.nodeType?[h]:h;q=m.match.PSEUDO.exec(g);){l+=q[0];g=g.replace(m.match.PSEUDO,"")}g=m.relative[g]?g+"*":g;q=0;for(var p=h.length;q=0===d})};c.fn.extend({find:function(a){for(var b=this.pushStack("","find",a),d=0,f=0,e=this.length;f0)for(var i=d;i0},closest:function(a,b){if(c.isArray(a)){var d=[],f=this[0],e,i={},j;if(f&&a.length){e=0;for(var n=a.length;e --1:c(f).is(e)){d.push({selector:j,elem:f});delete i[j]}}f=f.parentNode}}return d}var o=c.expr.match.POS.test(a)?c(a,b||this.context):null;return this.map(function(m,s){for(;s&&s.ownerDocument&&s!==b;){if(o?o.index(s)>-1:c(s).is(a))return s;s=s.parentNode}return null})},index:function(a){if(!a||typeof a==="string")return c.inArray(this[0],a?c(a):this.parent().children());return c.inArray(a.jquery?a[0]:a,this)},add:function(a,b){a=typeof a==="string"?c(a,b||this.context):c.makeArray(a);b=c.merge(this.get(), -a);return this.pushStack(pa(a[0])||pa(b[0])?b:c.unique(b))},andSelf:function(){return this.add(this.prevObject)}});c.each({parent:function(a){return(a=a.parentNode)&&a.nodeType!==11?a:null},parents:function(a){return c.dir(a,"parentNode")},parentsUntil:function(a,b,d){return c.dir(a,"parentNode",d)},next:function(a){return c.nth(a,2,"nextSibling")},prev:function(a){return c.nth(a,2,"previousSibling")},nextAll:function(a){return c.dir(a,"nextSibling")},prevAll:function(a){return c.dir(a,"previousSibling")}, -nextUntil:function(a,b,d){return c.dir(a,"nextSibling",d)},prevUntil:function(a,b,d){return c.dir(a,"previousSibling",d)},siblings:function(a){return c.sibling(a.parentNode.firstChild,a)},children:function(a){return c.sibling(a.firstChild)},contents:function(a){return c.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:c.makeArray(a.childNodes)}},function(a,b){c.fn[a]=function(d,f){var e=c.map(this,b,d);bb.test(a)||(f=d);if(f&&typeof f==="string")e=c.filter(f,e);e=this.length>1?c.unique(e): -e;if((this.length>1||db.test(f))&&cb.test(a))e=e.reverse();return this.pushStack(e,a,Q.call(arguments).join(","))}});c.extend({filter:function(a,b,d){if(d)a=":not("+a+")";return c.find.matches(a,b)},dir:function(a,b,d){var f=[];for(a=a[b];a&&a.nodeType!==9&&(d===v||a.nodeType!==1||!c(a).is(d));){a.nodeType===1&&f.push(a);a=a[b]}return f},nth:function(a,b,d){b=b||1;for(var f=0;a;a=a[d])if(a.nodeType===1&&++f===b)break;return a},sibling:function(a,b){for(var d=[];a;a=a.nextSibling)a.nodeType===1&&a!== -b&&d.push(a);return d}});var Fa=/ jQuery\d+="(?:\d+|null)"/g,V=/^\s+/,Ga=/(<([\w:]+)[^>]*?)\/>/g,eb=/^(?:area|br|col|embed|hr|img|input|link|meta|param)$/i,Ha=/<([\w:]+)/,fb=/"},F={option:[1,""],legend:[1,"
","
"],thead:[1,"","
"],tr:[2,"","
"],td:[3,"","
"], -col:[2,"","
"],area:[1,"",""],_default:[0,"",""]};F.optgroup=F.option;F.tbody=F.tfoot=F.colgroup=F.caption=F.thead;F.th=F.td;if(!c.support.htmlSerialize)F._default=[1,"div
","
"];c.fn.extend({text:function(a){if(c.isFunction(a))return this.each(function(b){var d=c(this);d.text(a.call(this,b,d.text()))});if(typeof a!=="object"&&a!==v)return this.empty().append((this[0]&&this[0].ownerDocument||r).createTextNode(a));return c.getText(this)}, -wrapAll:function(a){if(c.isFunction(a))return this.each(function(d){c(this).wrapAll(a.call(this,d))});if(this[0]){var b=c(a,this[0].ownerDocument).eq(0).clone(true);this[0].parentNode&&b.insertBefore(this[0]);b.map(function(){for(var d=this;d.firstChild&&d.firstChild.nodeType===1;)d=d.firstChild;return d}).append(this)}return this},wrapInner:function(a){if(c.isFunction(a))return this.each(function(b){c(this).wrapInner(a.call(this,b))});return this.each(function(){var b=c(this),d=b.contents();d.length? -d.wrapAll(a):b.append(a)})},wrap:function(a){return this.each(function(){c(this).wrapAll(a)})},unwrap:function(){return this.parent().each(function(){c.nodeName(this,"body")||c(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.appendChild(a)})},prepend:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments, -false,function(b){this.parentNode.insertBefore(b,this)});else if(arguments.length){var a=c(arguments[0]);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,this.nextSibling)});else if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,c(arguments[0]).toArray());return a}},clone:function(a){var b=this.map(function(){if(!c.support.noCloneEvent&& -!c.isXMLDoc(this)){var d=this.outerHTML,f=this.ownerDocument;if(!d){d=f.createElement("div");d.appendChild(this.cloneNode(true));d=d.innerHTML}return c.clean([d.replace(Fa,"").replace(V,"")],f)[0]}else return this.cloneNode(true)});if(a===true){qa(this,b);qa(this.find("*"),b.find("*"))}return b},html:function(a){if(a===v)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(Fa,""):null;else if(typeof a==="string"&&!/ diff --git a/views/_thirty_day_graph.erb b/views/_thirty_day_graph.erb index 1ede44f..d380e7c 100644 --- a/views/_thirty_day_graph.erb +++ b/views/_thirty_day_graph.erb @@ -1,20 +1,36 @@
diff --git a/views/dashboard.erb b/views/dashboard.erb index 6898bd8..3b84bb4 100644 --- a/views/dashboard.erb +++ b/views/dashboard.erb @@ -1,11 +1,12 @@

<%= @title %>

<% for project in @projects %> -
-

<%= project.name %> (<%= project.exceptions_count %>)

+
+

<%= project.name %> (<%= project.exceptions_count %>) + <% if project.last_deploy %> - deploy: <%= format_time(project.last_deploy.occurred_at) %> <% else %> - no deploy found in db<% end %>

-
- <%= partial(:thirty_day_graph, :holder_div_id => "holder_#{project.name}", :data => project.last_thirty_days) %> +
+ <%= partial(:thirty_day_graph, :holder_div_id => "holder_#{project.name}", :occur_count => project.last_thirty_days, :deploys => project.deploys_last_thirty_days) %> +
-
<% end %> diff --git a/views/deploy.erb b/views/deploy.erb new file mode 100644 index 0000000..569d1e6 --- /dev/null +++ b/views/deploy.erb @@ -0,0 +1,23 @@ +

<%= @title %>

+ + + +
+ +<% if @deploys && @deploys.any? %> + + <% for deploy in @deploys %> +
+ <%= format_time(deploy.occurred_at) %> + version: <%= truncate(deploy.version, 80) %> + <%= truncate(deploy.changelog_link, 100) %> +
+ <% end %> + +<% else %> +

No deploys!

+<% end %> diff --git a/views/index.erb b/views/index.erb index b56c5bf..7b80af1 100644 --- a/views/index.erb +++ b/views/index.erb @@ -1,30 +1,45 @@

<%= @title %>

-