├── 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 | | <%= t('Worker') %> |
44 | <%= t('Show/Hide') %> |
45 |
46 |
47 |
48 | <% @workers.each do |worker| %>
49 |
50 | | <%= worker %> |
51 |
52 |
55 | |
56 |
57 | <% end %>
58 |
59 |
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 |
34 |
35 |
36 |
<%= t('WorkerTablePerDay') %>
37 |
38 |
39 |
40 | | <%= t('Date') %> |
41 | <%= t('LastRun') %> |
42 | <%= t('Success') %> |
43 | <%= t('Failure') %> |
44 | <%= t('Total') %> |
45 | <%= t('TimeSec') %> |
46 | <%= t('AverageSec') %> |
47 | <%= t('MinTimeSec') %> |
48 | <%= t('MaxTimeSec') %> |
49 | <%= t('LastJobStatus') %> |
50 |
51 | <% @worker_statistic.each do |worker| %>
52 |
53 | | <%= format_date worker[:date] %> |
54 | <%= format_date worker[:runtime][:last], 'datetime' %> |
55 | <%= worker[:success] %> |
56 | <%= worker[:failure] %> |
57 | <%= worker[:total] %> |
58 | <%= worker[:runtime][:total] %> |
59 | <%= worker[:runtime][:average] %> |
60 | <%= worker[:runtime][:min] %> |
61 | <%= worker[:runtime][:max] %> |
62 | <%= worker[:last_job_status] %> |
63 |
64 | <% end %>
65 |
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 |
34 |
35 |
36 |
<%= t('Failed') %>
37 |
38 |
<%= t('Passed') %>
39 |
40 |
41 |
42 |
<%= t('WorkersTable') %>
43 |
44 |
45 | | <%= t('Worker') %> |
46 | <%= t('Date') %> |
47 | <%= t('Queue') %> |
48 | <%= t('Success') %> |
49 | <%= t('Failure') %> |
50 | <%= t('Total') %> |
51 | <%= t('TimeSec') %> |
52 | <%= t('AverageSec') %> |
53 | <%= t('MinTimeSec') %> |
54 | <%= t('MaxTimeSec') %> |
55 | <%= t('LastJobStatus') %> |
56 |
57 |
58 | <% @all_workers.each do |worker| %>
59 |
60 | |
61 | <%= worker[:name] %>
62 | |
63 | <%= format_date worker[:runtime][:last], 'datetime' %> |
64 | <%= worker[:queue] %> |
65 | <%= worker[:number_of_calls][:success] %> |
66 | <%= worker[:number_of_calls][:failure] %> |
67 | <%= worker[:number_of_calls][:total] %> |
68 | <%= worker[:runtime][:total] %> |
69 | <%= worker[:runtime][:average] %> |
70 | <%= worker[:runtime][:min] %> |
71 | <%= worker[:runtime][:max] %> |
72 | <%= worker[:last_job_status] %> |
73 |
74 | <% end %>
75 |
76 |
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 | [](https://travis-ci.org/davydovanton/sidekiq-statistic) [](https://codeclimate.com/github/davydovanton/sidekiq-history) [](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 | 
12 |
13 | ### Worker page with table (per day):
14 | 
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 | 
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;M"}for(x+="",Y=h?"| "+this._get(e,"weekHeader")+" | ":"",v=0;7>v;v++)S=(v+l)%7,Y+="=5?" class='ui-datepicker-week-end'":"")+">"+p[S]+" | ";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?"| "+this._get(e,"calculateWeek")(K)+" | ":"",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+=""+(E&&!f?" ":L?""+K.getDate()+"":""+K.getDate()+"")+" | ",K.setDate(K.getDate()+1),K=this._daylightSavingAdjust(K);x+=O+"
"}G++,G>11&&(G=0,et++),x+="
"+(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 |
--------------------------------------------------------------------------------