sidekiq-status 3.0.3 → 4.0.0
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.
- checksums.yaml +4 -4
- data/.devcontainer/Dockerfile +2 -0
- data/.devcontainer/README.md +57 -0
- data/.devcontainer/devcontainer.json +55 -0
- data/.devcontainer/docker-compose.yml +19 -0
- data/.github/workflows/ci.yaml +9 -6
- data/Appraisals +14 -6
- data/CHANGELOG.md +12 -0
- data/Dockerfile +5 -0
- data/README.md +756 -41
- data/Rakefile +153 -0
- data/docker-compose.yml +15 -0
- data/gemfiles/{sidekiq_6.1.gemfile → sidekiq_7.0.gemfile} +1 -1
- data/gemfiles/sidekiq_7.3.gemfile +7 -0
- data/gemfiles/sidekiq_8.0.gemfile +7 -0
- data/gemfiles/{sidekiq_6.x.gemfile → sidekiq_8.x.gemfile} +1 -1
- data/lib/sidekiq-status/client_middleware.rb +4 -3
- data/lib/sidekiq-status/helpers.rb +94 -0
- data/lib/sidekiq-status/server_middleware.rb +6 -21
- data/lib/sidekiq-status/storage.rb +12 -3
- data/lib/sidekiq-status/version.rb +1 -1
- data/lib/sidekiq-status/web.rb +67 -93
- data/lib/sidekiq-status/worker.rb +6 -10
- data/lib/sidekiq-status.rb +21 -5
- data/sidekiq-status.gemspec +7 -1
- data/spec/environment.rb +12 -1
- data/spec/lib/sidekiq-status/client_middleware_spec.rb +8 -0
- data/spec/lib/sidekiq-status/server_middleware_spec.rb +13 -0
- data/spec/lib/sidekiq-status/web_spec.rb +72 -3
- data/spec/lib/sidekiq-status/worker_spec.rb +3 -3
- data/spec/lib/sidekiq-status_spec.rb +20 -3
- data/spec/spec_helper.rb +3 -8
- data/spec/support/test_jobs.rb +11 -0
- data/spec/test_environment.rb +1 -0
- data/web/assets/statuses.css +124 -0
- data/web/assets/statuses.js +24 -0
- data/web/views/status.erb +131 -93
- data/web/views/status_not_found.erb +1 -1
- data/web/views/statuses.erb +23 -79
- metadata +93 -14
@@ -15,13 +15,14 @@ module Sidekiq::Status::Worker
|
|
15
15
|
end
|
16
16
|
|
17
17
|
# Read value from job status hash
|
18
|
-
# @param String|Symbol
|
18
|
+
# @param String|Symbol hash key
|
19
19
|
# @return [String]
|
20
20
|
def retrieve(name)
|
21
21
|
read_field_for_id @provider_job_id || @job_id || @jid || "", name
|
22
22
|
end
|
23
23
|
|
24
|
-
# Sets current task progress
|
24
|
+
# Sets current task progress. This will stop the job if `.stop!` has been
|
25
|
+
# called with this job's ID.
|
25
26
|
# (inspired by resque-status)
|
26
27
|
# @param Fixnum number of tasks done
|
27
28
|
# @param String optional message
|
@@ -29,7 +30,8 @@ module Sidekiq::Status::Worker
|
|
29
30
|
def at(num, message = nil)
|
30
31
|
@_status_total = 100 if @_status_total.nil?
|
31
32
|
pct_complete = ((num / @_status_total.to_f) * 100).to_i rescue 0
|
32
|
-
store(at: num, total: @_status_total, pct_complete: pct_complete, message: message
|
33
|
+
store(at: num, total: @_status_total, pct_complete: pct_complete, message: message)
|
34
|
+
raise Stopped if retrieve(:stop) == 'true'
|
33
35
|
end
|
34
36
|
|
35
37
|
# Sets total number of tasks
|
@@ -37,12 +39,6 @@ module Sidekiq::Status::Worker
|
|
37
39
|
# @return [String]
|
38
40
|
def total(num)
|
39
41
|
@_status_total = num
|
40
|
-
store(total: num
|
41
|
-
end
|
42
|
-
|
43
|
-
private
|
44
|
-
|
45
|
-
def working_at
|
46
|
-
@working_at ||= Time.now.to_i
|
42
|
+
store(total: num)
|
47
43
|
end
|
48
44
|
end
|
data/lib/sidekiq-status.rb
CHANGED
@@ -42,6 +42,10 @@ module Sidekiq::Status
|
|
42
42
|
delete_status(job_id)
|
43
43
|
end
|
44
44
|
|
45
|
+
def stop!(job_id)
|
46
|
+
store_for_id(job_id, {stop: 'true'})
|
47
|
+
end
|
48
|
+
|
45
49
|
alias_method :unschedule, :cancel
|
46
50
|
|
47
51
|
STATUS.each do |name|
|
@@ -63,19 +67,31 @@ module Sidekiq::Status
|
|
63
67
|
get(job_id, :pct_complete).to_i
|
64
68
|
end
|
65
69
|
|
66
|
-
def
|
67
|
-
|
70
|
+
def enqueued_at(job_id)
|
71
|
+
get(job_id, :enqueued_at)&.to_i
|
72
|
+
end
|
73
|
+
|
74
|
+
def started_at(job_id)
|
75
|
+
get(job_id, :started_at)&.to_i
|
76
|
+
end
|
77
|
+
|
78
|
+
def updated_at(job_id)
|
79
|
+
# sidekiq-status v3.x and earlier used :update_time
|
80
|
+
get(job_id, :updated_at)&.to_i || get(job_id, :update_time)&.to_i
|
68
81
|
end
|
69
82
|
|
70
|
-
def
|
71
|
-
|
83
|
+
def ended_at(job_id)
|
84
|
+
get(job_id, :ended_at)&.to_i
|
72
85
|
end
|
73
86
|
|
74
87
|
def eta(job_id)
|
75
88
|
at = at(job_id)
|
76
89
|
return nil if at.zero?
|
77
90
|
|
78
|
-
|
91
|
+
start_time = started_at(job_id) || enqueued_at(job_id) || updated_at(job_id)
|
92
|
+
elapsed = Time.now.to_i - start_time if start_time
|
93
|
+
return nil unless elapsed
|
94
|
+
elapsed.to_f / at * (total(job_id) - at)
|
79
95
|
end
|
80
96
|
|
81
97
|
def message(job_id)
|
data/sidekiq-status.gemspec
CHANGED
@@ -12,14 +12,20 @@ Gem::Specification.new do |gem|
|
|
12
12
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
13
13
|
gem.name = 'sidekiq-status'
|
14
14
|
gem.require_paths = ['lib']
|
15
|
+
gem.required_ruby_version = '>= 3.2'
|
15
16
|
gem.version = Sidekiq::Status::VERSION
|
16
17
|
|
17
|
-
gem.add_dependency 'sidekiq', '>=
|
18
|
+
gem.add_dependency 'sidekiq', '>= 7', '< 9'
|
18
19
|
gem.add_dependency 'chronic_duration'
|
20
|
+
gem.add_dependency 'logger'
|
21
|
+
gem.add_dependency 'base64'
|
19
22
|
gem.add_development_dependency 'appraisal'
|
20
23
|
gem.add_development_dependency 'colorize'
|
24
|
+
gem.add_development_dependency 'irb'
|
21
25
|
gem.add_development_dependency 'rack-test'
|
22
26
|
gem.add_development_dependency 'rake'
|
23
27
|
gem.add_development_dependency 'rspec'
|
24
28
|
gem.add_development_dependency 'sinatra'
|
29
|
+
gem.add_development_dependency 'webrick'
|
30
|
+
gem.add_development_dependency 'rack-session'
|
25
31
|
end
|
data/spec/environment.rb
CHANGED
@@ -1 +1,12 @@
|
|
1
|
-
# This file
|
1
|
+
# This file is used to load the test environment for Sidekiq::Status when launching Sidekiq workers directly
|
2
|
+
require "sidekiq-status"
|
3
|
+
require_relative "support/test_jobs"
|
4
|
+
|
5
|
+
Sidekiq.configure_client do |config|
|
6
|
+
Sidekiq::Status.configure_client_middleware config
|
7
|
+
end
|
8
|
+
|
9
|
+
Sidekiq.configure_server do |config|
|
10
|
+
Sidekiq::Status.configure_server_middleware config
|
11
|
+
Sidekiq::Status.configure_client_middleware config
|
12
|
+
end
|
@@ -38,6 +38,14 @@ describe Sidekiq::Status::ClientMiddleware do
|
|
38
38
|
end
|
39
39
|
end
|
40
40
|
|
41
|
+
context "when first argument is a string containing substring 'job_class'" do
|
42
|
+
it "uses the constantized class name" do
|
43
|
+
expect(StubJob.perform_async 'a string with job_class inside').to eq(job_id)
|
44
|
+
expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('queued')
|
45
|
+
expect(Sidekiq::Status::queued?(job_id)).to be_truthy
|
46
|
+
expect(Sidekiq::Status::get_all(job_id)).to include('worker' => 'StubJob')
|
47
|
+
end
|
48
|
+
end
|
41
49
|
end
|
42
50
|
|
43
51
|
describe "with :expiration parameter" do
|
@@ -45,6 +45,19 @@ describe Sidekiq::Status::ServerMiddleware do
|
|
45
45
|
expect(Sidekiq::Status::failed?(job_id)).to be_truthy
|
46
46
|
end
|
47
47
|
|
48
|
+
context "when first argument is a string containing substring 'job_class'" do
|
49
|
+
it "uses the default class name" do
|
50
|
+
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
51
|
+
start_server do
|
52
|
+
expect(capture_status_updates(3) {
|
53
|
+
expect(ConfirmationJob.perform_async 'a string with job_class inside').to eq(job_id)
|
54
|
+
}).to eq([job_id]*3)
|
55
|
+
end
|
56
|
+
expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('complete')
|
57
|
+
expect(Sidekiq::Status::get_all(job_id)).to include('worker' => 'ConfirmationJob')
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
48
61
|
context "when Sidekiq::Status::Worker is not included in the job" do
|
49
62
|
it "should not set a failed status" do
|
50
63
|
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
require 'sidekiq-status/web'
|
3
3
|
require 'rack/test'
|
4
|
+
require 'base64'
|
4
5
|
|
5
6
|
describe 'sidekiq status web' do
|
6
7
|
include Rack::Test::Methods
|
@@ -9,13 +10,14 @@ describe 'sidekiq status web' do
|
|
9
10
|
let!(:job_id) { SecureRandom.hex(12) }
|
10
11
|
|
11
12
|
def app
|
12
|
-
Sidekiq::Web
|
13
|
+
@app ||= Sidekiq::Web.new
|
13
14
|
end
|
14
15
|
|
15
16
|
before do
|
16
|
-
env 'rack.session', csrf: Base64.urlsafe_encode64('token')
|
17
|
-
client_middleware
|
18
17
|
allow(SecureRandom).to receive(:hex).and_return(job_id)
|
18
|
+
# Set up a basic session for Sidekiq's CSRF protection
|
19
|
+
env 'rack.session', {}
|
20
|
+
client_middleware
|
19
21
|
end
|
20
22
|
|
21
23
|
around { |example| start_server(&example) }
|
@@ -81,4 +83,71 @@ describe 'sidekiq status web' do
|
|
81
83
|
expect(last_response).to be_not_found
|
82
84
|
expect(last_response.body).to match(/That job can't be found/)
|
83
85
|
end
|
86
|
+
|
87
|
+
it 'handles POST with PUT method override for retrying failed jobs' do
|
88
|
+
# Create a failed job first
|
89
|
+
capture_status_updates(3) do
|
90
|
+
FailingJob.perform_async
|
91
|
+
end
|
92
|
+
|
93
|
+
# First make a GET request to establish the session and get the CSRF token
|
94
|
+
get '/statuses'
|
95
|
+
expect(last_response).to be_ok
|
96
|
+
|
97
|
+
# Extract the CSRF token from the environment
|
98
|
+
csrf_token = last_request.env[:csrf_token]
|
99
|
+
|
100
|
+
# Simulate the retry form submission with a referer header
|
101
|
+
header 'Referer', 'http://example.com/statuses'
|
102
|
+
post '/statuses', {
|
103
|
+
'jid' => job_id,
|
104
|
+
'_method' => 'put',
|
105
|
+
'authenticity_token' => csrf_token
|
106
|
+
}
|
107
|
+
|
108
|
+
expect(last_response.status).to eq(302)
|
109
|
+
expect(last_response.headers['Location']).to eq('http://example.com/statuses')
|
110
|
+
end
|
111
|
+
|
112
|
+
it 'handles POST with DELETE method override for removing completed jobs' do
|
113
|
+
# Create a completed job first
|
114
|
+
capture_status_updates(2) do
|
115
|
+
StubJob.perform_async
|
116
|
+
end
|
117
|
+
|
118
|
+
# First make a GET request to establish the session and get the CSRF token
|
119
|
+
get '/statuses'
|
120
|
+
expect(last_response).to be_ok
|
121
|
+
|
122
|
+
# Extract the CSRF token from the environment
|
123
|
+
csrf_token = last_request.env[:csrf_token]
|
124
|
+
|
125
|
+
# Simulate the remove form submission with a referer header
|
126
|
+
header 'Referer', 'http://example.com/statuses'
|
127
|
+
post '/statuses', {
|
128
|
+
'jid' => job_id,
|
129
|
+
'_method' => 'delete',
|
130
|
+
'authenticity_token' => csrf_token
|
131
|
+
}
|
132
|
+
|
133
|
+
expect(last_response.status).to eq(302)
|
134
|
+
expect(last_response.headers['Location']).to eq('http://example.com/statuses')
|
135
|
+
expect(Sidekiq::Status.status(job_id)).to be_nil
|
136
|
+
end
|
137
|
+
|
138
|
+
it 'returns 405 for POST without valid method override' do
|
139
|
+
# First make a GET request to establish the session and get the CSRF token
|
140
|
+
get '/statuses'
|
141
|
+
expect(last_response).to be_ok
|
142
|
+
|
143
|
+
# Extract the CSRF token from the environment
|
144
|
+
csrf_token = last_request.env[:csrf_token]
|
145
|
+
|
146
|
+
post '/statuses', {
|
147
|
+
'jid' => job_id,
|
148
|
+
'authenticity_token' => csrf_token
|
149
|
+
}
|
150
|
+
|
151
|
+
expect(last_response.status).to eq(405)
|
152
|
+
end
|
84
153
|
end
|
@@ -25,17 +25,17 @@ describe Sidekiq::Status::Worker do
|
|
25
25
|
subject { StubJob.new }
|
26
26
|
|
27
27
|
it "records when the worker has started" do
|
28
|
-
expect { subject.at(0) }.to(change { subject.retrieve('
|
28
|
+
expect { subject.at(0) }.to(change { subject.retrieve('updated_at') })
|
29
29
|
end
|
30
30
|
|
31
31
|
context "when setting the total for the worker" do
|
32
32
|
it "records when the worker has started" do
|
33
|
-
expect { subject.total(100) }.to(change { subject.retrieve('
|
33
|
+
expect { subject.total(100) }.to(change { subject.retrieve('updated_at') })
|
34
34
|
end
|
35
35
|
end
|
36
36
|
|
37
37
|
it "records when the worker last worked" do
|
38
|
-
expect { subject.at(0) }.to(change { subject.retrieve('
|
38
|
+
expect { subject.at(0) }.to(change { subject.retrieve('updated_at') })
|
39
39
|
end
|
40
40
|
end
|
41
41
|
end
|
@@ -72,10 +72,13 @@ describe Sidekiq::Status do
|
|
72
72
|
expect(LongJob.perform_async(0.5)).to eq(job_id)
|
73
73
|
}).to eq([job_id]*2)
|
74
74
|
expect(hash = Sidekiq::Status.get_all(job_id)).to include 'status' => 'working'
|
75
|
-
expect(hash).to include '
|
75
|
+
expect(hash).to include 'started_at'
|
76
|
+
expect(hash).to include 'updated_at'
|
76
77
|
end
|
77
78
|
expect(hash = Sidekiq::Status.get_all(job_id)).to include 'status' => 'complete'
|
78
|
-
expect(hash).to include '
|
79
|
+
expect(hash).to include 'started_at'
|
80
|
+
expect(hash).to include 'updated_at'
|
81
|
+
expect(hash).to include 'ended_at'
|
79
82
|
end
|
80
83
|
end
|
81
84
|
|
@@ -138,6 +141,20 @@ describe Sidekiq::Status do
|
|
138
141
|
end
|
139
142
|
end
|
140
143
|
|
144
|
+
describe ".stop!" do
|
145
|
+
it "allows a job to be stopped" do
|
146
|
+
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
147
|
+
start_server do
|
148
|
+
expect(capture_status_updates(1) {
|
149
|
+
expect(LongProgressJob.perform_async).to eq(job_id)
|
150
|
+
expect(Sidekiq::Status.stop!(job_id)).to be_truthy
|
151
|
+
}).to eq([job_id]*1)
|
152
|
+
end
|
153
|
+
expect(Sidekiq::Status.at(job_id)).to be(0)
|
154
|
+
expect(Sidekiq::Status.stopped?(job_id)).to be_truthy
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
141
158
|
context "keeps normal Sidekiq functionality" do
|
142
159
|
let(:expiration_param) { nil }
|
143
160
|
|
@@ -152,7 +169,7 @@ describe Sidekiq::Status do
|
|
152
169
|
seed_secure_random_with_job_ids
|
153
170
|
start_server(:expiration => expiration_param) do
|
154
171
|
expect {
|
155
|
-
Sidekiq::Client.new(Sidekiq.redis_pool).
|
172
|
+
Sidekiq::Client.new(pool: Sidekiq.redis_pool).
|
156
173
|
push("class" => "NotAKnownClass", "args" => [])
|
157
174
|
}.to_not raise_error
|
158
175
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,11 +1,6 @@
|
|
1
1
|
require "rspec"
|
2
2
|
require 'colorize'
|
3
3
|
require 'sidekiq'
|
4
|
-
|
5
|
-
# Celluloid should only be manually required before Sidekiq versions 4.+
|
6
|
-
require 'sidekiq/version'
|
7
|
-
require 'celluloid' if Gem::Version.new(Sidekiq::VERSION) < Gem::Version.new('4.0')
|
8
|
-
|
9
4
|
require 'sidekiq/processor'
|
10
5
|
require 'sidekiq/manager'
|
11
6
|
require 'sidekiq-status'
|
@@ -50,7 +45,7 @@ def redis_thread messages_limit, *channels
|
|
50
45
|
end
|
51
46
|
end
|
52
47
|
end
|
53
|
-
puts "
|
48
|
+
puts "Returning from thread".cyan if ENV['DEBUG']
|
54
49
|
messages
|
55
50
|
}
|
56
51
|
|
@@ -83,7 +78,7 @@ def redis_client_thread message_limit, *channels
|
|
83
78
|
end
|
84
79
|
end
|
85
80
|
end
|
86
|
-
puts "
|
81
|
+
puts "Returning from thread".cyan if ENV['DEBUG']
|
87
82
|
messages
|
88
83
|
}
|
89
84
|
sleep 0.1
|
@@ -126,7 +121,7 @@ def start_server server_middleware_options = {}
|
|
126
121
|
# Launch
|
127
122
|
puts "Server starting".yellow if ENV['DEBUG']
|
128
123
|
instance = Sidekiq::CLI.instance
|
129
|
-
instance.parse(['-r', File.expand_path('
|
124
|
+
instance.parse(['-r', File.expand_path('test_environment.rb', File.dirname(__FILE__))])
|
130
125
|
instance.run
|
131
126
|
|
132
127
|
end
|
data/spec/support/test_jobs.rb
CHANGED
@@ -32,6 +32,17 @@ class LongJob < StubJob
|
|
32
32
|
end
|
33
33
|
end
|
34
34
|
|
35
|
+
class LongProgressJob < StubJob
|
36
|
+
def perform(*args)
|
37
|
+
sleep 0.25
|
38
|
+
10.times do |i|
|
39
|
+
at i * 10
|
40
|
+
sleep (args[0] || 0.25) / 10.0
|
41
|
+
end
|
42
|
+
at 100
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
35
46
|
class DataJob < StubJob
|
36
47
|
def perform
|
37
48
|
sleep 0.1
|
@@ -0,0 +1 @@
|
|
1
|
+
# This file has been intentionally left blank
|
@@ -0,0 +1,124 @@
|
|
1
|
+
.progress {
|
2
|
+
background-color: #C8E1ED;
|
3
|
+
border-radius: 8px;
|
4
|
+
height: 100%;
|
5
|
+
}
|
6
|
+
.progress-bar {
|
7
|
+
display: flex;
|
8
|
+
align-items: center;
|
9
|
+
}
|
10
|
+
.progress-percentage {
|
11
|
+
padding-left: 6px;
|
12
|
+
color: #333;
|
13
|
+
text-align: left;
|
14
|
+
text-shadow: 0 0 5px white;
|
15
|
+
font-weight: bold;
|
16
|
+
}
|
17
|
+
.bar {
|
18
|
+
background-color: #2897cb;
|
19
|
+
color: white;
|
20
|
+
text-shadow: 0 0 0;
|
21
|
+
}
|
22
|
+
.message {
|
23
|
+
text-shadow: 0 0 5px white;
|
24
|
+
font-weight: bold; padding-left: 4px;
|
25
|
+
color: #333;
|
26
|
+
}
|
27
|
+
.actions {
|
28
|
+
text-align: center;
|
29
|
+
}
|
30
|
+
.btn-warning {
|
31
|
+
background-image: linear-gradient(#f0ad4e, #eea236)
|
32
|
+
}
|
33
|
+
.index-header {
|
34
|
+
display: flex;
|
35
|
+
justify-content: space-between;
|
36
|
+
}
|
37
|
+
.nav-container {
|
38
|
+
display: flex;
|
39
|
+
line-height: 45px;
|
40
|
+
}
|
41
|
+
.nav-container .pull-right {
|
42
|
+
float: none !important;
|
43
|
+
}
|
44
|
+
.nav-container .pagination {
|
45
|
+
display: flex;
|
46
|
+
align-items: center;
|
47
|
+
}
|
48
|
+
.nav-container .per-page, .filter-status {
|
49
|
+
display: flex;
|
50
|
+
align-items: center;
|
51
|
+
margin: 20px 0 20px 10px;
|
52
|
+
white-space: nowrap;
|
53
|
+
}
|
54
|
+
.nav-container .per-page SELECT {
|
55
|
+
margin: 0 0 0 5px;
|
56
|
+
}
|
57
|
+
.status-header {
|
58
|
+
display: flex;
|
59
|
+
align-items: center;
|
60
|
+
justify-content: space-between;
|
61
|
+
}
|
62
|
+
.status-table th {
|
63
|
+
font-weight: bold;
|
64
|
+
width: 25%;
|
65
|
+
padding: 12px;
|
66
|
+
}
|
67
|
+
.status-table td {
|
68
|
+
padding: 12px;
|
69
|
+
vertical-align: top;
|
70
|
+
}
|
71
|
+
.status-table .timestamp {
|
72
|
+
font-family: monospace;
|
73
|
+
font-size: 13px;
|
74
|
+
}
|
75
|
+
.status-table .multiline {
|
76
|
+
white-space: pre-wrap;
|
77
|
+
font-family: monospace;
|
78
|
+
background-color: #f8f8f8;
|
79
|
+
padding: 8px;
|
80
|
+
border-radius: 3px;
|
81
|
+
}
|
82
|
+
.label {
|
83
|
+
display: inline-block;
|
84
|
+
}
|
85
|
+
.label-primary {
|
86
|
+
background-color: #337ab7;
|
87
|
+
}
|
88
|
+
.center {
|
89
|
+
text-align: center;
|
90
|
+
}
|
91
|
+
.nowrap {
|
92
|
+
white-space: nowrap;
|
93
|
+
}
|
94
|
+
.h-30px {
|
95
|
+
height: 30px;
|
96
|
+
}
|
97
|
+
.mb-0 {
|
98
|
+
margin-bottom: 0;
|
99
|
+
}
|
100
|
+
.fs-lg {
|
101
|
+
font-size: 1.4em;
|
102
|
+
}
|
103
|
+
|
104
|
+
/* Dark mode support */
|
105
|
+
@media screen and (prefers-color-scheme: dark) {
|
106
|
+
.progress {
|
107
|
+
background-color: #233240;
|
108
|
+
}
|
109
|
+
|
110
|
+
.progress-percentage {
|
111
|
+
color: #ecf0f1;
|
112
|
+
text-shadow: none;
|
113
|
+
}
|
114
|
+
|
115
|
+
.bar {
|
116
|
+
background-color: #3498db;
|
117
|
+
color: #ecf0f1;
|
118
|
+
}
|
119
|
+
|
120
|
+
.message {
|
121
|
+
color: #ecf0f1;
|
122
|
+
text-shadow: none;
|
123
|
+
}
|
124
|
+
}
|
@@ -0,0 +1,24 @@
|
|
1
|
+
// Handles the navigation for the filter and per-page select dropdowns
|
2
|
+
document.addEventListener("change", function(event) {
|
3
|
+
if (event.target.matches(".nav-container select.form-control")) {
|
4
|
+
window.location = event.target.options[event.target.selectedIndex].getAttribute('data-url')
|
5
|
+
}
|
6
|
+
})
|
7
|
+
|
8
|
+
// Set width of progress bars based on their aria-valuenow attribute
|
9
|
+
function updateProgressBarWidths() {
|
10
|
+
document.querySelectorAll('.progress-bar').forEach(function(progressBar) {
|
11
|
+
const valueNow = progressBar.getAttribute('aria-valuenow');
|
12
|
+
if (valueNow !== null) {
|
13
|
+
progressBar.style.width = valueNow + '%';
|
14
|
+
}
|
15
|
+
});
|
16
|
+
}
|
17
|
+
updateProgressBarWidths();
|
18
|
+
|
19
|
+
// Update progress bar widths when the page loads
|
20
|
+
document.addEventListener("DOMContentLoaded", updateProgressBarWidths);
|
21
|
+
|
22
|
+
// Also update when new content is dynamically loaded
|
23
|
+
document.addEventListener("DOMContentMounted", updateProgressBarWidths);
|
24
|
+
|