good_job 2.11.1 → 2.12.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +39 -18
- data/engine/app/charts/good_job/scheduled_by_queue_chart.rb +2 -1
- data/engine/app/controllers/good_job/{base_controller.rb → application_controller.rb} +20 -2
- data/engine/app/controllers/good_job/cron_entries_controller.rb +1 -1
- data/engine/app/controllers/good_job/executions_controller.rb +1 -1
- data/engine/app/controllers/good_job/jobs_controller.rb +1 -1
- data/engine/app/controllers/good_job/processes_controller.rb +1 -1
- data/engine/app/filters/good_job/jobs_filter.rb +2 -2
- data/engine/app/views/good_job/shared/_alert.erb +13 -0
- data/engine/app/views/good_job/shared/_announcement.erb +7 -0
- data/engine/app/views/good_job/shared/_footer.erb +15 -0
- data/engine/app/views/good_job/shared/_navbar.erb +49 -0
- data/engine/app/views/layouts/good_job/application.html.erb +32 -0
- data/engine/config/locales/en.yml +56 -0
- data/engine/config/locales/es.yml +56 -0
- data/lib/good_job/active_job_job.rb +8 -1
- data/lib/good_job/adapter.rb +8 -2
- data/lib/good_job/cli.rb +5 -5
- data/lib/good_job/cron_entry.rb +11 -1
- data/lib/good_job/poller.rb +0 -3
- data/lib/good_job/version.rb +1 -1
- metadata +10 -4
- data/engine/app/views/layouts/good_job/base.html.erb +0 -96
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 398864eebf8f617924967bf2a73679bd0584493299570c20ea6dbc9598d23ff3
|
4
|
+
data.tar.gz: d781885d188ec4b3dab51f9609ba7a3935027426edf6cbf8147ae2b11a51ac72
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e9830078953d473bb48ec24cfcd4841029614be85c910b493d670eb1cfbbad287e95ce854a65aec609dd97954d6bb19f3a30c6a95cfd5d18c6913d777bab4d0f
|
7
|
+
data.tar.gz: ba0beb2c46d7f7044d1a7f2d5b1cb006ee67433432fb18e7bb04c7d4452df2e10ad45eff884c66bd7c1069ec47fee4fa18d776814dde4adf5b8a5bb4fb722904
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,57 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## [v2.12.0](https://github.com/bensheldon/good_job/tree/v2.12.0) (2022-04-05)
|
4
|
+
|
5
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v2.11.3...v2.12.0)
|
6
|
+
|
7
|
+
**Closed issues:**
|
8
|
+
|
9
|
+
- TimeTask timeouts are now ignored as these were not able to be implemented correctly [\#555](https://github.com/bensheldon/good_job/issues/555)
|
10
|
+
- undefined method `relative\_time' when include\_all\_helpers is false [\#550](https://github.com/bensheldon/good_job/issues/550)
|
11
|
+
- ArgumentError: wrong number of arguments \(given 1, expected 0; required keyword: schedule\) - cron [\#546](https://github.com/bensheldon/good_job/issues/546)
|
12
|
+
|
13
|
+
**Merged pull requests:**
|
14
|
+
|
15
|
+
- Deprecate Adapter configuration of job execution/cron [\#558](https://github.com/bensheldon/good_job/pull/558) ([bensheldon](https://github.com/bensheldon))
|
16
|
+
- Remove usage of Concurrent::TimerTask's timeout\_interval [\#557](https://github.com/bensheldon/good_job/pull/557) ([bensheldon](https://github.com/bensheldon))
|
17
|
+
- Include locale in html lang attribute [\#556](https://github.com/bensheldon/good_job/pull/556) ([bensheldon](https://github.com/bensheldon))
|
18
|
+
- Rename `GoodJob::BaseController` to `GoodJob::ApplicationController` [\#553](https://github.com/bensheldon/good_job/pull/553) ([shouichi](https://github.com/shouichi))
|
19
|
+
- Internationalize/I18n the Dashboard Engine [\#497](https://github.com/bensheldon/good_job/pull/497) ([JuanVqz](https://github.com/JuanVqz))
|
20
|
+
|
21
|
+
## [v2.11.3](https://github.com/bensheldon/good_job/tree/v2.11.3) (2022-03-30)
|
22
|
+
|
23
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v2.11.2...v2.11.3)
|
24
|
+
|
25
|
+
**Fixed bugs:**
|
26
|
+
|
27
|
+
- Add explicit `kwargs:` key to cron configuration [\#548](https://github.com/bensheldon/good_job/pull/548) ([bensheldon](https://github.com/bensheldon))
|
28
|
+
|
29
|
+
**Closed issues:**
|
30
|
+
|
31
|
+
- How to run clean up preserved jobs in cron? [\#541](https://github.com/bensheldon/good_job/issues/541)
|
32
|
+
- Erroring with "Too many open files" when good\_job tries reconnecting to database [\#530](https://github.com/bensheldon/good_job/issues/530)
|
33
|
+
- Can't cast Array [\#529](https://github.com/bensheldon/good_job/issues/529)
|
34
|
+
|
35
|
+
**Merged pull requests:**
|
36
|
+
|
37
|
+
- Use bundle add instead [\#542](https://github.com/bensheldon/good_job/pull/542) ([glaucocustodio](https://github.com/glaucocustodio))
|
38
|
+
- Update Readme to better explain queues, pools, threads, and database connections; update CLI to frontload queue option [\#539](https://github.com/bensheldon/good_job/pull/539) ([bensheldon](https://github.com/bensheldon))
|
39
|
+
|
40
|
+
## [v2.11.2](https://github.com/bensheldon/good_job/tree/v2.11.2) (2022-03-03)
|
41
|
+
|
42
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v2.11.1...v2.11.2)
|
43
|
+
|
44
|
+
**Closed issues:**
|
45
|
+
|
46
|
+
- Best practices in deploying and monitoring a queue [\#523](https://github.com/bensheldon/good_job/issues/523)
|
47
|
+
|
48
|
+
**Merged pull requests:**
|
49
|
+
|
50
|
+
- Wrap Rspec before and example blocks with a mutex for JRuby [\#537](https://github.com/bensheldon/good_job/pull/537) ([bensheldon](https://github.com/bensheldon))
|
51
|
+
- Delegate `ActiveJobJob.table_name` to `Execution` and prevent it from being directly assignable [\#536](https://github.com/bensheldon/good_job/pull/536) ([bensheldon](https://github.com/bensheldon))
|
52
|
+
- Enable DB table names customization [\#535](https://github.com/bensheldon/good_job/pull/535) ([dimvic](https://github.com/dimvic))
|
53
|
+
- Added a chapter on how to prepare for production. [\#525](https://github.com/bensheldon/good_job/pull/525) ([stas](https://github.com/stas))
|
54
|
+
|
3
55
|
## [v2.11.1](https://github.com/bensheldon/good_job/tree/v2.11.1) (2022-03-01)
|
4
56
|
|
5
57
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v2.11.0...v2.11.1)
|
data/README.md
CHANGED
@@ -52,6 +52,7 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
|
|
52
52
|
- [Timeouts](#timeouts)
|
53
53
|
- [Optimize queues, threads, and processes](#optimize-queues-threads-and-processes)
|
54
54
|
- [Database connections](#database-connections)
|
55
|
+
- [Production setup](#production-setup)
|
55
56
|
- [Execute jobs async / in-process](#execute-jobs-async--in-process)
|
56
57
|
- [Migrate to GoodJob from a different ActiveJob backend](#migrate-to-goodjob-from-a-different-activejob-backend)
|
57
58
|
- [Monitor and preserve worked jobs](#monitor-and-preserve-worked-jobs)
|
@@ -64,16 +65,10 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
|
|
64
65
|
|
65
66
|
## Set up
|
66
67
|
|
67
|
-
1. Add `good_job` to your application's Gemfile:
|
68
|
+
1. Add `good_job` to your application's Gemfile and install the gem:
|
68
69
|
|
69
|
-
```
|
70
|
-
|
71
|
-
```
|
72
|
-
|
73
|
-
1. Install the gem:
|
74
|
-
|
75
|
-
```bash
|
76
|
-
bundle install
|
70
|
+
```sh
|
71
|
+
bundle add good_job
|
77
72
|
```
|
78
73
|
|
79
74
|
1. Run the GoodJob install generator. This will generate a database migration to create a table for GoodJob's job records:
|
@@ -164,8 +159,8 @@ Usage:
|
|
164
159
|
good_job start
|
165
160
|
|
166
161
|
Options:
|
167
|
-
[--
|
168
|
-
[--
|
162
|
+
[--queues=QUEUE_LIST] # Queues or pools to work from. (env var: GOOD_JOB_QUEUES, default: *)
|
163
|
+
[--max-threads=COUNT] # Default number of threads per pool to use for working jobs. (env var: GOOD_JOB_MAX_THREADS, default: 5)
|
169
164
|
[--poll-interval=SECONDS] # Interval between polls for available jobs in seconds (env var: GOOD_JOB_POLL_INTERVAL, default: 1)
|
170
165
|
[--max-cache=COUNT] # Maximum number of scheduled jobs to cache in memory (env var: GOOD_JOB_MAX_CACHE, default: 10000)
|
171
166
|
[--shutdown-timeout=SECONDS] # Number of seconds to wait for jobs to finish when shutting down before stopping the thread. (env var: GOOD_JOB_SHUTDOWN_TIMEOUT, default: -1 (forever))
|
@@ -229,12 +224,12 @@ Rails.application.configure do
|
|
229
224
|
config.good_job.retry_on_unhandled_error = false
|
230
225
|
config.good_job.on_thread_error = -> (exception) { Raven.capture_exception(exception) }
|
231
226
|
config.good_job.execution_mode = :async
|
227
|
+
config.good_job.queues = '*'
|
232
228
|
config.good_job.max_threads = 5
|
233
229
|
config.good_job.poll_interval = 30 # seconds
|
234
230
|
config.good_job.shutdown_timeout = 25 # seconds
|
235
231
|
config.good_job.enable_cron = true
|
236
232
|
config.good_job.cron = { example: { cron: '0 * * * *', class: 'ExampleJob' } }
|
237
|
-
config.good_job.queues = '*'
|
238
233
|
|
239
234
|
# ...or all at once.
|
240
235
|
config.good_job = {
|
@@ -242,6 +237,7 @@ Rails.application.configure do
|
|
242
237
|
retry_on_unhandled_error: false,
|
243
238
|
on_thread_error: -> (exception) { Raven.capture_exception(exception) },
|
244
239
|
execution_mode: :async,
|
240
|
+
queues: '*',
|
245
241
|
max_threads: 5,
|
246
242
|
poll_interval: 30,
|
247
243
|
shutdown_timeout: 25,
|
@@ -252,7 +248,6 @@ Rails.application.configure do
|
|
252
248
|
class: 'ExampleJob'
|
253
249
|
},
|
254
250
|
},
|
255
|
-
queues: '*',
|
256
251
|
}
|
257
252
|
end
|
258
253
|
```
|
@@ -264,8 +259,8 @@ Available configuration options are:
|
|
264
259
|
- `:external` causes the adapter to enqueue jobs, but not execute them. When using this option (the default for production environments), you’ll need to use the command-line tool to actually execute your jobs.
|
265
260
|
- `:async` (or `:async_server`) executes jobs in separate threads within the Rails web server process (`bundle exec rails server`). It can be more economical for small workloads because you don’t need a separate machine or environment for running your jobs, but if your web server is under heavy load or your jobs require a lot of resources, you should choose `:external` instead. When not in the Rails web server, jobs will execute in `:external` mode to ensure jobs are not executed within `rails console`, `rails db:migrate`, `rails assets:prepare`, etc.
|
266
261
|
- `:async_all` executes jobs in separate threads in _any_ Rails process.
|
267
|
-
- `
|
268
|
-
- `
|
262
|
+
- `queues` (string) sets queues or pools to execute jobs. You can also set this with the environment variable `GOOD_JOB_QUEUES`.
|
263
|
+
- `max_threads` (integer) sets the default number of threads per pool to use for working jobs. You can also set this with the environment variable `GOOD_JOB_MAX_THREADS`.
|
269
264
|
- `poll_interval` (integer) sets the number of seconds between polls for jobs when `execution_mode` is set to `:async`. You can also set this with the environment variable `GOOD_JOB_POLL_INTERVAL`. A poll interval of `-1` disables polling completely.
|
270
265
|
- `max_cache` (integer) sets the maximum number of scheduled jobs that will be stored in memory to reduce execution latency when also polling for scheduled jobs. Caching 10,000 scheduled jobs uses approximately 20MB of memory. You can also set this with the environment variable `GOOD_JOB_MAX_CACHE`.
|
271
266
|
- `shutdown_timeout` (float) number of seconds to wait for jobs to finish when shutting down before stopping the thread. Defaults to forever: `-1`. You can also set this with the environment variable `GOOD_JOB_SHUTDOWN_TIMEOUT`.
|
@@ -441,7 +436,8 @@ config.good_job.cron = {
|
|
441
436
|
frequent_task: { # each recurring job must have a unique key
|
442
437
|
cron: "*/15 * * * *", # cron-style scheduling format by fugit gem
|
443
438
|
class: "ExampleJob", # reference the Job class with a string
|
444
|
-
args: [42,
|
439
|
+
args: [42, "life"], # positional arguments to pass; can also be a proc e.g. `-> { [Time.now] }`
|
440
|
+
kwargs: { name: "Alice" }, # keyword arguments to pass; can also be a proc e.g. `-> { { name: NAMES.sample } }`
|
445
441
|
set: { priority: -10 }, # additional ActiveJob properties; can also be a lambda/proc e.g. `-> { { priority: [1,2].sample } }`
|
446
442
|
description: "Something helpful", # optional description that appears in Dashboard (coming soon!)
|
447
443
|
},
|
@@ -674,13 +670,38 @@ Keep in mind, queue operations and management is an advanced discipline. This st
|
|
674
670
|
|
675
671
|
### Database connections
|
676
672
|
|
677
|
-
Each GoodJob execution thread requires its own database connection that is automatically checked out from Rails’ connection pool.
|
673
|
+
Each GoodJob execution thread requires its own database connection that is automatically checked out from Rails’ connection pool. For example:
|
678
674
|
|
679
675
|
```yaml
|
680
676
|
# config/database.yml
|
681
|
-
pool: <%=
|
677
|
+
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5).to_i + 3 + (ENV.fetch("GOOD_JOB_MAX_THREADS", 5).to_i %>
|
682
678
|
```
|
683
679
|
|
680
|
+
To calculate the total number of the database connections you'll need:
|
681
|
+
|
682
|
+
- 1 connection dedicated to the scheduler aka `LISTEN/NOTIFY`
|
683
|
+
- 1 connection per query pool thread e.g. `--queues=mice:2;elephants:1` is 3 threads. Pool thread size defaults to `--max-threads`
|
684
|
+
- (optional) 2 connections for Cron scheduler if you're running it
|
685
|
+
- (optional) 1 connection per subthread, if your application makes multithreaded database queries within a job
|
686
|
+
- When running `:async`, you must also add the number of threads by the webserver
|
687
|
+
|
688
|
+
The queue process will not crash if the connections pool is exhausted, instead it will report an exception (eg. `ActiveRecord::ConnectionTimeoutError`).
|
689
|
+
|
690
|
+
#### Production setup
|
691
|
+
|
692
|
+
When running GoodJob in a production environment, you should be mindful of:
|
693
|
+
|
694
|
+
- [Execution mode](execute-jobs-async--in-process)
|
695
|
+
- [Database connection pool size](#database-connections)
|
696
|
+
- [Health check probes](#cli-http-health-check-probes) and potentially the [instrumentation support](#monitor-and-preserve-worked-jobs)
|
697
|
+
|
698
|
+
The recommended way to monitor the queue in production is:
|
699
|
+
|
700
|
+
- have an exception notifier callback (see `on_thread_error`)
|
701
|
+
- if possible, run the queue as a dedicated instance and use available HTTP health check probes instead of pid-based monitoring
|
702
|
+
- keep an eye on the number of jobs in the queue (abnormal high number of unscheduled jobs means the queue could be underperforming)
|
703
|
+
- consider performance monitoring services which support the built-in Rails instrumentation (eg. Sentry, Skylight, etc.)
|
704
|
+
|
684
705
|
### Execute jobs async / in-process
|
685
706
|
|
686
707
|
GoodJob can execute jobs "async" in the same process as the web server (e.g. `bin/rails s`). GoodJob's async execution mode offers benefits of economy by not requiring a separate job worker process, but with the tradeoff of increased complexity. Async mode can be configured in two ways:
|
@@ -9,6 +9,7 @@ module GoodJob
|
|
9
9
|
def data
|
10
10
|
end_time = Time.current
|
11
11
|
start_time = end_time - 1.day
|
12
|
+
table_name = GoodJob::ActiveJobJob.table_name
|
12
13
|
|
13
14
|
count_query = Arel.sql(GoodJob::Execution.pg_or_jdbc_query(<<~SQL.squish))
|
14
15
|
SELECT *
|
@@ -23,7 +24,7 @@ module GoodJob
|
|
23
24
|
queue_name,
|
24
25
|
count(*) AS count
|
25
26
|
FROM (
|
26
|
-
#{@filter.filtered_query.except(:select, :order).select('queue_name',
|
27
|
+
#{@filter.filtered_query.except(:select, :order).select('queue_name', "COALESCE(#{table_name}.scheduled_at, #{table_name}.created_at)::timestamp AS scheduled_at").to_sql}
|
27
28
|
) sources
|
28
29
|
GROUP BY date_trunc('hour', scheduled_at), queue_name
|
29
30
|
) sources ON sources.scheduled_at = timestamp
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
module GoodJob
|
3
|
-
class
|
3
|
+
class ApplicationController < ActionController::Base
|
4
4
|
protect_from_forgery with: :exception
|
5
5
|
|
6
6
|
around_action :switch_locale
|
@@ -24,10 +24,28 @@ module GoodJob
|
|
24
24
|
request.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
|
25
25
|
end
|
26
26
|
|
27
|
+
def default_url_options(options = {})
|
28
|
+
{ locale: I18n.locale }.merge(options)
|
29
|
+
end
|
30
|
+
|
27
31
|
private
|
28
32
|
|
29
33
|
def switch_locale(&action)
|
30
|
-
I18n.with_locale(
|
34
|
+
I18n.with_locale(current_locale, &action)
|
35
|
+
end
|
36
|
+
|
37
|
+
def current_locale
|
38
|
+
if params[:locale]
|
39
|
+
params[:locale]
|
40
|
+
elsif good_job_available_locales.exclude?(I18n.default_locale) && I18n.available_locales.include?(:en)
|
41
|
+
:en
|
42
|
+
else
|
43
|
+
I18n.default_locale
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def good_job_available_locales
|
48
|
+
@_good_job_available_locales ||= GoodJob::Engine.root.join("config/locales").glob("*.yml").map { |path| File.basename(path, ".yml").to_sym }.uniq
|
31
49
|
end
|
32
50
|
end
|
33
51
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
module GoodJob
|
3
|
-
class JobsController < GoodJob::
|
3
|
+
class JobsController < GoodJob::ApplicationController
|
4
4
|
rescue_from GoodJob::ActiveJobJob::AdapterNotGoodJobError,
|
5
5
|
GoodJob::ActiveJobJob::ActionForStateMismatchError,
|
6
6
|
with: :redirect_on_error
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
module GoodJob
|
3
|
-
class ProcessesController < GoodJob::
|
3
|
+
class ProcessesController < GoodJob::ApplicationController
|
4
4
|
def index
|
5
5
|
@processes = GoodJob::Process.active.order(created_at: :desc) if GoodJob::Process.migrated?
|
6
6
|
end
|
@@ -14,7 +14,7 @@ module GoodJob
|
|
14
14
|
|
15
15
|
def filtered_query
|
16
16
|
query = base_query.includes(:executions)
|
17
|
-
.joins_advisory_locks.select(
|
17
|
+
.joins_advisory_locks.select("#{GoodJob::ActiveJobJob.table_name}.*", 'pg_locks.locktype AS locktype')
|
18
18
|
|
19
19
|
query = query.job_class(params[:job_class]) if params[:job_class].present?
|
20
20
|
query = query.where(queue_name: params[:queue_name]) if params[:queue_name].present?
|
@@ -31,7 +31,7 @@ module GoodJob
|
|
31
31
|
when 'scheduled'
|
32
32
|
query = query.scheduled
|
33
33
|
when 'running'
|
34
|
-
query = query.running.select(
|
34
|
+
query = query.running.select("#{GoodJob::ActiveJobJob.table_name}.*", 'pg_locks.locktype')
|
35
35
|
when 'queued'
|
36
36
|
query = query.queued
|
37
37
|
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
<% if notice %>
|
2
|
+
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center offset-md-3 col-6" role="alert">
|
3
|
+
<%= render "good_job/shared/icons/check", class: "flex-shrink-0 me-2" %>
|
4
|
+
<div><%= notice %></div>
|
5
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
6
|
+
</div>
|
7
|
+
<% elsif alert %>
|
8
|
+
<div class="alert alert-warning alert-dismissible fade show d-flex align-items-center offset-md-3 col-6" role="alert">
|
9
|
+
<%= render "good_job/shared/icons/exclamation", class: "flex-shrink-0 me-2" %>
|
10
|
+
<div><%= alert %></div>
|
11
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
12
|
+
</div>
|
13
|
+
<% end %>
|
@@ -0,0 +1,15 @@
|
|
1
|
+
<footer class="footer mt-auto py-3 bg-light fixed-bottom" id="footer" data-gj-poll-replace>
|
2
|
+
<div class="container-fluid">
|
3
|
+
<div class="row">
|
4
|
+
<div class="col-6">
|
5
|
+
<span class="text-muted">
|
6
|
+
<%= t(".last_update_html", time: Time.current.utc.iso8601) %>
|
7
|
+
</span>
|
8
|
+
</div>
|
9
|
+
|
10
|
+
<div class="col-6 text-end">
|
11
|
+
<%= t(".wording") %>
|
12
|
+
</div>
|
13
|
+
</div>
|
14
|
+
</div>
|
15
|
+
</footer>
|
@@ -0,0 +1,49 @@
|
|
1
|
+
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
2
|
+
<div class="container-fluid">
|
3
|
+
<%= link_to t(".name"), root_path, class: "navbar-brand mb-0 h1" %>
|
4
|
+
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
5
|
+
<span class="navbar-toggler-icon"></span>
|
6
|
+
</button>
|
7
|
+
|
8
|
+
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
9
|
+
<ul class="navbar-nav me-auto">
|
10
|
+
<li class="nav-item">
|
11
|
+
<%= link_to t(".executions"), root_path, class: ["nav-link", ("active" if current_page?(root_path))] %>
|
12
|
+
</li>
|
13
|
+
<li class="nav-item">
|
14
|
+
<%= link_to t(".jobs"), jobs_path, class: ["nav-link", ("active" if current_page?(jobs_path))] %>
|
15
|
+
</li>
|
16
|
+
<li class="nav-item">
|
17
|
+
<%= link_to t(".cron_schedules"), cron_entries_path, class: ["nav-link", ("active" if current_page?(cron_entries_path))] %>
|
18
|
+
</li>
|
19
|
+
<li class="nav-item">
|
20
|
+
<%= link_to t(".processes"), processes_path, class: ["nav-link", ("active" if current_page?(processes_path))] %>
|
21
|
+
</li>
|
22
|
+
<li class="nav-item">
|
23
|
+
<div class="nav-link">
|
24
|
+
<span class="badge bg-secondary"><%= t(".coming_soon") %></span>
|
25
|
+
</div>
|
26
|
+
</li>
|
27
|
+
</ul>
|
28
|
+
<div class="nav-item pe-2">
|
29
|
+
<div class="form-check">
|
30
|
+
<input type="checkbox" id="toggle-poll" name="toggle-poll" data-gj-action='change#togglePoll' <%= 'checked' if params[:poll].present? %>>
|
31
|
+
<label for="toggle-poll"><%= t(".live_poll") %></label>
|
32
|
+
</div>
|
33
|
+
</div>
|
34
|
+
<ul class="navbar-nav">
|
35
|
+
<li class="nav-item dropdown">
|
36
|
+
<a href="#" class="nav-link dropdown-toggle" type="button" id="localeOptions" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
37
|
+
<%= I18n.locale %>
|
38
|
+
</a>
|
39
|
+
|
40
|
+
<ul class="dropdown-menu" aria-labelledby="localeOptions">
|
41
|
+
<% I18n.available_locales.reject { |locale| locale == I18n.locale }.each do |locale| %>
|
42
|
+
<li><%= link_to locale, url_for(locale: locale), class: "dropdown-item" %></li>
|
43
|
+
<% end %>
|
44
|
+
</ul>
|
45
|
+
</li>
|
46
|
+
</ul>
|
47
|
+
</div>
|
48
|
+
</div>
|
49
|
+
</nav>
|
@@ -0,0 +1,32 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="<%= I18n.locale %>">
|
3
|
+
<head>
|
4
|
+
<title>Good Job Dashboard</title>
|
5
|
+
<meta charset="utf-8">
|
6
|
+
<meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport">
|
7
|
+
<%= csrf_meta_tags %>
|
8
|
+
<%= csp_meta_tag %>
|
9
|
+
|
10
|
+
<%# Assets must use *_url route helpers to avoid being overriden by config.asset_host %>
|
11
|
+
<%= stylesheet_link_tag bootstrap_url(format: :css, v: GoodJob::VERSION), skip_pipeline: true %>
|
12
|
+
<%= stylesheet_link_tag style_url(format: :css, v: GoodJob::VERSION) %>
|
13
|
+
|
14
|
+
<%= javascript_include_tag bootstrap_url(format: :js, v: GoodJob::VERSION), nonce: true %>
|
15
|
+
<%= javascript_include_tag chartjs_url(format: :js, v: GoodJob::VERSION), nonce: true %>
|
16
|
+
<%= javascript_include_tag scripts_url(format: :js, v: GoodJob::VERSION), nonce: true %>
|
17
|
+
|
18
|
+
<%= javascript_include_tag rails_ujs_url(format: :js, v: GoodJob::VERSION), nonce: true %>
|
19
|
+
</head>
|
20
|
+
<body>
|
21
|
+
<%= render "good_job/shared/navbar" %>
|
22
|
+
|
23
|
+
<div class="container-fluid">
|
24
|
+
<%= render "good_job/shared/announcement" %>
|
25
|
+
<%= render "good_job/shared/alert" %>
|
26
|
+
|
27
|
+
<%= yield %>
|
28
|
+
</div>
|
29
|
+
|
30
|
+
<%= render "good_job/shared/footer" %>
|
31
|
+
</body>
|
32
|
+
</html>
|
@@ -0,0 +1,56 @@
|
|
1
|
+
---
|
2
|
+
en:
|
3
|
+
datetime:
|
4
|
+
distance_in_words:
|
5
|
+
about_x_hours:
|
6
|
+
one: about 1 hour
|
7
|
+
other: about %{count} hours
|
8
|
+
about_x_months:
|
9
|
+
one: about 1 month
|
10
|
+
other: about %{count} months
|
11
|
+
about_x_years:
|
12
|
+
one: about 1 year
|
13
|
+
other: about %{count} years
|
14
|
+
almost_x_years:
|
15
|
+
one: almost 1 year
|
16
|
+
other: almost %{count} years
|
17
|
+
half_a_minute: half a minute
|
18
|
+
less_than_x_minutes:
|
19
|
+
one: less than a minute
|
20
|
+
other: less than %{count} minutes
|
21
|
+
less_than_x_seconds:
|
22
|
+
one: less than 1 second
|
23
|
+
other: less than %{count} seconds
|
24
|
+
over_x_years:
|
25
|
+
one: over 1 year
|
26
|
+
other: over %{count} years
|
27
|
+
x_days:
|
28
|
+
one: 1 day
|
29
|
+
other: "%{count} days"
|
30
|
+
x_minutes:
|
31
|
+
one: 1 minute
|
32
|
+
other: "%{count} minutes"
|
33
|
+
x_months:
|
34
|
+
one: 1 month
|
35
|
+
other: "%{count} months"
|
36
|
+
x_seconds:
|
37
|
+
one: 1 second
|
38
|
+
other: "%{count} seconds"
|
39
|
+
x_years:
|
40
|
+
one: 1 year
|
41
|
+
other: "%{count} years"
|
42
|
+
good_job:
|
43
|
+
shared:
|
44
|
+
announcement:
|
45
|
+
work_in_progress_html: "🚧 GoodJob's dashboard is a work in progress. Please contribute ideas and code on <a href=\"https://github.com/bensheldon/good_job/issues\" target=\"_blank\" rel=\"nofollow noopener noreferrer\">Github</a>."
|
46
|
+
footer:
|
47
|
+
last_update_html: Last updated <time id="page-updated-at" datetime="%{time}">%{time}</time>
|
48
|
+
wording: Remember, you're doing a Good Job too!
|
49
|
+
navbar:
|
50
|
+
coming_soon: More views coming soon
|
51
|
+
cron_schedules: Cron Schedules
|
52
|
+
executions: All Executions
|
53
|
+
jobs: All Jobs
|
54
|
+
live_poll: Live Poll
|
55
|
+
name: "GoodJob 👍"
|
56
|
+
processes: Processes
|
@@ -0,0 +1,56 @@
|
|
1
|
+
---
|
2
|
+
es:
|
3
|
+
datetime:
|
4
|
+
distance_in_words:
|
5
|
+
about_x_hours:
|
6
|
+
one: alrededor de 1 hora
|
7
|
+
other: alrededor de %{count} horas
|
8
|
+
about_x_months:
|
9
|
+
one: alrededor de 1 mes
|
10
|
+
other: alrededor de %{count} meses
|
11
|
+
about_x_years:
|
12
|
+
one: alrededor de 1 año
|
13
|
+
other: alrededor de %{count} años
|
14
|
+
almost_x_years:
|
15
|
+
one: casi 1 año
|
16
|
+
other: casi %{count} años
|
17
|
+
half_a_minute: medio minuto
|
18
|
+
less_than_x_minutes:
|
19
|
+
one: menos de un minuto
|
20
|
+
other: menos de %{count} minutos
|
21
|
+
less_than_x_seconds:
|
22
|
+
one: menos de 1 segundo
|
23
|
+
other: menos de %{count} segundos
|
24
|
+
over_x_years:
|
25
|
+
one: más de 1 año
|
26
|
+
other: durante %{count} años
|
27
|
+
x_days:
|
28
|
+
one: 1 día
|
29
|
+
other: "%{count} días"
|
30
|
+
x_minutes:
|
31
|
+
one: 1 minuto
|
32
|
+
other: "%{count} minutos"
|
33
|
+
x_months:
|
34
|
+
one: 1 mes
|
35
|
+
other: "%{count} meses"
|
36
|
+
x_seconds:
|
37
|
+
one: 1 segundo
|
38
|
+
other: "%{count} segundos"
|
39
|
+
x_years:
|
40
|
+
one: 1 año
|
41
|
+
other: "%{count} años"
|
42
|
+
good_job:
|
43
|
+
shared:
|
44
|
+
announcement:
|
45
|
+
work_in_progress_html: "🚧 GoodJob se encuentra en desarrollo. Por favor contribuya en <a href=\"https://github.com/bensheldon/good_job/issues\" target=\"_blank\" rel=\"nofollow noopener noreferrer\">GoodJob</a> con ideas y código."
|
46
|
+
footer:
|
47
|
+
last_update_html: Última actualización <time id="page-updated-at" datetime="%{time}">%{time}</time>
|
48
|
+
wording: "¡Recuerda, también tú estás haciendo un buen trabajo!"
|
49
|
+
navbar:
|
50
|
+
coming_soon: Próximamente más vistas
|
51
|
+
cron_schedules: Tareas Programadas
|
52
|
+
executions: Ejecuciones
|
53
|
+
jobs: Tareas
|
54
|
+
live_poll: En vivo
|
55
|
+
name: "GoodJob 👍"
|
56
|
+
processes: Procesos
|
@@ -15,7 +15,14 @@ module GoodJob
|
|
15
15
|
# Attached to a Job's Execution when the Job is discarded.
|
16
16
|
DiscardJobError = Class.new(StandardError)
|
17
17
|
|
18
|
-
|
18
|
+
class << self
|
19
|
+
delegate :table_name, to: Execution
|
20
|
+
|
21
|
+
def table_name=(_value)
|
22
|
+
raise NotImplementedError, 'Assign GoodJob::Execution.table_name directly'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
19
26
|
self.primary_key = 'active_job_id'
|
20
27
|
self.advisory_lockable_column = 'active_job_id'
|
21
28
|
|
data/lib/good_job/adapter.rb
CHANGED
@@ -27,7 +27,13 @@ module GoodJob
|
|
27
27
|
# @param queues [String, nil] determines which queues to execute jobs from when +execution_mode+ is set to +:async+. See {file:README.md#optimize-queues-threads-and-processes} for more details on the format of this string. You can also set this with the environment variable +GOOD_JOB_QUEUES+. Defaults to +"*"+.
|
28
28
|
# @param poll_interval [Integer, nil] sets the number of seconds between polls for jobs when +execution_mode+ is set to +:async+. You can also set this with the environment variable +GOOD_JOB_POLL_INTERVAL+. Defaults to +1+.
|
29
29
|
# @param start_async_on_initialize [Boolean] whether to start the async scheduler when the adapter is initialized.
|
30
|
-
def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil, start_async_on_initialize:
|
30
|
+
def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil, start_async_on_initialize: nil)
|
31
|
+
if execution_mode || queues || max_threads || poll_interval || start_async_on_initialize
|
32
|
+
ActiveSupport::Deprecation.warn(
|
33
|
+
"The GoodJob::Adapter's initialization parameters have been deprecated and will be removed in GoodJob v3. These options should be configured through GoodJob global configuration instead."
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
31
37
|
@configuration = GoodJob::Configuration.new(
|
32
38
|
{
|
33
39
|
execution_mode: execution_mode,
|
@@ -39,7 +45,7 @@ module GoodJob
|
|
39
45
|
@configuration.validate!
|
40
46
|
self.class.instances << self
|
41
47
|
|
42
|
-
start_async if start_async_on_initialize
|
48
|
+
start_async if start_async_on_initialize || GoodJob.async_ready?
|
43
49
|
end
|
44
50
|
|
45
51
|
# Enqueues the ActiveJob job to be performed.
|
data/lib/good_job/cli.rb
CHANGED
@@ -50,14 +50,14 @@ module GoodJob
|
|
50
50
|
separate isolated execution pools with semicolons and threads with colons.
|
51
51
|
|
52
52
|
DESCRIPTION
|
53
|
-
method_option :max_threads,
|
54
|
-
type: :numeric,
|
55
|
-
banner: 'COUNT',
|
56
|
-
desc: "Maximum number of threads to use for working jobs. (env var: GOOD_JOB_MAX_THREADS, default: 5)"
|
57
53
|
method_option :queues,
|
58
54
|
type: :string,
|
59
55
|
banner: "QUEUE_LIST",
|
60
|
-
desc: "Queues to work from. (env var: GOOD_JOB_QUEUES, default: *)"
|
56
|
+
desc: "Queues or queue pools to work from. (env var: GOOD_JOB_QUEUES, default: *)"
|
57
|
+
method_option :max_threads,
|
58
|
+
type: :numeric,
|
59
|
+
banner: 'COUNT',
|
60
|
+
desc: "Default number of threads per pool to use for working jobs. (env var: GOOD_JOB_MAX_THREADS, default: 5)"
|
61
61
|
method_option :poll_interval,
|
62
62
|
type: :numeric,
|
63
63
|
banner: 'SECONDS',
|
data/lib/good_job/cron_entry.rb
CHANGED
@@ -52,6 +52,10 @@ module GoodJob # :nodoc:
|
|
52
52
|
params[:args]
|
53
53
|
end
|
54
54
|
|
55
|
+
def kwargs
|
56
|
+
params[:kwargs]
|
57
|
+
end
|
58
|
+
|
55
59
|
def description
|
56
60
|
params[:description]
|
57
61
|
end
|
@@ -87,7 +91,8 @@ module GoodJob # :nodoc:
|
|
87
91
|
current_thread.cron_key = key
|
88
92
|
current_thread.cron_at = cron_at
|
89
93
|
|
90
|
-
job_class.constantize.set(set_value)
|
94
|
+
configured_job = job_class.constantize.set(set_value)
|
95
|
+
kwargs_value.present? ? configured_job.perform_later(*args_value, **kwargs_value) : configured_job.perform_later(*args_value)
|
91
96
|
end
|
92
97
|
rescue ActiveRecord::RecordNotUnique
|
93
98
|
false
|
@@ -124,6 +129,11 @@ module GoodJob # :nodoc:
|
|
124
129
|
value.respond_to?(:call) ? value.call : value
|
125
130
|
end
|
126
131
|
|
132
|
+
def kwargs_value
|
133
|
+
value = kwargs || nil
|
134
|
+
value.respond_to?(:call) ? value.call : value
|
135
|
+
end
|
136
|
+
|
127
137
|
def display_property(value)
|
128
138
|
case value
|
129
139
|
when NilClass
|
data/lib/good_job/poller.rb
CHANGED
@@ -6,13 +6,10 @@ module GoodJob # :nodoc:
|
|
6
6
|
# Pollers regularly wake up execution threads to check for new work.
|
7
7
|
#
|
8
8
|
class Poller
|
9
|
-
TIMEOUT_INTERVAL = 5
|
10
|
-
|
11
9
|
# Defaults for instance of Concurrent::TimerTask.
|
12
10
|
# The timer controls how and when sleeping threads check for new work.
|
13
11
|
DEFAULT_TIMER_OPTIONS = {
|
14
12
|
execution_interval: Configuration::DEFAULT_POLL_INTERVAL,
|
15
|
-
timeout_interval: TIMEOUT_INTERVAL,
|
16
13
|
run_now: true,
|
17
14
|
}.freeze
|
18
15
|
|
data/lib/good_job/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: good_job
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.12.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ben Sheldon
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-04-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activejob
|
@@ -367,8 +367,8 @@ files:
|
|
367
367
|
- engine/app/assets/vendor/chartjs/chart.min.js
|
368
368
|
- engine/app/assets/vendor/rails_ujs.js
|
369
369
|
- engine/app/charts/good_job/scheduled_by_queue_chart.rb
|
370
|
+
- engine/app/controllers/good_job/application_controller.rb
|
370
371
|
- engine/app/controllers/good_job/assets_controller.rb
|
371
|
-
- engine/app/controllers/good_job/base_controller.rb
|
372
372
|
- engine/app/controllers/good_job/cron_entries_controller.rb
|
373
373
|
- engine/app/controllers/good_job/executions_controller.rb
|
374
374
|
- engine/app/controllers/good_job/jobs_controller.rb
|
@@ -385,8 +385,12 @@ files:
|
|
385
385
|
- engine/app/views/good_job/jobs/index.html.erb
|
386
386
|
- engine/app/views/good_job/jobs/show.html.erb
|
387
387
|
- engine/app/views/good_job/processes/index.html.erb
|
388
|
+
- engine/app/views/good_job/shared/_alert.erb
|
389
|
+
- engine/app/views/good_job/shared/_announcement.erb
|
388
390
|
- engine/app/views/good_job/shared/_chart.erb
|
389
391
|
- engine/app/views/good_job/shared/_filter.erb
|
392
|
+
- engine/app/views/good_job/shared/_footer.erb
|
393
|
+
- engine/app/views/good_job/shared/_navbar.erb
|
390
394
|
- engine/app/views/good_job/shared/icons/_arrow_clockwise.html.erb
|
391
395
|
- engine/app/views/good_job/shared/icons/_check.html.erb
|
392
396
|
- engine/app/views/good_job/shared/icons/_exclamation.html.erb
|
@@ -394,7 +398,9 @@ files:
|
|
394
398
|
- engine/app/views/good_job/shared/icons/_skip_forward.html.erb
|
395
399
|
- engine/app/views/good_job/shared/icons/_stop.html.erb
|
396
400
|
- engine/app/views/good_job/shared/icons/_trash.html.erb
|
397
|
-
- engine/app/views/layouts/good_job/
|
401
|
+
- engine/app/views/layouts/good_job/application.html.erb
|
402
|
+
- engine/config/locales/en.yml
|
403
|
+
- engine/config/locales/es.yml
|
398
404
|
- engine/config/routes.rb
|
399
405
|
- engine/lib/good_job/engine.rb
|
400
406
|
- exe/good_job
|
@@ -1,96 +0,0 @@
|
|
1
|
-
<!DOCTYPE html>
|
2
|
-
<html lang="en">
|
3
|
-
<head>
|
4
|
-
<title>Good Job Dashboard</title>
|
5
|
-
<meta charset="utf-8">
|
6
|
-
<meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport">
|
7
|
-
<%= csrf_meta_tags %>
|
8
|
-
<%= csp_meta_tag %>
|
9
|
-
|
10
|
-
<%# Assets must use *_url route helpers to avoid being overriden by config.asset_host %>
|
11
|
-
<%= stylesheet_link_tag bootstrap_url(format: :css, v: GoodJob::VERSION), skip_pipeline: true %>
|
12
|
-
<%= stylesheet_link_tag style_url(format: :css, v: GoodJob::VERSION) %>
|
13
|
-
|
14
|
-
<%= javascript_include_tag bootstrap_url(format: :js, v: GoodJob::VERSION), nonce: true %>
|
15
|
-
<%= javascript_include_tag chartjs_url(format: :js, v: GoodJob::VERSION), nonce: true %>
|
16
|
-
<%= javascript_include_tag scripts_url(format: :js, v: GoodJob::VERSION), nonce: true %>
|
17
|
-
|
18
|
-
<%= javascript_include_tag rails_ujs_url(format: :js, v: GoodJob::VERSION), nonce: true %>
|
19
|
-
</head>
|
20
|
-
<body>
|
21
|
-
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
22
|
-
<div class="container-fluid">
|
23
|
-
<%= link_to "GoodJob 👍", root_path, class: 'navbar-brand mb-0 h1' %>
|
24
|
-
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
25
|
-
<span class="navbar-toggler-icon"></span>
|
26
|
-
</button>
|
27
|
-
|
28
|
-
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
29
|
-
<ul class="navbar-nav me-auto">
|
30
|
-
<li class="nav-item">
|
31
|
-
<%= link_to "All Executions", root_path, class: ["nav-link", ("active" if current_page?(root_path))] %>
|
32
|
-
</li>
|
33
|
-
<li class="nav-item">
|
34
|
-
<%= link_to "All Jobs", jobs_path, class: ["nav-link", ("active" if current_page?(jobs_path))] %>
|
35
|
-
</li>
|
36
|
-
<li class="nav-item">
|
37
|
-
<%= link_to "Cron Schedules", cron_entries_path, class: ["nav-link", ("active" if current_page?(cron_entries_path))] %>
|
38
|
-
</li>
|
39
|
-
<li class="nav-item">
|
40
|
-
<%= link_to "Processes", processes_path, class: ["nav-link", ("active" if current_page?(processes_path))] %>
|
41
|
-
</li>
|
42
|
-
<li class="nav-item">
|
43
|
-
<div class="nav-link">
|
44
|
-
<span class="badge bg-secondary">More views coming soon</span>
|
45
|
-
</div>
|
46
|
-
</li>
|
47
|
-
</ul>
|
48
|
-
<div>
|
49
|
-
<input type="checkbox" id="toggle-poll" name="toggle-poll" data-gj-action='change#togglePoll' <%= 'checked' if params[:poll].present? %>>
|
50
|
-
<label for="toggle-poll">Live Poll</label>
|
51
|
-
</div>
|
52
|
-
</div>
|
53
|
-
</div>
|
54
|
-
</nav>
|
55
|
-
|
56
|
-
<div class="container-fluid">
|
57
|
-
<div class="card border-warning text-dark my-3">
|
58
|
-
<div class="card-body">
|
59
|
-
<p class="card-text">🚧 GoodJob's dashboard is a work in progress. Please contribute ideas and code on <a href="https://github.com/bensheldon/good_job/issues" target="_blank" rel="nofollow noopener noreferrer">Github</a>.</p>
|
60
|
-
</div>
|
61
|
-
</div>
|
62
|
-
|
63
|
-
<% if notice %>
|
64
|
-
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center offset-md-3 col-6" role="alert">
|
65
|
-
<%= render "good_job/shared/icons/check", class: "flex-shrink-0 me-2" %>
|
66
|
-
<div><%= notice %></div>
|
67
|
-
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
68
|
-
</div>
|
69
|
-
<% elsif alert %>
|
70
|
-
<div class="alert alert-warning alert-dismissible fade show d-flex align-items-center offset-md-3 col-6" role="alert">
|
71
|
-
<%= render "good_job/shared/icons/exclamation", class: "flex-shrink-0 me-2" %>
|
72
|
-
<div><%= alert %></div>
|
73
|
-
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
74
|
-
</div>
|
75
|
-
<% end %>
|
76
|
-
|
77
|
-
<%= yield %>
|
78
|
-
</div>
|
79
|
-
|
80
|
-
<footer class="footer mt-auto py-3 bg-light fixed-bottom" id="footer" data-gj-poll-replace>
|
81
|
-
<div class="container-fluid">
|
82
|
-
<div class="row">
|
83
|
-
<div class="col-6">
|
84
|
-
<span class="text-muted">
|
85
|
-
Last updated: <time id="page-updated-at" datetime="<%= Time.current.utc.iso8601 %>"><%= Time.current %></time>
|
86
|
-
</span>
|
87
|
-
</div>
|
88
|
-
|
89
|
-
<div class="col-6 text-end">
|
90
|
-
Remember, you're doing a Good Job too!
|
91
|
-
</div>
|
92
|
-
</div>
|
93
|
-
</div>
|
94
|
-
</footer>
|
95
|
-
</body>
|
96
|
-
</html>
|