jiggler 0.1.0.rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE +4 -0
  4. data/README.md +423 -0
  5. data/bin/jiggler +31 -0
  6. data/lib/jiggler/cleaner.rb +130 -0
  7. data/lib/jiggler/cli.rb +263 -0
  8. data/lib/jiggler/config.rb +165 -0
  9. data/lib/jiggler/core.rb +22 -0
  10. data/lib/jiggler/errors.rb +5 -0
  11. data/lib/jiggler/job.rb +116 -0
  12. data/lib/jiggler/launcher.rb +69 -0
  13. data/lib/jiggler/manager.rb +73 -0
  14. data/lib/jiggler/redis_store.rb +55 -0
  15. data/lib/jiggler/retrier.rb +122 -0
  16. data/lib/jiggler/scheduled/enqueuer.rb +78 -0
  17. data/lib/jiggler/scheduled/poller.rb +97 -0
  18. data/lib/jiggler/stats/collection.rb +26 -0
  19. data/lib/jiggler/stats/monitor.rb +103 -0
  20. data/lib/jiggler/summary.rb +101 -0
  21. data/lib/jiggler/support/helper.rb +35 -0
  22. data/lib/jiggler/version.rb +5 -0
  23. data/lib/jiggler/web/assets/stylesheets/application.css +64 -0
  24. data/lib/jiggler/web/views/application.erb +329 -0
  25. data/lib/jiggler/web.rb +80 -0
  26. data/lib/jiggler/worker.rb +179 -0
  27. data/lib/jiggler.rb +10 -0
  28. data/spec/examples.txt +79 -0
  29. data/spec/fixtures/config/jiggler.yml +4 -0
  30. data/spec/fixtures/jobs.rb +5 -0
  31. data/spec/fixtures/my_failed_job.rb +10 -0
  32. data/spec/fixtures/my_job.rb +9 -0
  33. data/spec/fixtures/my_job_with_args.rb +18 -0
  34. data/spec/jiggler/cleaner_spec.rb +171 -0
  35. data/spec/jiggler/cli_spec.rb +87 -0
  36. data/spec/jiggler/config_spec.rb +56 -0
  37. data/spec/jiggler/core_spec.rb +34 -0
  38. data/spec/jiggler/job_spec.rb +99 -0
  39. data/spec/jiggler/launcher_spec.rb +66 -0
  40. data/spec/jiggler/manager_spec.rb +52 -0
  41. data/spec/jiggler/redis_store_spec.rb +20 -0
  42. data/spec/jiggler/retrier_spec.rb +55 -0
  43. data/spec/jiggler/scheduled/enqueuer_spec.rb +81 -0
  44. data/spec/jiggler/scheduled/poller_spec.rb +40 -0
  45. data/spec/jiggler/stats/monitor_spec.rb +40 -0
  46. data/spec/jiggler/summary_spec.rb +168 -0
  47. data/spec/jiggler/web_spec.rb +37 -0
  48. data/spec/jiggler/worker_spec.rb +110 -0
  49. data/spec/spec_helper.rb +54 -0
  50. metadata +230 -0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis_client'
4
+
5
+ module Jiggler
6
+ class RedisStore
7
+ def initialize(options = {})
8
+ @options = options
9
+ end
10
+
11
+ def pool
12
+ @options[:async] ? async_pool : sync_pool
13
+ end
14
+
15
+ def async_pool
16
+ @async_pool ||= begin
17
+ config = RedisClient.config(url: @options[:redis_url], timeout: nil)
18
+ Async::Pool::Controller.wrap(limit: @options[:concurrency]) do
19
+ config.new_client
20
+ end
21
+ end
22
+ end
23
+
24
+ def sync_pool
25
+ @sync_pool ||= begin
26
+ config = RedisClient.config(url: @options[:redis_url])
27
+ pool = config.new_pool(size: @options[:concurrency])
28
+ def pool.acquire(&block)
29
+ with(&block)
30
+ end
31
+ pool
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ module Jiggler
38
+ class RedisClient < ::RedisClient
39
+ def concurrency
40
+ 1
41
+ end
42
+
43
+ def viable?
44
+ connected?
45
+ end
46
+
47
+ def closed?
48
+ @raw_connection.nil?
49
+ end
50
+
51
+ def reusable?
52
+ !@raw_connection.nil?
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ class Retrier
5
+ include Support::Helper
6
+
7
+ attr_reader :config, :collection
8
+
9
+ def initialize(config, collection)
10
+ @config = config
11
+ @collection = collection
12
+ @tid = tid
13
+ end
14
+
15
+ def wrapped(instance, parsed_job, queue)
16
+ logger.info {
17
+ "Starting #{instance.class.name} queue=#{instance.class.queue} tid=#{@tid} jid=#{parsed_job['jid']}"
18
+ }
19
+ yield
20
+ logger.info {
21
+ "Finished #{instance.class.name} queue=#{instance.class.queue} tid=#{@tid} jid=#{parsed_job['jid']}"
22
+ }
23
+ rescue Async::Stop => stop
24
+ raise stop
25
+ rescue => err
26
+ raise Async::Stop if exception_caused_by_shutdown?(err)
27
+
28
+ process_retry(instance, parsed_job, queue, err)
29
+ collection.incr_failures
30
+
31
+ log_error(
32
+ err,
33
+ {
34
+ context: '\'Job raised exception\'',
35
+ error_class: err.class.name,
36
+ name: parsed_job['name'],
37
+ queue: parsed_job['queue'],
38
+ args: parsed_job['args'],
39
+ attempt: parsed_job['attempt'],
40
+ tid: @tid,
41
+ jid: parsed_job['jid']
42
+ }
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ def process_retry(jobinst, parsed_job, queue, exception)
49
+ job_class = jobinst.class
50
+ parsed_job['started_at'] ||= Time.now.to_f
51
+
52
+ message = exception_message(exception)
53
+ if message.respond_to?(:scrub!)
54
+ message.force_encoding('utf-8')
55
+ message.scrub!
56
+ end
57
+
58
+ parsed_job['error_message'] = message
59
+ parsed_job['error_class'] = exception.class.name
60
+
61
+ max_retry_attempts = parsed_job['retries'].to_i
62
+ count = parsed_job['attempt'].to_i + 1
63
+ return retries_exhausted(jobinst, parsed_job, exception) if count > max_retry_attempts
64
+
65
+ delay = count**4 + 15
66
+ retry_at = Time.now.to_f + delay
67
+ parsed_job['retry_at'] = retry_at
68
+ if count > 1
69
+ parsed_job['retried_at'] = Time.now.to_f
70
+ end
71
+ parsed_job['attempt'] = count
72
+ parsed_job['queue'] = job_class.retry_queue
73
+ payload = Oj.dump(parsed_job, mode: :compat)
74
+
75
+ config.with_async_redis do |conn|
76
+ conn.call('ZADD', config.retries_set, retry_at.to_s, payload)
77
+ end
78
+ end
79
+
80
+ def retries_exhausted(jobinst, parsed_job, exception)
81
+ logger.debug('Retrier') {
82
+ "Retries exhausted for #{parsed_job['name']} jid=#{parsed_job['jid']}"
83
+ }
84
+
85
+ send_to_morgue(parsed_job)
86
+ end
87
+
88
+ def send_to_morgue(parsed_job)
89
+ logger.warn('Retrier') {
90
+ "#{parsed_job['name']} has been sent to dead jid=#{parsed_job['jid']}"
91
+ }
92
+ payload = Oj.dump(parsed_job, mode: :compat)
93
+ now = Time.now.to_f
94
+
95
+ config.with_async_redis do |conn|
96
+ conn.multi do |xa|
97
+ xa.call('ZADD', config.dead_set, now.to_s, payload)
98
+ xa.call('ZREMRANGEBYSCORE', config.dead_set, '-inf', now - config[:dead_timeout])
99
+ xa.call('ZREMRANGEBYRANK', config.dead_set, 0, - config[:max_dead_jobs])
100
+ end
101
+ end
102
+ end
103
+
104
+ def exception_caused_by_shutdown?(e, checked_causes = [])
105
+ return false unless e.cause
106
+
107
+ # Handle circular causes
108
+ checked_causes << e.object_id
109
+ return false if checked_causes.include?(e.cause.object_id)
110
+
111
+ e.cause.instance_of?(Async::Stop) ||
112
+ exception_caused_by_shutdown?(e.cause, checked_causes)
113
+ end
114
+
115
+ def exception_message(exception)
116
+ # Message from app code
117
+ exception.message.to_s[0, 10_000]
118
+ rescue
119
+ 'Exception message unavailable'
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ module Scheduled
5
+ class Enqueuer
6
+ include Support::Helper
7
+
8
+ LUA_ZPOPBYSCORE = <<~LUA
9
+ local key, now = KEYS[1], ARGV[1]
10
+ local jobs = redis.call('zrangebyscore', key, '-inf', now, 'limit', 0, 1)
11
+ if jobs[1] then
12
+ redis.call('zrem', key, jobs[1])
13
+ return jobs[1]
14
+ end
15
+ LUA
16
+
17
+ def initialize(config)
18
+ @config = config
19
+ @done = false
20
+ @lua_zpopbyscore_sha = nil
21
+ @tid = tid
22
+ end
23
+
24
+ def enqueue_jobs
25
+ @config.with_async_redis do |conn|
26
+ sorted_sets.each do |sorted_set|
27
+ # Get next item in the queue with score (time to execute) <= now
28
+ job_args = zpopbyscore(conn, key: sorted_set, argv: Time.now.to_f.to_s)
29
+ while !@done && job_args
30
+ push_job(conn, job_args)
31
+ job_args = zpopbyscore(conn, key: sorted_set, argv: Time.now.to_f.to_s)
32
+ end
33
+ end
34
+ rescue => err
35
+ log_error_short(err, { context: '\'Enqueuing jobs error\'', tid: @tid })
36
+ end
37
+ end
38
+
39
+ def terminate
40
+ @done = true
41
+ end
42
+
43
+ def push_job(conn, job_args)
44
+ name = Oj.load(job_args, mode: :compat)['queue'] || @config.default_queue
45
+ list_name = "#{@config.queue_prefix}#{name}"
46
+ # logger.debug('Poller Enqueuer') { "Pushing #{job_args} to #{list_name}" }
47
+ conn.call('LPUSH', list_name, job_args)
48
+ rescue => err
49
+ log_error_short(
50
+ err, {
51
+ context: '\'Pushing scheduled job error\'',
52
+ tid: @tid,
53
+ job_args: job_args,
54
+ queue: list_name
55
+ }
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def sorted_sets
62
+ @sorted_sets ||= [@config.retries_set, @config.scheduled_set].freeze
63
+ end
64
+
65
+ def zpopbyscore(conn, key: nil, argv: nil)
66
+ if @lua_zpopbyscore_sha.nil?
67
+ @lua_zpopbyscore_sha = conn.call('SCRIPT', 'LOAD', LUA_ZPOPBYSCORE)
68
+ end
69
+ conn.call('EVALSHA', @lua_zpopbyscore_sha, 1, key, argv)
70
+ rescue RedisClient::CommandError => e
71
+ raise unless e.message.start_with?('NOSCRIPT')
72
+
73
+ @lua_zpopbyscore_sha = nil
74
+ retry
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Poller checks Redis every N seconds for jobs in the retry or scheduled
4
+ # set have passed their timestamp and should be enqueued.
5
+ module Jiggler
6
+ module Scheduled
7
+ class Poller
8
+ include Support::Helper
9
+
10
+ INITIAL_WAIT = 5
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ @enqueuer = Jiggler::Scheduled::Enqueuer.new(config)
15
+ @done = false
16
+ @job = nil
17
+ @count_calls = 0
18
+ @condition = Async::Condition.new
19
+ end
20
+
21
+ def terminate
22
+ @done = true
23
+ @enqueuer.terminate
24
+
25
+ Async do
26
+ @condition.signal
27
+ @job&.wait
28
+ end
29
+ end
30
+
31
+ def start
32
+ @job = safe_async('Poller') do
33
+ @tid = tid
34
+ initial_wait
35
+ until @done
36
+ enqueue
37
+ wait unless @done
38
+ end
39
+ end
40
+ end
41
+
42
+ def enqueue
43
+ # logger.warn('Poller runs')
44
+ @enqueuer.enqueue_jobs
45
+ end
46
+
47
+ private
48
+
49
+ def wait
50
+ Async(transient: true) do
51
+ sleep(random_poll_interval)
52
+ @condition.signal
53
+ end
54
+ @condition.wait
55
+ end
56
+
57
+ def random_poll_interval
58
+ count = process_count
59
+ interval = @config[:poll_interval]
60
+
61
+ if count < 10
62
+ interval * rand + interval.to_f / 2
63
+ else
64
+ interval * rand
65
+ end
66
+ end
67
+
68
+ def fetch_count
69
+ @config.with_sync_redis do |conn|
70
+ conn.call('SCAN', '0', 'MATCH', @config.process_scan_key).last.size
71
+ rescue => err
72
+ log_error_short(err, { context: '\'Poller getting processes error\'', tid: @tid })
73
+ 1
74
+ end
75
+ end
76
+
77
+ def process_count
78
+ count = fetch_count
79
+ count = 1 if count == 0
80
+ count
81
+ end
82
+
83
+ # wait a random amount of time so in case of multiple processes
84
+ # their pollers won't be synchronized
85
+ def initial_wait
86
+ total = INITIAL_WAIT + (12 * rand)
87
+
88
+ # in case of an early exit skip the initial wait
89
+ Async(transient: true) do
90
+ sleep(total)
91
+ @condition.signal
92
+ end
93
+ @condition.wait
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ module Stats
5
+ class Collection
6
+ attr_reader :uuid, :data
7
+
8
+ def initialize(uuid)
9
+ @uuid = uuid
10
+ @data = {
11
+ processed: 0,
12
+ failures: 0,
13
+ current_jobs: {}
14
+ }
15
+ end
16
+
17
+ def incr_processed
18
+ @data[:processed] += 1
19
+ end
20
+
21
+ def incr_failures
22
+ @data[:failures] += 1
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ module Stats
5
+ class Monitor
6
+ include Support::Helper
7
+
8
+ attr_reader :config, :collection, :data_key, :exp
9
+
10
+ def initialize(config, collection)
11
+ @config = config
12
+ @collection = collection
13
+ @done = false
14
+ @condition = Async::Condition.new
15
+ # the key expiration should be greater than the stats interval
16
+ # to avoid cases where the monitor is blocked
17
+ # by long running workers and the key is not updated in time
18
+ @exp = @config[:stats_interval] + 180 # interval + 3 minutes
19
+ @rss_path = "/proc/#{Process.pid}/status"
20
+ end
21
+
22
+ def start
23
+ @job = safe_async('Monitor') do
24
+ @tid = tid
25
+ until @done
26
+ load_data_into_redis
27
+ wait unless @done
28
+ end
29
+ end
30
+ end
31
+
32
+ def terminate
33
+ @condition.signal
34
+ @done = true
35
+ cleanup
36
+ end
37
+
38
+ def process_data
39
+ Oj.dump({
40
+ heartbeat: Time.now.to_f,
41
+ rss: process_rss,
42
+ current_jobs: collection.data[:current_jobs],
43
+ }, mode: :compat)
44
+ end
45
+
46
+ def load_data_into_redis
47
+ # logger.warn('Monitor runs')
48
+ processed_jobs = collection.data[:processed]
49
+ failed_jobs = collection.data[:failures]
50
+ collection.data[:processed] -= processed_jobs
51
+ collection.data[:failures] -= failed_jobs
52
+
53
+ config.with_async_redis do |conn|
54
+ conn.pipelined do |pipeline|
55
+ pipeline.call('SET', collection.uuid, process_data, ex: exp)
56
+ pipeline.call('INCRBY', config.processed_counter, processed_jobs)
57
+ pipeline.call('INCRBY', config.failures_counter, failed_jobs)
58
+ end
59
+
60
+ cleanup_with(conn) if @done
61
+ rescue => err
62
+ log_error_short(err, { context: '\'Monitor loading stats error\'', tid: @tid })
63
+ end
64
+ end
65
+
66
+ def process_rss
67
+ case RUBY_PLATFORM
68
+ when /linux/
69
+ IO.readlines(@rss_path).each do |line|
70
+ next unless line.start_with?('VmRSS:')
71
+ break line.split[1].to_i
72
+ end
73
+ when /darwin|bsd/
74
+ `ps -o pid,rss -p #{Process.pid}`.lines.last.split.last.to_i
75
+ else
76
+ nil
77
+ end
78
+ end
79
+
80
+ def cleanup
81
+ config.with_async_redis do |conn|
82
+ cleanup_with(conn)
83
+ rescue => err
84
+ log_error_short(err, { context: '\'Cleanup error\'', tid: @tid })
85
+ end
86
+ end
87
+
88
+ def wait
89
+ Async(transient: true) do
90
+ sleep(config[:stats_interval])
91
+ @condition.signal
92
+ end
93
+ @condition.wait
94
+ end
95
+
96
+ private
97
+
98
+ def cleanup_with(conn)
99
+ conn.call('DEL', collection.uuid)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ class Summary
5
+ KEYS = %w[
6
+ retry_jobs_count
7
+ dead_jobs_count
8
+ scheduled_jobs_count
9
+ failures_count
10
+ processed_count
11
+ processes
12
+ queues
13
+ ].freeze
14
+
15
+ attr_reader :config
16
+
17
+ def initialize(config)
18
+ @config = config
19
+ end
20
+
21
+ def all
22
+ summary = {}
23
+ collected_data = config.client_redis_pool.acquire do |conn|
24
+ data = conn.pipelined do |pipeline|
25
+ pipeline.call('ZCARD', config.retries_set)
26
+ pipeline.call('ZCARD', config.dead_set)
27
+ pipeline.call('ZCARD', config.scheduled_set)
28
+ pipeline.call('GET', config.failures_counter)
29
+ pipeline.call('GET', config.processed_counter)
30
+ end
31
+ [*data, fetch_and_format_processes(conn), fetch_and_format_queues(conn)]
32
+ end
33
+ KEYS.each_with_index do |key, index|
34
+ val = collected_data[index]
35
+ val = val.to_i if index <= 4 # counters
36
+ summary[key] = val
37
+ end
38
+ summary
39
+ end
40
+
41
+ def last_retry_jobs(num)
42
+ config.client_redis_pool.acquire do |conn|
43
+ conn.call('ZRANGE', config.retries_set, '+inf', '-inf', 'BYSCORE', 'REV', 'LIMIT', 0, num)
44
+ end.map { |job| Oj.load(job, mode: :compat) }
45
+ end
46
+
47
+ def last_scheduled_jobs(num)
48
+ config.client_redis_pool.acquire do |conn|
49
+ conn.call('ZRANGE', config.scheduled_set, '+inf', '-inf', 'BYSCORE', 'REV', 'LIMIT', 0, num, 'WITHSCORES')
50
+ end.map do |(job, score)|
51
+ Oj.load(job).merge('scheduled_at' => score)
52
+ end
53
+ end
54
+
55
+ def last_dead_jobs(num)
56
+ config.client_redis_pool.acquire do |conn|
57
+ conn.call('ZRANGE', config.dead_set, '+inf', '-inf', 'BYSCORE', 'REV', 'LIMIT', 0, num)
58
+ end.map { |job| Oj.load(job, mode: :compat) }
59
+ end
60
+
61
+ private
62
+
63
+ def fetch_processes(conn)
64
+ # in case they keys were deleted/modified could return incorrect results
65
+ conn.call('SCAN', '0', 'MATCH', config.process_scan_key).last
66
+ end
67
+
68
+ def fetch_and_format_processes(conn)
69
+ fetch_processes(conn).reduce({}) do |acc, uuid|
70
+ process_data = Oj.load(conn.call('GET', uuid), mode: :compat) || {}
71
+ values = uuid.split(':')
72
+ acc[uuid] = process_data.merge({
73
+ 'name' => values[0..2].join(':'),
74
+ 'concurrency' => values[3],
75
+ 'timeout' => values[4],
76
+ 'queues' => values[5],
77
+ 'poller_enabled' => values[6] == '1',
78
+ 'started_at' => values[7],
79
+ 'pid' => values[8]
80
+ })
81
+ acc[uuid]['hostname'] = values[9..-1].join(':')
82
+ acc
83
+ end
84
+ end
85
+
86
+ def fetch_and_format_queues(conn)
87
+ lists = conn.call('SCAN', '0', 'MATCH', config.queue_scan_key).last
88
+ lists_data = {}
89
+
90
+ collected_data = conn.pipelined do |pipeline|
91
+ lists.each do |list|
92
+ pipeline.call('LLEN', list)
93
+ end
94
+ end
95
+ lists.each_with_index do |list, index|
96
+ lists_data[list.split(':').last] = collected_data[index]
97
+ end
98
+ lists_data
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ module Support
5
+ module Helper
6
+ def safe_async(name)
7
+ Async do
8
+ yield
9
+ rescue Exception => ex
10
+ log_error(ex, { context: name, tid: tid })
11
+ end
12
+ end
13
+
14
+ def log_error(ex, ctx = {})
15
+ err_context = ctx.compact.map { |k, v| "#{k}=#{v}" }.join(' ')
16
+ logger.error("error_message='#{ex.message}' #{err_context}")
17
+ logger.error(ex.backtrace.first(12).join("\n")) unless ex.backtrace.nil?
18
+ end
19
+
20
+ def log_error_short(err, ctx = {})
21
+ err_context = ctx.compact.map { |k, v| "#{k}=#{v}" }.join(' ')
22
+ logger.error("error_message='#{err.message}' #{err_context}")
23
+ end
24
+
25
+ def logger
26
+ @config.logger
27
+ end
28
+
29
+ def tid
30
+ return unless Async::Task.current?
31
+ (Async::Task.current.object_id ^ ::Process.pid).to_s(36)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ VERSION = '0.1.0.rc2'
5
+ end
@@ -0,0 +1,64 @@
1
+ body {
2
+ background: rgb(45, 44, 49);
3
+ color: #d8d9da;
4
+ padding: 20px;
5
+ font-family: "Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;
6
+ }
7
+
8
+ .main-table {
9
+ border-collapse: collapse;
10
+ margin: 1rem 0;
11
+ width: 100%;
12
+ }
13
+
14
+ .main-table th, td {
15
+ border: 1px solid #858585;
16
+ border-collapse: collapse;
17
+ padding: 0.5rem;
18
+ }
19
+
20
+ .main-table tr:nth-child(even) {
21
+ background-color: rgb(45, 44, 49);
22
+ }
23
+
24
+ .main-table thead {
25
+ background-color: rgb(54 52 64);
26
+ }
27
+
28
+ .main-table tbody tr:hover {
29
+ background-color: rgb(56, 55, 61);
30
+ }
31
+
32
+ .right {
33
+ text-align: right;
34
+ }
35
+
36
+ .stats-entry {
37
+ background-color: #363440;
38
+ border: 1px solid #858585;
39
+ display: inline-block;
40
+ padding: 10px;
41
+ }
42
+
43
+ .badge {
44
+ background-color: #858585;
45
+ color: #fff;
46
+ padding: 0.1rem 0.3rem;
47
+ text-align: center;
48
+ border-radius: 3px;
49
+ font-size: 0.7rem;
50
+ font-weight: 500;
51
+ vertical-align: top;
52
+ }
53
+
54
+ .badge-success {
55
+ background-color: #008B69;
56
+ }
57
+
58
+ .outdated {
59
+ color: #858585;
60
+ }
61
+
62
+ .outdated .badge-success {
63
+ background-color: #858585;
64
+ }