diff --git a/Gemfile b/Gemfile index 0c038d2..6d2665f 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,11 @@ group :production do gem 'unicorn' end +group :development do + gem 'quiet_assets' # skip logs for serving assets in development.log + gem 'thin' # to avoid wobrick content-length confusion +end + # test suite group :development, :test do gem 'capistrano' diff --git a/Gemfile.lock b/Gemfile.lock index 6b42818..c49801c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -41,8 +41,8 @@ GEM activesupport (3.2.8) i18n (~> 0.6) multi_json (~> 1.0) - annotator (0.0.2) - rails (~> 3.0) + annotator (0.0.8.1) + activerecord (~> 3.0) arbre (1.0.1) activesupport (>= 3.0.0) arel (3.0.2) @@ -81,6 +81,7 @@ GEM coffee-script-source (1.2.0) columnize (0.3.6) commonjs (0.2.6) + daemons (1.1.9) debugger (1.2.1) columnize (>= 0.3.1) debugger-linecache (~> 1.1.1) @@ -160,8 +161,8 @@ GEM activerecord (~> 3.1) activesupport (~> 3.1) polyamorous (~> 0.5.0) - mime-types (1.18) - multi_json (1.3.6) + mime-types (1.19) + multi_json (1.3.7) multipart-post (1.1.5) net-scp (1.0.4) net-ssh (>= 1.99.1) @@ -181,6 +182,8 @@ GEM pusher (0.10.0) multi_json (~> 1.0) signature (~> 0.1.4) + quiet_assets (1.0.1) + railties (~> 3.1) rack (1.4.1) rack-cache (1.2) rack (>= 0.4) @@ -251,6 +254,10 @@ GEM sqlite3 (1.3.5) therubyracer (0.10.2) libv8 (~> 3.3.10) + thin (1.5.0) + daemons (>= 1.0.9) + eventmachine (>= 0.12.6) + rack (>= 1.0.0) thor (0.16.0) tilt (1.3.3) tinder (1.9.1) @@ -263,7 +270,7 @@ GEM multi_json (~> 1.0) multipart-post (~> 1.1) twitter-stream (~> 0.1) - treetop (1.4.10) + treetop (1.4.12) polyglot polyglot (>= 0.3.1) twitter-bootstrap-rails (2.1.3) @@ -275,7 +282,7 @@ GEM eventmachine (>= 0.12.8) http_parser.rb (~> 0.5.1) simple_oauth (~> 0.1.4) - tzinfo (0.3.33) + tzinfo (0.3.35) unicorn (4.3.1) kgio (~> 2.6) rack @@ -312,6 +319,7 @@ DEPENDENCIES open4 pg pusher + quiet_assets rails (= 3.2.8) rspec-mocks rspec-rails @@ -321,6 +329,7 @@ DEPENDENCIES simplecov sql_queries_count sqlite3 + thin tinder twitter-bootstrap-rails unicorn diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 95e5ecd..8a8517f 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -5,6 +5,8 @@ //= require jquery_ujs //= require twitter/bootstrap //= require pusher.min +//= require moment +//= require livestamp //= require moci //= require webs diff --git a/app/assets/javascripts/webs.js.coffee.erb b/app/assets/javascripts/webs.js.coffee.erb index ca73858..823223e 100644 --- a/app/assets/javascripts/webs.js.coffee.erb +++ b/app/assets/javascripts/webs.js.coffee.erb @@ -20,7 +20,25 @@ Webs = receive: (event, data) -> Moci.debug("Got project event '" + event + "' with data: " + JSON.stringify(data)) switch event + when 'worker' + tr = $('table.workers tr.worker_'+data['id']) + if tr.length == 0 + q = $.ajax + url: '/workers/'+data['id']+'/tr_worker' + dataType: 'html' + async: false + success: (data,t,j) -> + $(data).prependTo('table.workers tbody') + else + if data['destroyed?'] == true + tr.remove() + else + tr.find('.last_seen_at span').attr('data-livestamp',data['last_seen_at']) + tr.find('.state').html(data['state']) + tr.find('.task').html(data['task']['name']) + when 'test_suite_run' + # TODO we probably want to switch to js view rendering with these on the long run if $('.last_runs .test_suite_run_'+data['id']).length == 0 q = $.ajax url: '/p/' + Project.slug + '/tr_last_run?'+$.param({test_suite_run_id: data['id']}) @@ -34,6 +52,7 @@ Webs = tr.removeClass('state_running') tr.removeClass('state_finished') tr.addClass('state_'+data['state']) + when 'test_unit_run' tsr_id = data['test_suite_run']['id'] tr_details = $('.last_runs .details.test_suite_run_'+tsr_id) @@ -82,6 +101,10 @@ $(document).ready -> Moci.debug("Subscribing to project channel " + channel) Webs.subscribe(channel) setInterval LastTestSuiteRuns.update_progress, 500 + if $('input#private_webs_channel').length != 0 + Moci.debug("Subscribing to private channel") + Webs.subscribe($('input#private_webs_channel').val()) + diff --git a/app/assets/stylesheets/moci.sass b/app/assets/stylesheets/moci.sass index 3195261..ef79b0e 100644 --- a/app/assets/stylesheets/moci.sass +++ b/app/assets/stylesheets/moci.sass @@ -69,6 +69,8 @@ table.last_runs color: darken($fail-color, 50%) border: 1px solid darken($fail-color, 20%) background-color: $fail-color +.workers tr.dead + opacity: 0.5 .orange color: orange diff --git a/app/controllers/workers_controller.rb b/app/controllers/workers_controller.rb new file mode 100644 index 0000000..00c2a0a --- /dev/null +++ b/app/controllers/workers_controller.rb @@ -0,0 +1,12 @@ +class WorkersController < ApplicationController + before_filter :authenticate_admin! + + def index + @workers = Worker.order('worker_type_id', 'last_seen_at DESC').all + end + + def tr_worker + worker = Worker.find params[:id] + render worker + end +end diff --git a/app/helpers/workers_helper.rb b/app/helpers/workers_helper.rb new file mode 100644 index 0000000..34b8cb0 --- /dev/null +++ b/app/helpers/workers_helper.rb @@ -0,0 +1,2 @@ +module WorkersHelper +end diff --git a/app/models/commit.rb b/app/models/commit.rb index 525d0d2..cf3b9ac 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -10,6 +10,7 @@ # * preparation_log [text] - FIXME: remove me, it's present in ProjectInstanceCommit # * project_id [integer] - belongs_to Project # * skipped [boolean] - commit can be marked as skipped if we don't want moci to run it +# * slug [string] - TODO: document me # * updated_at [datetime] - last update time class Commit < ActiveRecord::Base diff --git a/app/models/project.rb b/app/models/project.rb index e70ae40..1293fab 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -6,6 +6,7 @@ # * project_type [string, default=Base] - same as class name that will be used as project handler # (Moci::ProjectHandler::..) # * public [boolean] - if true, it can be viewed by everybody (without singing in) +# * slug [string] - TODO: document me # * updated_at [datetime] - last update time # * vcs_type [string, default=Base] - VCS type e.g. Git, Mercurial (see Moci::VCS::Base) class Project < ActiveRecord::Base diff --git a/app/models/user.rb b/app/models/user.rb index 863dbb5..e039fc0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,3 +1,5 @@ +require 'digest/md5' + # Attributes: # * id [integer, primary, not null] - primary key # * admin [boolean, not null] - TODO: document me @@ -36,4 +38,10 @@ class User < ActiveRecord::Base has_many :manage_project_permissions, :class_name => 'ProjectPermission', :conditions => {:name => 'manage'} has_many :projects_can_manage, :through => :manage_project_permissions, :source => :project + + scope :admin, where(admin: true) + + def webs_channel + Digest::MD5.hexdigest(id.to_s + created_at.to_i.to_s + encrypted_password.to_s) + end end diff --git a/app/models/worker.rb b/app/models/worker.rb new file mode 100644 index 0000000..f6e5a4c --- /dev/null +++ b/app/models/worker.rb @@ -0,0 +1,62 @@ +# Attributes: +# * id [integer, primary, not null] - primary key +# * created_at [datetime] - creation time +# * last_seen_at [datetime] - columne periodically updated by worker process +# * pid [integer, not null] - worker process pid +# * state [string] - current worker state [idle, working, waiting - for lock on instance] +# * task [text] - currently executed task in form of JSON +# * worker_type_id [integer, not null] - see Worker::TYPE +class Worker < ActiveRecord::Base + + # Master worker spawns slave and these are workers that are actually running some tests + TYPES = { + 0 => :master, + 1 => :slave + } + + # How often should worker report that it is alive + PING_FREQUENCY = 30 + + # Current task, stored as serialized hash: + # * name - human readable task to display in UI + # * task_id - currently processed queue element + # * project_instance_id - instance used + serialize :task, Hash + + validates :worker_type_id, inclusion: {in: TYPES.keys} + + scope :alive, lambda { where('last_seen_at >= ?', Time.now - PING_FREQUENCY*2) } + scope :dead, lambda { where('last_seen_at < ?', Time.now - PING_FREQUENCY*2) } + + scope :slave, where(worker_type_id: TYPES.invert[:slave]) + scope :master, where(worker_type_id: TYPES.invert[:master]) + + def self.cleanup + dead.delete_all + end + + def as_json(options=nil) + super(only: [:id, :last_seen_at, :pid, :state, :task], + methods: [:worker_type, :destroyed?]) + end + + def alive? + last_seen_at > Time.now - PING_FREQUENCY*2 + end + + def worker_type + TYPES[worker_type_id] + end + + def worker_type=(name) + self.worker_type_id = TYPES.invert[name.to_sym] + end + + # Live web notifications + # FIXME pusher is extremaly slow, especially if we keep using it we + # may want to send these notifications in some bg process. It's most painfull + # on destroy when we are waiting 2s for process to finish because of tihs pusher + after_save { Webs.notify :worker, self } + after_destroy { Webs.notify :worker, self } + +end diff --git a/app/views/common/_sidebar.html.haml b/app/views/common/_sidebar.html.haml index 4c9bac0..98172e5 100644 --- a/app/views/common/_sidebar.html.haml +++ b/app/views/common/_sidebar.html.haml @@ -13,3 +13,5 @@ - else %li.nav-header No project selected %li= link_to "Latest test runs", test_suite_runs_path + %li.divider + %li= link_to "Workers", workers_path diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 3f643bd..67ab8ef 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -8,16 +8,16 @@ = stylesheet_link_tag 'application', media: 'all' = javascript_include_tag 'application' = csrf_meta_tag - -# Le HTML5 shim, for IE6-8 support of HTML5 elements + -# Le HTML5 shim, for IE6-8 support of HTML5 elements = yield :head %body %header.navbar.navbar-fixed-top %nav.navbar-inner .container-fluid - %a(class="brand" href="/") + %a(class="brand" href="/") %i.icon-th Moci %ul.nav @@ -33,6 +33,7 @@ - if user_signed_in? - if current_user.admin? %li= link_to 'admin panel', '/admin' + %input#private_webs_channel.hidden{:value => current_user.webs_channel} %li= link_to 'log out', destroy_user_session_path, :method => :delete %li.avatar= image_tag current_user.gravatar_url(size: 24), class: :gravatar, title: current_user.email - else diff --git a/app/views/workers/_worker.html.haml b/app/views/workers/_worker.html.haml new file mode 100644 index 0000000..f720e80 --- /dev/null +++ b/app/views/workers/_worker.html.haml @@ -0,0 +1,9 @@ +%tr{:class => [(worker.alive? ? 'alive' : 'dead'), "worker_#{worker.id}"]} + %td.worker_type= worker.worker_type + %td.pid= worker.pid + %td.state= worker.state + %td.task= worker.task[:name] || 'n/d' + %td.last_seen_at + %span{:'data-livestamp' => worker.last_seen_at.to_i} + + diff --git a/app/views/workers/index.html.haml b/app/views/workers/index.html.haml new file mode 100644 index 0000000..8938ee7 --- /dev/null +++ b/app/views/workers/index.html.haml @@ -0,0 +1,15 @@ +%h2 Workers + +%table.table.table-hover.table-condensed.workers + %thead + %tr + %th Type + %th PID + %th State + %th Task + %th Last seen + %tbody + = render @workers + + + diff --git a/config/moci.yml.example b/config/moci.yml.example index 1b5b2a9..cb53c30 100644 --- a/config/moci.yml.example +++ b/config/moci.yml.example @@ -4,4 +4,8 @@ pusher: app_id: '' key: '' secret: '' +# number of slave workers to spawn +# TODO since it can be changed live it should be possible to change it from web, +# or this should just be upper limit and number should be adjusted manually +number_of_workers: 3 rvm_source: $HOME/.rvm/scripts/rvm diff --git a/config/routes.rb b/config/routes.rb index 3726bb5..31c8736 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,10 @@ resources :test_suite_runs end + resources :workers, :only => [:index] do + member { get :tr_worker } + end + # You can have the root of your site routed with "root" # just remember to delete public/index.html. root :to => "test_suite_runs#index" diff --git a/db/migrate/20121105090057_create_workers.rb b/db/migrate/20121105090057_create_workers.rb new file mode 100644 index 0000000..ef3a075 --- /dev/null +++ b/db/migrate/20121105090057_create_workers.rb @@ -0,0 +1,12 @@ +class CreateWorkers < ActiveRecord::Migration + def change + create_table :workers do |t| + t.integer :pid, :unsigned => true, :null => false + t.integer :worker_type_id, :null => false, :size => 4 + t.string :state + t.text :task + t.timestamp :last_seen_at + t.timestamp :created_at + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7e4657e..b363fb4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended to check this file into your version control system. -ActiveRecord::Schema.define(:version => 20121010123121) do +ActiveRecord::Schema.define(:version => 20121105090057) do create_table "active_admin_comments", :force => true do |t| t.string "resource_id", :null => false @@ -31,8 +31,8 @@ create_table "authors", :force => true do |t| t.string "name" t.string "email" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" end create_table "commits", :force => true do |t| @@ -42,8 +42,8 @@ t.datetime "committed_at" t.text "preparation_log" t.text "dev_structure" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" t.integer "project_id" t.boolean "skipped", :default => false t.string "build_state", :default => "pending" @@ -61,8 +61,8 @@ t.string "name" t.string "notification_type" t.text "notification_options" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" end create_table "notifications_projects", :id => false, :force => true do |t| @@ -76,8 +76,8 @@ t.string "state", :default => "new" t.text "preparation_log" t.text "data_yaml" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" end create_table "project_instances", :force => true do |t| @@ -85,8 +85,8 @@ t.string "state", :default => "new" t.string "locked_by" t.string "working_directory" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" end create_table "project_permissions", :force => true do |t| @@ -104,8 +104,8 @@ t.text "project_options" t.string "vcs_type", :default => "Base" t.string "project_type", :default => "Base" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" t.boolean "public", :default => false t.string "slug" end @@ -123,8 +123,8 @@ t.string "state" t.boolean "exitstatus" t.text "run_log" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" t.integer "project_instance_id" end @@ -133,8 +133,8 @@ t.string "suite_type" t.text "suite_options" t.integer "project_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" end create_table "test_unit_runs", :force => true do |t| @@ -149,8 +149,8 @@ t.integer "test_suite_id" t.string "class_name" t.text "name" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at" + t.datetime "updated_at" end create_table "users", :force => true do |t| @@ -173,4 +173,13 @@ add_index "users", ["email"], :name => "index_users_on_email", :unique => true add_index "users", ["reset_password_token"], :name => "index_users_on_reset_password_token", :unique => true + create_table "workers", :force => true do |t| + t.integer "pid", :null => false + t.integer "worker_type_id", :null => false + t.string "state" + t.text "task" + t.datetime "last_seen_at" + t.datetime "created_at" + end + end diff --git a/lib/moci.rb b/lib/moci.rb index e9054e9..9dee801 100644 --- a/lib/moci.rb +++ b/lib/moci.rb @@ -8,12 +8,38 @@ module Moci # IMPROVE: consider keeping config options in database, this way it will be easier # to create step by step setup (assuming app not always may have permission to edit files in config dir) include ConfigFile - has_config 'moci.yml', :environments => false + has_config 'moci.yml', environments: false - def default_config + def self.default_config { - :default_timeout => 40.minutes + default_timeout: 40.minutes, + number_of_workers: 3 } end + # Stub for some better error reporting (stored in DB in the future), + # whatever happens this will be easier to find and replace then some + # writes to random logs + # * error - exception object or string + # * options - options hash or symbol deciding error class e.g.: internal, worker, test_runner, maybe some more) + def self.report_error(error, options=nil) + desc = "[#{options.inspect}] " + if error.kind_of? Exception + desc << "#{error.message}\n\t" + desc << error.backtrace.join("\n\t") + else + desc << error + end + error_logger.error desc + end + + def self.error_logger + @error_logger ||= ( + logger = Logger.new(File.join(Rails.root,'log','error.log')) + logger.formatter = ::Logger::Formatter.new + logger.datetime_format = "%Y-%m-%d %H:%M:%S " + logger + ) + end + end diff --git a/lib/moci/worker.rb b/lib/moci/worker.rb deleted file mode 100644 index db38cfe..0000000 --- a/lib/moci/worker.rb +++ /dev/null @@ -1,38 +0,0 @@ -module Moci - # Just a skeleton currently - class Worker - def self.go - my_id = "worker:#{Process.pid}" - # TODO instead of updated_at use something like - # last_checked_at + check_frequency - loop do - - # Updates, makes sure all commits are built - Project.where('updated_at < ?', Time.now - 3.minutes).each do |project| - project.acquire_instance(my_id) do |instance| - instance.ping - end - end - - # Looking for randomness (optional?), do these commits some more times - # TODO: we really need queue checking all this is way to heavy - # FIXME I mean it, like below it really really sucks - Project.all.each do |project| - project.acquire_instance(my_id) do |instance| - project.commits.order('committed_at DESC').limit(10).each do |commit| - unless commit.skipped? - instance.checkout commit - if instance.prepare_env commit - break if instance.run_test_suites(4) # target number of commit builts - end - end - end - end - end - - puts "loop" - sleep 60 - end - end - end -end diff --git a/lib/moci/worker/base.rb b/lib/moci/worker/base.rb new file mode 100644 index 0000000..f101ca2 --- /dev/null +++ b/lib/moci/worker/base.rb @@ -0,0 +1,99 @@ +module Moci + module Worker + class Base + attr_accessor :model + + def initialize + register + monitoring_loop + Signal.trap("EXIT") do + safe_quit + exit 0 + end + $0 = "moci worker #{worker_type}" + @id = "#{self.class}:#{Process.pid}" + end + + def safe_quit + unregister + end + + def to_s + "#<#{self.class}:ID#{@model.try :id}:PID#{Process.pid}>" + end + + def worker_type + raise "not implemented" + end + + protected + + def update_state(state) + @model.update_attribute :state, state + end + + def register + @model = ::Worker.create( + pid: Process.pid, + last_seen_at: Time.now, + state: 'idle', + worker_type: worker_type + ) + end + + def unregister + @monitoring_loop.kill + @model.destroy + end + + def monitoring_loop + @monitoring_loop = Thread.new do + loop do + begin + sleep ::Worker::PING_FREQUENCY + @model.update_attribute :last_seen_at, Time.now + rescue Exception => e + Moci.report_error e, :worker + end + end + end + end + + def process_alive?(pid) + Process.kill 0, pid + return true + rescue Errno::ESRCH + return false + end + + def process_name(pid) + `ps -o command #{pid}`.split("\n").last.strip + end + + def process_kill(pid) + # ask politely + begin + Process.kill 'SIGINT', pid + rescue Errno::ESRCH + return false + end + + # wait 3s to die + 30.times do + sleep 0.1 + break unless process_alive?(pid) + end + + # -9 if still alive + if process_alive?(pid) + begin + Process.kill 'SIGKILL', pid + rescue Errno::ESRCH + # it's fine if it was already dead + end + end + return true + end + end + end +end diff --git a/lib/moci/worker/master.rb b/lib/moci/worker/master.rb new file mode 100644 index 0000000..edb39a9 --- /dev/null +++ b/lib/moci/worker/master.rb @@ -0,0 +1,90 @@ +module Moci + module Worker + # Master worker job is to manage all slave workers on given machine. + # In case some worker would die or stop responding (should not happen in theory), + # it's master worker responsibility to kill it or start again. + # Sending SigINT to master worker stops all slave workers and then master, + # but easier way to achieve that is jus using rake workers:stop + class Master < Base + + def start + number_of_workers.times { start_slave } + monitor_slaves + end + + protected + + def monitor_slaves + loop do + begin + sleep 30 + + # check for zombies + ::Worker.slave.dead.each do |worker| + if process_alive?(worker.pid) && process_name(worker.pid).match(/moci worker/) + Moci.report_error "moci slave worker found alive but not reporting #{worker.attributes.inspect}", 'worker' + process_kill(worker.pid) + else + Moci.report_error "looks like some moci slave worker died #{worker.attributes.inspect}", 'worker' + end + worker.destroy + end + + # or maybe we need some more? + (number_of_workers - ::Worker.slave.alive.count).times { start_slave } + + # whatever happens in the loop, we want to keep it going + rescue Exception => e + # unless we are shutting down + raise e if e.kind_of?(SignalException) || e.kind_of?(Interrupt) + Moci.report_error e, :worker + # unless it was interrupt + end + end + end + + def number_of_workers + Moci.config[:number_of_workers] + end + + def start_slave + ActiveRecord::Base.establish_connection + fork do + ActiveRecord::Base.establish_connection + Process.daemon(true) + Worker::Slave.new.start + end + end + + # TODO with multi machine confirguration master should probably + # only manage slaves on given machine (additional column in workers table) + def stop_slaves + slaves = ::Worker.slave.alive.all + + # ask politely + slaves.each do |worker| + Process.kill 'SIGINT', worker.pid + end + + # wait + sleep 4 # IMPROVE no need for waiting if they are dead + slaves.each do |worker| + begin + Process.kill 'SIGKILL', worker.pid + rescue Errno::ESRCH + # it was already dead, that's fine + end + end + end + + def safe_quit + stop_slaves + super + end + + def worker_type + :master + end + end + end +end diff --git a/lib/moci/worker/slave.rb b/lib/moci/worker/slave.rb new file mode 100644 index 0000000..2823cbb --- /dev/null +++ b/lib/moci/worker/slave.rb @@ -0,0 +1,47 @@ +module Moci + module Worker + + class Slave < Base + + # TODO As commented within method, this is to be rewritten using queue + def start + # TODO instead of updated_at use something like + # last_checked_at + check_frequency + loop do + + # Updates, makes sure all commits are built + Project.where('updated_at < ?', Time.now - 3.minutes).each do |project| + project.acquire_instance(@id) do |instance| + update_state :working + instance.ping + end + end + + # Looking for randomness (optional?), do these commits some more times + # TODO: we really need queue checking all this is way to heavy + # FIXME I mean it, like below it really really sucks + Project.all.each do |project| + project.acquire_instance(@id) do |instance| + project.commits.order('committed_at DESC').limit(10).each do |commit| + unless commit.skipped? + update_state :working + instance.checkout commit + if instance.prepare_env commit + break if instance.run_test_suites(4) # target number of commit builts + end + end + end + end + end + update_state :idle + sleep 60 + end + end + + def worker_type + :slave + end + + end + end +end diff --git a/lib/tasks/workers.rake b/lib/tasks/workers.rake new file mode 100644 index 0000000..d66c94b --- /dev/null +++ b/lib/tasks/workers.rake @@ -0,0 +1,29 @@ +namespace :workers do + + desc "Start moci workers" + task :start => :environment do + fork do + ActiveRecord::Base.establish_connection + master = Moci::Worker::Master.new + master.start + end + end + + desc "Stop moci workers" + task :stop => :environment do + master = Worker.master.first + Process.kill 'SIGINT', master.pid + sleep 5 # IMPROVE don't wait if it was killed right away + begin + Process.kill 'SIGKILL', master.pid + rescue Errno::ESRCH + # it's fine if it was already dead + end + end + + desc "Start single slave worker without deamonizing (for dubugging purposes)" + task :run_slave => :environment do # TODO: rename me? naming is hard + Moci::Worker::Slave.new.start + end + +end diff --git a/lib/webs.rb b/lib/webs.rb index 3cb160f..4bc1571 100644 --- a/lib/webs.rb +++ b/lib/webs.rb @@ -1,3 +1,5 @@ +require 'timeout' + # Abstraction layer around whatever is used to push live notification to the website. # Currently Pusher is used. # * it enables us to easily change pusher to something else @@ -11,12 +13,15 @@ def self.push(channel, data={}) def self.event(channel, event, data={}) return channel.map { |ch| self.event(ch, event, data) }.all? if channel.kind_of? Array - # begin - Pusher[channel].trigger(event, data) - true - # rescue Exception => e - # false - # end + begin + Timeout::timeout(3) do + Pusher[channel].trigger(event, data) + end + rescue Timeout::Error + # if it didn't make it within 3 seconds, that's too bad. + return false + end + return true end def self.notify(event, *params) diff --git a/lib/webs/notifier.rb b/lib/webs/notifier.rb index 785bc78..ac6cee5 100644 --- a/lib/webs/notifier.rb +++ b/lib/webs/notifier.rb @@ -27,8 +27,18 @@ def test_unit_run(tur) :test_suite_run => {:id => tur.test_suite_run.id} end + def worker(worker) + webs.event ch_admins, 'worker', worker.as_json['worker'] # IMPROVE use root: false when fixed in rails + end + protected + def ch_admins + Rails.cache.fetch(:admin_webs_channels, expires_in: 10.minutes) do + User.admin.map &:webs_channel + end + end + def ch_global 'moci' end diff --git a/spec/controllers/workers_controller_spec.rb b/spec/controllers/workers_controller_spec.rb new file mode 100644 index 0000000..290a2b7 --- /dev/null +++ b/spec/controllers/workers_controller_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe WorkersController do + +end diff --git a/spec/factories/workers.rb b/spec/factories/workers.rb new file mode 100644 index 0000000..a60b2df --- /dev/null +++ b/spec/factories/workers.rb @@ -0,0 +1,8 @@ +# Read about factories at https://github.com/thoughtbot/factory_girl + +FactoryGirl.define do + factory :worker do + worker_type :slave + pid 0 + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 6d0c034..78f5ac1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -64,4 +64,21 @@ subject.projects_can_manage.to_set.should == [project3].to_set end end + + context "#webs_channel" do + it "should not be nil" do + subject.webs_channel.should_not be_nil + end + + it "should not duplicate" do + user2 = create :user + subject.webs_channel.should_not == user2.webs_channel + end + + it "should depend on password" do + prev = subject.webs_channel + subject.password = 'pass2' + subject.webs_channel.should_not == prev + end + end end diff --git a/spec/models/worker_spec.rb b/spec/models/worker_spec.rb new file mode 100644 index 0000000..a81cf29 --- /dev/null +++ b/spec/models/worker_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe Worker do + subject { build :worker } + + context "worker_type" do + it "should set worker type" do + subject.worker_type = :master + subject.worker_type_id.should == 0 + subject.should be_valid + end + + it "should read worker type" do + subject.worker_type_id = 1 + subject.worker_type.should == :slave + end + + it "should validate worker type" do + subject.worker_type = :foo + subject.should_not be_valid + end + end + + context "task" do + it "should be serialized" do + subject.task = {:name => "doing stuff"} + subject.save! + worker = Worker.find subject.id + worker.task.should == {:name => "doing stuff"} + end + end + + context ".alive" do + before do + @w1 = create :worker, last_seen_at: Time.now + @w2 = create :worker, last_seen_at: Time.now - Worker::PING_FREQUENCY + @w3 = create :worker, last_seen_at: Time.now - Worker::PING_FREQUENCY*3 + end + + it { Worker.alive.all.to_set.should == [@w1, @w2].to_set } + it { Worker.dead.all.should == [@w3] } + it { @w1.alive?.should == true } + it { @w3.alive?.should == false } + end + + context "webs notifications" do + before do + Rails.cache.delete :admin_webs_channels + @admin = create(:admin) + end + + it "should receive notification on create" do + Webs.should_receive(:event).with([@admin.webs_channel], 'worker', anything) + worker = create :worker + end + + it "should receive notification on update" do + worker = create :worker + Webs.should_receive(:event).with([@admin.webs_channel], 'worker', anything) + worker.update_attribute :state, 'working' + end + + it "should receive notification on destroy" do + worker = create :worker + Webs.should_receive(:event).with([@admin.webs_channel], 'worker', hash_including(:destroyed? => true)) + worker.destroy + end + + end +end diff --git a/vendor/assets/javascripts/livestamp.js b/vendor/assets/javascripts/livestamp.js new file mode 100644 index 0000000..289060f --- /dev/null +++ b/vendor/assets/javascripts/livestamp.js @@ -0,0 +1,130 @@ +// Livestamp.js / v1.1.1 / (c) 2012 Matt Bradley / MIT License +(function($, moment) { + var updateInterval = 1e3, + paused = false, + $livestamps = $([]), + + init = function() { + livestampGlobal.resume(); + }, + + prep = function($el, timestamp) { + var oldData = $el.data('livestampdata'); + if (typeof timestamp == 'number') + timestamp *= 1e3; + + $el.removeAttr('data-livestamp') + .removeData('livestamp'); + + timestamp = moment(timestamp); + if (moment.isMoment(timestamp) && !isNaN(+timestamp)) { + var newData = $.extend({ }, { 'original': $el.contents() }, oldData); + newData.moment = moment(timestamp); + + $el.data('livestampdata', newData).empty(); + $livestamps = $livestamps.add($el); + } + }, + + run = function() { + if (paused) return; + livestampGlobal.update(); + setTimeout(run, updateInterval); + }, + + livestampGlobal = { + update: function() { + $('[data-livestamp]').each(function() { + var $this = $(this); + prep($this, $this.data('livestamp')); + }); + + var toRemove = [ ]; + + $livestamps.each(function() { + var $this = $(this), + data = $this.data('livestampdata'); + + if (data === undefined) + toRemove.push(this); + else if (moment.isMoment(data.moment)) { + var from = $this.html(), + to = data.moment.fromNow(); + + if (from != to) { + var e = $.Event('change.livestamp'); + $this.trigger(e, [from, to]); + if (!e.isDefaultPrevented()) + $this.html(to); + } + } + }); + + $livestamps = $livestamps.not(toRemove); + }, + + pause: function() { + paused = true; + }, + + resume: function() { + paused = false; + run(); + }, + + interval: function(interval) { + if (interval === undefined) + return updateInterval; + updateInterval = interval; + } + }, + + livestampLocal = { + add: function($el, timestamp) { + if (typeof timestamp == 'number') + timestamp *= 1e3; + timestamp = moment(timestamp); + + if (moment.isMoment(timestamp) && !isNaN(+timestamp)) { + $el.each(function() { + prep($(this), timestamp); + }); + livestampGlobal.update(); + } + + return $el; + }, + + destroy: function($el) { + $livestamps = $livestamps.not($el); + $el.each(function() { + var $this = $(this), + data = $this.data('livestampdata'); + + if (data === undefined) + return $el; + + $this + .html(data.original ? data.original : '') + .removeData('livestampdata'); + }); + + return $el; + }, + + isLivestamp: function($el) { + return $el.data('livestampdata') !== undefined; + } + }; + + $.livestamp = livestampGlobal; + $(init); + $.fn.livestamp = function(method, options) { + if (!livestampLocal[method]) { + options = method; + method = 'add'; + } + + return livestampLocal[method](this, options); + }; +})(jQuery, moment); diff --git a/vendor/assets/javascripts/moment.js b/vendor/assets/javascripts/moment.js new file mode 100644 index 0000000..e6fa145 --- /dev/null +++ b/vendor/assets/javascripts/moment.js @@ -0,0 +1,1213 @@ +// moment.js +// version : 1.7.2 +// author : Tim Wood +// license : MIT +// momentjs.com + +(function (undefined) { + + /************************************ + Constants + ************************************/ + + var moment, + VERSION = "1.7.2", + round = Math.round, i, + // internal storage for language config files + languages = {}, + currentLanguage = 'en', + + // check for nodeJS + hasModule = (typeof module !== 'undefined' && module.exports), + + // Parameters to check for on the lang config. This list of properties + // will be inherited from English if not provided in a language + // definition. monthsParse is also a lang config property, but it + // cannot be inherited and as such cannot be enumerated here. + langConfigProperties = 'months|monthsShort|weekdays|weekdaysShort|weekdaysMin|longDateFormat|calendar|relativeTime|ordinal|meridiem'.split('|'), + + // ASP.NET json date format regex + aspNetJsonRegex = /^\/?Date\((\-?\d+)/i, + + // format tokens + formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|YYYY|YY|a|A|hh?|HH?|mm?|ss?|SS?S?|zz?|ZZ?|.)/g, + localFormattingTokens = /(\[[^\[]*\])|(\\)?(LT|LL?L?L?)/g, + + // parsing tokens + parseMultipleFormatChunker = /([0-9a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+)/gi, + + // parsing token regexes + parseTokenOneOrTwoDigits = /\d\d?/, // 0 - 99 + parseTokenOneToThreeDigits = /\d{1,3}/, // 0 - 999 + parseTokenThreeDigits = /\d{3}/, // 000 - 999 + parseTokenFourDigits = /\d{1,4}/, // 0 - 9999 + parseTokenWord = /[0-9a-z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+/i, // any word characters or numbers + parseTokenTimezone = /Z|[\+\-]\d\d:?\d\d/i, // +00:00 -00:00 +0000 -0000 or Z + parseTokenT = /T/i, // T (ISO seperator) + + // preliminary iso regex + // 0000-00-00 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 + isoRegex = /^\s*\d{4}-\d\d-\d\d(T(\d\d(:\d\d(:\d\d(\.\d\d?\d?)?)?)?)?([\+\-]\d\d:?\d\d)?)?/, + isoFormat = 'YYYY-MM-DDTHH:mm:ssZ', + + // iso time formats and regexes + isoTimes = [ + ['HH:mm:ss.S', /T\d\d:\d\d:\d\d\.\d{1,3}/], + ['HH:mm:ss', /T\d\d:\d\d:\d\d/], + ['HH:mm', /T\d\d:\d\d/], + ['HH', /T\d\d/] + ], + + // timezone chunker "+10:00" > ["10", "00"] or "-1530" > ["-15", "30"] + parseTimezoneChunker = /([\+\-]|\d\d)/gi, + + // getter and setter names + proxyGettersAndSetters = 'Month|Date|Hours|Minutes|Seconds|Milliseconds'.split('|'), + unitMillisecondFactors = { + 'Milliseconds' : 1, + 'Seconds' : 1e3, + 'Minutes' : 6e4, + 'Hours' : 36e5, + 'Days' : 864e5, + 'Months' : 2592e6, + 'Years' : 31536e6 + }, + + // format function strings + formatFunctions = {}, + + // tokens to ordinalize and pad + ordinalizeTokens = 'DDD w M D d'.split(' '), + paddedTokens = 'M D H h m s w'.split(' '), + + /* + * moment.fn.format uses new Function() to create an inlined formatting function. + * Results are a 3x speed boost + * http://jsperf.com/momentjs-cached-format-functions + * + * These strings are appended into a function using replaceFormatTokens and makeFormatFunction + */ + formatTokenFunctions = { + // a = placeholder + // b = placeholder + // t = the current moment being formatted + // v = getValueAtKey function + // o = language.ordinal function + // p = leftZeroFill function + // m = language.meridiem value or function + M : function () { + return this.month() + 1; + }, + MMM : function (format) { + return getValueFromArray("monthsShort", this.month(), this, format); + }, + MMMM : function (format) { + return getValueFromArray("months", this.month(), this, format); + }, + D : function () { + return this.date(); + }, + DDD : function () { + var a = new Date(this.year(), this.month(), this.date()), + b = new Date(this.year(), 0, 1); + return ~~(((a - b) / 864e5) + 1.5); + }, + d : function () { + return this.day(); + }, + dd : function (format) { + return getValueFromArray("weekdaysMin", this.day(), this, format); + }, + ddd : function (format) { + return getValueFromArray("weekdaysShort", this.day(), this, format); + }, + dddd : function (format) { + return getValueFromArray("weekdays", this.day(), this, format); + }, + w : function () { + var a = new Date(this.year(), this.month(), this.date() - this.day() + 5), + b = new Date(a.getFullYear(), 0, 4); + return ~~((a - b) / 864e5 / 7 + 1.5); + }, + YY : function () { + return leftZeroFill(this.year() % 100, 2); + }, + YYYY : function () { + return leftZeroFill(this.year(), 4); + }, + a : function () { + return this.lang().meridiem(this.hours(), this.minutes(), true); + }, + A : function () { + return this.lang().meridiem(this.hours(), this.minutes(), false); + }, + H : function () { + return this.hours(); + }, + h : function () { + return this.hours() % 12 || 12; + }, + m : function () { + return this.minutes(); + }, + s : function () { + return this.seconds(); + }, + S : function () { + return ~~(this.milliseconds() / 100); + }, + SS : function () { + return leftZeroFill(~~(this.milliseconds() / 10), 2); + }, + SSS : function () { + return leftZeroFill(this.milliseconds(), 3); + }, + Z : function () { + var a = -this.zone(), + b = "+"; + if (a < 0) { + a = -a; + b = "-"; + } + return b + leftZeroFill(~~(a / 60), 2) + ":" + leftZeroFill(~~a % 60, 2); + }, + ZZ : function () { + var a = -this.zone(), + b = "+"; + if (a < 0) { + a = -a; + b = "-"; + } + return b + leftZeroFill(~~(10 * a / 6), 4); + } + }; + + function getValueFromArray(key, index, m, format) { + var lang = m.lang(); + return lang[key].call ? lang[key](m, format) : lang[key][index]; + } + + function padToken(func, count) { + return function (a) { + return leftZeroFill(func.call(this, a), count); + }; + } + function ordinalizeToken(func) { + return function (a) { + var b = func.call(this, a); + return b + this.lang().ordinal(b); + }; + } + + while (ordinalizeTokens.length) { + i = ordinalizeTokens.pop(); + formatTokenFunctions[i + 'o'] = ordinalizeToken(formatTokenFunctions[i]); + } + while (paddedTokens.length) { + i = paddedTokens.pop(); + formatTokenFunctions[i + i] = padToken(formatTokenFunctions[i], 2); + } + formatTokenFunctions.DDDD = padToken(formatTokenFunctions.DDD, 3); + + + /************************************ + Constructors + ************************************/ + + + // Moment prototype object + function Moment(date, isUTC, lang) { + this._d = date; + this._isUTC = !!isUTC; + this._a = date._a || null; + this._lang = lang || false; + } + + // Duration Constructor + function Duration(duration) { + var data = this._data = {}, + years = duration.years || duration.y || 0, + months = duration.months || duration.M || 0, + weeks = duration.weeks || duration.w || 0, + days = duration.days || duration.d || 0, + hours = duration.hours || duration.h || 0, + minutes = duration.minutes || duration.m || 0, + seconds = duration.seconds || duration.s || 0, + milliseconds = duration.milliseconds || duration.ms || 0; + + // representation for dateAddRemove + this._milliseconds = milliseconds + + seconds * 1e3 + // 1000 + minutes * 6e4 + // 1000 * 60 + hours * 36e5; // 1000 * 60 * 60 + // Because of dateAddRemove treats 24 hours as different from a + // day when working around DST, we need to store them separately + this._days = days + + weeks * 7; + // It is impossible translate months into days without knowing + // which months you are are talking about, so we have to store + // it separately. + this._months = months + + years * 12; + + // The following code bubbles up values, see the tests for + // examples of what that means. + data.milliseconds = milliseconds % 1000; + seconds += absRound(milliseconds / 1000); + + data.seconds = seconds % 60; + minutes += absRound(seconds / 60); + + data.minutes = minutes % 60; + hours += absRound(minutes / 60); + + data.hours = hours % 24; + days += absRound(hours / 24); + + days += weeks * 7; + data.days = days % 30; + + months += absRound(days / 30); + + data.months = months % 12; + years += absRound(months / 12); + + data.years = years; + + this._lang = false; + } + + + /************************************ + Helpers + ************************************/ + + + function absRound(number) { + if (number < 0) { + return Math.ceil(number); + } else { + return Math.floor(number); + } + } + + // left zero fill a number + // see http://jsperf.com/left-zero-filling for performance comparison + function leftZeroFill(number, targetLength) { + var output = number + ''; + while (output.length < targetLength) { + output = '0' + output; + } + return output; + } + + // helper function for _.addTime and _.subtractTime + function addOrSubtractDurationFromMoment(mom, duration, isAdding) { + var ms = duration._milliseconds, + d = duration._days, + M = duration._months, + currentDate; + + if (ms) { + mom._d.setTime(+mom + ms * isAdding); + } + if (d) { + mom.date(mom.date() + d * isAdding); + } + if (M) { + currentDate = mom.date(); + mom.date(1) + .month(mom.month() + M * isAdding) + .date(Math.min(currentDate, mom.daysInMonth())); + } + } + + // check if is an array + function isArray(input) { + return Object.prototype.toString.call(input) === '[object Array]'; + } + + // compare two arrays, return the number of differences + function compareArrays(array1, array2) { + var len = Math.min(array1.length, array2.length), + lengthDiff = Math.abs(array1.length - array2.length), + diffs = 0, + i; + for (i = 0; i < len; i++) { + if (~~array1[i] !== ~~array2[i]) { + diffs++; + } + } + return diffs + lengthDiff; + } + + // convert an array to a date. + // the array should mirror the parameters below + // note: all values past the year are optional and will default to the lowest possible value. + // [year, month, day , hour, minute, second, millisecond] + function dateFromArray(input, asUTC, hoursOffset, minutesOffset) { + var i, date, forValid = []; + for (i = 0; i < 7; i++) { + forValid[i] = input[i] = (input[i] == null) ? (i === 2 ? 1 : 0) : input[i]; + } + // we store whether we used utc or not in the input array + input[7] = forValid[7] = asUTC; + // if the parser flagged the input as invalid, we pass the value along + if (input[8] != null) { + forValid[8] = input[8]; + } + // add the offsets to the time to be parsed so that we can have a clean array + // for checking isValid + input[3] += hoursOffset || 0; + input[4] += minutesOffset || 0; + date = new Date(0); + if (asUTC) { + date.setUTCFullYear(input[0], input[1], input[2]); + date.setUTCHours(input[3], input[4], input[5], input[6]); + } else { + date.setFullYear(input[0], input[1], input[2]); + date.setHours(input[3], input[4], input[5], input[6]); + } + date._a = forValid; + return date; + } + + // Loads a language definition into the `languages` cache. The function + // takes a key and optionally values. If not in the browser and no values + // are provided, it will load the language file module. As a convenience, + // this function also returns the language values. + function loadLang(key, values) { + var i, m, + parse = []; + + if (!values && hasModule) { + values = require('./lang/' + key); + } + + for (i = 0; i < langConfigProperties.length; i++) { + // If a language definition does not provide a value, inherit + // from English + values[langConfigProperties[i]] = values[langConfigProperties[i]] || + languages.en[langConfigProperties[i]]; + } + + for (i = 0; i < 12; i++) { + m = moment([2000, i]); + parse[i] = new RegExp('^' + (values.months[i] || values.months(m, '')) + + '|^' + (values.monthsShort[i] || values.monthsShort(m, '')).replace('.', ''), 'i'); + } + values.monthsParse = values.monthsParse || parse; + + languages[key] = values; + + return values; + } + + // Determines which language definition to use and returns it. + // + // With no parameters, it will return the global language. If you + // pass in a language key, such as 'en', it will return the + // definition for 'en', so long as 'en' has already been loaded using + // moment.lang. If you pass in a moment or duration instance, it + // will decide the language based on that, or default to the global + // language. + function getLangDefinition(m) { + var langKey = (typeof m === 'string') && m || + m && m._lang || + null; + + return langKey ? (languages[langKey] || loadLang(langKey)) : moment; + } + + + /************************************ + Formatting + ************************************/ + + + function removeFormattingTokens(input) { + if (input.match(/\[.*\]/)) { + return input.replace(/^\[|\]$/g, ""); + } + return input.replace(/\\/g, ""); + } + + function makeFormatFunction(format) { + var array = format.match(formattingTokens), i, length; + + for (i = 0, length = array.length; i < length; i++) { + if (formatTokenFunctions[array[i]]) { + array[i] = formatTokenFunctions[array[i]]; + } else { + array[i] = removeFormattingTokens(array[i]); + } + } + + return function (mom) { + var output = ""; + for (i = 0; i < length; i++) { + output += typeof array[i].call === 'function' ? array[i].call(mom, format) : array[i]; + } + return output; + }; + } + + // format date using native date object + function formatMoment(m, format) { + var i = 5; + + function replaceLongDateFormatTokens(input) { + return m.lang().longDateFormat[input] || input; + } + + while (i-- && localFormattingTokens.test(format)) { + format = format.replace(localFormattingTokens, replaceLongDateFormatTokens); + } + + if (!formatFunctions[format]) { + formatFunctions[format] = makeFormatFunction(format); + } + + return formatFunctions[format](m); + } + + + /************************************ + Parsing + ************************************/ + + + // get the regex to find the next token + function getParseRegexForToken(token) { + switch (token) { + case 'DDDD': + return parseTokenThreeDigits; + case 'YYYY': + return parseTokenFourDigits; + case 'S': + case 'SS': + case 'SSS': + case 'DDD': + return parseTokenOneToThreeDigits; + case 'MMM': + case 'MMMM': + case 'dd': + case 'ddd': + case 'dddd': + case 'a': + case 'A': + return parseTokenWord; + case 'Z': + case 'ZZ': + return parseTokenTimezone; + case 'T': + return parseTokenT; + case 'MM': + case 'DD': + case 'YY': + case 'HH': + case 'hh': + case 'mm': + case 'ss': + case 'M': + case 'D': + case 'd': + case 'H': + case 'h': + case 'm': + case 's': + return parseTokenOneOrTwoDigits; + default : + return new RegExp(token.replace('\\', '')); + } + } + + // function to convert string input to date + function addTimeToArrayFromToken(token, input, datePartArray, config) { + var a, b; + + switch (token) { + // MONTH + case 'M' : // fall through to MM + case 'MM' : + datePartArray[1] = (input == null) ? 0 : ~~input - 1; + break; + case 'MMM' : // fall through to MMMM + case 'MMMM' : + for (a = 0; a < 12; a++) { + if (getLangDefinition().monthsParse[a].test(input)) { + datePartArray[1] = a; + b = true; + break; + } + } + // if we didn't find a month name, mark the date as invalid. + if (!b) { + datePartArray[8] = false; + } + break; + // DAY OF MONTH + case 'D' : // fall through to DDDD + case 'DD' : // fall through to DDDD + case 'DDD' : // fall through to DDDD + case 'DDDD' : + if (input != null) { + datePartArray[2] = ~~input; + } + break; + // YEAR + case 'YY' : + datePartArray[0] = ~~input + (~~input > 70 ? 1900 : 2000); + break; + case 'YYYY' : + datePartArray[0] = ~~Math.abs(input); + break; + // AM / PM + case 'a' : // fall through to A + case 'A' : + config.isPm = ((input + '').toLowerCase() === 'pm'); + break; + // 24 HOUR + case 'H' : // fall through to hh + case 'HH' : // fall through to hh + case 'h' : // fall through to hh + case 'hh' : + datePartArray[3] = ~~input; + break; + // MINUTE + case 'm' : // fall through to mm + case 'mm' : + datePartArray[4] = ~~input; + break; + // SECOND + case 's' : // fall through to ss + case 'ss' : + datePartArray[5] = ~~input; + break; + // MILLISECOND + case 'S' : + case 'SS' : + case 'SSS' : + datePartArray[6] = ~~ (('0.' + input) * 1000); + break; + // TIMEZONE + case 'Z' : // fall through to ZZ + case 'ZZ' : + config.isUTC = true; + a = (input + '').match(parseTimezoneChunker); + if (a && a[1]) { + config.tzh = ~~a[1]; + } + if (a && a[2]) { + config.tzm = ~~a[2]; + } + // reverse offsets + if (a && a[0] === '+') { + config.tzh = -config.tzh; + config.tzm = -config.tzm; + } + break; + } + + // if the input is null, the date is not valid + if (input == null) { + datePartArray[8] = false; + } + } + + // date from string and format string + function makeDateFromStringAndFormat(string, format) { + // This array is used to make a Date, either with `new Date` or `Date.UTC` + // We store some additional data on the array for validation + // datePartArray[7] is true if the Date was created with `Date.UTC` and false if created with `new Date` + // datePartArray[8] is false if the Date is invalid, and undefined if the validity is unknown. + var datePartArray = [0, 0, 1, 0, 0, 0, 0], + config = { + tzh : 0, // timezone hour offset + tzm : 0 // timezone minute offset + }, + tokens = format.match(formattingTokens), + i, parsedInput; + + for (i = 0; i < tokens.length; i++) { + parsedInput = (getParseRegexForToken(tokens[i]).exec(string) || [])[0]; + if (parsedInput) { + string = string.slice(string.indexOf(parsedInput) + parsedInput.length); + } + // don't parse if its not a known token + if (formatTokenFunctions[tokens[i]]) { + addTimeToArrayFromToken(tokens[i], parsedInput, datePartArray, config); + } + } + // handle am pm + if (config.isPm && datePartArray[3] < 12) { + datePartArray[3] += 12; + } + // if is 12 am, change hours to 0 + if (config.isPm === false && datePartArray[3] === 12) { + datePartArray[3] = 0; + } + // return + return dateFromArray(datePartArray, config.isUTC, config.tzh, config.tzm); + } + + // date from string and array of format strings + function makeDateFromStringAndArray(string, formats) { + var output, + inputParts = string.match(parseMultipleFormatChunker) || [], + formattedInputParts, + scoreToBeat = 99, + i, + currentDate, + currentScore; + for (i = 0; i < formats.length; i++) { + currentDate = makeDateFromStringAndFormat(string, formats[i]); + formattedInputParts = formatMoment(new Moment(currentDate), formats[i]).match(parseMultipleFormatChunker) || []; + currentScore = compareArrays(inputParts, formattedInputParts); + if (currentScore < scoreToBeat) { + scoreToBeat = currentScore; + output = currentDate; + } + } + return output; + } + + // date from iso format + function makeDateFromString(string) { + var format = 'YYYY-MM-DDT', + i; + if (isoRegex.exec(string)) { + for (i = 0; i < 4; i++) { + if (isoTimes[i][1].exec(string)) { + format += isoTimes[i][0]; + break; + } + } + return parseTokenTimezone.exec(string) ? + makeDateFromStringAndFormat(string, format + ' Z') : + makeDateFromStringAndFormat(string, format); + } + return new Date(string); + } + + + /************************************ + Relative Time + ************************************/ + + + // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize + function substituteTimeAgo(string, number, withoutSuffix, isFuture, lang) { + var rt = lang.relativeTime[string]; + return (typeof rt === 'function') ? + rt(number || 1, !!withoutSuffix, string, isFuture) : + rt.replace(/%d/i, number || 1); + } + + function relativeTime(milliseconds, withoutSuffix, lang) { + var seconds = round(Math.abs(milliseconds) / 1000), + minutes = round(seconds / 60), + hours = round(minutes / 60), + days = round(hours / 24), + years = round(days / 365), + args = seconds < 45 && ['s', seconds] || + minutes === 1 && ['m'] || + minutes < 45 && ['mm', minutes] || + hours === 1 && ['h'] || + hours < 22 && ['hh', hours] || + days === 1 && ['d'] || + days <= 25 && ['dd', days] || + days <= 45 && ['M'] || + days < 345 && ['MM', round(days / 30)] || + years === 1 && ['y'] || ['yy', years]; + args[2] = withoutSuffix; + args[3] = milliseconds > 0; + args[4] = lang; + return substituteTimeAgo.apply({}, args); + } + + + /************************************ + Top Level Functions + ************************************/ + + + moment = function (input, format) { + if (input === null || input === '') { + return null; + } + var date, + matched; + // parse Moment object + if (moment.isMoment(input)) { + return new Moment(new Date(+input._d), input._isUTC, input._lang); + // parse string and format + } else if (format) { + if (isArray(format)) { + date = makeDateFromStringAndArray(input, format); + } else { + date = makeDateFromStringAndFormat(input, format); + } + // evaluate it as a JSON-encoded date + } else { + matched = aspNetJsonRegex.exec(input); + date = input === undefined ? new Date() : + matched ? new Date(+matched[1]) : + input instanceof Date ? input : + isArray(input) ? dateFromArray(input) : + typeof input === 'string' ? makeDateFromString(input) : + new Date(input); + } + + return new Moment(date); + }; + + // creating with utc + moment.utc = function (input, format) { + if (isArray(input)) { + return new Moment(dateFromArray(input, true), true); + } + // if we don't have a timezone, we need to add one to trigger parsing into utc + if (typeof input === 'string' && !parseTokenTimezone.exec(input)) { + input += ' +0000'; + if (format) { + format += ' Z'; + } + } + return moment(input, format).utc(); + }; + + // creating with unix timestamp (in seconds) + moment.unix = function (input) { + return moment(input * 1000); + }; + + // duration + moment.duration = function (input, key) { + var isDuration = moment.isDuration(input), + isNumber = (typeof input === 'number'), + duration = (isDuration ? input._data : (isNumber ? {} : input)), + ret; + + if (isNumber) { + if (key) { + duration[key] = input; + } else { + duration.milliseconds = input; + } + } + + ret = new Duration(duration); + + if (isDuration) { + ret._lang = input._lang; + } + + return ret; + }; + + // humanizeDuration + // This method is deprecated in favor of the new Duration object. Please + // see the moment.duration method. + moment.humanizeDuration = function (num, type, withSuffix) { + return moment.duration(num, type === true ? null : type).humanize(type === true ? true : withSuffix); + }; + + // version number + moment.version = VERSION; + + // default format + moment.defaultFormat = isoFormat; + + // This function will load languages and then set the global language. If + // no arguments are passed in, it will simply return the current global + // language key. + moment.lang = function (key, values) { + var i; + + if (!key) { + return currentLanguage; + } + if (values || !languages[key]) { + loadLang(key, values); + } + if (languages[key]) { + // deprecated, to get the language definition variables, use the + // moment.fn.lang method or the getLangDefinition function. + for (i = 0; i < langConfigProperties.length; i++) { + moment[langConfigProperties[i]] = languages[key][langConfigProperties[i]]; + } + moment.monthsParse = languages[key].monthsParse; + currentLanguage = key; + } + }; + + // returns language data + moment.langData = getLangDefinition; + + // compare moment object + moment.isMoment = function (obj) { + return obj instanceof Moment; + }; + + // for typechecking Duration objects + moment.isDuration = function (obj) { + return obj instanceof Duration; + }; + + // Set default language, other languages will inherit from English. + moment.lang('en', { + months : "January_February_March_April_May_June_July_August_September_October_November_December".split("_"), + monthsShort : "Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"), + weekdays : "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"), + weekdaysShort : "Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"), + weekdaysMin : "Su_Mo_Tu_We_Th_Fr_Sa".split("_"), + longDateFormat : { + LT : "h:mm A", + L : "MM/DD/YYYY", + LL : "MMMM D YYYY", + LLL : "MMMM D YYYY LT", + LLLL : "dddd, MMMM D YYYY LT" + }, + meridiem : function (hours, minutes, isLower) { + if (hours > 11) { + return isLower ? 'pm' : 'PM'; + } else { + return isLower ? 'am' : 'AM'; + } + }, + calendar : { + sameDay : '[Today at] LT', + nextDay : '[Tomorrow at] LT', + nextWeek : 'dddd [at] LT', + lastDay : '[Yesterday at] LT', + lastWeek : '[last] dddd [at] LT', + sameElse : 'L' + }, + relativeTime : { + future : "in %s", + past : "%s ago", + s : "a few seconds", + m : "a minute", + mm : "%d minutes", + h : "an hour", + hh : "%d hours", + d : "a day", + dd : "%d days", + M : "a month", + MM : "%d months", + y : "a year", + yy : "%d years" + }, + ordinal : function (number) { + var b = number % 10; + return (~~ (number % 100 / 10) === 1) ? 'th' : + (b === 1) ? 'st' : + (b === 2) ? 'nd' : + (b === 3) ? 'rd' : 'th'; + } + }); + + + /************************************ + Moment Prototype + ************************************/ + + + moment.fn = Moment.prototype = { + + clone : function () { + return moment(this); + }, + + valueOf : function () { + return +this._d; + }, + + unix : function () { + return Math.floor(+this._d / 1000); + }, + + toString : function () { + return this._d.toString(); + }, + + toDate : function () { + return this._d; + }, + + toArray : function () { + var m = this; + return [ + m.year(), + m.month(), + m.date(), + m.hours(), + m.minutes(), + m.seconds(), + m.milliseconds(), + !!this._isUTC + ]; + }, + + isValid : function () { + if (this._a) { + // if the parser finds that the input is invalid, it sets + // the eighth item in the input array to false. + if (this._a[8] != null) { + return !!this._a[8]; + } + return !compareArrays(this._a, (this._a[7] ? moment.utc(this._a) : moment(this._a)).toArray()); + } + return !isNaN(this._d.getTime()); + }, + + utc : function () { + this._isUTC = true; + return this; + }, + + local : function () { + this._isUTC = false; + return this; + }, + + format : function (inputString) { + return formatMoment(this, inputString ? inputString : moment.defaultFormat); + }, + + add : function (input, val) { + var dur = val ? moment.duration(+val, input) : moment.duration(input); + addOrSubtractDurationFromMoment(this, dur, 1); + return this; + }, + + subtract : function (input, val) { + var dur = val ? moment.duration(+val, input) : moment.duration(input); + addOrSubtractDurationFromMoment(this, dur, -1); + return this; + }, + + diff : function (input, val, asFloat) { + var inputMoment = this._isUTC ? moment(input).utc() : moment(input).local(), + zoneDiff = (this.zone() - inputMoment.zone()) * 6e4, + diff = this._d - inputMoment._d - zoneDiff, + year = this.year() - inputMoment.year(), + month = this.month() - inputMoment.month(), + date = this.date() - inputMoment.date(), + output; + if (val === 'months') { + output = year * 12 + month + date / 30; + } else if (val === 'years') { + output = year + (month + date / 30) / 12; + } else { + output = val === 'seconds' ? diff / 1e3 : // 1000 + val === 'minutes' ? diff / 6e4 : // 1000 * 60 + val === 'hours' ? diff / 36e5 : // 1000 * 60 * 60 + val === 'days' ? diff / 864e5 : // 1000 * 60 * 60 * 24 + val === 'weeks' ? diff / 6048e5 : // 1000 * 60 * 60 * 24 * 7 + diff; + } + return asFloat ? output : round(output); + }, + + from : function (time, withoutSuffix) { + return moment.duration(this.diff(time)).lang(this._lang).humanize(!withoutSuffix); + }, + + fromNow : function (withoutSuffix) { + return this.from(moment(), withoutSuffix); + }, + + calendar : function () { + var diff = this.diff(moment().sod(), 'days', true), + calendar = this.lang().calendar, + allElse = calendar.sameElse, + format = diff < -6 ? allElse : + diff < -1 ? calendar.lastWeek : + diff < 0 ? calendar.lastDay : + diff < 1 ? calendar.sameDay : + diff < 2 ? calendar.nextDay : + diff < 7 ? calendar.nextWeek : allElse; + return this.format(typeof format === 'function' ? format.apply(this) : format); + }, + + isLeapYear : function () { + var year = this.year(); + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + }, + + isDST : function () { + return (this.zone() < moment([this.year()]).zone() || + this.zone() < moment([this.year(), 5]).zone()); + }, + + day : function (input) { + var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay(); + return input == null ? day : + this.add({ d : input - day }); + }, + + startOf: function (val) { + // the following switch intentionally omits break keywords + // to utilize falling through the cases. + switch (val.replace(/s$/, '')) { + case 'year': + this.month(0); + /* falls through */ + case 'month': + this.date(1); + /* falls through */ + case 'day': + this.hours(0); + /* falls through */ + case 'hour': + this.minutes(0); + /* falls through */ + case 'minute': + this.seconds(0); + /* falls through */ + case 'second': + this.milliseconds(0); + /* falls through */ + } + return this; + }, + + endOf: function (val) { + return this.startOf(val).add(val.replace(/s?$/, 's'), 1).subtract('ms', 1); + }, + + sod: function () { + return this.clone().startOf('day'); + }, + + eod: function () { + // end of day = start of day plus 1 day, minus 1 millisecond + return this.clone().endOf('day'); + }, + + zone : function () { + return this._isUTC ? 0 : this._d.getTimezoneOffset(); + }, + + daysInMonth : function () { + return moment.utc([this.year(), this.month() + 1, 0]).date(); + }, + + // If passed a language key, it will set the language for this + // instance. Otherwise, it will return the language configuration + // variables for this instance. + lang : function (lang) { + if (lang === undefined) { + return getLangDefinition(this); + } else { + this._lang = lang; + return this; + } + } + }; + + // helper for adding shortcuts + function makeGetterAndSetter(name, key) { + moment.fn[name] = function (input) { + var utc = this._isUTC ? 'UTC' : ''; + if (input != null) { + this._d['set' + utc + key](input); + return this; + } else { + return this._d['get' + utc + key](); + } + }; + } + + // loop through and add shortcuts (Month, Date, Hours, Minutes, Seconds, Milliseconds) + for (i = 0; i < proxyGettersAndSetters.length; i ++) { + makeGetterAndSetter(proxyGettersAndSetters[i].toLowerCase(), proxyGettersAndSetters[i]); + } + + // add shortcut for year (uses different syntax than the getter/setter 'year' == 'FullYear') + makeGetterAndSetter('year', 'FullYear'); + + + /************************************ + Duration Prototype + ************************************/ + + + moment.duration.fn = Duration.prototype = { + weeks : function () { + return absRound(this.days() / 7); + }, + + valueOf : function () { + return this._milliseconds + + this._days * 864e5 + + this._months * 2592e6; + }, + + humanize : function (withSuffix) { + var difference = +this, + rel = this.lang().relativeTime, + output = relativeTime(difference, !withSuffix, this.lang()), + fromNow = difference <= 0 ? rel.past : rel.future; + + if (withSuffix) { + if (typeof fromNow === 'function') { + output = fromNow(output); + } else { + output = fromNow.replace(/%s/i, output); + } + } + + return output; + }, + + lang : moment.fn.lang + }; + + function makeDurationGetter(name) { + moment.duration.fn[name] = function () { + return this._data[name]; + }; + } + + function makeDurationAsGetter(name, factor) { + moment.duration.fn['as' + name] = function () { + return +this / factor; + }; + } + + for (i in unitMillisecondFactors) { + if (unitMillisecondFactors.hasOwnProperty(i)) { + makeDurationAsGetter(i, unitMillisecondFactors[i]); + makeDurationGetter(i.toLowerCase()); + } + } + + makeDurationAsGetter('Weeks', 6048e5); + + + /************************************ + Exposing Moment + ************************************/ + + + // CommonJS module is defined + if (hasModule) { + module.exports = moment; + } + /*global ender:false */ + if (typeof ender === 'undefined') { + // here, `this` means `window` in the browser, or `global` on the server + // add `moment` as a global object via a string identifier, + // for Closure Compiler "advanced" mode + this['moment'] = moment; + } + /*global define:false */ + if (typeof define === "function" && define.amd) { + define("moment", [], function () { + return moment; + }); + } +}).call(this);