├── Gemfile ├── lib ├── sidekiq-statistic.rb └── sidekiq │ ├── statistic │ ├── version.rb │ ├── views.rb │ ├── configuration.rb │ ├── middleware.rb │ ├── locales │ │ ├── jp.yml │ │ ├── en.yml │ │ ├── fr.yml │ │ ├── pl.yml │ │ ├── de.yml │ │ ├── it.yml │ │ ├── es.yml │ │ ├── uk.yml │ │ ├── ru.yml │ │ └── pt-br.yml │ ├── statistic │ │ ├── charts.rb │ │ ├── metric.rb │ │ ├── metrics │ │ │ ├── cache_keys.rb │ │ │ └── store.rb │ │ ├── runtime.rb │ │ ├── realtime.rb │ │ └── workers.rb │ ├── web_api_extension.rb │ ├── helpers │ │ ├── date.rb │ │ └── color.rb │ ├── views │ │ ├── styles │ │ │ ├── sidekiq-statistic-light.css │ │ │ ├── common.css │ │ │ ├── sidekiq-statistic-dark.css │ │ │ └── ui-datepicker.css │ │ ├── realtime.erb │ │ ├── worker.erb │ │ ├── statistic.erb │ │ ├── realtime_statistic.js │ │ └── statistic.js │ ├── web_extension.rb │ └── base.rb │ └── statistic.rb ├── .gitignore ├── test ├── helpers │ └── logfile.log ├── test_sidekiq │ ├── history.rb │ ├── charts_test.rb │ ├── helpers │ │ ├── color_test.rb │ │ └── date_test.rb │ ├── configuration_test.rb │ ├── realtime_test.rb │ ├── web_api_extension_test.rb │ ├── runtime_test.rb │ ├── base_statistic_test.rb │ ├── web_extension_test.rb │ ├── middleware_test.rb │ └── statistic_test.rb ├── statistic │ ├── metrics │ │ ├── store_test.rb │ │ └── cache_keys_test.rb │ ├── views_test.rb │ └── metric_test.rb └── minitest_helper.rb ├── .travis.yml ├── Rakefile ├── bin └── console ├── LICENSE.txt ├── .github └── workflows │ └── ci.yml ├── sidekiq-statistic.gemspec ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/sidekiq-statistic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sidekiq/statistic' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /dump.rdb 11 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | VERSION = '1.5.1' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/helpers/logfile.log: -------------------------------------------------------------------------------- 1 | HistoryWorker (done) JID-36a2b8bd6a370834f979f5ee 2 | HistoryWorker (start) 3 | AnotherHistoryWorker (fail) JID-79d0a83b5eb21f7d0ae49f99 4 | HistoryWorker (fail) JID-219f4e9b9013bfec76faa270 5 | -------------------------------------------------------------------------------- /test/test_sidekiq/history.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | class TestSidekiq::Statistic < Minitest::Test 6 | def test_that_it_has_a_version_number 7 | refute_nil ::Sidekiq::Statistic::VERSION 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/statistic/metrics/store_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | describe Sidekiq::Statistic::Metrics::Store do 6 | # The behavior from this class is fully exercised on the 7 | # integration test below: 8 | # 9 | # test/test_sidekiq/middleware_test.rb 10 | end 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | cache: bundler 4 | services: 5 | - redis-server 6 | before_install: 7 | - gem install bundler 8 | - gem update bundler 9 | rvm: 10 | - 2.3.8 11 | - 2.4.5 12 | - 2.5.3 13 | - jruby-head 14 | - rbx-2 15 | matrix: 16 | allow_failures: 17 | - rvm: rbx-2 18 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/gem_tasks' 5 | require 'rake/testtask' 6 | 7 | task default: :test 8 | 9 | Rake::TestTask.new do |t| 10 | t.libs << 'lib' 11 | t.libs << 'test' 12 | t.test_files = FileList['test/**/*_test.rb'] 13 | t.verbose = true 14 | end 15 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | module Views 6 | PATH = File.join(File.expand_path('..', __FILE__), 'views') 7 | 8 | def self.require_assets(name) 9 | path = File.join(PATH, name) 10 | 11 | [File.read(path)] 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "sidekiq/statistic" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /test/test_sidekiq/charts_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | module Sidekiq 6 | module Statistic 7 | describe 'Charts' do 8 | before { Sidekiq.redis(&:flushdb) } 9 | 10 | let(:chart) { Sidekiq::Statistic::Charts.new(1) } 11 | 12 | describe '#dates' do 13 | it 'returns array with all days' do 14 | days = chart.dates 15 | assert_equal Time.now.utc.to_date.to_s, days.last 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | class Configuration 6 | attr_accessor :max_timelist_length 7 | 8 | def initialize 9 | @max_timelist_length = 250_000 10 | end 11 | 12 | def max_timelist_length=(value) 13 | if(value.is_a?(Numeric) && value > 0) 14 | @max_timelist_length = value 15 | else 16 | raise ArgumentError, 'max_timelist_length must be a positive number' 17 | end 18 | end 19 | 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/statistic/views_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | describe Sidekiq::Statistic::Views do 6 | before do 7 | @views = Sidekiq::Statistic::Views 8 | end 9 | 10 | describe '.require_assets' do 11 | it 'returns content if file exists' do 12 | content = @views.require_assets('styles/common.css').first 13 | 14 | _(content).must_match(/=== COMMON ===/) 15 | end 16 | 17 | it 'raises an error if file does not exist' do 18 | assert_raises Errno::ENOENT do 19 | @views.require_assets('styles/abc.css') 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | class Middleware 6 | def call(worker, message, queue) 7 | class_name = message['wrapped'] || worker.class.to_s 8 | 9 | metric = Metric.for(class_name: class_name, arguments: message['args']) 10 | metric.queue = message['queue'] || queue 11 | metric.start 12 | 13 | yield 14 | rescue => e 15 | metric.fails! 16 | 17 | raise e 18 | ensure 19 | metric.finish 20 | 21 | Metrics::Store.call(metric) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/jp.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | jp: # <---- change this to your locale code 3 | Queue: キュー 4 | Show/Hide: 表示/非表示 5 | Index: 索引 6 | RealtimeStatistic: リアルタイム統計 7 | Statistic: 統計 8 | Start: 開始 9 | End: 終了 10 | Stop: やめる 11 | Failed: 失敗 12 | Passed: 成功 13 | WorkersTable: ワーカー表 14 | Worker: ワーカー 15 | Date: 日付 16 | Success: 成功 17 | Failure: 失敗 18 | Total: 合計 19 | TimeSec: 時間(秒) 20 | AverageSec: 平均時間(秒) 21 | MinTimeSec: 最小時間(秒) 22 | MaxTimeSec: 最長時間(秒) 23 | LastJobStatus: 最後の状態 24 | WorkerInformation: '%{worker}の情報' 25 | InformationTable: 情報表 26 | WorkerTablePerDay: ワーカー表 (日) 27 | LastRun: 最後の実行 28 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/statistic/charts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | class Charts < Base 6 | def information_for(type) 7 | worker_names.reverse.map.with_index do |worker, i| 8 | index = "data#{i}" 9 | dataset = [index] + statistic_for(worker).map { |val| val.fetch(type, 0) } 10 | 11 | { 12 | worker: worker, 13 | dataset: dataset, 14 | color: color(worker) 15 | } 16 | end 17 | end 18 | 19 | def dates 20 | @dates ||= statistic_hash.flat_map(&:keys) 21 | end 22 | 23 | private 24 | 25 | def color(phrase) 26 | Helpers::Color.for(phrase, format: :hex) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/en.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | en: # <---- change this to your locale code 3 | Queue: Queue 4 | Show/Hide: Show / Hide 5 | Index: Index 6 | RealtimeStatistic: Realtime Statistic 7 | Statistic: Statistic 8 | Start: Start 9 | End: End 10 | Stop: Stop 11 | Failed: Failed 12 | Passed: Passed 13 | WorkersTable: Workers table 14 | Worker: Worker 15 | Date: Date 16 | Success: Success 17 | Failure: Failure 18 | Total: Total 19 | TimeSec: Time(sec) 20 | AverageSec: Average(sec) 21 | MinTimeSec: Min time(sec) 22 | MaxTimeSec: Max time(sec) 23 | LastJobStatus: Last Job Status 24 | WorkerInformation: '%{worker} information' 25 | InformationTable: Information table 26 | WorkerTablePerDay: Worker table (per day) 27 | LastRun: Last run 28 | -------------------------------------------------------------------------------- /test/test_sidekiq/helpers/color_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | module Sidekiq 6 | module Statistic 7 | module Helpers 8 | describe Color do 9 | include Rack::Test::Methods 10 | 11 | let(:phrase) { 'HistoryWorker' } 12 | 13 | describe 'when passes rgb format' do 14 | describe '.for' do 15 | it 'returns rgb format' do 16 | assert_equal '102,63,28', Color.for(phrase, format: :rgb) 17 | end 18 | end 19 | end 20 | 21 | describe 'when passes hex format' do 22 | describe '.for' do 23 | it 'return hex format' do 24 | assert_equal '#663f1c', Color.for(phrase, format: :hex) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/fr.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | fr: # <---- change this to your locale code 3 | Queue: Queue 4 | Show/Hide: Afficher / Masquer 5 | Index: Indice 6 | RealtimeStatistic: Statistique en temps réel 7 | Statistic: Statistique 8 | Start: Début 9 | End: Fin 10 | Stop: Arrêtez 11 | Failed: Échoué 12 | Passed: Passé 13 | WorkersTable: Workers Table 14 | Worker: Worker 15 | Date: Date 16 | Success: Succès 17 | Failure: Échec 18 | Total: Total 19 | TimeSec: Temps (sec) 20 | AverageSec: Moyenne (sec) 21 | MinTimeSec: Min temps (sec) 22 | MaxTimeSec: Max temps (sec) 23 | LastJobStatus: Dernier statut job 24 | WorkerInformation: '%{worker} information' 25 | InformationTable: Information table 26 | WorkerTablePerDay: Worker Table (par jour) 27 | LastRun: Dernier Exécution 28 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/pl.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | pl: # <---- change this to your locale code 3 | Queue: Kolejka 4 | Show/Hide: Pokazać / Ukryć 5 | Index: Indeks 6 | RealtimeStatistic: Statystyka w czasie rzeczywistym 7 | Statistic: Statystyka 8 | Start: Start 9 | End: Koniec 10 | Failed: Nie udało się 11 | Passed: Wykonano 12 | WorkersTable: Lista Workeròw 13 | Worker: Worker 14 | Date: Data 15 | Success: Sukces 16 | Failure: Porażka 17 | Total: Suma 18 | TimeSec: Czas (sec) 19 | AverageSec: Średni (sec) 20 | MinTimeSec: Min czas (sec) 21 | MaxTimeSec: Max czas (sec) 22 | LastJobStatus: Status ostatniego joba 23 | WorkerInformation: 'Informacja o %{worker}' 24 | InformationTable: Tablica informacyjna 25 | WorkerTablePerDay: Lista Workeròw (dziennie) 26 | LastRun: Ostatnie użycie 27 | -------------------------------------------------------------------------------- /test/test_sidekiq/configuration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | module Sidekiq 6 | module Statistic 7 | describe 'Configuration' do 8 | describe '#max_timelist_length=' do 9 | it 'assigns correct value' do 10 | config = Configuration.new 11 | config.max_timelist_length = 12345 12 | assert_equal 12345, config.max_timelist_length 13 | end 14 | 15 | it 'raises error if value is not a number' do 16 | config = Configuration.new 17 | assert_raises(ArgumentError) { config.max_timelist_length = nil } 18 | end 19 | 20 | it 'raises error if value is less than 1' do 21 | config = Configuration.new 22 | assert_raises(ArgumentError) { config.max_timelist_length = 0 } 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/de.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | de: # <---- change this to your locale code 3 | Queue: Queue 4 | Show/Hide: Anzeigen / Ausblenden 5 | Index: Übersicht 6 | RealtimeStatistic: Echtzeitstatistik 7 | Statistic: Statistik 8 | Start: Beginn 9 | End: Ende 10 | Stop: Halt 11 | Failed: Fehlgeschlagen 12 | Passed: Erfolgreich 13 | WorkersTable: Arbeiter-Tabelle 14 | Worker: Arbeiter 15 | Date: Datum 16 | Success: Erfolg 17 | Failure: Fehler 18 | Total: Gesamt 19 | TimeSec: Zeit(sek) 20 | AverageSec: Durchschnitt(sek) 21 | MinTimeSec: Min. Zeit(sek) 22 | MaxTimeSec: Max. Zeit(sek) 23 | LastJobStatus: Letzter Job Status 24 | WorkerInformation: '%{worker} Information' 25 | InformationTable: Info-Tabelle 26 | WorkerTablePerDay: Arbeiter-Tabelle (pro Tag) 27 | LastRun: Letzte Ausführung 28 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/it.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | it: # <---- change this to your locale code 3 | Queue: Fila 4 | Show/Hide: Mostrare / Nascondere 5 | Index: Indice 6 | RealtimeStatistic: Statistiche in Tempo Reale 7 | Statistic: Statistiche 8 | Start: Inizio 9 | End: Fine 10 | Stop: Fermare 11 | Failed: Fallito 12 | Passed: Passato 13 | WorkersTable: Tabella Worker 14 | Worker: Worker 15 | Date: Data 16 | Success: Successo 17 | Failure: Fallito 18 | Total: Totale 19 | TimeSec: Tempo (sec) 20 | AverageSec: Media (sec) 21 | MinTimeSec: Minimo (sec) 22 | MaxTimeSec: Massimo (sec) 23 | LastJobStatus: Stato Ultimo Job 24 | WorkerInformation: 'Informazioni %{worker}' 25 | InformationTable: Tabella Informazioni 26 | WorkerTablePerDay: Tabella Worker (per giorno) 27 | LastRun: Ultima Esecuzione 28 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/es.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | es: # <---- change this to your locale code 3 | Queue: Fila 4 | Show/Hide: Mostrar / Esconder 5 | Index: Índice 6 | RealtimeStatistic: Estadística en tiempo real 7 | Statistic: Estadística 8 | Start: Iniciar 9 | End: Fin 10 | Stop: Detener 11 | Failed: Fracasado 12 | Passed: Exitoso 13 | WorkersTable: Tabla de Workers 14 | Worker: Worker 15 | Date: Fecha 16 | Success: Exito 17 | Failure: Fracaso 18 | Total: Total 19 | TimeSec: Tiempo(s) 20 | AverageSec: Promedio(s) 21 | MinTimeSec: Tiempo Mínimo(s) 22 | MaxTimeSec: Tiempo Máximo(s) 23 | LastJobStatus: Status del último trabajo 24 | WorkerInformation: 'Información del %{worker}' 25 | InformationTable: Tabla de información 26 | WorkerTablePerDay: Tabla de Worker (por día) 27 | LastRun: Última ejecución 28 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/uk.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | uk: # <---- change this to your locale code 3 | Queue: Черга 4 | Show/Hide: шоу / ховатися 5 | Index: Головна 6 | RealtimeStatistic: Статистика у реальному часі 7 | Statistic: Статистика 8 | Start: Початок 9 | End: Кінець 10 | Stop: Стоп 11 | Failed: Невиконані 12 | Passed: Виконані 13 | WorkersTable: Таблиця обробників 14 | Worker: Обробник 15 | Date: Дата 16 | Success: Успішно 17 | Failure: Невдало 18 | Total: Сумарно 19 | TimeSec: Час(сек) 20 | AverageSec: Середнє(сек) 21 | MinTimeSec: Мін. час(сек) 22 | MaxTimeSec: Макс. час(сек) 23 | LastJobStatus: Стан останнього обробника 24 | WorkerInformation: 'Інформація про %{worker}' 25 | InformationTable: Таблиця з інформацією 26 | WorkerTablePerDay: Таблиця обробника (по днях) 27 | LastRun: Останній запуск 28 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/ru.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | ru: # <---- change this to your locale code 3 | Queue: Очередь 4 | Show/Hide: шоу / скрывать 5 | Index: Главная 6 | RealtimeStatistic: Статистика в реальном времени 7 | Statistic: Статистика 8 | Start: Начало 9 | End: Конец 10 | Stop: Cтоп 11 | Failed: Не выполненные 12 | Passed: Выполненные 13 | WorkersTable: Таблица воркеров 14 | Worker: Воркер 15 | Date: Дата 16 | Success: Успешно 17 | Failure: Неудачно 18 | Total: Суммарно 19 | TimeSec: Время(сек) 20 | AverageSec: Среднее время(сек) 21 | MinTimeSec: 'Мин. время(сек)' 22 | MaxTimeSec: 'Макс. время(сек)' 23 | LastJobStatus: Статус последнего джоба 24 | WorkerInformation: 'Информация о %{worker}' 25 | InformationTable: Таблица с информацией 26 | WorkerTablePerDay: Таблица воркера (по дням) 27 | LastRun: Последний запуск 28 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/web_api_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | module Sidekiq 6 | module Statistic 7 | module WebApiExtension 8 | def self.registered(app) 9 | app.helpers Helpers::Color 10 | app.helpers Helpers::Date 11 | 12 | app.before '/api/*' do 13 | type = :json 14 | end 15 | 16 | app.get '/api/statistic.json' do 17 | statistic = Sidekiq::Statistic::Workers.new(*calculate_date_range(params)) 18 | Sidekiq.dump_json(workers: statistic.display) 19 | end 20 | 21 | app.get '/api/statistic/:worker.json' do 22 | worker_statistic = 23 | Sidekiq::Statistic::Workers 24 | .new(*calculate_date_range(params)) 25 | .display_per_day(params[:worker]) 26 | 27 | Sidekiq.dump_json(days: worker_statistic) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/locales/pt-br.yml: -------------------------------------------------------------------------------- 1 | # elements like %{queue} are variables and should not be translated 2 | pt-br: # <---- change this to your locale code 3 | Queue: Fila 4 | Show/Hide: Mostrar / Esconder 5 | Index: Índice 6 | RealtimeStatistic: Estatística em tempo real 7 | Statistic: Estatística 8 | Start: Iniciar 9 | End: Fim 10 | Stop: Parar 11 | Failed: Falhou 12 | Passed: Passou 13 | WorkersTable: Tabela de Workers 14 | Worker: Worker 15 | Date: Data 16 | Success: Successo 17 | Failure: Falha 18 | Total: Total 19 | TimeSec: Tempo(s) 20 | AverageSec: Média(s) 21 | MinTimeSec: Tempo Min(s) 22 | MaxTimeSec: Tempo Max(s) 23 | LastJobStatus: Status da última execução 24 | WorkerInformation: 'Informações do %{worker}' 25 | InformationTable: Tabela de informação 26 | WorkerTablePerDay: Tabela do Worker (por dia) 27 | LastRun: Última execução 28 | date: 29 | formats: 30 | default: "%d/%m/%Y" 31 | datetime: "%d/%m/%Y - %T" 32 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/helpers/date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | module Helpers 6 | module Date 7 | DEFAULT_DAYS = 20 8 | 9 | def format_date(date_to_format, format = nil) 10 | time = date_to_format ? convert_to_date_object(date_to_format) : Time.now 11 | time.strftime(date_format(format)) 12 | end 13 | 14 | def calculate_date_range(params) 15 | if params['dateFrom'] && params['dateTo'] 16 | from = ::Date.parse(params['dateFrom']) 17 | to = ::Date.parse(params['dateTo']) 18 | 19 | [(to - from).to_i, to] 20 | else 21 | [DEFAULT_DAYS] 22 | end 23 | end 24 | 25 | module_function 26 | 27 | def date_format(format = nil) 28 | get_locale.dig('date', 'formats', format || 'default') || '%m/%d/%Y' 29 | end 30 | 31 | def convert_to_date_object(date) 32 | date.is_a?(String) ? Time.parse(date) : Time.at(date) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Anton Davydov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/helpers/color.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | module Helpers 6 | module Color 7 | class << self 8 | BASE = 16 9 | LIMIT_NUMBER_TO_AVOID_WHITES = 215 10 | 11 | def for(phrase, format: :rgb) 12 | return hex(phrase) if format == :hex 13 | 14 | rgb(phrase) 15 | end 16 | 17 | private 18 | 19 | def rgb(phrase) 20 | digested_unique_color(phrase).join(',') 21 | end 22 | 23 | def hex(phrase) 24 | '#' + digested_unique_color(phrase).map do |number| 25 | number.to_s(BASE).rjust(2, '0') 26 | end.join 27 | end 28 | 29 | def digested_unique_color(phrase) 30 | Digest::MD5.hexdigest(phrase)[0..5] 31 | .scan(/../) 32 | .map(&method(:hex_pair_to_number)) 33 | end 34 | 35 | def hex_pair_to_number(pair) 36 | pair.to_i(BASE) % LIMIT_NUMBER_TO_AVOID_WHITES 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/minitest_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 4 | 5 | Encoding.default_external = Encoding::UTF_8 6 | Encoding.default_internal = Encoding::UTF_8 7 | 8 | require 'minitest/autorun' 9 | require 'minitest/spec' 10 | require 'minitest/mock' 11 | 12 | require 'rack/test' 13 | 14 | require 'sidekiq' 15 | require 'sidekiq-statistic' 16 | 17 | Sidekiq.logger.level = Logger::ERROR 18 | 19 | class HistoryWorker 20 | include Sidekiq::Worker 21 | end 22 | 23 | class HistoryWorkerWithQueue 24 | include Sidekiq::Worker 25 | sidekiq_options queue: :new 26 | end 27 | 28 | class OtherHistoryWorker 29 | include Sidekiq::Worker 30 | end 31 | 32 | class ActiveJobWrapper 33 | include Sidekiq::Worker 34 | end 35 | 36 | module Nested 37 | class HistoryWorker 38 | include Sidekiq::Worker 39 | end 40 | end 41 | 42 | def travel_to(time) 43 | Time.stub :now, time do 44 | yield 45 | end 46 | end 47 | 48 | def middlewared(worker_class = HistoryWorker, msg = {}) 49 | middleware = Sidekiq::Statistic::Middleware.new 50 | middleware.call worker_class.new, msg, 'default' do 51 | yield 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: build 9 | 10 | on: [pull_request, push] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-20.04 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: 19 | - '3.1' 20 | - '3.0' 21 | - '2.7' 22 | - '2.6' 23 | - '2.5' 24 | - ruby-head 25 | - jruby-head 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | bundler-cache: true 32 | - run: bundle exec rake 33 | 34 | services: 35 | redis: 36 | image: redis:alpine 37 | options: >- 38 | --health-cmd "redis-cli ping" 39 | --health-interval 10s 40 | --health-timeout 5s 41 | --health-retries 5 42 | ports: 43 | - 6379:6379 44 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/statistic/metric.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | class Metric 6 | JOB_MAILER_ID = 'ActionMailer::DeliveryJob' 7 | STATUSES = { 8 | success: :passed, 9 | failure: :failed 10 | }.freeze 11 | 12 | def self.for(class_name:, arguments: []) 13 | if class_name == JOB_MAILER_ID 14 | clazz_from_mailer = arguments 15 | .first['arguments'] 16 | .first 17 | 18 | new(clazz_from_mailer) 19 | else 20 | new(class_name) 21 | end 22 | end 23 | 24 | attr_accessor :queue, :status 25 | attr_reader :class_name, :finished_at 26 | 27 | def initialize(class_name) 28 | @class_name = class_name 29 | @status = STATUSES[:success] 30 | @queue = 'default' 31 | 32 | Time.now.utc.tap do |t| 33 | @started_at = t 34 | @finished_at = t 35 | end 36 | end 37 | 38 | def fails! 39 | @status = STATUSES[:failure] 40 | end 41 | 42 | def start 43 | @started_at = Time.now.utc 44 | end 45 | 46 | def finish 47 | @finished_at = Time.now.utc 48 | end 49 | 50 | def duration 51 | (@finished_at - @started_at).to_f.round(3) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/statistic/metrics/cache_keys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | module Metrics 6 | class CacheKeys 7 | def initialize(metric) 8 | @metric = metric 9 | end 10 | 11 | def status 12 | format([key, metric.status]) 13 | end 14 | 15 | def class_name 16 | format([metric.status, metric.class_name]) 17 | end 18 | 19 | def last_job_status 20 | format([key, 'last_job_status']) 21 | end 22 | 23 | def last_time 24 | format([key, 'last_time']) 25 | end 26 | 27 | def queue 28 | format([key, 'queue']) 29 | end 30 | 31 | def timeslist 32 | format([key, 'timeslist']) 33 | end 34 | 35 | def realtime 36 | format([Store::REDIS_HASH, 'realtime', metric.finished_at.utc.sec]) 37 | end 38 | 39 | private 40 | 41 | attr_reader :metric 42 | 43 | def key 44 | datetime = metric.finished_at.utc 45 | 46 | format( 47 | [ 48 | datetime.strftime('%Y-%m-%d'), metric.class_name 49 | ] 50 | ) 51 | end 52 | 53 | def format(arr) 54 | arr.join(':') 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /sidekiq-statistic.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'sidekiq/statistic/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = 'sidekiq-statistic' 8 | gem.version = Sidekiq::Statistic::VERSION 9 | gem.authors = ['Anton Davydov'] 10 | gem.email = ['antondavydov.o@gmail.com'] 11 | 12 | gem.summary = %q{See statistic about your workers (GSoC project)} 13 | gem.description = %q{See statistic about your workers (GSoC project)} 14 | gem.homepage = "https://github.com/davydovanton/sidekiq-statistic" 15 | gem.license = 'MIT' 16 | 17 | gem.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|images)/}) } 18 | gem.bindir = 'exe' 19 | gem.executables = gem.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | gem.require_paths = ['lib'] 21 | 22 | gem.add_dependency 'sidekiq', '>= 5.0' 23 | gem.add_dependency 'tilt', '~> 2.0' 24 | 25 | gem.add_development_dependency 'rake', '~> 13.0' 26 | gem.add_development_dependency 'mocha', '~> 0' 27 | gem.add_development_dependency 'rack-test', '~> 0' 28 | gem.add_development_dependency 'rack', '~> 1.6.4' 29 | gem.add_development_dependency 'minitest', '~> 5.0', '>= 5.0.7' 30 | gem.add_development_dependency 'minitest-utils', '~> 0' 31 | end 32 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/statistic/runtime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | class Runtime 6 | def initialize(redis_statistic, worker, values = nil) 7 | @redis_statistic = redis_statistic 8 | @worker = worker 9 | @values = values 10 | end 11 | 12 | def values_hash 13 | { 14 | last: last_runtime, 15 | max: max_runtime.round(3), 16 | min: min_runtime.round(3), 17 | average: average_runtime.round(3), 18 | total: total_runtime.round(3) 19 | } 20 | end 21 | 22 | def max_runtime 23 | values(:max_time).map(&:to_f).max || 0.0 24 | end 25 | 26 | def min_runtime 27 | values(:min_time).map(&:to_f).min || 0.0 28 | end 29 | 30 | def last_runtime 31 | @redis_statistic.statistic_for(@worker).last[:last_time] 32 | end 33 | 34 | def total_runtime 35 | values(:total_time).map(&:to_f).inject(:+) || 0.0 36 | end 37 | 38 | def average_runtime 39 | averages = values(:average_time).map(&:to_f) 40 | count = averages.count 41 | return 0.0 if count == 0 42 | averages.inject(:+) / count 43 | end 44 | 45 | private 46 | 47 | def values(key) 48 | @values ||= @redis_statistic.statistic_for(@worker) 49 | @values = @values.is_a?(Array) ? @values : [@values] 50 | @values.map{ |s| s[key] }.compact 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require 'sidekiq/web' 5 | rescue LoadError 6 | # client-only usage 7 | end 8 | 9 | require 'sidekiq/api' 10 | require 'sidekiq/statistic/configuration' 11 | require 'sidekiq/statistic/statistic/metric' 12 | require 'sidekiq/statistic/statistic/metrics/cache_keys' 13 | require 'sidekiq/statistic/statistic/metrics/store' 14 | require 'sidekiq/statistic/middleware' 15 | require 'sidekiq/statistic/base' 16 | require 'sidekiq/statistic/statistic/charts' 17 | require 'sidekiq/statistic/statistic/realtime' 18 | require 'sidekiq/statistic/statistic/runtime' 19 | require 'sidekiq/statistic/statistic/workers' 20 | require 'sidekiq/statistic/version' 21 | require 'sidekiq/statistic/views' 22 | require 'sidekiq/statistic/web_extension' 23 | require 'sidekiq/statistic/web_api_extension' 24 | require 'sidekiq/statistic/helpers/color' 25 | require 'sidekiq/statistic/helpers/date' 26 | 27 | module Sidekiq 28 | module Statistic 29 | class << self 30 | attr_writer :configuration 31 | end 32 | 33 | def self.configuration 34 | @configuration ||= Configuration.new 35 | end 36 | 37 | def self.configure 38 | yield(configuration) 39 | end 40 | end 41 | end 42 | 43 | Sidekiq.configure_server do |config| 44 | config.server_middleware do |chain| 45 | chain.add Sidekiq::Statistic::Middleware 46 | end 47 | end 48 | 49 | if defined?(Sidekiq::Web) 50 | Sidekiq::Web.register Sidekiq::Statistic::WebApiExtension 51 | Sidekiq::Web.register Sidekiq::Statistic::WebExtension 52 | Sidekiq::Web.tabs['Statistic'] = 'statistic' 53 | end 54 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/statistic/realtime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | class Realtime < Base 6 | DAYS_PREVIOUS = 30 7 | 8 | def self.charts_initializer 9 | workers = new.worker_names.map{ |w| Array.new(12, 0).unshift(w) } 10 | workers << Array.new(12) { |i| (Time.now - i).strftime('%T') }.unshift('x') 11 | workers 12 | end 13 | 14 | def initialize 15 | @start_date = Time.now.utc.to_date 16 | @end_date = @start_date - DAYS_PREVIOUS 17 | end 18 | 19 | def realtime_hash 20 | Sidekiq.redis do |conn| 21 | redis_hash = {} 22 | conn 23 | .hgetall("#{Metrics::Store::REDIS_HASH}:realtime:#{Time.now.sec - 1}") 24 | .each do |keys, value| 25 | *keys, last = keys.split(KEY_SEPARATOR) 26 | keys.inject(redis_hash, &key_or_empty_hash)[last] = value.to_i 27 | end 28 | 29 | redis_hash 30 | end 31 | end 32 | 33 | def statistic(params = {}) 34 | { 35 | failed: { columns: columns_for('failed', params) }, 36 | passed: { columns: columns_for('passed', params) } 37 | } 38 | end 39 | 40 | private 41 | 42 | def columns_for(status, params = {}) 43 | workers = params['excluded'] ? worker_names - Array(params['excluded']) : worker_names 44 | 45 | workers.map do |worker| 46 | [worker, realtime.fetch(status, {})[worker] || 0] 47 | end << axis_array 48 | end 49 | 50 | def realtime 51 | @realtime_hash ||= realtime_hash 52 | end 53 | 54 | def axis_array 55 | @array ||= ['x', Time.now.strftime('%T')] 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/styles/sidekiq-statistic-light.css: -------------------------------------------------------------------------------- 1 | /* === LIGHT MODE STYLES === */ 2 | 3 | /* === CONTAINER === */ 4 | 5 | .statistic__container { 6 | background-color: #fff; 7 | -webkit-box-shadow: 0 0 5px rgba(50, 50, 50, 0.25); 8 | -moz-box-shadow: 0 0 5px rgba(50, 50, 50, 0.25); 9 | box-shadow: 0 0 5px rgba(50, 50, 50, 0.25); 10 | } 11 | 12 | /* === DATEPICKER === */ 13 | 14 | .statistic__datepicker { 15 | border-bottom: 1px solid rgb(236, 236, 236); 16 | } 17 | 18 | .statistic__datepicker input { 19 | background: #FFF; 20 | border: 1px solid #CCC; 21 | box-shadow: 0 0 5px rgba(50, 50, 50, 0.25); 22 | } 23 | 24 | /* === Worker table TOGGLE === */ 25 | 26 | .worker__toggle-visibility { 27 | background: inherit; 28 | } 29 | 30 | .worker__toggle-visibility:hover { 31 | background: rgba(0, 0, 0, 0.04); 32 | } 33 | 34 | .worker__toggle-visibility:active { 35 | background: rgba(0, 0, 0, 0.1); 36 | } 37 | 38 | .worker__toggle-visibility:focus, 39 | .worker__toggle-visibility:active { 40 | outline: none !important; 41 | box-shadow: none; 42 | } 43 | 44 | /* === UI DATEPICKER jQuery === */ 45 | 46 | .ui-datepicker { 47 | background-color: #fff; 48 | border: 1px solid #d7d7d7; 49 | } 50 | 51 | .ui-datepicker-today { 52 | background: #F5F5F5; 53 | font-weight: bold; 54 | } 55 | 56 | .ui-datepicker th { 57 | padding: .5em 0; 58 | font-size: 10px; 59 | font-weight: bold; 60 | text-transform: uppercase; 61 | border: none; 62 | border-bottom: 1px solid #d5d9e2; 63 | background: #f3f3f3; 64 | text-align: center; 65 | } 66 | 67 | .ui-datepicker td a:hover { 68 | background: #F5F5F5; 69 | } 70 | 71 | .ui-state-disabled { 72 | color: #d7d7d7; 73 | } 74 | 75 | /* === CHART C3.js === */ 76 | 77 | .c3 line, 78 | .c3 path { 79 | fill: none; 80 | stroke: #000; 81 | } 82 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/statistic/metrics/store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | module Metrics 6 | class Store 7 | REDIS_HASH = 'sidekiq:statistic' 8 | 9 | def self.call(metric) 10 | new(metric).call 11 | end 12 | 13 | def initialize(metric) 14 | @metric = metric 15 | @keys = CacheKeys.new(metric) 16 | end 17 | 18 | def call 19 | cache_length = 0 20 | 21 | Sidekiq.redis do |redis| 22 | cache_length = store_cache_metrics(redis) 23 | release_cache_allocation(redis, cache_length) 24 | end 25 | end 26 | 27 | private 28 | 29 | def store_cache_metrics(redis) 30 | redis.pipelined do |pipeline| 31 | pipeline.hincrby(REDIS_HASH, @keys.status, 1) 32 | 33 | pipeline.hmset(REDIS_HASH, @keys.last_job_status, @metric.status, 34 | @keys.last_time, @metric.finished_at.to_i, 35 | @keys.queue, @metric.queue) 36 | 37 | pipeline.hincrby(@keys.realtime, @keys.class_name, 1) 38 | pipeline.expire(@keys.realtime, 2) 39 | end 40 | 41 | redis.lpush(@keys.timeslist, @metric.duration) 42 | end 43 | 44 | # The "timeslist" stores an array of decimal numbers representing 45 | # the time in seconds for a worker duration time. 46 | # 47 | # Whenever the "timeslist" exceeds the stipulated max length it is 48 | # going to remove 25% of the last values inside the array. 49 | # 50 | # https://github.com/davydovanton/sidekiq-statistic/issues/73 51 | def release_cache_allocation(redis, timelist_length) 52 | max_timelist_length = Sidekiq::Statistic.configuration.max_timelist_length 53 | 54 | if timelist_length > max_timelist_length 55 | redis.ltrim(@keys.timeslist, 0, (max_timelist_length * 0.75).to_i) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/test_sidekiq/helpers/date_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | module Sidekiq 6 | class Helper < Sidekiq::WebAction 7 | include Sidekiq::Statistic::Helpers::Date 8 | end 9 | 10 | describe Statistic::Helpers::Date do 11 | include Rack::Test::Methods 12 | 13 | let(:header) { { 'HTTP_ACCEPT_LANGUAGE' => 'pt-br' } } 14 | let(:helper_date) { Helper.new(header, {}) } 15 | 16 | describe '.format_date' do 17 | let(:datetime) { Time.now } 18 | 19 | describe "when doesn't have translation" do 20 | before { header['HTTP_ACCEPT_LANGUAGE'] = 'xx-xx' } 21 | 22 | it 'returns date with en format' do 23 | expected = datetime.strftime('%m/%d/%Y') 24 | assert_equal helper_date.format_date(datetime), expected 25 | end 26 | end 27 | 28 | describe 'when have translation' do 29 | it 'returns date with default format' do 30 | default_format = helper_date.get_locale.dig('date', 'formats', 'default') 31 | expected = datetime.strftime(default_format) 32 | 33 | assert_equal helper_date.format_date(datetime), expected 34 | end 35 | 36 | it 'returns date with datetime format' do 37 | datetime_format = helper_date.get_locale.dig('date', 'formats', 'datetime') 38 | expected = datetime.strftime(datetime_format) 39 | 40 | assert_equal helper_date.format_date(datetime, 'datetime'), expected 41 | end 42 | end 43 | end 44 | 45 | describe '.calculate_date_range' do 46 | let(:helper_date) { Helper.new({}, {}) } 47 | 48 | it 'returns the range between dates' do 49 | diference = 2 50 | today = Date.new 51 | two_days_ago = today - diference 52 | params = { 'dateFrom' => two_days_ago.to_s, 53 | 'dateTo' => today.to_s } 54 | 55 | _(helper_date.calculate_date_range(params)).must_equal([diference, today]) 56 | end 57 | 58 | it 'returns default range' do 59 | _(helper_date.calculate_date_range({})).must_equal([20]) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/statistic/workers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | class Workers < Base 6 | JOB_STATES = [:passed, :failed] 7 | 8 | def display 9 | worker_names.map do |worker| 10 | { 11 | name: worker, 12 | last_job_status: last_job_status_for(worker), 13 | number_of_calls: number_of_calls(worker), 14 | queue: last_queue(worker), 15 | runtime: runtime_statistic(worker).values_hash 16 | } 17 | end 18 | end 19 | 20 | def display_per_day(worker_name) 21 | statistic_hash.flat_map do |day| 22 | day.reject{ |_, workers| workers.empty? }.map do |date, workers| 23 | worker_data = workers[worker_name] 24 | next unless worker_data 25 | 26 | { 27 | date: date, 28 | failure: worker_data[:failed], 29 | success: worker_data[:passed], 30 | total: worker_data[:failed] + worker_data[:passed], 31 | last_job_status: worker_data[:last_job_status], 32 | runtime: runtime_for_day(worker_name, worker_data) 33 | } 34 | end 35 | end.compact.reverse 36 | end 37 | 38 | def runtime_for_day(worker_name, worker_data) 39 | runtime_statistic(worker_name, worker_data) 40 | .values_hash 41 | .merge!(last: worker_data[:last_time]) 42 | end 43 | 44 | def number_of_calls(worker) 45 | number_of_calls = JOB_STATES.map{ |state| number_of_calls_for state, worker } 46 | 47 | { 48 | success: number_of_calls.first, 49 | failure: number_of_calls.last, 50 | total: number_of_calls.inject(:+) 51 | } 52 | end 53 | 54 | def number_of_calls_for(state, worker) 55 | statistic_for(worker) 56 | .select(&:any?) 57 | .map{ |hash| hash[state] }.inject(:+) || 0 58 | end 59 | 60 | def last_job_status_for(worker) 61 | statistic_for(worker) 62 | .select(&:any?) 63 | .last[:last_job_status] 64 | end 65 | 66 | def last_queue(worker) 67 | statistic_for(worker).last[:queue] 68 | end 69 | 70 | def runtime_statistic(worker, values = nil) 71 | Runtime.new(self, worker, values) 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/realtime.erb: -------------------------------------------------------------------------------- 1 | <% add_to_head do %> 2 | 3 | 4 | 5 | 6 | 7 | <% end %> 8 | 9 | 10 | 11 | 12 |
13 |

<%= t('RealtimeStatistic') %>

14 | 15 | 19 | 20 |
21 |
22 | 31 |
32 |
33 |

<%= t('Failed') %>

34 |
35 |

<%= t('Passed') %>

36 |
37 |
38 | 39 |

<%= t('Workers') %>

40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | <% @workers.each do |worker| %> 49 | 50 | 51 | 56 | 57 | <% end %> 58 | 59 |
<%= t('Worker') %><%= t('Show/Hide') %>
<%= worker %> 52 | 55 |
60 |
61 |
62 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/styles/common.css: -------------------------------------------------------------------------------- 1 | /* === COMMON === */ 2 | 3 | .border-radius { 4 | -webkit-border-radius: 4px; 5 | -moz-border-radius: 4px; 6 | border-radius: 4px; 7 | } 8 | 9 | .straight-top-left-radius { 10 | -webkit-border-top-left-radius: 0; 11 | -moz-border-top-left-radius: 0; 12 | border-top-left-radius: 0; 13 | } 14 | 15 | /* === CONTAINER === */ 16 | 17 | .statistic__container { 18 | padding: 8px; 19 | margin-bottom: 10px; 20 | border-width: 0; 21 | } 22 | 23 | .statistic__container > .realtime__toggle-container { 24 | float: right; 25 | } 26 | 27 | /* === TABLES === */ 28 | 29 | .statistic__table > tbody > tr > td { 30 | vertical-align: middle; 31 | } 32 | 33 | /* === DATEPICKER === */ 34 | 35 | .statistic__datepicker { 36 | display: flex; 37 | justify-content: center; 38 | margin-top: 10px; 39 | text-align: center; 40 | padding-bottom: 15px; 41 | } 42 | 43 | .statistic__datepicker > div { 44 | position: relative; 45 | } 46 | 47 | .statistic__datepicker > div > label { 48 | font-size: 10px; 49 | font-weight: normal; 50 | margin-top: 2px; 51 | margin-left: 5px; 52 | position: absolute; 53 | text-transform: uppercase; 54 | transition: 0.2s; 55 | } 56 | 57 | .statistic__datepicker__input { 58 | padding: 15px 5px 2px 5px; 59 | -webkit-border-radius: 4px; 60 | -moz-border-radius: 4px; 61 | border-radius: 4px; 62 | } 63 | 64 | .statistic__datepicker__input.left { 65 | -webkit-border-top-right-radius: 0 !important; 66 | -moz-border-radius-topright: 0 !important; 67 | border-top-right-radius: 0 !important; 68 | -webkit-border-bottom-right-radius: 0 !important; 69 | -moz-border-radius-bottomright: 0 !important; 70 | border-bottom-right-radius: 0 !important; 71 | } 72 | 73 | .statistic__datepicker__input.right { 74 | -webkit-border-top-left-radius: 0 !important; 75 | -moz-border-radius-topleft: 0 !important; 76 | border-top-left-radius: 0 !important; 77 | -webkit-border-bottom-left-radius: 0 !important; 78 | -moz-border-radius-bottomleft: 0 !important; 79 | border-bottom-left-radius: 0 !important; 80 | } 81 | 82 | /* === Worker table TOGGLE === */ 83 | 84 | .worker__toggle-visibility { 85 | padding: 2px 10px; 86 | } 87 | 88 | /* === Worker page LOGS === */ 89 | 90 | .statistic__log { 91 | display: none; 92 | word-break: break-word; 93 | } 94 | 95 | .statistic__log > input[type=text] { 96 | padding: 5px 2px 5px 5px; 97 | float: right; 98 | margin: -50px -7px 0; 99 | width: 200px; 100 | } 101 | 102 | /* === CHART C3.js === */ 103 | 104 | .c3 svg { 105 | font: 10px sans-serif; 106 | } 107 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/styles/sidekiq-statistic-dark.css: -------------------------------------------------------------------------------- 1 | /* === DARK MODE STYLES === */ 2 | 3 | /* === CONTAINER === */ 4 | 5 | .statistic__container { 6 | background-color: #282828; 7 | } 8 | 9 | /* === DATEPICKER === */ 10 | 11 | .statistic__datepicker { 12 | border-bottom: 1px solid #333333; 13 | } 14 | 15 | .statistic__datepicker input { 16 | background: #333333; 17 | border: 1px solid #555; 18 | box-shadow: 0 0 5px rgba(255, 255, 255, 0.1); 19 | } 20 | 21 | #statistic__search { 22 | background: #333333; 23 | border: 1px solid #555; 24 | box-shadow: 0 0 5px rgba(255, 255, 255, 0.1); 25 | -webkit-border-radius: 4px; 26 | -moz-border-radius: 4px; 27 | border-radius: 4px; 28 | } 29 | 30 | /* === UI DATEPICKER jQuery === */ 31 | 32 | .ui-datepicker { 33 | background-color: #282828; 34 | border: 1px solid #d5d5d5; 35 | } 36 | 37 | .ui-datepicker-today { 38 | background: #CD3844; 39 | } 40 | 41 | .ui-datepicker a:active, 42 | .ui-datepicker a:hover, 43 | .ui-datepicker a:visited { 44 | color: #ccc; 45 | } 46 | 47 | /* === TABS === */ 48 | 49 | .nav-tabs { 50 | background-color: #333333; 51 | border-bottom: 1px solid #333333; 52 | } 53 | 54 | .nav-tabs > li > a { 55 | background-color: #3D3D3D; 56 | } 57 | 58 | .nav-tabs > li.active > a { 59 | background-color: #282828; 60 | border: 1px solid #282828; 61 | color: #DCDCDC; 62 | } 63 | 64 | .nav-tabs > li > a:hover { 65 | background-color: #4A4443; 66 | border: 1px solid #4A4443; 67 | color: #DCDCDC; 68 | cursor: pointer; 69 | } 70 | 71 | .nav-tabs > li.active > a:hover { 72 | background-color: #282828; 73 | border: 1px solid #282828; 74 | color: #DCDCDC; 75 | cursor: pointer; 76 | } 77 | 78 | /* === Worker table TOGGLE === */ 79 | 80 | .worker__toggle-visibility { 81 | color: rgb(213, 213, 213); 82 | background: inherit; 83 | border: 1px solid rgb(102, 102, 102); 84 | } 85 | 86 | .worker__toggle-visibility:hover { 87 | background:rgb(153, 153, 153); 88 | border: 1px solid rgb(153, 153, 153); 89 | } 90 | 91 | .worker__toggle-visibility:active { 92 | background: rgba(0, 0, 0, 0.1); 93 | } 94 | 95 | .worker__toggle-visibility:focus, 96 | .worker__toggle-visibility:active { 97 | outline: none !important; 98 | box-shadow: none; 99 | } 100 | 101 | /* === Worker page LOGS === */ 102 | 103 | .statistic__log > input[type=text] { 104 | background: #333333; 105 | border: 1px solid #555; 106 | box-shadow: 0 0 5px rgba(255, 255, 255, 0.1); 107 | } 108 | 109 | /* === CHART C3.js === */ 110 | 111 | .c3 svg { 112 | background-color: #555; 113 | } 114 | 115 | .c3 line, 116 | .c3 path { 117 | fill: none; 118 | stroke: #FFF; 119 | } 120 | 121 | .tick text { 122 | fill: #FFF; 123 | } 124 | 125 | .c3-legend-item text { fill: #FFF; } 126 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/worker.erb: -------------------------------------------------------------------------------- 1 | <% add_to_head do %> 2 | 3 | 4 | 5 | 6 | <% if Sidekiq::VERSION >= '6.3' %> 7 | 8 | 9 | <% end %> 10 | <% end %> 11 | 12 | 13 | 14 |
15 |

<%= t('WorkerInformation', worker: @name) %>

16 | 17 | 22 | 23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 |
36 |

<%= t('WorkerTablePerDay') %>

37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | <% @worker_statistic.each do |worker| %> 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | <% end %> 65 |
<%= t('Date') %><%= t('LastRun') %><%= t('Success') %><%= t('Failure') %><%= t('Total') %><%= t('TimeSec') %><%= t('AverageSec') %><%= t('MinTimeSec') %><%= t('MaxTimeSec') %><%= t('LastJobStatus') %>
<%= format_date worker[:date] %><%= format_date worker[:runtime][:last], 'datetime' %><%= worker[:success] %><%= worker[:failure] %><%= worker[:total] %><%= worker[:runtime][:total] %><%= worker[:runtime][:average] %><%= worker[:runtime][:min] %><%= worker[:runtime][:max] %><%= worker[:last_job_status] %>
66 |
67 |
68 |
69 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/web_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'tilt/erb' 4 | require 'json' 5 | 6 | module Sidekiq 7 | module Statistic 8 | module WebExtension 9 | JAVASCRIPT_CONTENT_TYPE = { "Content-Type" => "application/javascript" } 10 | CSS_CONTENT_TYPE = { "Content-Type" => "text/css" } 11 | 12 | def self.registered(app) 13 | Sidekiq::Web.settings.locales << File.expand_path(File.dirname(__FILE__) + '/locales') 14 | 15 | app.helpers Helpers::Color 16 | app.helpers Helpers::Date 17 | 18 | app.get '/c3.js' do 19 | [200, JAVASCRIPT_CONTENT_TYPE, Views.require_assets('c3.js')] 20 | end 21 | 22 | app.get '/realtime_statistic.js' do 23 | [200, JAVASCRIPT_CONTENT_TYPE, Views.require_assets('realtime_statistic.js')] 24 | end 25 | 26 | app.get '/statistic.js' do 27 | [200, JAVASCRIPT_CONTENT_TYPE, Views.require_assets('statistic.js')] 28 | end 29 | 30 | app.get '/ui-datepicker.css' do 31 | [200, CSS_CONTENT_TYPE, Views.require_assets('styles/ui-datepicker.css')] 32 | end 33 | 34 | app.get '/common.css' do 35 | [200, CSS_CONTENT_TYPE, Views.require_assets('styles/common.css')] 36 | end 37 | 38 | app.get '/sidekiq-statistic-light.css' do 39 | [200, CSS_CONTENT_TYPE, Views.require_assets('styles/sidekiq-statistic-light.css')] 40 | end 41 | 42 | app.get '/sidekiq-statistic-dark.css' do 43 | [200, CSS_CONTENT_TYPE, Views.require_assets('styles/sidekiq-statistic-dark.css')] 44 | end 45 | 46 | app.get '/statistic' do 47 | statistic = Workers.new(*calculate_date_range(params)) 48 | 49 | @all_workers = statistic.display 50 | 51 | render(:erb, Views.require_assets('statistic.erb').first) 52 | end 53 | 54 | app.get '/statistic/charts.json' do 55 | charts = Charts.new(*calculate_date_range(params)) 56 | date = { 57 | format: date_format, 58 | labels: charts.dates 59 | } 60 | 61 | json({ 62 | date: date, 63 | failed_data: charts.information_for(:failed), 64 | passed_data: charts.information_for(:passed) 65 | }) 66 | end 67 | 68 | app.get '/statistic/realtime' do 69 | @workers = Realtime.new.worker_names 70 | 71 | render(:erb, Views.require_assets('realtime.erb').first) 72 | end 73 | 74 | app.get '/statistic/realtime/charts.json' do 75 | realtime = Realtime.new 76 | 77 | json(realtime.statistic(params)) 78 | end 79 | 80 | app.get '/statistic/realtime/charts_initializer.json' do 81 | json(Realtime.charts_initializer) 82 | end 83 | 84 | app.get '/statistic/:worker' do 85 | @name = params[:worker] 86 | 87 | @worker_statistic = Workers.new(*calculate_date_range(params)) 88 | .display_per_day(@name) 89 | 90 | render(:erb, Views.require_assets('worker.erb').first) 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sidekiq 4 | module Statistic 5 | class Base 6 | KEY_SEPARATOR = /(?(h, k) { h[k] || h[k] = {} } 43 | end 44 | 45 | def desired_dates 46 | (@end_date..@start_date).map { |date| date.strftime "%Y-%m-%d" } 47 | end 48 | 49 | def result_hash(redis_hash, key) 50 | redis_hash.fetch(key, {}).each { |_, v| update_hash_statments v } 51 | { key => (redis_hash[key] || {}) } 52 | end 53 | 54 | def update_hash_statments(hash) 55 | hash[:passed] ||= 0 56 | hash[:failed] ||= 0 57 | end 58 | 59 | def to_number(value) 60 | case value 61 | when /\A-?\d+\.\d+\z/ then value.to_f 62 | when /\A-?\d+\z/ then value.to_i 63 | else value 64 | end 65 | end 66 | 67 | def update_time_values(conn, redis_hash) 68 | redis_hash.each do |time, workers| 69 | workers.each do |worker, _| 70 | worker_key = "#{time}:#{worker}" 71 | 72 | timeslist, _ = conn.multi do |multi| 73 | multi.lrange("#{worker_key}:timeslist", 0, -1) 74 | multi.del("#{worker_key}:timeslist") 75 | end 76 | 77 | timeslist.map!(&:to_f) 78 | redis_hash[time][worker].merge! time_hash(timeslist, worker_key) 79 | end 80 | end 81 | end 82 | 83 | def time_hash(timeslist, worker_key) 84 | return {} if timeslist.empty? 85 | statistics = time_statistics(timeslist) 86 | 87 | Sidekiq.redis do |redis| 88 | redis.hmset Metrics::Store::REDIS_HASH, 89 | statistics.flat_map{ |(k, v)| ["#{worker_key}:#{k}", v] } 90 | end 91 | 92 | statistics 93 | end 94 | 95 | def time_statistics(timeslist) 96 | total = timeslist.inject(:+) 97 | 98 | { 99 | average_time: total / timeslist.count, 100 | min_time: timeslist.min, 101 | max_time: timeslist.max, 102 | total_time: total 103 | } 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/statistic/metric_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | describe Sidekiq::Statistic::Metric do 6 | describe '.for' do 7 | describe 'when running "ActionMailer" jobs' do 8 | it 'extracts class name from "args"' do 9 | instance = Sidekiq::Statistic::Metric.for( 10 | class_name: 'ActionMailer::DeliveryJob', 11 | arguments: [ 12 | { 'arguments' => ['MyWorkerClass'] } 13 | ] 14 | ) 15 | 16 | assert_equal 'MyWorkerClass', instance.class_name 17 | end 18 | end 19 | 20 | describe 'when running common jobs' do 21 | it 'does not try to extract class name from "args"' do 22 | instance = Sidekiq::Statistic::Metric.for( 23 | class_name: 'MyWorkerClass' 24 | ) 25 | 26 | assert_equal 'MyWorkerClass', instance.class_name 27 | end 28 | end 29 | end 30 | 31 | describe '#status' do 32 | it 'assigns "default" for "status" attribute' do 33 | instance = Sidekiq::Statistic::Metric.new('MyWorkerClass') 34 | 35 | assert_equal :passed, instance.status 36 | end 37 | end 38 | 39 | describe '#status=' do 40 | it 'assigns given value for "status" attribute' do 41 | instance = Sidekiq::Statistic::Metric.new('MyWorkerClass') 42 | 43 | assert_equal :passed, instance.status 44 | 45 | instance.status = Sidekiq::Statistic::Metric::STATUSES[:failure] 46 | 47 | assert_equal :failed, instance.status 48 | end 49 | end 50 | 51 | describe '#queue' do 52 | it 'assigns "default" for "queue" attribute' do 53 | instance = Sidekiq::Statistic::Metric.new('MyWorkerClass') 54 | 55 | assert_equal 'default', instance.queue 56 | end 57 | end 58 | 59 | describe '#queue=' do 60 | it 'assigns given value for "queue" attribute' do 61 | instance = Sidekiq::Statistic::Metric.new('MyWorkerClass') 62 | 63 | assert_equal 'default', instance.queue 64 | 65 | instance.queue = 'test' 66 | 67 | assert_equal 'test', instance.queue 68 | end 69 | end 70 | 71 | describe '#finished_at' do 72 | it 'assigns current time for "finished_at" attribute' do 73 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 74 | instance = Sidekiq::Statistic::Metric.new('MyWorkerClass') 75 | 76 | assert_equal Time.new(2021, 1, 17, 14, 00, 00), instance.finished_at 77 | end 78 | end 79 | end 80 | 81 | describe '#fails!' do 82 | it 'assigns failure for status' do 83 | instance = Sidekiq::Statistic::Metric.new('MyWorkerClass') 84 | 85 | assert_equal :passed, instance.status 86 | 87 | instance.fails! 88 | 89 | assert_equal :failed, instance.status 90 | end 91 | end 92 | 93 | describe '#duration' do 94 | it 'returns the difference between start and finish' do 95 | instance = nil 96 | 97 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 98 | instance = Sidekiq::Statistic::Metric.new('MyWorkerClass') 99 | end 100 | 101 | travel_to Time.new(2021, 1, 17, 14, 10, 00) do 102 | instance.start 103 | end 104 | 105 | travel_to Time.new(2021, 1, 17, 14, 12, 00) do 106 | instance.finish 107 | end 108 | 109 | assert_equal 120, instance.duration 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/test_sidekiq/realtime_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | module Sidekiq 6 | module Statistic 7 | describe 'Realtime' do 8 | before { Sidekiq.redis(&:flushdb) } 9 | 10 | let(:realtime) { Sidekiq::Statistic::Realtime.new } 11 | let(:current_time) { Time.new(2015, 8, 11, 23, 22, 21, "+00:00").utc } 12 | 13 | describe '::charts_initializer' do 14 | describe 'before any jobs' do 15 | it 'returns initialize array for realtime chart' do 16 | travel_to current_time do 17 | initialize_array = Sidekiq::Statistic::Realtime.charts_initializer 18 | assert_equal [['x', '23:22:21', '23:22:20', '23:22:19', '23:22:18', '23:22:17', '23:22:16', '23:22:15', '23:22:14', '23:22:13', '23:22:12', '23:22:11', '23:22:10']], initialize_array 19 | end 20 | end 21 | end 22 | 23 | describe 'after job' do 24 | it 'returns initialize array for realtime chart' do 25 | travel_to current_time - 1 do 26 | middlewared {} 27 | end 28 | 29 | travel_to current_time do 30 | initialize_array = Sidekiq::Statistic::Realtime.charts_initializer 31 | assert_equal [['HistoryWorker', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ['x', '23:22:21', '23:22:20', '23:22:19', '23:22:18', '23:22:17', '23:22:16', '23:22:15', '23:22:14', '23:22:13', '23:22:12', '23:22:11', '23:22:10']], initialize_array 32 | end 33 | end 34 | end 35 | end 36 | 37 | describe '#realtime_hash' do 38 | describe 'before any jobs' do 39 | it 'returns empty hash' do 40 | assert_equal({}, realtime.realtime_hash) 41 | end 42 | end 43 | 44 | describe 'after job' do 45 | it 'returns worker run count' do 46 | travel_to (current_time - 1) do 47 | middlewared {} 48 | 49 | begin 50 | middlewared do 51 | raise StandardError.new('failed') 52 | end 53 | rescue 54 | end 55 | end 56 | 57 | travel_to current_time do 58 | assert_equal({'passed'=>{'HistoryWorker'=>1}, 'failed'=>{'HistoryWorker'=>1}}, realtime.realtime_hash) 59 | end 60 | end 61 | end 62 | end 63 | 64 | describe '#statistic' do 65 | describe 'before any jobs' do 66 | it 'returns hash with empty values' do 67 | travel_to current_time do 68 | assert_equal({failed: {columns: [['x', '23:22:21']]}, passed: {columns: [['x', '23:22:21']]}}, realtime.statistic) 69 | end 70 | end 71 | end 72 | 73 | describe 'after job' do 74 | it 'returns worker run count for each realtime chart' do 75 | travel_to (current_time - 1) do 76 | middlewared {} 77 | 78 | begin 79 | middlewared do 80 | raise StandardError.new('failed') 81 | end 82 | rescue 83 | end 84 | end 85 | 86 | travel_to current_time do 87 | assert_equal({failed: {columns: [['HistoryWorker', 1], ['x', '23:22:21']]}, passed: {columns: [['HistoryWorker', 1], ['x', '23:22:21']]}}, realtime.statistic) 88 | end 89 | end 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/statistic.erb: -------------------------------------------------------------------------------- 1 | <% add_to_head do %> 2 | 3 | 4 | 5 | 6 | <% if Sidekiq::VERSION >= '6.3' %> 7 | 8 | 9 | <% end %> 10 | <% end %> 11 | 12 | 13 | 14 | 15 |
16 |

<%= t('Statistic') %>

17 | 18 | 22 | 23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 |
36 |

<%= t('Failed') %>

37 |
38 |

<%= t('Passed') %>

39 |
40 |
41 | 42 |

<%= t('WorkersTable') %>

43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | <% @all_workers.each do |worker| %> 59 | 60 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | <% end %> 75 | 76 |
<%= t('Worker') %><%= t('Date') %><%= t('Queue') %><%= t('Success') %><%= t('Failure') %><%= t('Total') %><%= t('TimeSec') %><%= t('AverageSec') %><%= t('MinTimeSec') %><%= t('MaxTimeSec') %><%= t('LastJobStatus') %>
61 | <%= worker[:name] %> 62 | <%= format_date worker[:runtime][:last], 'datetime' %><%= worker[:queue] %><%= worker[:number_of_calls][:success] %><%= worker[:number_of_calls][:failure] %><%= worker[:number_of_calls][:total] %><%= worker[:runtime][:total] %><%= worker[:runtime][:average] %><%= worker[:runtime][:min] %><%= worker[:runtime][:max] %><%= worker[:last_job_status] %>
77 |
78 |
79 | -------------------------------------------------------------------------------- /test/statistic/metrics/cache_keys_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | describe Sidekiq::Statistic::Metrics::CacheKeys do 6 | describe '#status' do 7 | describe 'for success status' do 8 | let(:metric) { Sidekiq::Statistic::Metric.new('HistoryWorker') } 9 | 10 | it 'returns correct key for "status" attribute' do 11 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 12 | result = Sidekiq::Statistic::Metrics::CacheKeys.new(metric) 13 | 14 | assert_equal '2021-01-17:HistoryWorker:passed', result.status 15 | end 16 | end 17 | end 18 | 19 | describe 'for failure status' do 20 | let(:metric) { Sidekiq::Statistic::Metric.new('HistoryWorker') } 21 | 22 | it 'returns correct key for "status" attribute' do 23 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 24 | metric.fails! 25 | result = Sidekiq::Statistic::Metrics::CacheKeys.new(metric) 26 | 27 | assert_equal '2021-01-17:HistoryWorker:failed', result.status 28 | end 29 | end 30 | end 31 | end 32 | 33 | describe '#class_name' do 34 | let(:metric) { Sidekiq::Statistic::Metric.new('HistoryWorker') } 35 | 36 | it 'returns correct key for "class_name" attribute' do 37 | result = Sidekiq::Statistic::Metrics::CacheKeys.new(metric) 38 | 39 | assert_equal 'passed:HistoryWorker', result.class_name 40 | end 41 | end 42 | 43 | describe '#last_job_status' do 44 | let(:metric) { Sidekiq::Statistic::Metric.new('HistoryWorker') } 45 | 46 | it 'returns correct key for "last_job_status" attribute' do 47 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 48 | result = Sidekiq::Statistic::Metrics::CacheKeys.new(metric) 49 | 50 | assert_equal '2021-01-17:HistoryWorker:last_job_status', result.last_job_status 51 | end 52 | end 53 | end 54 | 55 | describe '#last_time' do 56 | let(:metric) { Sidekiq::Statistic::Metric.new('HistoryWorker') } 57 | 58 | it 'returns correct key for "last_time" attribute' do 59 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 60 | result = Sidekiq::Statistic::Metrics::CacheKeys.new(metric) 61 | 62 | assert_equal '2021-01-17:HistoryWorker:last_time', result.last_time 63 | end 64 | end 65 | end 66 | 67 | describe '#queue' do 68 | let(:metric) { Sidekiq::Statistic::Metric.new('HistoryWorker') } 69 | 70 | it 'returns correct key for "queue" attribute' do 71 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 72 | result = Sidekiq::Statistic::Metrics::CacheKeys.new(metric) 73 | 74 | assert_equal '2021-01-17:HistoryWorker:queue', result.queue 75 | end 76 | end 77 | end 78 | 79 | describe '#timeslist' do 80 | let(:metric) { Sidekiq::Statistic::Metric.new('HistoryWorker') } 81 | 82 | it 'returns correct key for "timeslist" attribute' do 83 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 84 | result = Sidekiq::Statistic::Metrics::CacheKeys.new(metric) 85 | 86 | assert_equal '2021-01-17:HistoryWorker:timeslist', result.timeslist 87 | end 88 | end 89 | end 90 | 91 | describe '#realtime' do 92 | let(:metric) { Sidekiq::Statistic::Metric.new('HistoryWorker') } 93 | 94 | it 'returns correct key for "realtime" attribute' do 95 | travel_to Time.new(2021, 1, 17, 14, 00, 00) do 96 | result = Sidekiq::Statistic::Metrics::CacheKeys.new(metric) 97 | 98 | assert_equal 'sidekiq:statistic:realtime:0', result.realtime 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/test_sidekiq/web_api_extension_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # encoding: utf-8 4 | 5 | require 'minitest_helper' 6 | 7 | module Sidekiq 8 | describe 'WebApiExtension' do 9 | include Rack::Test::Methods 10 | 11 | def app 12 | Sidekiq::Web 13 | end 14 | 15 | before { Sidekiq.redis(&:flushdb) } 16 | 17 | describe 'GET /api/statistic.json' do 18 | describe 'without jobs' do 19 | it 'returns empty workers statistic' do 20 | get '/api/statistic.json' 21 | 22 | response = Sidekiq.load_json(last_response.body) 23 | _(response['workers']).must_equal [] 24 | end 25 | end 26 | 27 | describe 'for perfomed jobs' do 28 | it 'returns workers statistic' do 29 | middlewared {} 30 | get '/api/statistic.json' 31 | 32 | response = Sidekiq.load_json(last_response.body) 33 | _(response['workers']).wont_equal [] 34 | _( response['workers'].first.keys).must_equal %w[name last_job_status number_of_calls queue runtime] 35 | end 36 | end 37 | 38 | describe 'for any range' do 39 | before do 40 | middlewared {} 41 | end 42 | 43 | describe 'for date range with empty statistic' do 44 | it 'returns empty statistic' do 45 | get '/api/statistic.json?dateFrom=2015-07-28&dateTo=2015-07-29' 46 | 47 | response = Sidekiq.load_json(last_response.body) 48 | _(response['workers']).must_equal [] 49 | end 50 | end 51 | 52 | describe 'for any date range with existed statistic' do 53 | it 'returns workers statistic' do 54 | get "/api/statistic.json?dateFrom=2015-07-28&dateTo=#{Date.today}" 55 | 56 | response = Sidekiq.load_json(last_response.body) 57 | _(response['workers']).wont_equal [] 58 | _(response['workers'].count).must_equal 1 59 | end 60 | end 61 | end 62 | end 63 | 64 | describe 'GET /api/statistic/:worker.json' do 65 | describe 'without jobs' do 66 | it 'returns empty workers statistic' do 67 | get '/api/statistic/HistoryWorker.json' 68 | 69 | response = Sidekiq.load_json(last_response.body) 70 | _(response['days']).must_equal [] 71 | end 72 | end 73 | 74 | describe 'for perfomed jobs' do 75 | it 'returns workers statistic' do 76 | middlewared {} 77 | get '/api/statistic/HistoryWorker.json' 78 | 79 | response = Sidekiq.load_json(last_response.body) 80 | _(response['days']).wont_equal [] 81 | _(response['days'].first.keys).must_equal %w[date failure success total last_job_status runtime] 82 | end 83 | end 84 | 85 | describe 'for any range' do 86 | before do 87 | middlewared {} 88 | end 89 | 90 | describe 'for date range with empty statistic' do 91 | it 'returns empty statistic' do 92 | get '/api/statistic/HistoryWorker.json?dateFrom=2015-07-28&dateTo=2015-07-29' 93 | 94 | response = Sidekiq.load_json(last_response.body) 95 | _(response['days']).must_equal [] 96 | end 97 | end 98 | 99 | describe 'for any date range with existed statistic' do 100 | it 'returns workers statistic' do 101 | get "/api/statistic/HistoryWorker.json?dateFrom=2015-07-28&dateTo=#{Date.today}" 102 | 103 | response = Sidekiq.load_json(last_response.body) 104 | _(response['days']).wont_equal [] 105 | _(response['days'].count).must_equal 1 106 | end 107 | end 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/test_sidekiq/runtime_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | module Sidekiq 6 | module Statistic 7 | describe 'Runtime' do 8 | before { Sidekiq.redis(&:flushdb) } 9 | 10 | let(:statistic) { Sidekiq::Statistic::Base.new(1) } 11 | let(:runtime_statistic) { Sidekiq::Statistic::Runtime.new(statistic, 'HistoryWorker') } 12 | 13 | describe '#last_runtime' do 14 | it 'returns last runtime for worker' do 15 | middlewared {} 16 | 17 | time = Time.now.utc 18 | Time.stub :now, time do 19 | assert_equal time.to_i, runtime_statistic.last_runtime 20 | end 21 | end 22 | 23 | describe 'when jobs were not call' do 24 | it 'returns nil' do 25 | assert_nil runtime_statistic.last_runtime 26 | end 27 | end 28 | end 29 | 30 | describe '#total_runtime' do 31 | it 'returns totle runtime HistoryWorker' do 32 | middlewared { sleep 0.2 } 33 | 34 | values = runtime_statistic.total_runtime 35 | assert_equal 0.2, values.round(1) 36 | end 37 | 38 | describe 'when jobs were not call' do 39 | it 'returns array with empty values' do 40 | values = runtime_statistic.total_runtime 41 | assert_equal 0.0, values 42 | end 43 | end 44 | end 45 | 46 | describe '#average_runtime' do 47 | it 'returns totle runtime HistoryWorker' do 48 | middlewared { sleep 0.2 } 49 | middlewared { sleep 0.1 } 50 | middlewared { sleep 0.3 } 51 | 52 | values = runtime_statistic.average_runtime 53 | assert_equal 0.2, values.round(1) 54 | end 55 | 56 | it 'returns precise average runtime HistoryWorker' do 57 | middlewared { sleep 0.2423 } 58 | middlewared { sleep 0.1513 } 59 | middlewared { sleep 0.3125 } 60 | middlewared { sleep 0.34587 } 61 | middlewared { sleep 1.12908 } 62 | 63 | values = runtime_statistic.average_runtime 64 | assert_equal 0.44, values.round(2) 65 | end 66 | 67 | describe 'when jobs were not call' do 68 | it 'returns array with empty values' do 69 | values = runtime_statistic.average_runtime 70 | assert_equal 0.0, values 71 | end 72 | end 73 | 74 | describe 'when values are strings' do 75 | it 'should return with precise value' do 76 | job_worker = Sidekiq::Statistic::Runtime.new(statistic, 'JobWorker', { average_time: '1.12908' }) 77 | values = job_worker.average_runtime 78 | assert_equal 1.1291, values.round(4) 79 | end 80 | end 81 | end 82 | 83 | describe '#max_runtime' do 84 | it 'returns max runtime for worker HistoryWorker' do 85 | middlewared { sleep 0.2 } 86 | middlewared { sleep 0.3 } 87 | middlewared { sleep 0.1 } 88 | 89 | values = runtime_statistic.max_runtime 90 | assert_equal 0.3, values.round(1) 91 | end 92 | 93 | describe 'when jobs were not call' do 94 | it 'returns zero' do 95 | values = runtime_statistic.max_runtime 96 | assert_equal 0.0, values 97 | end 98 | end 99 | end 100 | 101 | describe '#min_runtime' do 102 | it 'returns min runtime for worker HistoryWorker' do 103 | middlewared { sleep 0.2 } 104 | middlewared { sleep 0.3 } 105 | middlewared { sleep 0.1 } 106 | 107 | values = runtime_statistic.min_runtime 108 | assert_equal 0.1, values.round(1) 109 | end 110 | 111 | describe 'when jobs were not call' do 112 | it 'returns zero' do 113 | values = runtime_statistic.min_runtime 114 | assert_equal 0.0, values 115 | end 116 | end 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/test_sidekiq/base_statistic_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | module Sidekiq 6 | module Statistic 7 | describe 'Base' do 8 | before { Sidekiq.redis(&:flushdb) } 9 | 10 | let(:base_statistic) { Sidekiq::Statistic::Base.new(1) } 11 | 12 | describe '#redis_hash' do 13 | it 'returns hash for each day' do 14 | statistic = base_statistic.statistic_hash 15 | assert_equal 2, statistic.size 16 | end 17 | 18 | it 'returns array with statistic hash for each worker' do 19 | begin 20 | middlewared do 21 | raise StandardError.new('failed') 22 | end 23 | rescue 24 | end 25 | middlewared {} 26 | 27 | statistic = base_statistic.statistic_hash 28 | worker_hash = statistic.last[Time.now.utc.to_date.to_s] 29 | 30 | assert_equal 1, worker_hash['HistoryWorker'][:failed] 31 | assert_equal 1, worker_hash['HistoryWorker'][:passed] 32 | end 33 | 34 | describe 'after call' do 35 | it 'deletes timeslist list from redis' do 36 | middlewared {} 37 | 38 | Sidekiq.redis do |conn| 39 | assert_equal true, conn.hget(Metrics::Store::REDIS_HASH, "#{Time.now.utc.to_date}:HistoryWorker:total_time").nil? 40 | assert_equal 1, conn.lrange("#{Time.now.utc.to_date}:HistoryWorker:timeslist", 0, -1).size 41 | end 42 | 43 | base_statistic.statistic_hash 44 | 45 | Sidekiq.redis do |conn| 46 | assert_equal false, conn.hget(Metrics::Store::REDIS_HASH, "#{Time.now.utc.to_date}:HistoryWorker:total_time").nil? 47 | assert_equal 0, conn.lrange("#{Time.now.utc.to_date}:HistoryWorker:timeslist", 0, -1).size 48 | end 49 | end 50 | end 51 | end 52 | 53 | describe '#statistic_for' do 54 | it 'returns array with values for HistoryWorker per day' do 55 | middlewared {} 56 | time = Time.now.utc 57 | 58 | travel_to time do 59 | values = base_statistic.statistic_for('HistoryWorker') 60 | assert_equal [{}, { passed: 1.0, last_job_status: "passed", last_time: time.to_i, queue: "default", average_time: 0.0, min_time: 0.0, max_time: 0.0, total_time: 0.0, failed: 0 }], values 61 | end 62 | end 63 | 64 | describe 'when jobs were not call' do 65 | it 'returns array with empty values' do 66 | values = base_statistic.statistic_for('HistoryWorker') 67 | assert_equal [{}, {}], values 68 | end 69 | end 70 | end 71 | 72 | describe 'last worker job' do 73 | it 'returns "passed" for last passed job' do 74 | begin 75 | middlewared do 76 | raise StandardError.new('failed') 77 | end 78 | rescue 79 | end 80 | middlewared {} 81 | 82 | last_job_status = base_statistic.statistic_for('HistoryWorker').last[:last_job_status] 83 | assert_equal "passed", last_job_status 84 | end 85 | 86 | it 'returns "failed" for last failed job' do 87 | middlewared {} 88 | begin 89 | middlewared do 90 | raise StandardError.new('failed') 91 | end 92 | rescue 93 | end 94 | 95 | last_job_status = base_statistic.statistic_for('HistoryWorker').last[:last_job_status] 96 | assert_equal "failed", last_job_status 97 | end 98 | end 99 | 100 | describe '#worker_names' do 101 | it 'returns array with worker names' do 102 | middlewared {} 103 | worker_names = base_statistic.worker_names 104 | assert_equal ['HistoryWorker'], worker_names 105 | end 106 | 107 | describe 'when jobs were not call' do 108 | it 'returns empty array' do 109 | worker_names = base_statistic.worker_names 110 | assert_equal [], worker_names 111 | end 112 | end 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/test_sidekiq/web_extension_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # encoding: utf-8 4 | 5 | require 'minitest_helper' 6 | require 'json' 7 | 8 | module Sidekiq 9 | describe 'WebExtension' do 10 | include Rack::Test::Methods 11 | 12 | def app 13 | Sidekiq::Web 14 | end 15 | 16 | describe 'GET /' do 17 | it 'can show text with any locales' do 18 | rackenv = { 'HTTP_ACCEPT_LANGUAGE' => 'ru,en' } 19 | 20 | get '/', {}, rackenv 21 | 22 | assert_match(/Статистика/, last_response.body) 23 | rackenv = { 'HTTP_ACCEPT_LANGUAGE' => 'en-us' } 24 | 25 | get '/', {}, rackenv 26 | 27 | assert_match(/Statistic/, last_response.body) 28 | end 29 | end 30 | 31 | describe 'GET /sidekiq' do 32 | before do 33 | get '/' 34 | end 35 | 36 | it 'can display home with statistic tab' do 37 | _(last_response.status).must_equal 200 38 | _(last_response.body).must_match(/Sidekiq/) 39 | _(last_response.body).must_match(/Statistic/) 40 | end 41 | end 42 | 43 | describe 'GET /sidekiq/statistic' do 44 | before do 45 | get '/statistic' 46 | end 47 | 48 | it 'can display statistic page without any failures' do 49 | _(last_response.status).must_equal 200 50 | _(last_response.body).must_match(/statistic/) 51 | end 52 | 53 | describe 'when there are statistic' do 54 | it 'should be successful' do 55 | _(last_response.status).must_equal 200 56 | end 57 | end 58 | 59 | it 'can display worker table' do 60 | _(last_response.body).must_match(/Worker/) 61 | _(last_response.body).must_match(/Date/) 62 | _(last_response.body).must_match(/Success/) 63 | _(last_response.body).must_match(/Failure/) 64 | _(last_response.body).must_match(/Total/) 65 | _(last_response.body).must_match(/Time\(sec\)/) 66 | _(last_response.body).must_match(/Average\(sec\)/) 67 | end 68 | end 69 | 70 | describe 'GET /sidekiq/statistic/charts.json' do 71 | before do 72 | get '/statistic/charts.json' 73 | end 74 | 75 | it 'can display statistic page without any failures' do 76 | _(last_response.status).must_equal 200 77 | response = JSON.parse(last_response.body) 78 | 79 | assert_includes response, 'date' 80 | assert_includes response, 'failed_data' 81 | assert_includes response, 'passed_data' 82 | assert_includes response['date'], 'labels' 83 | assert_includes response['date'], 'format' 84 | end 85 | 86 | describe 'when there are statistic' do 87 | it 'should be successful' do 88 | _(last_response.status).must_equal 200 89 | end 90 | end 91 | end 92 | 93 | describe 'GET /sidekiq/common.css' do 94 | before do 95 | get '/common.css' 96 | end 97 | 98 | it 'displays common styles successfully' do 99 | _(last_response.status).must_equal 200 100 | _(last_response.content_type).must_match(/text\/css/) 101 | _(last_response.body).must_match(/=== COMMON ===/) 102 | end 103 | end 104 | 105 | describe 'GET /sidekiq/ui-datepicker.css' do 106 | before do 107 | get '/ui-datepicker.css' 108 | end 109 | 110 | it 'displays ui-datepicker styles successfully' do 111 | _(last_response.status).must_equal 200 112 | _(last_response.content_type).must_match(/text\/css/) 113 | _(last_response.body).must_match(/jQuery UI Datepicker/) 114 | end 115 | end 116 | 117 | describe 'GET /sidekiq/sidekiq-statistic-light.css' do 118 | before do 119 | get '/sidekiq-statistic-light.css' 120 | end 121 | 122 | it 'displays light mode styles successfully' do 123 | _(last_response.status).must_equal 200 124 | _(last_response.content_type).must_match(/text\/css/) 125 | _(last_response.body).must_match(/LIGHT MODE STYLES/) 126 | end 127 | end 128 | 129 | describe 'GET /sidekiq/sidekiq-statistic-dark.css' do 130 | before do 131 | get '/sidekiq-statistic-dark.css' 132 | end 133 | 134 | it 'displays dark mode styles successfully' do 135 | _(last_response.status).must_equal 200 136 | _(last_response.content_type).must_match(/text\/css/) 137 | _(last_response.body).must_match(/DARK MODE STYLES/) 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/realtime_statistic.js: -------------------------------------------------------------------------------- 1 | const BASEURL = window.location.pathname; 2 | 3 | const requestJSON = (url, config) => 4 | fetch( 5 | `${BASEURL}/${url}`, 6 | { 7 | dataType: "json", 8 | ...config 9 | } 10 | ); 11 | 12 | const Charts = { 13 | paths: { 14 | init: 'charts_initializer.json', 15 | realtime: 'charts.json' 16 | }, 17 | options: (columns) => ({ 18 | data: { 19 | x: 'x', 20 | xFormat: '%H:%M:%S', 21 | columns: columns 22 | }, 23 | axis: { 24 | x: { 25 | type: 'timeseries', 26 | tick: { 27 | count: 5, 28 | format: '%H:%M:%S' 29 | } 30 | } 31 | } 32 | }) 33 | } 34 | 35 | const Data = { 36 | excludedWorkers: [], 37 | failedChart: [], 38 | passedChart: [] 39 | } 40 | 41 | const Elements = { 42 | toggleVisibilityButton: '.worker__toggle-visibility', 43 | realtimeToggleButton: '.realtime__toggle-button' 44 | } 45 | 46 | const Listeners = { 47 | toggleVisibilityButton: function() { 48 | document.querySelector(Elements.toggleVisibilityButton).addEventListener('click', toggleWorkerVisibility) 49 | }, 50 | realtimeToggleButton: function() { 51 | document.querySelector(Elements.realtimeToggleButton).addEventListener('click', toggleRealTime) 52 | }, 53 | } 54 | 55 | class ChartsUpdateService { 56 | intervalId = null 57 | 58 | static start(interval = 1000) { 59 | this.intervalId = setInterval(updateCharts, interval) 60 | } 61 | 62 | static stop() { 63 | clearInterval(this.intervalId) 64 | } 65 | } 66 | 67 | const initialize = () => { 68 | initializeCharts(); 69 | setEventListeners(); 70 | } 71 | 72 | const initializeCharts = () => { 73 | requestJSON(Charts.paths.init) 74 | .then(response => response.json()) 75 | .then(function (response) { 76 | const options = Charts.options(response); 77 | 78 | Data.failedChart = c3.generate({ ...options, ...{ bindto: '.realtime__failed-chart' } }); 79 | Data.passedChart = c3.generate({ ...options, ...{ bindto: '.realtime__passed-chart' } }); 80 | 81 | ChartsUpdateService.start() 82 | }) 83 | } 84 | 85 | const setEventListeners = () => { 86 | Object.keys(Listeners).forEach(listener => { 87 | Listeners[listener](); 88 | }) 89 | } 90 | 91 | function toggleWorkerVisibility(event) { 92 | const toggleWorkerButton = event.target 93 | const { name } = this 94 | 95 | const currentStatus = toggleWorkerButton.dataset.visible; 96 | const visible = !currentStatus; 97 | const visibilityIcon = toggleWorkerButton.querySelector('.worker__visibility-icon'); 98 | if(visibilityIcon) { 99 | visibilityIcon.classList.toggle(visibilityStatusClasses[currentStatus]); 100 | visibilityIcon.classList.toggle(visibilityStatusClasses[visible]); 101 | } 102 | toggleWorkerButton.dataset.visible = visible; 103 | 104 | Data.failedChart.toggle(name) 105 | Data.passedChart.toggle(name) 106 | 107 | if (!visible) { 108 | Data.excludedWorkers.push(this.name) 109 | } else { 110 | Data.excludedWorkers.splice(Data.excludedWorkers.indexOf(this.name), 1) 111 | } 112 | } 113 | 114 | function toggleRealTime(event) { 115 | const toggleRealtimeButton = event.target; 116 | const { start, stop, started } = toggleRealtimeButton.data(); 117 | 118 | const buttonText = { 119 | true: stop, 120 | false: start 121 | } 122 | 123 | const toggleButton = value => { 124 | toggleRealtimeButton.text(buttonText[value]) 125 | toggleRealtimeButton.dataset.started = value 126 | } 127 | 128 | if (started) { 129 | ChartsUpdateService.stop() 130 | } 131 | else { 132 | ChartsUpdateService.start() 133 | } 134 | 135 | toggleButton(!started) 136 | } 137 | 138 | const visibilityStatusClasses = { 139 | true: 'fa-eye', 140 | false: 'fa-eye-slash', 141 | } 142 | 143 | const noVisibleWorkers = () => 144 | Array 145 | .from(document.querySelectorAll(Elements.toggleVisibilityButton)) 146 | .every(element => element.dataset.visible === false) 147 | 148 | const updateCharts = () => { 149 | if (noVisibleWorkers()) { 150 | return 151 | } 152 | 153 | requestJSON(Charts.paths.realtime, { data: { excluded: Data.excludedWorkers } }) 154 | .then(response => response.json()) 155 | .then(function (data) { 156 | Data.failedChart.flow(data['failed']) 157 | Data.passedChart.flow(data['passed']) 158 | }) 159 | } 160 | 161 | const docReady = (callback) => { 162 | if (document.readyState !== "loading") callback(); 163 | else document.addEventListener("DOMContentLoaded", callback); 164 | } 165 | 166 | docReady(() => initialize()); 167 | -------------------------------------------------------------------------------- /test/test_sidekiq/middleware_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | require 'mocha/setup' 5 | 6 | module Sidekiq 7 | module Statistic 8 | describe 'Middleware' do 9 | def to_number(i) 10 | i.match('\.').nil? ? Integer(i) : Float(i) rescue i.to_s 11 | end 12 | 13 | before { Sidekiq.redis(&:flushdb) } 14 | 15 | let(:date) { Time.now.utc.to_date } 16 | let(:actual) do 17 | Sidekiq.redis do |conn| 18 | redis_hash = {} 19 | conn 20 | .hgetall(Metrics::Store::REDIS_HASH) 21 | .each do |keys, value| 22 | *keys, last = keys.split(":") 23 | keys.inject(redis_hash){ |hash, key| hash[key] || hash[key] = {} }[last.to_sym] = to_number(value) 24 | end 25 | 26 | redis_hash.values.last 27 | end 28 | 29 | end 30 | 31 | it 'records statistic for passed worker' do 32 | middlewared {} 33 | 34 | assert_equal 1, actual['HistoryWorker'][:passed] 35 | assert_nil actual['HistoryWorker'][:failed] 36 | end 37 | 38 | it 'records statistic for failed worker' do 39 | begin 40 | middlewared do 41 | raise StandardError.new('failed') 42 | end 43 | rescue 44 | end 45 | 46 | assert_nil actual['HistoryWorker'][:passed] 47 | assert_equal 1, actual['HistoryWorker'][:failed] 48 | end 49 | 50 | it 'records statistic for any workers' do 51 | middlewared { sleep 0.001 } 52 | begin 53 | middlewared do 54 | sleep 0.1 55 | raise StandardError.new('failed') 56 | end 57 | rescue 58 | end 59 | middlewared { sleep 0.001 } 60 | 61 | assert_equal 2, actual['HistoryWorker'][:passed] 62 | assert_equal 1, actual['HistoryWorker'][:failed] 63 | end 64 | 65 | it 'support multithreaded calculations' do 66 | workers = [] 67 | 20.times do 68 | workers << Thread.new do 69 | 25.times { middlewared {} } 70 | end 71 | end 72 | 73 | workers.each(&:join) 74 | 75 | assert_equal 500, actual['HistoryWorker'][:passed] 76 | end 77 | 78 | it 'removes 1/4 the timelist entries after crossing max_timelist_length' do 79 | workers = [] 80 | Sidekiq::Statistic.configuration.max_timelist_length = 10 81 | 11.times do 82 | middlewared {} 83 | end 84 | 85 | assert_equal 8, Sidekiq.redis { |conn| conn.llen("#{Time.now.strftime "%Y-%m-%d"}:HistoryWorker:timeslist") } 86 | end 87 | 88 | it 'supports ActiveJob workers' do 89 | message = { 90 | 'class' => 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper', 91 | 'wrapped' => 'RealWorkerClassName' 92 | } 93 | 94 | middlewared(ActiveJobWrapper, message) {} 95 | 96 | assert_equal actual.keys, ['RealWorkerClassName'] 97 | assert_equal 1, actual['RealWorkerClassName'][:passed] 98 | assert_nil actual['RealWorkerClassName'][:failed] 99 | end 100 | 101 | it 'supports mailers called from AJ' do 102 | message = { 103 | 'class' => 'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper', 104 | 'wrapped' => 'ActionMailer::DeliveryJob', 105 | 'args' => [{ 106 | 'job_class' => 'ActionMailer::DeliveryJob', 107 | 'job_id'=>'cdcc67fb-8fdc-490c-9226-9c7f46a2dbaf', 108 | 'queue_name'=>'mailers', 109 | 'arguments' => ['WrappedMailer', 'welcome_email', 'deliver_now'] 110 | }] 111 | } 112 | 113 | middlewared(ActiveJobWrapper, message) {} 114 | 115 | assert_equal actual.keys, ['WrappedMailer'] 116 | assert_equal 1, actual['WrappedMailer'][:passed] 117 | assert_nil actual['WrappedMailer'][:failed] 118 | end 119 | 120 | it 'records statistic for more than one worker' do 121 | middlewared{} 122 | middlewared(OtherHistoryWorker){} 123 | 124 | assert_equal 1, actual['HistoryWorker'][:passed] 125 | assert_nil actual['HistoryWorker'][:failed] 126 | assert_equal 1, actual['OtherHistoryWorker'][:passed] 127 | assert_nil actual['OtherHistoryWorker'][:failed] 128 | end 129 | 130 | it 'records queue statistic for each worker' do 131 | message = { 'queue' => 'default' } 132 | middlewared(HistoryWorker, message){} 133 | message = { 'queue' => 'test' } 134 | middlewared(HistoryWorkerWithQueue, message){} 135 | 136 | assert_equal 'default', actual['HistoryWorker'][:queue] 137 | assert_equal 'test', actual['HistoryWorkerWithQueue'][:queue] 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/styles/ui-datepicker.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery UI Datepicker @VERSION 3 | * http://jqueryui.com 4 | * 5 | * Copyright jQuery Foundation and other contributors 6 | * Released under the MIT license. 7 | * http://jquery.org/license 8 | * 9 | * http://api.jqueryui.com/datepicker/#theming 10 | */ 11 | 12 | .ui-datepicker { 13 | width: 17em; 14 | padding: 0.2em 0.2em 0; 15 | display: none; 16 | } 17 | 18 | .ui-datepicker .ui-datepicker-header { 19 | position: relative; 20 | padding: 0.2em 0; 21 | } 22 | 23 | .ui-datepicker-header .ui-state-hover { 24 | background: transparent; 25 | border-color: transparent; 26 | cursor: pointer; 27 | border-radius: 0; 28 | -webkit-border-radius: 0; 29 | -moz-border-radius: 0; 30 | } 31 | 32 | .ui-datepicker-header .ui-state-disabled { 33 | cursor: text; 34 | } 35 | 36 | .ui-datepicker .ui-datepicker-prev, 37 | .ui-datepicker .ui-datepicker-next { 38 | position: absolute; 39 | top: 2px; 40 | width: 1.8em; 41 | height: 1.8em; 42 | } 43 | 44 | .ui-datepicker .ui-datepicker-prev-hover, 45 | .ui-datepicker .ui-datepicker-next-hover { 46 | top: 1px; 47 | } 48 | 49 | .ui-datepicker .ui-datepicker-prev { 50 | left: 2px; 51 | } 52 | 53 | .ui-datepicker .ui-datepicker-next { 54 | right: 15px; 55 | } 56 | 57 | .ui-datepicker .ui-datepicker-prev-hover { 58 | left: 1px; 59 | } 60 | 61 | .ui-datepicker .ui-datepicker-next-hover { 62 | right: 15px; 63 | } 64 | 65 | .ui-datepicker .ui-datepicker-prev span, 66 | .ui-datepicker .ui-datepicker-next span { 67 | display: block; 68 | position: absolute; 69 | left: 50%; 70 | margin-left: -8px; 71 | top: 50%; 72 | margin-top: -8px; 73 | } 74 | 75 | .ui-datepicker .ui-datepicker-title { 76 | margin: 0 2.3em; 77 | line-height: 1.8em; 78 | text-align: center; 79 | } 80 | 81 | .ui-datepicker .ui-datepicker-title select { 82 | font-size: 1em; 83 | margin: 1px 0; 84 | } 85 | 86 | .ui-datepicker select.ui-datepicker-month, 87 | .ui-datepicker select.ui-datepicker-year { 88 | width: 45%; 89 | } 90 | 91 | .ui-datepicker table { 92 | width: 100%; 93 | font-size: 0.9em; 94 | border-collapse: collapse; 95 | margin: 0 0 0.4em; 96 | } 97 | 98 | .ui-datepicker td { 99 | border: none; 100 | padding: 0; 101 | } 102 | 103 | .ui-datepicker td span, 104 | .ui-datepicker td a { 105 | display: block; 106 | padding: 0.1em; 107 | text-align: center; 108 | text-decoration: none; 109 | } 110 | 111 | .ui-datepicker td a:hover { 112 | font-weight: bold; 113 | } 114 | 115 | .ui-datepicker .ui-datepicker-buttonpane { 116 | background-image: none; 117 | margin: 0.7em 0 0; 118 | padding: 0 0.2em; 119 | border-left: 0; 120 | border-right: 0; 121 | border-bottom: 0; 122 | } 123 | 124 | .ui-datepicker .ui-datepicker-buttonpane button { 125 | float: right; 126 | margin: 0.5em 0.2em 0.4em; 127 | cursor: pointer; 128 | padding: 0.2em 0.6em 0.3em; 129 | width: auto; 130 | overflow: visible; 131 | } 132 | 133 | .ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { 134 | float: left; 135 | } 136 | 137 | .ui-datepicker.ui-datepicker-multi { 138 | width: auto; 139 | } 140 | 141 | .ui-datepicker-multi .ui-datepicker-group { 142 | float: left; 143 | } 144 | 145 | .ui-datepicker-multi .ui-datepicker-group table { 146 | width: 95%; 147 | margin: 0 auto 0.4em; 148 | } 149 | 150 | .ui-datepicker-multi-2 .ui-datepicker-group { 151 | width: 50%; 152 | } 153 | 154 | .ui-datepicker-multi-3 .ui-datepicker-group { 155 | width: 33.3%; 156 | } 157 | 158 | .ui-datepicker-multi-4 .ui-datepicker-group { 159 | width: 25%; 160 | } 161 | 162 | .ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header, 163 | .ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { 164 | border-left-width: 0; 165 | } 166 | 167 | .ui-datepicker-multi .ui-datepicker-buttonpane { 168 | clear: left; 169 | } 170 | 171 | .ui-datepicker-row-break { 172 | clear: both; 173 | width: 100%; 174 | font-size: 0; 175 | } 176 | 177 | .ui-datepicker-rtl { 178 | direction: rtl; 179 | } 180 | 181 | .ui-datepicker-rtl .ui-datepicker-prev { 182 | right: 2px; 183 | left: auto; 184 | } 185 | 186 | .ui-datepicker-rtl .ui-datepicker-next { 187 | left: 2px; 188 | right: auto; 189 | } 190 | 191 | .ui-datepicker-rtl .ui-datepicker-prev:hover { 192 | right: 1px; 193 | left: auto; 194 | } 195 | 196 | .ui-datepicker-rtl .ui-datepicker-next:hover { 197 | left: 1px; 198 | right: auto; 199 | } 200 | 201 | .ui-datepicker-rtl .ui-datepicker-buttonpane { 202 | clear: right; 203 | } 204 | 205 | .ui-datepicker-rtl .ui-datepicker-buttonpane button { 206 | float: left; 207 | } 208 | 209 | .ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current, 210 | .ui-datepicker-rtl .ui-datepicker-group { 211 | float: right; 212 | } 213 | 214 | .ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header, 215 | .ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { 216 | border-right-width: 0; 217 | border-left-width: 1px; 218 | } 219 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## HEAD 2 | 3 | ## v2.0.0 4 | 5 | #### BREAK 6 | * 27.10.2021: Remove support for Logs feature (#171) *Wender Freese* 7 | 8 | ## v1.5.1 9 | 10 | * 06.02.2021: Generate new TAG to fix "version.rb" not updated in the previous one (#170) *Wender Freese* 11 | * 02.02.2021: Fix tests (#167) *Kirill Tatchihin* 12 | * 17.01.2021: Refactor middleware to break responsibilities (#165) *Wender Freese* 13 | 14 | ## v1.5.0 15 | 16 | * 16.01.2021: Fully support dark mode (#164) *Wender Freese* 17 | * 02.09.2020: Improve dark mode workers table links readability (#160) *V-Gutierrez* 18 | * 25.08.2020: Refactor realtime statistic JS code (#159) *kostadriano* 19 | * 24.08.2020: Fix translation pt-Br start/stop (#153) *brunnohenrique* 20 | * 01.07.2020: Update workers toggle visibility button on RealTime (#156) *kostadriano* 21 | * 06.12.2019: Avoid whites when generating colors (#141) *Wender Freese* 22 | * 25.11.2019: Add line break in log visualization *Dmitriy* 23 | * 25.11.2019: Fix high memory usage in Log Parser *Dmitriy* 24 | * 04.10.2019: Fix UI problem when the number of workers increases too much (#140) *Guilherme Quirino* 25 | 26 | ## v1.4.0 27 | 28 | * 13.09.2019: Replace `chart.js` to `c3.js` (#139) *Guilherme Quirino* 29 | * 17.08.2019: Improve Date translations (#136) *Guilherme Quirino* 30 | * 05.08.2019: Add translation to FR (#135) *Wender Freese* 31 | * 02.08.2019: Add translation to JP (#133) *Emerson Araki* 32 | * 30.06.2019: Fix UI problem in realtime graphics when hided/showed (#130) *Wender Freese* 33 | * 28.06.2019: Fix UI problem in busy workers counter (#129) *Guilherme Quirino* 34 | * 24.06.2019: Update `chart.js` to V2 (#128) *Guilherme Quirino* 35 | * 11.05.2019: Add translations to PT-BR (#126) *Guilherme Quirino* 36 | * 08.03.2019: Change LogParser regexp (#81) *Kirill Tatchihin* 37 | * 03.02.2019: Change storing of last_runtime from date to timestamp (#87) *Kirill Tatchihin* 38 | * 30.03.2017: Prevent excessive Redis memory usage (#107) *Gareth du Plooy* 39 | * 08.04.2016: Add new option for displaying last N lines of log file (#91) *Nick Zhebrun* 40 | * 20.11.2015: Convert value in redis time array to float (#76) *Anton Davydov* 41 | * 20.11.2015: Add Ukrainian Localization (#85) *@POStroi* 42 | 43 | ## v1.2 44 | * 15.11.2015: Update gemspec to allow usage with sidekiq 4 (#83) *Felix Bünemann* 45 | * 21.10.2015: Fix charts initialize and Uncaught TypeError (#70, #79) *Anton Davydov* 46 | * 17.10.2015: Fix worker's per day stats (#78) *Alexander Yunin* 47 | * 28.09.2015: Use strftime to ensure date string format (#77) *@stan* 48 | * 02.09.2015: Sort worker names in GUI (#69) *Anton Davydov* 49 | 50 | ## v1.1 51 | * 29.08.2015: Create custom tooltip for charts on index page (fix #63) *Anton Davydov* 52 | * 26.08.2015: Add queue to workers table in index page *Anton Davydov* 53 | * 25.08.2015: Italian localization *Fabio Napoleoni* 54 | * 25.08.2015: Fix worker naming for AJ mailers (fix #59) *Anton Davydov* 55 | * 21.08.2015: Use dynamic path generation for json requests (fix #56) *Anton Davydov* 56 | * 21.08.2015: Add button in log page for display only special job (#40) *Anton Davydov* 57 | * 20.08.2015: Add German Localization (#54) *Felix Bünemann* 58 | * 20.08.2015: Fix statistics display for nested worker classes (#48) *Felix Bünemann* 59 | 60 | ## v1.0 61 | * 19.08.2015: Middleware refactoring (#45) *Mike Perham* 62 | * 19.08.2015: Use redis lists for save all job runtimes *Anton Davydov* 63 | * 12.08.2015: Add filters (by worker) for realtime charts *Anton Davydov* 64 | * 11.08.2015: Realtime chart for each worker and job *Anton Davydov* 65 | * 31.07.2015: Add JSON API *Anton Davydov* 66 | * 29.07.2015: Add localizations for plugin *Anton Davydov* 67 | * 28.07.2015: Read first 1_000 lines from changelog *Anton Davydov* 68 | * 28.07.2015: Rename plugin to sidekiq-statistic *Anton Davydov* 69 | * 23.07.2015: Use native redis hash instead json serialization *Anton Davydov* 70 | * 15.07.2015: Improve integration with active job *Anton Davydov* 71 | * 01.07.2015: New realisation for thread safe history middleware *Anton Davydov* 72 | * 13.05.2015: Add ability to change any date range on any history page *Anton Davydov* 73 | * 12.05.2015: Add last job status data parameter for each worker *Anton Davydov* 74 | * 11.05.2015: Add page woth worker data table for each day *Anton Davydov* 75 | * 28.04.2015: Formating worker date in web UI *Anton Davydov* 76 | * 27.04.2015: Set specific color for any worker *Anton Davydov* 77 | * 10.04.2015: Add search field on worker page *Anton Davydov* 78 | * 04.04.2015: Fix livereload button in index page *Anton Davydov* 79 | * 23.03.2015: Add max runtime column to worker web table *Anton Davydov* 80 | * 19.03.2015: Add functionality for adding custom css and js files to web page *Anton Davydov* 81 | * 18.03.2015: Add configuration class with log_file options *Anton Davydov* 82 | * 16.03.2015: Add worker page where user can see log for this worker *Anton Davydov* 83 | * 15.03.2015: Add worker statistic table to index history page *Anton Davydov* 84 | * 08.03.2015: Add charts for each passed and failed jobs for each worker. *Anton Davydov* 85 | * 08.03.2015: Add Statistic class which provide statistics for each day and each worker. *Anton Davydov* 86 | * 08.03.2015: Save in redis json with failed and passed jobs for each worker. *Anton Davydov* 87 | * 04.03.2015: Created simple midelware and static page. *Anton Davydov* 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Sidekiq::Statistic 3 | 4 | [![Build Status](https://travis-ci.org/davydovanton/sidekiq-statistic.svg)](https://travis-ci.org/davydovanton/sidekiq-statistic) [![Code Climate](https://codeclimate.com/github/davydovanton/sidekiq-history/badges/gpa.svg)](https://codeclimate.com/github/davydovanton/sidekiq-history) [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/davydovanton/sidekiq-history?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 5 | 6 | Improved display of statistics for your Sidekiq workers and jobs. 7 | 8 | ## Screenshots 9 | 10 | ### Index page: 11 | ![sidekiq-history_index](https://user-images.githubusercontent.com/15057257/66249364-74645d80-e708-11e9-8f06-a9a224be4e37.png) 12 | 13 | ### Worker page with table (per day): 14 | ![sidekiq-history_worker](https://cloud.githubusercontent.com/assets/1147484/8071171/1706924a-0f10-11e5-9ddc-8aeeb7f5c794.png) 15 | 16 | ## Installation 17 | Add this line to your application's Gemfile: 18 | 19 | gem 'sidekiq-statistic' 20 | 21 | And then execute: 22 | 23 | $ bundle 24 | 25 | ## Usage 26 | 27 | ### Using Rails 28 | 29 | Read [Sidekiq documentation](https://github.com/mperham/sidekiq/wiki/Monitoring#rails) to configure Sidekiq Web UI in your `routes.rb`. 30 | 31 | When Sidekiq Web UI is active you're going be able to see the option `Statistic` on the menu. 32 | 33 | ### Using a standalone application 34 | 35 | Read [Sidekiq documentation](https://github.com/mperham/sidekiq/wiki/Monitoring#standalone) to configure Sidekiq in your Rack server. 36 | 37 | Next add `require 'sidekiq-statistic'` to your `config.ru`. 38 | 39 | ``` ruby 40 | # config.ru 41 | require 'sidekiq/web' 42 | require 'sidekiq-statistic' 43 | 44 | use Rack::Session::Cookie, secret: 'some unique secret string here' 45 | run Sidekiq::Web 46 | ``` 47 | 48 | ## Configuration 49 | 50 | The Statistic configuration is an initializer that GEM uses to configure itself. The option `max_timelist_length` 51 | is used to avoid memory leak, in practice, whenever the cache size reaches that number, the GEM is going 52 | to remove 25% of the key values, avoiding inflating memory. 53 | 54 | ``` ruby 55 | Sidekiq::Statistic.configure do |config| 56 | config.max_timelist_length = 250_000 57 | end 58 | ``` 59 | 60 | ## Supported Sidekiq versions 61 | 62 | Statistic support the following Sidekiq versions: 63 | 64 | - Sidekiq 6. 65 | - Sidekiq 5. 66 | - Sidekiq 4. 67 | - Sidekiq 3.5. 68 | 69 | ## JSON API 70 | ### /api/statistic.json 71 | Returns statistic for each worker. 72 | 73 | Params: 74 | * `dateFrom` - Date start (format: `yyyy-mm-dd`) 75 | * `dateTo` - Date end (format: `yyyy-mm-dd`) 76 | 77 | Example: 78 | ``` 79 | $ curl http://example.com/sidekiq/api/statistic.json?dateFrom=2015-07-30&dateTo=2015-07-31 80 | 81 | # => 82 | { 83 | "workers": [ 84 | { 85 | "name": "Worker", 86 | "last_job_status": "passed", 87 | "number_of_calls": { 88 | "success": 1, 89 | "failure": 0, 90 | "total": 1 91 | }, 92 | "runtime": { 93 | "last": "2015-07-31 10:42:13 UTC", 94 | "max": 4.002, 95 | "min": 4.002, 96 | "average": 4.002, 97 | "total": 4.002 98 | } 99 | }, 100 | 101 | ... 102 | ] 103 | } 104 | ``` 105 | 106 | ### /api/statistic/:worker_name.json 107 | Returns worker statistic for each day in range. 108 | 109 | Params: 110 | * `dateFrom` - Date start (format: `yyyy-mm-dd`) 111 | * `dateTo` - Date end (format: `yyyy-mm-dd`) 112 | 113 | Example: 114 | ``` 115 | $ curl http://example.com/sidekiq/api/statistic/Worker.json?dateFrom=2015-07-30&dateTo=2015-07-31 116 | 117 | # => 118 | { 119 | "days": [ 120 | { 121 | "date": "2015-07-31", 122 | "failure": 0, 123 | "success": 1, 124 | "total": 1, 125 | "last_job_status": "passed", 126 | "runtime": { 127 | "last": null, 128 | "max": 0, 129 | "min": 0, 130 | "average": 0, 131 | "total": 0 132 | } 133 | }, 134 | 135 | ... 136 | ] 137 | } 138 | ``` 139 | 140 | ## Update statistic inside middleware 141 | You can update your worker statistic inside middleware. For this you should to update `sidekiq:statistic` redis hash. 142 | This hash has the following structure: 143 | * `sidekiq:statistic` - redis hash with all statistic 144 | - `yyyy-mm-dd:WorkerName:passed` - count of passed jobs for Worker name on yyyy-mm-dd 145 | - `yyyy-mm-dd:WorkerName:failed` - count of failed jobs for Worker name on yyyy-mm-dd 146 | - `yyyy-mm-dd:WorkerName:last_job_status` - string with status (`passed` or `failed`) for last job 147 | - `yyyy-mm-dd:WorkerName:last_time` - date of last job performing 148 | - `yyyy-mm-dd:WorkerName:queue` - name of job queue (`default` by default) 149 | 150 | For time information you should push the runtime value to `yyyy-mm-dd:WorkerName:timeslist` redis list. 151 | 152 | ## How it works 153 |
154 | Big image 'how it works' 155 | 156 | ![how-it-works](https://cloud.githubusercontent.com/assets/1147484/8802272/fc0a1302-2fc8-11e5-86a5-817409259338.png) 157 | 158 |
159 | 160 | ## Contributing 161 | 1. Fork it ( https://github.com/davydovanton/sidekiq-statistic/fork ) 162 | 2. Create your feature branch (`git checkout -b my-new-feature`) 163 | 3. Commit your changes (`git commit -am 'Add some feature'`) 164 | 4. Push to the branch (`git push origin my-new-feature`) 165 | 5. Create a new Pull Request 166 | -------------------------------------------------------------------------------- /test/test_sidekiq/statistic_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest_helper' 4 | 5 | module Sidekiq 6 | module Statistic 7 | describe 'Workers' do 8 | before { Sidekiq.redis(&:flushdb) } 9 | 10 | let(:statistic) { Sidekiq::Statistic::Workers.new(1) } 11 | let(:base_statistic) { Sidekiq::Statistic::Base.new(1) } 12 | let(:worker) { 'HistoryWorker' } 13 | 14 | describe '#number_of_calls' do 15 | it 'returns success jobs count for worker' do 16 | 10.times { middlewared {} } 17 | 18 | count = statistic.number_of_calls('HistoryWorker') 19 | assert_equal 10, count[:success] 20 | end 21 | 22 | describe 'when success jobs were not call' do 23 | it 'returns zero' do 24 | 10.times do 25 | begin 26 | middlewared do 27 | raise StandardError.new('failed') 28 | end 29 | rescue 30 | end 31 | end 32 | 33 | count = statistic.number_of_calls('HistoryWorker') 34 | assert_equal 0, count[:success] 35 | end 36 | end 37 | 38 | it 'returns failure jobs count for worker' do 39 | 10.times do 40 | begin 41 | middlewared do 42 | raise StandardError.new('failed') 43 | end 44 | rescue 45 | end 46 | end 47 | 48 | count = statistic.number_of_calls('HistoryWorker') 49 | assert_equal 10, count[:failure] 50 | end 51 | 52 | describe 'when failure jobs were not call' do 53 | it 'returns zero' do 54 | 10.times { middlewared {} } 55 | 56 | count = statistic.number_of_calls('HistoryWorker') 57 | assert_equal 0, count[:failure] 58 | end 59 | end 60 | 61 | it 'returns total jobs count for worker' do 62 | 10.times do 63 | middlewared {} 64 | 65 | begin 66 | middlewared do 67 | raise StandardError.new('failed') 68 | end 69 | rescue 70 | end 71 | end 72 | 73 | count = statistic.number_of_calls('HistoryWorker') 74 | 75 | assert_equal 20, count[:total] 76 | end 77 | 78 | describe 'when total jobs were not call' do 79 | it 'returns zero' do 80 | count = statistic.number_of_calls('HistoryWorker') 81 | assert_equal 0, count[:failure] 82 | end 83 | end 84 | 85 | it 'returns proper stats for nested workers' do 86 | middlewared(Nested::HistoryWorker) {} 87 | 88 | count = statistic.number_of_calls('Nested::HistoryWorker') 89 | assert_equal 1, count[:total] 90 | end 91 | end 92 | 93 | describe '#display' do 94 | it 'return workers' do 95 | middlewared {} 96 | 97 | subject = statistic.display 98 | 99 | _(subject).must_be_instance_of Array 100 | assert_equal subject[0].keys.sort, 101 | %i[name last_job_status number_of_calls queue runtime].sort 102 | 103 | assert_equal worker, subject[0][:name] 104 | end 105 | end 106 | 107 | describe '#display_per_day' do 108 | it 'return workers job per day' do 109 | middlewared {} 110 | 111 | subject = statistic.display_per_day(worker) 112 | 113 | _(subject).must_be_instance_of Array 114 | assert_equal subject[0].keys.sort, 115 | %i[date failure last_job_status runtime success total].sort 116 | assert_equal Time.now.strftime("%Y-%m-%d"), subject[0][:date] 117 | end 118 | end 119 | 120 | describe '#runtime_for_day' do 121 | it 'return runtime' do 122 | middlewared {} 123 | 124 | worker_statistic = base_statistic.statistic_for(worker)[1] 125 | subject = statistic.runtime_for_day(worker, worker_statistic) 126 | 127 | _(subject).must_be_instance_of Hash 128 | assert_equal subject.keys.sort, %i[average last max min total].sort 129 | assert_equal worker_statistic[:average_time], subject[:average] 130 | assert_equal worker_statistic[:last_time], subject[:last] 131 | assert_equal worker_statistic[:max_time], subject[:max] 132 | assert_equal worker_statistic[:min_time], subject[:min] 133 | assert_equal worker_statistic[:total_time], subject[:total] 134 | end 135 | end 136 | 137 | describe '#number_of_calls_for' do 138 | it 'count passed jobs' do 139 | 5.times { middlewared {} } 140 | 141 | count = statistic.number_of_calls_for(:passed, worker) 142 | 143 | assert_equal 5, count 144 | end 145 | 146 | it 'count failed jobs' do 147 | 5.times do 148 | middlewared {} 149 | 150 | begin 151 | middlewared do 152 | raise StandardError.new('failed') 153 | end 154 | rescue 155 | end 156 | end 157 | 158 | count = statistic.number_of_calls_for(:failed, worker) 159 | 160 | assert_equal 5, count 161 | end 162 | end 163 | 164 | describe '#last_job_status_for' do 165 | it 'failed last job' do 166 | middlewared {} 167 | middlewared {} 168 | begin 169 | middlewared do 170 | raise StandardError.new('failed') 171 | end 172 | rescue 173 | end 174 | 175 | status = statistic.last_job_status_for(worker) 176 | 177 | assert_equal 'failed', status 178 | end 179 | 180 | it 'passed last job' do 181 | middlewared {} 182 | begin 183 | middlewared do 184 | raise StandardError.new('failed') 185 | end 186 | rescue 187 | end 188 | middlewared {} 189 | 190 | status = statistic.last_job_status_for(worker) 191 | 192 | assert_equal 'passed', status 193 | end 194 | end 195 | 196 | describe '#last_queue' do 197 | it 'returns last queue' do 198 | message = { 'queue' => 'queue_test' } 199 | middlewared(HistoryWorker, message) {} 200 | 201 | last_queue = statistic.last_queue(worker) 202 | assert_equal message['queue'], last_queue 203 | end 204 | end 205 | 206 | describe '#runtime_statistic' do 207 | it 'returns instance of Runtime' do 208 | middlewared {} 209 | 210 | runtime_statistic = statistic.runtime_statistic(worker) 211 | assert_instance_of Sidekiq::Statistic::Runtime, runtime_statistic 212 | end 213 | 214 | it 'returns object with value passed' do 215 | middlewared {} 216 | 217 | time = 1.00268 218 | runtime_statistic = statistic.runtime_statistic(worker, { average_time: time }) 219 | 220 | assert_equal runtime_statistic.average_runtime, time 221 | end 222 | end 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /lib/sidekiq/statistic/views/statistic.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery UI Datepicker @VERSION 3 | * http://jqueryui.com 4 | * 5 | * Copyright jQuery Foundation and other contributors 6 | * Released under the MIT license. 7 | * http://jquery.org/license 8 | */ 9 | 10 | !function(e){"function"==typeof define&&define.amd?define(["jquery","./core"],e):e(jQuery)}(function(e){function t(e){for(var t,a;e.length&&e[0]!==document;){if(t=e.css("position"),("absolute"===t||"relative"===t||"fixed"===t)&&(a=parseInt(e.css("zIndex"),10),!isNaN(a)&&0!==a))return a;e=e.parent()}return 0}function a(){this._curInst=null,this._keyEvent=!1,this._disabledInputs=[],this._datepickerShowing=!1,this._inDialog=!1,this._mainDivId="ui-datepicker-div",this._inlineClass="ui-datepicker-inline",this._appendClass="ui-datepicker-append",this._triggerClass="ui-datepicker-trigger",this._dialogClass="ui-datepicker-dialog",this._disableClass="ui-datepicker-disabled",this._unselectableClass="ui-datepicker-unselectable",this._currentClass="ui-datepicker-current-day",this._dayOverClass="ui-datepicker-days-cell-over",this.regional=[],this.regional[""]={closeText:"Done",prevText:"Prev",nextText:"Next",currentText:"Today",monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],weekHeader:"Wk",dateFormat:"mm/dd/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""},this._defaults={showOn:"focus",showAnim:"fadeIn",showOptions:{},defaultDate:null,appendText:"",buttonText:"...",buttonImage:"",buttonImageOnly:!1,hideIfNoPrevNext:!1,navigationAsDateFormat:!1,gotoCurrent:!1,changeMonth:!1,changeYear:!1,yearRange:"c-10:c+10",showOtherMonths:!1,selectOtherMonths:!1,showWeek:!1,calculateWeek:this.iso8601Week,shortYearCutoff:"+10",minDate:null,maxDate:null,duration:"fast",beforeShowDay:null,beforeShow:null,onSelect:null,onChangeMonthYear:null,onClose:null,numberOfMonths:1,showCurrentAtPos:0,stepMonths:1,stepBigMonths:12,altField:"",altFormat:"",constrainInput:!0,showButtonPanel:!1,autoSize:!1,disabled:!1},e.extend(this._defaults,this.regional[""]),this.regional.en=e.extend(!0,{},this.regional[""]),this.regional["en-US"]=e.extend(!0,{},this.regional.en),this.dpDiv=i(e("
"))}function i(t){var a="button, .ui-datepicker-prev, .ui-datepicker-next, .ui-datepicker-calendar td a";return t.delegate(a,"mouseout",function(){e(this).removeClass("ui-state-hover"),-1!==this.className.indexOf("ui-datepicker-prev")&&e(this).removeClass("ui-datepicker-prev-hover"),-1!==this.className.indexOf("ui-datepicker-next")&&e(this).removeClass("ui-datepicker-next-hover")}).delegate(a,"mouseover",s)}function s(){e.datepicker._isDisabledDatepicker(n.inline?n.dpDiv.parent()[0]:n.input[0])||(e(this).parents(".ui-datepicker-calendar").find("a").removeClass("ui-state-hover"),e(this).addClass("ui-state-hover"),-1!==this.className.indexOf("ui-datepicker-prev")&&e(this).addClass("ui-datepicker-prev-hover"),-1!==this.className.indexOf("ui-datepicker-next")&&e(this).addClass("ui-datepicker-next-hover"))}function r(t,a){e.extend(t,a);for(var i in a)null==a[i]&&(t[i]=a[i]);return t}e.extend(e.ui,{datepicker:{version:"@VERSION"}});var n;return e.extend(a.prototype,{markerClassName:"hasDatepicker",maxRows:4,_widgetDatepicker:function(){return this.dpDiv},setDefaults:function(e){return r(this._defaults,e||{}),this},_attachDatepicker:function(t,a){var i,s,r;i=t.nodeName.toLowerCase(),s="div"===i||"span"===i,t.id||(this.uuid+=1,t.id="dp"+this.uuid),r=this._newInst(e(t),s),r.settings=e.extend({},a||{}),"input"===i?this._connectDatepicker(t,r):s&&this._inlineDatepicker(t,r)},_newInst:function(t,a){var s=t[0].id.replace(/([^A-Za-z0-9_\-])/g,"\\\\$1");return{id:s,input:t,selectedDay:0,selectedMonth:0,selectedYear:0,drawMonth:0,drawYear:0,inline:a,dpDiv:a?i(e("
")):this.dpDiv}},_connectDatepicker:function(t,a){var i=e(t);a.append=e([]),a.trigger=e([]),i.hasClass(this.markerClassName)||(this._attachments(i,a),i.addClass(this.markerClassName).keydown(this._doKeyDown).keypress(this._doKeyPress).keyup(this._doKeyUp),this._autoSize(a),e.data(t,"datepicker",a),a.settings.disabled&&this._disableDatepicker(t))},_attachments:function(t,a){var i,s,r,n=this._get(a,"appendText"),d=this._get(a,"isRTL");a.append&&a.append.remove(),n&&(a.append=e(""+n+""),t[d?"before":"after"](a.append)),t.unbind("focus",this._showDatepicker),a.trigger&&a.trigger.remove(),i=this._get(a,"showOn"),("focus"===i||"both"===i)&&t.focus(this._showDatepicker),("button"===i||"both"===i)&&(s=this._get(a,"buttonText"),r=this._get(a,"buttonImage"),a.trigger=e(this._get(a,"buttonImageOnly")?e("").addClass(this._triggerClass).attr({src:r,alt:s,title:s}):e("").addClass(this._triggerClass).html(r?e("").attr({src:r,alt:s,title:s}):s)),t[d?"before":"after"](a.trigger),a.trigger.click(function(){return e.datepicker._datepickerShowing&&e.datepicker._lastInput===t[0]?e.datepicker._hideDatepicker():e.datepicker._datepickerShowing&&e.datepicker._lastInput!==t[0]?(e.datepicker._hideDatepicker(),e.datepicker._showDatepicker(t[0])):e.datepicker._showDatepicker(t[0]),!1}))},_autoSize:function(e){if(this._get(e,"autoSize")&&!e.inline){var t,a,i,s,r=new Date(2009,11,20),n=this._get(e,"dateFormat");n.match(/[DM]/)&&(t=function(e){for(a=0,i=0,s=0;sa&&(a=e[s].length,i=s);return i},r.setMonth(t(this._get(e,n.match(/MM/)?"monthNames":"monthNamesShort"))),r.setDate(t(this._get(e,n.match(/DD/)?"dayNames":"dayNamesShort"))+20-r.getDay())),e.input.attr("size",this._formatDate(e,r).length)}},_inlineDatepicker:function(t,a){var i=e(t);i.hasClass(this.markerClassName)||(i.addClass(this.markerClassName).append(a.dpDiv),e.data(t,"datepicker",a),this._setDate(a,this._getDefaultDate(a),!0),this._updateDatepicker(a),this._updateAlternate(a),a.settings.disabled&&this._disableDatepicker(t),a.dpDiv.css("display","block"))},_dialogDatepicker:function(t,a,i,s,n){var d,c,o,l,h,u=this._dialogInst;return u||(this.uuid+=1,d="dp"+this.uuid,this._dialogInput=e(""),this._dialogInput.keydown(this._doKeyDown),e("body").append(this._dialogInput),u=this._dialogInst=this._newInst(this._dialogInput,!1),u.settings={},e.data(this._dialogInput[0],"datepicker",u)),r(u.settings,s||{}),a=a&&a.constructor===Date?this._formatDate(u,a):a,this._dialogInput.val(a),this._pos=n?n.length?n:[n.pageX,n.pageY]:null,this._pos||(c=document.documentElement.clientWidth,o=document.documentElement.clientHeight,l=document.documentElement.scrollLeft||document.body.scrollLeft,h=document.documentElement.scrollTop||document.body.scrollTop,this._pos=[c/2-100+l,o/2-150+h]),this._dialogInput.css("left",this._pos[0]+20+"px").css("top",this._pos[1]+"px"),u.settings.onSelect=i,this._inDialog=!0,this.dpDiv.addClass(this._dialogClass),this._showDatepicker(this._dialogInput[0]),e.blockUI&&e.blockUI(this.dpDiv),e.data(this._dialogInput[0],"datepicker",u),this},_destroyDatepicker:function(t){var a,i=e(t),s=e.data(t,"datepicker");i.hasClass(this.markerClassName)&&(a=t.nodeName.toLowerCase(),e.removeData(t,"datepicker"),"input"===a?(s.append.remove(),s.trigger.remove(),i.removeClass(this.markerClassName).unbind("focus",this._showDatepicker).unbind("keydown",this._doKeyDown).unbind("keypress",this._doKeyPress).unbind("keyup",this._doKeyUp)):("div"===a||"span"===a)&&i.removeClass(this.markerClassName).empty(),n===s&&(n=null))},_enableDatepicker:function(t){var a,i,s=e(t),r=e.data(t,"datepicker");s.hasClass(this.markerClassName)&&(a=t.nodeName.toLowerCase(),"input"===a?(t.disabled=!1,r.trigger.filter("button").each(function(){this.disabled=!1}).end().filter("img").css({opacity:"1.0",cursor:""})):("div"===a||"span"===a)&&(i=s.children("."+this._inlineClass),i.children().removeClass("ui-state-disabled"),i.find("select.ui-datepicker-month, select.ui-datepicker-year").prop("disabled",!1)),this._disabledInputs=e.map(this._disabledInputs,function(e){return e===t?null:e}))},_disableDatepicker:function(t){var a,i,s=e(t),r=e.data(t,"datepicker");s.hasClass(this.markerClassName)&&(a=t.nodeName.toLowerCase(),"input"===a?(t.disabled=!0,r.trigger.filter("button").each(function(){this.disabled=!0}).end().filter("img").css({opacity:"0.5",cursor:"default"})):("div"===a||"span"===a)&&(i=s.children("."+this._inlineClass),i.children().addClass("ui-state-disabled"),i.find("select.ui-datepicker-month, select.ui-datepicker-year").prop("disabled",!0)),this._disabledInputs=e.map(this._disabledInputs,function(e){return e===t?null:e}),this._disabledInputs[this._disabledInputs.length]=t)},_isDisabledDatepicker:function(e){if(!e)return!1;for(var t=0;ti||!a||a.indexOf(i)>-1):void 0},_doKeyUp:function(t){var a,i=e.datepicker._getInst(t.target);if(i.input.val()!==i.lastVal)try{a=e.datepicker.parseDate(e.datepicker._get(i,"dateFormat"),i.input?i.input.val():null,e.datepicker._getFormatConfig(i)),a&&(e.datepicker._setDateFromField(i),e.datepicker._updateAlternate(i),e.datepicker._updateDatepicker(i))}catch(s){}return!0},_showDatepicker:function(a){if(a=a.target||a,"input"!==a.nodeName.toLowerCase()&&(a=e("input",a.parentNode)[0]),!e.datepicker._isDisabledDatepicker(a)&&e.datepicker._lastInput!==a){var i,s,n,d,c,o,l;i=e.datepicker._getInst(a),e.datepicker._curInst&&e.datepicker._curInst!==i&&(e.datepicker._curInst.dpDiv.stop(!0,!0),i&&e.datepicker._datepickerShowing&&e.datepicker._hideDatepicker(e.datepicker._curInst.input[0])),s=e.datepicker._get(i,"beforeShow"),n=s?s.apply(a,[a,i]):{},n!==!1&&(r(i.settings,n),i.lastVal=null,e.datepicker._lastInput=a,e.datepicker._setDateFromField(i),e.datepicker._inDialog&&(a.value=""),e.datepicker._pos||(e.datepicker._pos=e.datepicker._findPos(a),e.datepicker._pos[1]+=a.offsetHeight),d=!1,e(a).parents().each(function(){return d|="fixed"===e(this).css("position"),!d}),c={left:e.datepicker._pos[0],top:e.datepicker._pos[1]},e.datepicker._pos=null,i.dpDiv.empty(),i.dpDiv.css({position:"absolute",display:"block",top:"-1000px"}),e.datepicker._updateDatepicker(i),c=e.datepicker._checkOffset(i,c,d),i.dpDiv.css({position:e.datepicker._inDialog&&e.blockUI?"static":d?"fixed":"absolute",display:"none",left:c.left+"px",top:c.top+"px"}),i.inline||(o=e.datepicker._get(i,"showAnim"),l=e.datepicker._get(i,"duration"),i.dpDiv.css("z-index",t(e(a))+1),e.datepicker._datepickerShowing=!0,e.effects&&e.effects.effect[o]?i.dpDiv.show(o,e.datepicker._get(i,"showOptions"),l):i.dpDiv[o||"show"](o?l:null),e.datepicker._shouldFocusInput(i)&&i.input.focus(),e.datepicker._curInst=i))}},_updateDatepicker:function(t){this.maxRows=4,n=t,t.dpDiv.empty().append(this._generateHTML(t)),this._attachHandlers(t);var a,i=this._getNumberOfMonths(t),r=i[1],d=17,c=t.dpDiv.find("."+this._dayOverClass+" a");c.length>0&&s.apply(c.get(0)),t.dpDiv.removeClass("ui-datepicker-multi-2 ui-datepicker-multi-3 ui-datepicker-multi-4").width(""),r>1&&t.dpDiv.addClass("ui-datepicker-multi-"+r).css("width",d*r+"em"),t.dpDiv[(1!==i[0]||1!==i[1]?"add":"remove")+"Class"]("ui-datepicker-multi"),t.dpDiv[(this._get(t,"isRTL")?"add":"remove")+"Class"]("ui-datepicker-rtl"),t===e.datepicker._curInst&&e.datepicker._datepickerShowing&&e.datepicker._shouldFocusInput(t)&&t.input.focus(),t.yearshtml&&(a=t.yearshtml,setTimeout(function(){a===t.yearshtml&&t.yearshtml&&t.dpDiv.find("select.ui-datepicker-year:first").replaceWith(t.yearshtml),a=t.yearshtml=null},0))},_shouldFocusInput:function(e){return e.input&&e.input.is(":visible")&&!e.input.is(":disabled")&&!e.input.is(":focus")},_checkOffset:function(t,a,i){var s=t.dpDiv.outerWidth(),r=t.dpDiv.outerHeight(),n=t.input?t.input.outerWidth():0,d=t.input?t.input.outerHeight():0,c=document.documentElement.clientWidth+(i?0:e(document).scrollLeft()),o=document.documentElement.clientHeight+(i?0:e(document).scrollTop());return a.left-=this._get(t,"isRTL")?s-n:0,a.left-=i&&a.left===t.input.offset().left?e(document).scrollLeft():0,a.top-=i&&a.top===t.input.offset().top+d?e(document).scrollTop():0,a.left-=Math.min(a.left,a.left+s>c&&c>s?Math.abs(a.left+s-c):0),a.top-=Math.min(a.top,a.top+r>o&&o>r?Math.abs(r+d):0),a},_findPos:function(t){for(var a,i=this._getInst(t),s=this._get(i,"isRTL");t&&("hidden"===t.type||1!==t.nodeType||e.expr.filters.hidden(t));)t=t[s?"previousSibling":"nextSibling"];return a=e(t).offset(),[a.left,a.top]},_hideDatepicker:function(t){var a,i,s,r,n=this._curInst;!n||t&&n!==e.data(t,"datepicker")||this._datepickerShowing&&(a=this._get(n,"showAnim"),i=this._get(n,"duration"),s=function(){e.datepicker._tidyDialog(n)},e.effects&&(e.effects.effect[a]||e.effects[a])?n.dpDiv.hide(a,e.datepicker._get(n,"showOptions"),i,s):n.dpDiv["slideDown"===a?"slideUp":"fadeIn"===a?"fadeOut":"hide"](a?i:null,s),a||s(),this._datepickerShowing=!1,r=this._get(n,"onClose"),r&&r.apply(n.input?n.input[0]:null,[n.input?n.input.val():"",n]),this._lastInput=null,this._inDialog&&(this._dialogInput.css({position:"absolute",left:"0",top:"-100px"}),e.blockUI&&(e.unblockUI(),e("body").append(this.dpDiv))),this._inDialog=!1)},_tidyDialog:function(e){e.dpDiv.removeClass(this._dialogClass).unbind(".ui-datepicker-calendar")},_checkExternalClick:function(t){if(e.datepicker._curInst){var a=e(t.target),i=e.datepicker._getInst(a[0]);(a[0].id!==e.datepicker._mainDivId&&0===a.parents("#"+e.datepicker._mainDivId).length&&!a.hasClass(e.datepicker.markerClassName)&&!a.closest("."+e.datepicker._triggerClass).length&&e.datepicker._datepickerShowing&&(!e.datepicker._inDialog||!e.blockUI)||a.hasClass(e.datepicker.markerClassName)&&e.datepicker._curInst!==i)&&e.datepicker._hideDatepicker()}},_adjustDate:function(t,a,i){var s=e(t),r=this._getInst(s[0]);this._isDisabledDatepicker(s[0])||(this._adjustInstDate(r,a+("M"===i?this._get(r,"showCurrentAtPos"):0),i),this._updateDatepicker(r))},_gotoToday:function(t){var a,i=e(t),s=this._getInst(i[0]);this._get(s,"gotoCurrent")&&s.currentDay?(s.selectedDay=s.currentDay,s.drawMonth=s.selectedMonth=s.currentMonth,s.drawYear=s.selectedYear=s.currentYear):(a=new Date,s.selectedDay=a.getDate(),s.drawMonth=s.selectedMonth=a.getMonth(),s.drawYear=s.selectedYear=a.getFullYear()),this._notifyChange(s),this._adjustDate(i)},_selectMonthYear:function(t,a,i){var s=e(t),r=this._getInst(s[0]);r["selected"+("M"===i?"Month":"Year")]=r["draw"+("M"===i?"Month":"Year")]=parseInt(a.options[a.selectedIndex].value,10),this._notifyChange(r),this._adjustDate(s)},_selectDay:function(t,a,i,s){var r,n=e(t);e(s).hasClass(this._unselectableClass)||this._isDisabledDatepicker(n[0])||(r=this._getInst(n[0]),r.selectedDay=r.currentDay=e("a",s).html(),r.selectedMonth=r.currentMonth=a,r.selectedYear=r.currentYear=i,this._selectDate(t,this._formatDate(r,r.currentDay,r.currentMonth,r.currentYear)))},_clearDate:function(t){var a=e(t);this._selectDate(a,"")},_selectDate:function(t,a){var i,s=e(t),r=this._getInst(s[0]);a=null!=a?a:this._formatDate(r),r.input&&r.input.val(a),this._updateAlternate(r),i=this._get(r,"onSelect"),i?i.apply(r.input?r.input[0]:null,[a,r]):r.input&&r.input.trigger("change"),r.inline?this._updateDatepicker(r):(this._hideDatepicker(),this._lastInput=r.input[0],"object"!=typeof r.input[0]&&r.input.focus(),this._lastInput=null)},_updateAlternate:function(t){var a,i,s,r=this._get(t,"altField");r&&(a=this._get(t,"altFormat")||this._get(t,"dateFormat"),i=this._getDate(t),s=this.formatDate(a,i,this._getFormatConfig(t)),e(r).each(function(){e(this).val(s)}))},noWeekends:function(e){var t=e.getDay();return[t>0&&6>t,""]},iso8601Week:function(e){var t,a=new Date(e.getTime());return a.setDate(a.getDate()+4-(a.getDay()||7)),t=a.getTime(),a.setMonth(0),a.setDate(1),Math.floor(Math.round((t-a)/864e5)/7)+1},parseDate:function(t,a,i){if(null==t||null==a)throw"Invalid arguments";if(a="object"==typeof a?a.toString():a+"",""===a)return null;var s,r,n,d,c=0,o=(i?i.shortYearCutoff:null)||this._defaults.shortYearCutoff,l="string"!=typeof o?o:(new Date).getFullYear()%100+parseInt(o,10),h=(i?i.dayNamesShort:null)||this._defaults.dayNamesShort,u=(i?i.dayNames:null)||this._defaults.dayNames,p=(i?i.monthNamesShort:null)||this._defaults.monthNamesShort,g=(i?i.monthNames:null)||this._defaults.monthNames,_=-1,k=-1,f=-1,m=-1,D=!1,y=function(e){var a=s+1_&&(_+=(new Date).getFullYear()-(new Date).getFullYear()%100+(l>=_?0:-100)),m>-1)for(k=1,f=m;;){if(r=this._getDaysInMonth(_,k-1),r>=f)break;k++,f-=r}if(d=this._daylightSavingAdjust(new Date(_,k-1,f)),d.getFullYear()!==_||d.getMonth()+1!==k||d.getDate()!==f)throw"Invalid date";return d},ATOM:"yy-mm-dd",COOKIE:"D, dd M yy",ISO_8601:"yy-mm-dd",RFC_822:"D, d M y",RFC_850:"DD, dd-M-y",RFC_1036:"D, d M y",RFC_1123:"D, d M yy",RFC_2822:"D, d M yy",RSS:"D, d M y",TICKS:"!",TIMESTAMP:"@",W3C:"yy-mm-dd",_ticksTo1970:24*(718685+Math.floor(492.5)-Math.floor(19.7)+Math.floor(4.925))*60*60*1e7,formatDate:function(e,t,a){if(!t)return"";var i,s=(a?a.dayNamesShort:null)||this._defaults.dayNamesShort,r=(a?a.dayNames:null)||this._defaults.dayNames,n=(a?a.monthNamesShort:null)||this._defaults.monthNamesShort,d=(a?a.monthNames:null)||this._defaults.monthNames,c=function(t){var a=i+112?e.getHours()+2:0),e):null},_setDate:function(e,t,a){var i=!t,s=e.selectedMonth,r=e.selectedYear,n=this._restrictMinMax(e,this._determineDate(e,t,new Date));e.selectedDay=e.currentDay=n.getDate(),e.drawMonth=e.selectedMonth=e.currentMonth=n.getMonth(),e.drawYear=e.selectedYear=e.currentYear=n.getFullYear(),s===e.selectedMonth&&r===e.selectedYear||a||this._notifyChange(e),this._adjustInstDate(e),e.input&&e.input.val(i?"":this._formatDate(e))},_getDate:function(e){var t=!e.currentYear||e.input&&""===e.input.val()?null:this._daylightSavingAdjust(new Date(e.currentYear,e.currentMonth,e.currentDay));return t},_attachHandlers:function(t){var a=this._get(t,"stepMonths"),i="#"+t.id.replace(/\\\\/g,"\\");t.dpDiv.find("[data-handler]").map(function(){var t={prev:function(){e.datepicker._adjustDate(i,-a,"M")},next:function(){e.datepicker._adjustDate(i,+a,"M")},hide:function(){e.datepicker._hideDatepicker()},today:function(){e.datepicker._gotoToday(i)},selectDay:function(){return e.datepicker._selectDay(i,+this.getAttribute("data-month"),+this.getAttribute("data-year"),this),!1},selectMonth:function(){return e.datepicker._selectMonthYear(i,this,"M"),!1},selectYear:function(){return e.datepicker._selectMonthYear(i,this,"Y"),!1}};e(this).bind(this.getAttribute("data-event"),t[this.getAttribute("data-handler")])})},_generateHTML:function(e){var t,a,i,s,r,n,d,c,o,l,h,u,p,g,_,k,f,m,D,y,v,M,b,w,I,C,x,Y,S,N,F,T,A,K,j,O,R,E,L,W=new Date,H=this._daylightSavingAdjust(new Date(W.getFullYear(),W.getMonth(),W.getDate())),P=this._get(e,"isRTL"),U=this._get(e,"showButtonPanel"),z=this._get(e,"hideIfNoPrevNext"),B=this._get(e,"navigationAsDateFormat"),J=this._getNumberOfMonths(e),V=this._get(e,"showCurrentAtPos"),q=this._get(e,"stepMonths"),Q=1!==J[0]||1!==J[1],X=this._daylightSavingAdjust(e.currentDay?new Date(e.currentYear,e.currentMonth,e.currentDay):new Date(9999,9,9)),Z=this._getMinMaxDate(e,"min"),$=this._getMinMaxDate(e,"max"),G=e.drawMonth-V,et=e.drawYear;if(0>G&&(G+=12,et--),$)for(t=this._daylightSavingAdjust(new Date($.getFullYear(),$.getMonth()-J[0]*J[1]+1,$.getDate())),t=Z&&Z>t?Z:t;this._daylightSavingAdjust(new Date(et,G,1))>t;)G--,0>G&&(G=11,et--);for(e.drawMonth=G,e.drawYear=et,a=this._get(e,"prevText"),a=B?this.formatDate(a,this._daylightSavingAdjust(new Date(et,G-q,1)),this._getFormatConfig(e)):a,i=this._canAdjustMonth(e,-1,et,G)?""+a+"":z?"":""+a+"",s=this._get(e,"nextText"),s=B?this.formatDate(s,this._daylightSavingAdjust(new Date(et,G+q,1)),this._getFormatConfig(e)):s,r=this._canAdjustMonth(e,1,et,G)?""+s+"":z?"":""+s+"",n=this._get(e,"currentText"),d=this._get(e,"gotoCurrent")&&e.currentDay?X:H,n=B?this.formatDate(n,d,this._getFormatConfig(e)):n,c=e.inline?"":"",o=U?"
"+(P?c:"")+(this._isInRange(e,d)?"":"")+(P?"":c)+"
":"",l=parseInt(this._get(e,"firstDay"),10),l=isNaN(l)?0:l,h=this._get(e,"showWeek"),u=this._get(e,"dayNames"),p=this._get(e,"dayNamesMin"),g=this._get(e,"monthNames"),_=this._get(e,"monthNamesShort"),k=this._get(e,"beforeShowDay"),f=this._get(e,"showOtherMonths"),m=this._get(e,"selectOtherMonths"),D=this._getDefaultDate(e),y="",M=0;M1)switch(w){case 0:x+=" ui-datepicker-group-first",C=" ui-corner-"+(P?"right":"left");break;case J[1]-1:x+=" ui-datepicker-group-last",C=" ui-corner-"+(P?"left":"right");break;default:x+=" ui-datepicker-group-middle",C=""}x+="'>"}for(x+="
"+(/all|left/.test(C)&&0===M?P?r:i:"")+(/all|right/.test(C)&&0===M?P?i:r:"")+this._generateMonthYearHeader(e,G,et,Z,$,M>0||w>0,g,_)+"
",Y=h?"":"",v=0;7>v;v++)S=(v+l)%7,Y+="";for(x+=Y+"",N=this._getDaysInMonth(et,G),et===e.selectedYear&&G===e.selectedMonth&&(e.selectedDay=Math.min(e.selectedDay,N)),F=(this._getFirstDayOfMonth(et,G)-l+7)%7,T=Math.ceil((F+N)/7),A=Q&&this.maxRows>T?this.maxRows:T,this.maxRows=A,K=this._daylightSavingAdjust(new Date(et,G,1-F)),j=0;A>j;j++){for(x+="",O=h?"":"",v=0;7>v;v++)R=k?k.apply(e.input?e.input[0]:null,[K]):[!0,""],E=K.getMonth()!==G,L=E&&!m||!R[0]||Z&&Z>K||$&&K>$,O+="",K.setDate(K.getDate()+1),K=this._daylightSavingAdjust(K);x+=O+""}G++,G>11&&(G=0,et++),x+="
"+this._get(e,"weekHeader")+"=5?" class='ui-datepicker-week-end'":"")+">"+p[S]+"
"+this._get(e,"calculateWeek")(K)+""+(E&&!f?" ":L?""+K.getDate()+"":""+K.getDate()+"")+"
"+(Q?""+(J[0]>0&&w===J[1]-1?"
":""):""),b+=x}y+=b}return y+=o,e._keyEvent=!1,y},_generateMonthYearHeader:function(e,t,a,i,s,r,n,d){var c,o,l,h,u,p,g,_,k=this._get(e,"changeMonth"),f=this._get(e,"changeYear"),m=this._get(e,"showMonthAfterYear"),D="
",y="";if(r||!k)y+=""+n[t]+""; 11 | else{for(c=i&&i.getFullYear()===a,o=s&&s.getFullYear()===a,y+=""}if(m||(D+=y+(!r&&k&&f?"":" ")),!e.yearshtml)if(e.yearshtml="",r||!f)D+=""+a+"";else{for(h=this._get(e,"yearRange").split(":"),u=(new Date).getFullYear(),p=function(e){var t=e.match(/c[+\-].*/)?a+parseInt(e.substring(1),10):e.match(/[+\-].*/)?u+parseInt(e,10):parseInt(e,10);return isNaN(t)?u:t},g=p(h[0]),_=Math.max(g,p(h[1]||"")),g=i?Math.max(g,i.getFullYear()):g,_=s?Math.min(_,s.getFullYear()):_,e.yearshtml+="",D+=e.yearshtml,e.yearshtml=null}return D+=this._get(e,"yearSuffix"),m&&(D+=(!r&&k&&f?"":" ")+y),D+="
"},_adjustInstDate:function(e,t,a){var i=e.drawYear+("Y"===a?t:0),s=e.drawMonth+("M"===a?t:0),r=Math.min(e.selectedDay,this._getDaysInMonth(i,s))+("D"===a?t:0),n=this._restrictMinMax(e,this._daylightSavingAdjust(new Date(i,s,r)));e.selectedDay=n.getDate(),e.drawMonth=e.selectedMonth=n.getMonth(),e.drawYear=e.selectedYear=n.getFullYear(),("M"===a||"Y"===a)&&this._notifyChange(e)},_restrictMinMax:function(e,t){var a=this._getMinMaxDate(e,"min"),i=this._getMinMaxDate(e,"max"),s=a&&a>t?a:t;return i&&s>i?i:s},_notifyChange:function(e){var t=this._get(e,"onChangeMonthYear");t&&t.apply(e.input?e.input[0]:null,[e.selectedYear,e.selectedMonth+1,e])},_getNumberOfMonths:function(e){var t=this._get(e,"numberOfMonths");return null==t?[1,1]:"number"==typeof t?[1,t]:t},_getMinMaxDate:function(e,t){return this._determineDate(e,this._get(e,t+"Date"),null)},_getDaysInMonth:function(e,t){return 32-this._daylightSavingAdjust(new Date(e,t,32)).getDate()},_getFirstDayOfMonth:function(e,t){return new Date(e,t,1).getDay()},_canAdjustMonth:function(e,t,a,i){var s=this._getNumberOfMonths(e),r=this._daylightSavingAdjust(new Date(a,i+(0>t?t:s[0]*s[1]),1));return 0>t&&r.setDate(this._getDaysInMonth(r.getFullYear(),r.getMonth())),this._isInRange(e,r)},_isInRange:function(e,t){var a,i,s=this._getMinMaxDate(e,"min"),r=this._getMinMaxDate(e,"max"),n=null,d=null,c=this._get(e,"yearRange");return c&&(a=c.split(":"),i=(new Date).getFullYear(),n=parseInt(a[0],10),d=parseInt(a[1],10),a[0].match(/[+\-].*/)&&(n+=i),a[1].match(/[+\-].*/)&&(d+=i)),(!s||t.getTime()>=s.getTime())&&(!r||t.getTime()<=r.getTime())&&(!n||t.getFullYear()>=n)&&(!d||t.getFullYear()<=d)},_getFormatConfig:function(e){var t=this._get(e,"shortYearCutoff");return t="string"!=typeof t?t:(new Date).getFullYear()%100+parseInt(t,10),{shortYearCutoff:t,dayNamesShort:this._get(e,"dayNamesShort"),dayNames:this._get(e,"dayNames"),monthNamesShort:this._get(e,"monthNamesShort"),monthNames:this._get(e,"monthNames")}},_formatDate:function(e,t,a,i){t||(e.currentDay=e.selectedDay,e.currentMonth=e.selectedMonth,e.currentYear=e.selectedYear);var s=t?"object"==typeof t?t:this._daylightSavingAdjust(new Date(i,a,t)):this._daylightSavingAdjust(new Date(e.currentYear,e.currentMonth,e.currentDay));return this.formatDate(this._get(e,"dateFormat"),s,this._getFormatConfig(e))}}),e.fn.datepicker=function(t){if(!this.length)return this;e.datepicker.initialized||(e(document).mousedown(e.datepicker._checkExternalClick),e.datepicker.initialized=!0),0===e("#"+e.datepicker._mainDivId).length&&e("body").append(e.datepicker.dpDiv);var a=Array.prototype.slice.call(arguments,1);return"string"!=typeof t||"isDisabled"!==t&&"getDate"!==t&&"widget"!==t?"option"===t&&2===arguments.length&&"string"==typeof arguments[1]?e.datepicker["_"+t+"Datepicker"].apply(e.datepicker,[this[0]].concat(a)):this.each(function(){"string"==typeof t?e.datepicker["_"+t+"Datepicker"].apply(e.datepicker,[this].concat(a)):e.datepicker._attachDatepicker(this,t)}):e.datepicker["_"+t+"Datepicker"].apply(e.datepicker,[this[0]].concat(a))},e.datepicker=new a,e.datepicker.initialized=!1,e.datepicker.uuid=(new Date).getTime(),e.datepicker.version="@VERSION",e.datepicker}); 12 | 13 | $(function () { 14 | 15 | var datePickerFrom, datePickerTo 16 | 17 | initializeDatePickers() 18 | 19 | if (isHaveCharts()) { initializeChars({}) } 20 | 21 | $(document).on('click', '#live-poll', function (e) { 22 | e.preventDefault() 23 | $(this).toggleClass('active') 24 | setInterval(function () { 25 | updatePagePart('.live-reload', {}) 26 | }, parseInt(localStorage.timeInterval) || 2000) 27 | }) 28 | 29 | $(document).on('click', '.statistic__tab', function (e) { 30 | var $this = $(this) 31 | $('.nav-tabs li').removeClass('active') 32 | $this.parent().toggleClass('active') 33 | 34 | $('.statistic__container').children().hide() 35 | $($this.data('target')).show() 36 | }) 37 | 38 | $(document).on('click', '.statistic__jid', function (e) { 39 | $('.statistic__i').toggle() 40 | $($(this).data('target')).parent().show() 41 | }) 42 | 43 | $('#statistic__search').keyup(function() { 44 | var $this = $(this) 45 | delay(function(){ 46 | if ($this.val().length) { 47 | $('.statistic__i:not(:contains('+ $this.val() +'))').hide() 48 | } else { 49 | $('.statistic__i').show() 50 | } 51 | }, 200 ) 52 | }); 53 | 54 | var delay = (function(){ 55 | var timer = 0 56 | return function(callback, ms){ 57 | clearTimeout (timer) 58 | timer = setTimeout(callback, ms) 59 | } 60 | })() 61 | 62 | function initializeChars(data) { 63 | var path = window.location.pathname 64 | $.getJSON(path + '/charts.json', data, function (data){ 65 | createChart('failed', data.date, data.failed_data) 66 | createChart('passed', data.date, data.passed_data) 67 | }) 68 | } 69 | 70 | function initializeDatePickers() { 71 | $('#datepicker_from').datepicker({ 72 | dateFormat: 'dd-mm-yy', 73 | firstDay: 1, 74 | maxDate: -1, 75 | onSelect: function (selected) { 76 | datePickerFrom = selected 77 | $("#datepicker_to").datepicker("option","minDate", selected) 78 | rerenderPage() 79 | } 80 | }) 81 | 82 | $('#datepicker_to').datepicker({ 83 | dateFormat: 'dd-mm-yy', 84 | firstDay: 1, 85 | maxDate: 0, 86 | onSelect: function (selected) { 87 | datePickerTo = selected 88 | $("#datepicker_from").datepicker("option","maxDate", selected) 89 | rerenderPage() 90 | } 91 | }) 92 | } 93 | 94 | function rerenderPage() { 95 | if (datePickerFrom && datePickerTo) { 96 | var requestData = { 97 | dateFrom: datePickerFrom, 98 | dateTo: datePickerTo 99 | } 100 | 101 | updatePagePart('.statistic', requestData, function () { 102 | initializeChars(requestData) 103 | }) 104 | 105 | $(document).ajaxComplete(function() { 106 | initializeDatePickers() 107 | 108 | $('#datepicker_from').datepicker('option','maxDate', datePickerTo) 109 | $('#datepicker_from').datepicker('setDate', datePickerFrom) 110 | $('#datepicker_to').datepicker('option','minDate', datePickerFrom) 111 | $('#datepicker_to').datepicker('setDate', datePickerTo) 112 | }) 113 | } 114 | } 115 | 116 | function updatePagePart(selector, data, callback) { 117 | $.ajax({ 118 | url: window.location.pathname, 119 | dataType: 'html', 120 | data: data 121 | }).done(function (data) { 122 | var $html = $(data).filter('#page').find(selector) 123 | $(selector).replaceWith($html) 124 | 125 | if (typeof callback === "function") { 126 | callback() 127 | } 128 | }) 129 | } 130 | 131 | function isHaveCharts() { 132 | return (document.getElementById("js-passed-chart") && document.getElementById("js-failed-chart")) 133 | } 134 | 135 | function getDataSet(data) { 136 | return data.map(function(v) { 137 | return v.dataset 138 | }) 139 | } 140 | 141 | function getWorkersNames(data) { 142 | var workers_names = {} 143 | data.forEach(function(v) { 144 | workers_names[v.dataset[0]] = v.worker 145 | }) 146 | return workers_names 147 | } 148 | 149 | function getColors(data) { 150 | var colors = {} 151 | data.forEach(function(v) { 152 | colors[v.dataset[0]] = v.color 153 | }) 154 | return colors 155 | } 156 | 157 | function createChart(type, date, data) { 158 | var axis = ['x', ...date.labels] 159 | var dateFormat = date.format 160 | var height = 500 161 | 162 | c3.generate({ 163 | bindto: "#js-" + type + "-chart", 164 | data: { 165 | x: 'x', 166 | columns: [ 167 | axis, 168 | ...getDataSet(data) 169 | ], 170 | names: getWorkersNames(data), 171 | colors: getColors(data) 172 | }, 173 | axis: { 174 | x: { 175 | type: 'timeseries', 176 | tick: { 177 | format: dateFormat 178 | } 179 | } 180 | }, 181 | size: { 182 | height: height 183 | } 184 | }); 185 | } 186 | }) 187 | --------------------------------------------------------------------------------