clockwork_web_plus 1.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE.txt +22 -0
- data/README.md +169 -0
- data/app/controllers/clockwork_web/home_controller.rb +53 -0
- data/app/helpers/clockwork_web/home_helper.rb +71 -0
- data/app/views/clockwork_web/home/index.html.erb +476 -0
- data/config/routes.rb +6 -0
- data/lib/clockwork_web/engine.rb +10 -0
- data/lib/clockwork_web/version.rb +3 -0
- data/lib/clockwork_web.rb +297 -0
- data/lib/clockwork_web_plus/version.rb +5 -0
- data/lib/clockwork_web_plus.rb +6 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: eb90588bcdf155500c67ad5950849b3604258ebc8dee6fa1e6d3b6c23a3a1ade
|
|
4
|
+
data.tar.gz: 04c70b48a91eb02b5ba90310ce8917d6438225adf0914746c88b92747d3483b0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: df81e08fd74e56d6497753d87cfc40373bc080ce639856c6adf1f9927f79682a8dd2118f6bc5493eca6d5e9a1812fd8abe8c2c9b0aab6f0c5db5864c417e26fe
|
|
7
|
+
data.tar.gz: da9c17ce70d80e6c207ed84869c586b2e8e8ac5d794f850f88b272660726e53b137ea739a6dbba5f7eb145cc120909086f882a920fe72743bc590436d427dde6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
## 1.0.0 (2025-11-11)
|
|
2
|
+
|
|
3
|
+
- New: Fuzzy find search across jobs
|
|
4
|
+
- New: "Run now" — run any job immediately from the index page
|
|
5
|
+
- New: View the Ruby implementation of each job
|
|
6
|
+
- New: Highlight overdue jobs at a glance
|
|
7
|
+
- New: Hourly health check callback via `ClockworkWebPlus.on_health_check`, with detailed overdue context
|
|
8
|
+
- New: Redesigned jobs table with a sleeker, modern UI
|
|
9
|
+
|
|
10
|
+
Note: Versions prior to 1.0.0 (0.x.y) correspond to the original `clockwork_web` project by ankane. See `https://github.com/ankane/clockwork_web`.
|
|
11
|
+
|
|
12
|
+
## 0.3.1 (2024-09-04)
|
|
13
|
+
|
|
14
|
+
- Improved CSP support
|
|
15
|
+
|
|
16
|
+
## 0.3.0 (2024-06-24)
|
|
17
|
+
|
|
18
|
+
- Dropped support for Clockwork < 3
|
|
19
|
+
- Dropped support for Ruby < 3.1 and Rails < 6.1
|
|
20
|
+
|
|
21
|
+
## 0.2.0 (2023-02-01)
|
|
22
|
+
|
|
23
|
+
- Dropped support for Ruby < 2.7 and Rails < 6
|
|
24
|
+
|
|
25
|
+
## 0.1.2 (2023-02-01)
|
|
26
|
+
|
|
27
|
+
- Fixed CSRF vulnerability with Rails < 5.2 - [more info](https://github.com/ankane/clockwork_web/issues/4)
|
|
28
|
+
|
|
29
|
+
## 0.1.1 (2020-03-19)
|
|
30
|
+
|
|
31
|
+
- Fixed load error
|
|
32
|
+
|
|
33
|
+
## 0.1.0 (2019-10-28)
|
|
34
|
+
|
|
35
|
+
- Added `on_job_update` hook
|
|
36
|
+
|
|
37
|
+
## 0.0.5 (2015-05-13)
|
|
38
|
+
|
|
39
|
+
- Added `running_threshold` option
|
|
40
|
+
|
|
41
|
+
## 0.0.4 (2015-03-15)
|
|
42
|
+
|
|
43
|
+
- Better monitoring for multiple processes
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2015-2024 Andrew Kane
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Clockwork Web Plus
|
|
2
|
+
|
|
3
|
+
A fully compatible drop-in enhancement to [ankane/clockwork_web](https://github.com/ankane/clockwork_web), providing a modern web interface for [Clockwork](https://github.com/Rykian/clockwork) with fuzzy search, manual job run, overdue visibility, and hourly health checks.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/chaadow/clockwork_web_plus/actions/workflows/build.yml)
|
|
6
|
+
|
|
7
|
+
## Preview
|
|
8
|
+
|
|
9
|
+
<video src="https://github.com/user-attachments/assets/d145a4d5-834d-4c0d-9f11-397272b2d013" controls muted playsinline loop>
|
|
10
|
+
Sorry, your browser doesn't support embedded videos. Here’s a <a href="https://github.com/user-attachments/assets/d145a4d5-834d-4c0d-9f11-397272b2d013">direct link</a>.
|
|
11
|
+
</video>
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
### Core (from `clockwork_web`)
|
|
16
|
+
|
|
17
|
+
- see list of jobs
|
|
18
|
+
- monitor jobs ( when they were last run at)
|
|
19
|
+
- Temporarily disable jobs
|
|
20
|
+
|
|
21
|
+
### New compared to clockwork_web
|
|
22
|
+
|
|
23
|
+
- fuzzy find search across jobs
|
|
24
|
+
- run any job immediately through the `Run now` button
|
|
25
|
+
- view the Ruby implementation of each job
|
|
26
|
+
- highlight overdue jobs at a glance
|
|
27
|
+
- optional hourly health check callback with custom alerting
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Add this line to your application’s Gemfile:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem "clockwork_web_plus"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> [!TIP]
|
|
38
|
+
> Already using `clockwork_web`? Keep your existing `ClockworkWeb::Engine` mount and initializers—no renaming needed. `ClockworkWebPlus` aliases `ClockworkWeb`, so it works out of the box.
|
|
39
|
+
|
|
40
|
+
And add it to your `config/routes.rb`.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
mount ClockworkWebPlus::Engine, at: "clockwork"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> [!IMPORTANT]
|
|
47
|
+
> Secure the dashboard in production. Protect access with Basic Auth, Devise, or your app’s auth layer to avoid exposing job controls and status.
|
|
48
|
+
|
|
49
|
+
To monitor and disable jobs, hook up Redis in an initializer.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
ClockworkWebPlus.redis = Redis.new
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
#### Basic Authentication
|
|
56
|
+
|
|
57
|
+
Set the following variables in your environment or an initializer.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
ENV["CLOCKWORK_USERNAME"] = "chaadow"
|
|
61
|
+
ENV["CLOCKWORK_PASSWORD"] = "secret"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> [!NOTE]
|
|
65
|
+
> These are example credentials. Use environment-specific secrets and rotate them regularly.
|
|
66
|
+
|
|
67
|
+
#### Devise
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
authenticate :user, ->(user) { user.admin? } do
|
|
71
|
+
mount ClockworkWebPlus::Engine, at: "clockwork"
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> [!TIP]
|
|
76
|
+
> Any authentication framework works—wrap the mount with whatever guard your app already uses for admin/ops access.
|
|
77
|
+
|
|
78
|
+
## Monitoring
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
ClockworkWebPlus.running?
|
|
82
|
+
ClockworkWebPlus.multiple?
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> [!NOTE]
|
|
86
|
+
> `running?` reflects recent heartbeats. `multiple?` indicates multiple active Clockwork processes (based on heartbeat contention).
|
|
87
|
+
|
|
88
|
+
## Customize
|
|
89
|
+
|
|
90
|
+
Change clock path
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
ClockworkWebPlus.clock_path = Rails.root.join("clock") # default
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
> [!NOTE]
|
|
97
|
+
> The default `clock_path` matches `clockwork_web`. Change it only if your clock file lives elsewhere.
|
|
98
|
+
|
|
99
|
+
Turn off monitoring
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
ClockworkWebPlus.monitor = false
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> [!CAUTION]
|
|
106
|
+
> Disabling monitoring stops heartbeats and multiple-process detection. The dashboard won’t show “running” status, but other features still work.
|
|
107
|
+
|
|
108
|
+
### Overdue Jobs & Health Checks
|
|
109
|
+
|
|
110
|
+
The dashboard highlights overdue jobs based on schedule and last run. You can also configure an hourly health check to alert when jobs are overdue:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
ClockworkWebPlus.on_health_check = ->(overdue_jobs:) do
|
|
114
|
+
# backlog contains array of hashes with details like:
|
|
115
|
+
# { job:, should_have_run_at:, last_run:, period:, at: { hour:, min: } }
|
|
116
|
+
if overdue_jobs.any?
|
|
117
|
+
# send notification to Slack, email, etc.
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
> [!NOTE]
|
|
123
|
+
> Overdue detection uses `ClockworkWebPlus.warning_threshold` (default: 300 seconds).
|
|
124
|
+
> - For `@at` schedules: a job is overdue when the most recent scheduled time has passed by more than `warning_threshold` and the job hasn’t run since that time.
|
|
125
|
+
> - For periodic jobs (no `@at`): a job is overdue when `now > last_run + period + warning_threshold`.
|
|
126
|
+
>
|
|
127
|
+
> Example:
|
|
128
|
+
> ```ruby
|
|
129
|
+
> # consider jobs overdue 10 minutes after their expected time
|
|
130
|
+
> ClockworkWebPlus.warning_threshold = 600
|
|
131
|
+
> ```
|
|
132
|
+
|
|
133
|
+
> [!IMPORTANT]
|
|
134
|
+
> With Redis configured, the health check runs at most once per hour across processes. Without Redis, throttling is per-process and approximate.
|
|
135
|
+
|
|
136
|
+
## History
|
|
137
|
+
|
|
138
|
+
View the [changelog](CHANGELOG.md)
|
|
139
|
+
|
|
140
|
+
## Compatibility
|
|
141
|
+
|
|
142
|
+
This gem is a drop-in replacement for `clockwork_web`. For backward compatibility, the original namespace is aliased:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# Both of these work:
|
|
146
|
+
mount ClockworkWebPlus::Engine, at: "clockwork"
|
|
147
|
+
mount ClockworkWeb::Engine, at: "clockwork"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
> [!TIP]
|
|
151
|
+
> Adopting this gem can be as simple as swapping the gem name in your Gemfile. Your existing `ClockworkWeb` mounts and initializers continue to work unchanged.
|
|
152
|
+
|
|
153
|
+
## Contributing
|
|
154
|
+
|
|
155
|
+
Everyone is encouraged to help improve this project. Here are a few ways you can help:
|
|
156
|
+
|
|
157
|
+
- [Report bugs](https://github.com/chaadow/clockwork_web_plus/issues)
|
|
158
|
+
- Fix bugs and [submit pull requests](https://github.com/chaadow/clockwork_web_plus/pulls)
|
|
159
|
+
- Write, clarify, or fix documentation
|
|
160
|
+
- Suggest or add new features
|
|
161
|
+
|
|
162
|
+
To get started with development:
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
git clone https://github.com/chaadow/clockwork_web_plus.git
|
|
166
|
+
cd clockwork_web_plus
|
|
167
|
+
bundle install
|
|
168
|
+
bundle exec rake test
|
|
169
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module ClockworkWeb
|
|
2
|
+
class HomeController < ActionController::Base
|
|
3
|
+
layout false
|
|
4
|
+
helper ClockworkWeb::HomeHelper
|
|
5
|
+
|
|
6
|
+
protect_from_forgery with: :exception
|
|
7
|
+
|
|
8
|
+
http_basic_authenticate_with name: ENV["CLOCKWORK_USERNAME"], password: ENV["CLOCKWORK_PASSWORD"] if ENV["CLOCKWORK_PASSWORD"]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@last_runs = ClockworkWeb.last_runs
|
|
12
|
+
@disabled = ClockworkWeb.disabled_jobs
|
|
13
|
+
@events =
|
|
14
|
+
Clockwork.manager.instance_variable_get(:@events).sort_by do |e|
|
|
15
|
+
at = e.instance_variable_get(:@at)
|
|
16
|
+
enabled = !@disabled.include?(e.job)
|
|
17
|
+
overdue = enabled && ClockworkWeb.overdue?(e, @last_runs[e.job])
|
|
18
|
+
[
|
|
19
|
+
overdue ? 0 : 1, # prioritize overdue first
|
|
20
|
+
e.instance_variable_get(:@period),
|
|
21
|
+
(at && at.instance_variable_get(:@hour)) || -1,
|
|
22
|
+
(at && at.instance_variable_get(:@min)) || -1,
|
|
23
|
+
e.job.to_s
|
|
24
|
+
]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@last_heartbeat = ClockworkWeb.last_heartbeat
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def job
|
|
31
|
+
job = params[:job]
|
|
32
|
+
enable = params[:enable] == "true"
|
|
33
|
+
if enable
|
|
34
|
+
ClockworkWeb.enable(job)
|
|
35
|
+
else
|
|
36
|
+
ClockworkWeb.disable(job)
|
|
37
|
+
end
|
|
38
|
+
ClockworkWeb.on_job_update.call(job: job, enable: enable, user: try(ClockworkWeb.user_method)) if ClockworkWeb.on_job_update
|
|
39
|
+
redirect_to root_path
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def execute
|
|
43
|
+
job = params[:job]
|
|
44
|
+
|
|
45
|
+
event = Clockwork.manager.events.find { _1.job == params[:job] }
|
|
46
|
+
|
|
47
|
+
event.run(Time.now.utc)
|
|
48
|
+
ClockworkWeb.set_last_run(event.job)
|
|
49
|
+
|
|
50
|
+
redirect_to root_path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module ClockworkWeb
|
|
2
|
+
module HomeHelper
|
|
3
|
+
def friendly_period(period)
|
|
4
|
+
if period % 1.day == 0
|
|
5
|
+
pluralize(period / 1.day, "day")
|
|
6
|
+
elsif period % 1.hour == 0
|
|
7
|
+
pluralize(period / 1.hour, "hour")
|
|
8
|
+
elsif period % 1.minute == 0
|
|
9
|
+
"#{period / 1.minute} min"
|
|
10
|
+
else
|
|
11
|
+
"#{period} sec"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def last_run(time)
|
|
16
|
+
if time
|
|
17
|
+
"#{time_ago_in_words(time, include_seconds: true)} ago"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def friendly_time_part(time_part)
|
|
22
|
+
if time_part
|
|
23
|
+
time_part.to_s.rjust(2, "0")
|
|
24
|
+
else
|
|
25
|
+
"**"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def overdue?(event, last_run)
|
|
30
|
+
ClockworkWeb.overdue?(event, last_run)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def should_have_run_at(event, last_run)
|
|
34
|
+
ClockworkWeb.should_have_run_at(event, last_run)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def friendly_should_have_run(event, last_run)
|
|
38
|
+
at = should_have_run_at(event, last_run)
|
|
39
|
+
if at
|
|
40
|
+
"#{time_ago_in_words(at, include_seconds: true)} ago"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def friendly_extract_source_from_callable(callable, with_affixes: true)
|
|
45
|
+
iseq = RubyVM::InstructionSequence.of(callable)
|
|
46
|
+
source =
|
|
47
|
+
if iseq.script_lines
|
|
48
|
+
iseq.script_lines.join("\n")
|
|
49
|
+
elsif File.readable?(iseq.absolute_path)
|
|
50
|
+
File.read(iseq.absolute_path)
|
|
51
|
+
end
|
|
52
|
+
return '-' unless source
|
|
53
|
+
|
|
54
|
+
location = iseq.to_a[4][:code_location]
|
|
55
|
+
return callable unless location
|
|
56
|
+
|
|
57
|
+
lines = source.lines[(location[0] - 1)..(location[2] - 1)]
|
|
58
|
+
lines[-1] = lines[-1].byteslice(...location[3])
|
|
59
|
+
lines[0] = lines[0].byteslice(location[1]...)
|
|
60
|
+
source = lines.join.strip
|
|
61
|
+
|
|
62
|
+
source.tap do |source|
|
|
63
|
+
source.delete_prefix!('{')
|
|
64
|
+
source.delete_suffix!('}')
|
|
65
|
+
|
|
66
|
+
source.delete_prefix!('do')
|
|
67
|
+
source.delete_suffix!('end')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Clockwork</title>
|
|
5
|
+
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
|
|
8
|
+
<%= content_tag :style, nonce: request.content_security_policy_nonce_directives&.include?("style-src") ? content_security_policy_nonce : nil do %>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f6f7fb;
|
|
11
|
+
--card-bg: #ffffff;
|
|
12
|
+
--text: #0f172a;
|
|
13
|
+
--muted: #64748b;
|
|
14
|
+
--border: #e5e7eb;
|
|
15
|
+
--primary: #2563eb;
|
|
16
|
+
--primary-600: #1d4ed8;
|
|
17
|
+
--success: #16a34a;
|
|
18
|
+
--warning-50: #fff7ed;
|
|
19
|
+
--warning-600: #d97706;
|
|
20
|
+
--danger-50: #fef2f2;
|
|
21
|
+
--danger-600: #dc2626;
|
|
22
|
+
--disabled-50: #f8fafc;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
html, body {
|
|
26
|
+
height: 100%;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
|
31
|
+
margin: 0;
|
|
32
|
+
padding: 20px;
|
|
33
|
+
font-size: 14px;
|
|
34
|
+
line-height: 1.5;
|
|
35
|
+
color: var(--text);
|
|
36
|
+
background:
|
|
37
|
+
radial-gradient(60rem 60rem at 120% -10%, #c7d2fe 0%, transparent 40%),
|
|
38
|
+
radial-gradient(50rem 50rem at -20% -20%, #bae6fd 0%, transparent 35%),
|
|
39
|
+
var(--bg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.container {
|
|
43
|
+
max-width: none;
|
|
44
|
+
width: 100%;
|
|
45
|
+
margin: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.card {
|
|
49
|
+
background: var(--card-bg);
|
|
50
|
+
border: 1px solid var(--border);
|
|
51
|
+
border-radius: 12px;
|
|
52
|
+
box-shadow:
|
|
53
|
+
0 1px 2px rgba(0,0,0,0.04),
|
|
54
|
+
0 10px 20px rgba(2,6,23,0.04);
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.header {
|
|
59
|
+
padding: 20px 20px 8px 20px;
|
|
60
|
+
border-bottom: 1px solid var(--border);
|
|
61
|
+
background: linear-gradient(180deg, rgba(248,250,252,0.7), rgba(255,255,255,1));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.title-row {
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 16px;
|
|
69
|
+
flex-wrap: wrap;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
h1 {
|
|
73
|
+
font-size: 20px;
|
|
74
|
+
margin: 0;
|
|
75
|
+
font-weight: 700;
|
|
76
|
+
letter-spacing: -0.01em;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.status {
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 8px;
|
|
83
|
+
font-size: 12px;
|
|
84
|
+
color: var(--muted);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.badge {
|
|
88
|
+
display: inline-flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: 6px;
|
|
91
|
+
padding: 4px 8px;
|
|
92
|
+
border-radius: 999px;
|
|
93
|
+
border: 1px solid var(--border);
|
|
94
|
+
background: #ffffff;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
font-size: 12px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.badge.success {
|
|
100
|
+
color: var(--success);
|
|
101
|
+
border-color: rgba(22,163,74,0.18);
|
|
102
|
+
background: #f0fdf4;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.badge.warn {
|
|
106
|
+
color: var(--warning-600);
|
|
107
|
+
border-color: rgba(217,119,6,0.18);
|
|
108
|
+
background: #fffbeb;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.badge.danger {
|
|
112
|
+
color: var(--danger-600);
|
|
113
|
+
border-color: rgba(220,38,38,0.18);
|
|
114
|
+
background: var(--danger-50);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.badge.info {
|
|
118
|
+
color: var(--muted);
|
|
119
|
+
background: #f8fafc;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.toolbar {
|
|
123
|
+
padding: 12px 20px 16px 20px;
|
|
124
|
+
display: flex;
|
|
125
|
+
gap: 12px;
|
|
126
|
+
align-items: center;
|
|
127
|
+
flex-wrap: wrap;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.search {
|
|
131
|
+
position: relative;
|
|
132
|
+
flex: 1 1 320px;
|
|
133
|
+
max-width: 420px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.search input {
|
|
137
|
+
width: 100%;
|
|
138
|
+
padding: 10px 12px 10px 34px;
|
|
139
|
+
border: 1px solid var(--border);
|
|
140
|
+
border-radius: 8px;
|
|
141
|
+
background: #fff;
|
|
142
|
+
color: var(--text);
|
|
143
|
+
outline: none;
|
|
144
|
+
transition: box-shadow .15s ease, border-color .15s ease;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.search input:focus {
|
|
148
|
+
border-color: rgba(37,99,235,0.5);
|
|
149
|
+
box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.search .icon {
|
|
153
|
+
position: absolute;
|
|
154
|
+
top: 50%;
|
|
155
|
+
left: 10px;
|
|
156
|
+
transform: translateY(-50%);
|
|
157
|
+
color: var(--muted);
|
|
158
|
+
font-size: 14px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.table-wrap {
|
|
162
|
+
padding: 0 0 8px 0;
|
|
163
|
+
overflow-x: auto;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
table {
|
|
167
|
+
width: 100%;
|
|
168
|
+
border-collapse: collapse;
|
|
169
|
+
border-spacing: 0;
|
|
170
|
+
background: #fff;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
thead th {
|
|
174
|
+
text-align: left;
|
|
175
|
+
font-weight: 700;
|
|
176
|
+
font-size: 12px;
|
|
177
|
+
color: var(--muted);
|
|
178
|
+
letter-spacing: .02em;
|
|
179
|
+
text-transform: uppercase;
|
|
180
|
+
background: #f9fafb;
|
|
181
|
+
border-bottom: 1px solid var(--border);
|
|
182
|
+
padding: 12px 12px;
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
tbody td {
|
|
187
|
+
padding: 12px;
|
|
188
|
+
border-bottom: 1px solid var(--border);
|
|
189
|
+
vertical-align: top;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
tbody tr:hover {
|
|
193
|
+
background: #f8fafc;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.row-disabled {
|
|
197
|
+
background: var(--danger-50);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.row-warning {
|
|
201
|
+
background: var(--warning-50);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
tbody tr.row-disabled:hover {
|
|
205
|
+
background: var(--danger-50);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
tbody tr.row-warning:hover {
|
|
209
|
+
background: var(--warning-50);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.width-15 {
|
|
213
|
+
width: 15%;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
details summary {
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
color: var(--muted);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.button-form {
|
|
222
|
+
display: inline-flex;
|
|
223
|
+
align-items: center;
|
|
224
|
+
margin: 0;
|
|
225
|
+
vertical-align: middle;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.btn {
|
|
229
|
+
display: inline-flex;
|
|
230
|
+
align-items: center;
|
|
231
|
+
justify-content: center;
|
|
232
|
+
gap: 6px;
|
|
233
|
+
border: 1px solid var(--border);
|
|
234
|
+
padding: 8px 12px;
|
|
235
|
+
line-height: 1.2;
|
|
236
|
+
border-radius: 8px;
|
|
237
|
+
background: #fff;
|
|
238
|
+
color: var(--text);
|
|
239
|
+
cursor: pointer;
|
|
240
|
+
font-weight: 600;
|
|
241
|
+
transition: box-shadow .15s ease, border-color .15s ease, background .15s ease;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.btn:hover {
|
|
245
|
+
background: #f8fafc;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.btn:disabled {
|
|
249
|
+
opacity: .6;
|
|
250
|
+
cursor: not-allowed;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.btn-primary {
|
|
254
|
+
border-color: rgba(37,99,235,0.3);
|
|
255
|
+
background: #eff6ff;
|
|
256
|
+
color: var(--primary-600);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.btn-primary:hover {
|
|
260
|
+
background: #dbeafe;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.btn-danger {
|
|
264
|
+
border-color: rgba(220, 38, 38, 0.25);
|
|
265
|
+
background: var(--danger-50);
|
|
266
|
+
color: var(--danger-600);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.btn-danger:hover {
|
|
270
|
+
background: #fee2e2;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.meta {
|
|
274
|
+
color: var(--muted);
|
|
275
|
+
font-size: 12px;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.empty {
|
|
279
|
+
padding: 16px 20px;
|
|
280
|
+
color: var(--muted);
|
|
281
|
+
display: none;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
td[data-col="actions"] {
|
|
285
|
+
white-space: nowrap;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.button-form + .button-form {
|
|
289
|
+
margin-left: 8px;
|
|
290
|
+
}
|
|
291
|
+
<% end %>
|
|
292
|
+
</head>
|
|
293
|
+
<body>
|
|
294
|
+
<div class="container">
|
|
295
|
+
<div class="card">
|
|
296
|
+
<div class="header">
|
|
297
|
+
<div class="title-row">
|
|
298
|
+
<h1>Clockwork Jobs</h1>
|
|
299
|
+
<div class="status">
|
|
300
|
+
<% if ClockworkWeb.redis %>
|
|
301
|
+
<% if ClockworkWeb.monitor %>
|
|
302
|
+
<% if ClockworkWeb.multiple? %>
|
|
303
|
+
<span class="badge warn" title="Multiple processes are updating heartbeat">Multiple processes</span>
|
|
304
|
+
<% elsif ClockworkWeb.running? %>
|
|
305
|
+
<span class="badge success" title="Heartbeat received recently">Running</span>
|
|
306
|
+
<% else %>
|
|
307
|
+
<span class="badge" style="color: var(--muted)" title="No recent heartbeat">Stopped</span>
|
|
308
|
+
<% if @last_heartbeat %>
|
|
309
|
+
<span class="meta">Last heartbeat <%= time_ago_in_words(@last_heartbeat) %> ago</span>
|
|
310
|
+
<% end %>
|
|
311
|
+
<% end %>
|
|
312
|
+
<% end %>
|
|
313
|
+
<% else %>
|
|
314
|
+
<span class="badge info">Redis not configured • monitoring/actions disabled</span>
|
|
315
|
+
<% end %>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<div class="toolbar">
|
|
321
|
+
<div class="search">
|
|
322
|
+
<span class="icon">🔎</span>
|
|
323
|
+
<input id="jobs-search" type="search" placeholder="Search jobs, schedules, or conditions…" aria-label="Filter jobs" autofocus />
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<div class="table-wrap">
|
|
328
|
+
<table id="jobs-table">
|
|
329
|
+
<thead>
|
|
330
|
+
<tr>
|
|
331
|
+
<th class="width-15">Job</th>
|
|
332
|
+
<th class="width-15">Schedule</th>
|
|
333
|
+
<th class="width-15">Implementation</th>
|
|
334
|
+
<th class="width-15">Last run</th>
|
|
335
|
+
<th class="width-15">Should have run</th>
|
|
336
|
+
<th class="width-15">Actions</th>
|
|
337
|
+
</tr>
|
|
338
|
+
</thead>
|
|
339
|
+
<tbody>
|
|
340
|
+
<% @events.each do |event| %>
|
|
341
|
+
<% enabled = !@disabled.include?(event.job) %>
|
|
342
|
+
<% is_overdue = (enabled && overdue?(event, @last_runs[event.job])) %>
|
|
343
|
+
<tr class="<%= [enabled ? nil : 'row-disabled', is_overdue ? 'row-warning' : nil].compact.join(' ') %>">
|
|
344
|
+
<td data-col="job">
|
|
345
|
+
<strong><%= event.job %></strong>
|
|
346
|
+
<% unless enabled %>
|
|
347
|
+
<span class="badge danger" title="Job is disabled">Disabled</span>
|
|
348
|
+
<% end %>
|
|
349
|
+
<% if is_overdue %>
|
|
350
|
+
<span class="badge warn" title="Job is overdue">Overdue</span>
|
|
351
|
+
<% end %>
|
|
352
|
+
</td>
|
|
353
|
+
<td data-col="schedule">
|
|
354
|
+
<%= friendly_period(event.instance_variable_get(:@period)) %>
|
|
355
|
+
<% at = event.instance_variable_get(:@at) %>
|
|
356
|
+
<% if at %>
|
|
357
|
+
<span class="meta">at <%= friendly_time_part(at.instance_variable_get(:@hour)) %>:<%= friendly_time_part(at.instance_variable_get(:@min)) %></span>
|
|
358
|
+
<% end %>
|
|
359
|
+
<% if if_lambda = event.instance_variable_get(:@if) %>
|
|
360
|
+
<div class="meta">if: -> <%= friendly_extract_source_from_callable(if_lambda)%></div>
|
|
361
|
+
<% end %>
|
|
362
|
+
</td>
|
|
363
|
+
<td data-col="impl">
|
|
364
|
+
<% if block = event.instance_variable_get(:@block) %>
|
|
365
|
+
<details>
|
|
366
|
+
<summary>View implementation</summary>
|
|
367
|
+
{
|
|
368
|
+
<%= friendly_extract_source_from_callable(block, with_affixes: false) %>
|
|
369
|
+
}
|
|
370
|
+
</details>
|
|
371
|
+
<% else %>
|
|
372
|
+
<span class="meta">-</span>
|
|
373
|
+
<% end %>
|
|
374
|
+
</td>
|
|
375
|
+
<td data-col="last"><%= last_run(@last_runs[event.job]) || content_tag(:span, '-', class: 'meta') %></td>
|
|
376
|
+
<td data-col="should">
|
|
377
|
+
<% if is_overdue %>
|
|
378
|
+
<%= friendly_should_have_run(event, @last_runs[event.job]) %>
|
|
379
|
+
<% else %>
|
|
380
|
+
<span class="meta">-</span>
|
|
381
|
+
<% end %>
|
|
382
|
+
</td>
|
|
383
|
+
<td data-col="actions">
|
|
384
|
+
<%= button_to(enabled ? "Disable" : "Enable",
|
|
385
|
+
home_job_path(job: event.job, enable: !enabled),
|
|
386
|
+
disabled: !ClockworkWeb.redis,
|
|
387
|
+
form_class: 'button-form',
|
|
388
|
+
class: enabled ? 'btn btn-danger' : 'btn') %>
|
|
389
|
+
<%= button_to "Run now",
|
|
390
|
+
home_execute_path(job: event.job),
|
|
391
|
+
disabled: !ClockworkWeb.redis,
|
|
392
|
+
form_class: 'button-form',
|
|
393
|
+
class: 'btn btn-primary' %>
|
|
394
|
+
</td>
|
|
395
|
+
</tr>
|
|
396
|
+
<% end %>
|
|
397
|
+
</tbody>
|
|
398
|
+
</table>
|
|
399
|
+
<div id="no-results" class="empty">No jobs match your search.</div>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<%= content_tag :script, nonce: request.content_security_policy_nonce_directives&.include?("script-src") ? content_security_policy_nonce : nil do %>
|
|
405
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
406
|
+
var input = document.getElementById('jobs-search');
|
|
407
|
+
var table = document.getElementById('jobs-table');
|
|
408
|
+
if (!input || !table) return;
|
|
409
|
+
|
|
410
|
+
var tbody = table.tBodies[0];
|
|
411
|
+
var rows = Array.prototype.slice.call(tbody.rows);
|
|
412
|
+
var emptyState = document.getElementById('no-results');
|
|
413
|
+
|
|
414
|
+
try { input.focus({ preventScroll: true }); } catch (e) { try { input.focus(); } catch (_) {} }
|
|
415
|
+
|
|
416
|
+
function normalize(text) {
|
|
417
|
+
return (text || '').toString().toLowerCase();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function stripNonAlnum(text) {
|
|
421
|
+
return text.replace(/[^a-z0-9]/g, '');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Returns true if all chars of needle appear in order within haystack
|
|
425
|
+
function fuzzyIncludes(haystack, needle) {
|
|
426
|
+
if (!needle) return true;
|
|
427
|
+
var i = 0, j = 0;
|
|
428
|
+
while (i < haystack.length && j < needle.length) {
|
|
429
|
+
if (haystack.charCodeAt(i) === needle.charCodeAt(j)) {
|
|
430
|
+
j++;
|
|
431
|
+
}
|
|
432
|
+
i++;
|
|
433
|
+
}
|
|
434
|
+
return j === needle.length;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function rowMatches(row, query) {
|
|
438
|
+
if (!query) return true;
|
|
439
|
+
var text = normalize(row.textContent);
|
|
440
|
+
// direct substring match across the row
|
|
441
|
+
if (text.indexOf(query) !== -1) return true;
|
|
442
|
+
|
|
443
|
+
// fuzzy match only against the job name to reduce false positives
|
|
444
|
+
var jobCell = row.querySelector('[data-col="job"]');
|
|
445
|
+
if (!jobCell) return false;
|
|
446
|
+
var jobText = normalize(jobCell.textContent);
|
|
447
|
+
|
|
448
|
+
var qNorm = stripNonAlnum(query);
|
|
449
|
+
if (qNorm.length < 3) {
|
|
450
|
+
// for very short queries, avoid fuzzy to prevent over-matching
|
|
451
|
+
return jobText.indexOf(query) !== -1;
|
|
452
|
+
}
|
|
453
|
+
return fuzzyIncludes(stripNonAlnum(jobText), qNorm);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function applyFilter() {
|
|
457
|
+
var q = normalize(input.value.trim());
|
|
458
|
+
var visible = 0;
|
|
459
|
+
rows.forEach(function (row) {
|
|
460
|
+
if (rowMatches(row, q)) {
|
|
461
|
+
row.style.display = '';
|
|
462
|
+
visible += 1;
|
|
463
|
+
} else {
|
|
464
|
+
row.style.display = 'none';
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
if (emptyState) {
|
|
468
|
+
emptyState.style.display = visible === 0 ? 'block' : 'none';
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
input.addEventListener('input', applyFilter);
|
|
473
|
+
});
|
|
474
|
+
<% end %>
|
|
475
|
+
</body>
|
|
476
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# dependencies
|
|
2
|
+
require "clockwork"
|
|
3
|
+
require "safely/core"
|
|
4
|
+
|
|
5
|
+
# modules
|
|
6
|
+
require_relative "clockwork_web/engine" if defined?(Rails)
|
|
7
|
+
require_relative "clockwork_web/version"
|
|
8
|
+
|
|
9
|
+
module ClockworkWeb
|
|
10
|
+
LAST_RUNS_KEY = "clockwork:last_runs"
|
|
11
|
+
DISABLED_KEY = "clockwork:disabled"
|
|
12
|
+
HEARTBEAT_KEY = "clockwork:heartbeat"
|
|
13
|
+
STATUS_KEY = "clockwork:status"
|
|
14
|
+
HEALTH_CHECK_KEY = "clockwork:health_check"
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :clock_path
|
|
18
|
+
attr_accessor :redis
|
|
19
|
+
attr_accessor :monitor
|
|
20
|
+
attr_accessor :running_threshold
|
|
21
|
+
attr_accessor :on_job_update
|
|
22
|
+
attr_accessor :user_method
|
|
23
|
+
attr_accessor :warning_threshold
|
|
24
|
+
attr_accessor :on_health_check
|
|
25
|
+
end
|
|
26
|
+
self.monitor = true
|
|
27
|
+
self.running_threshold = 60 # seconds
|
|
28
|
+
self.user_method = :current_user
|
|
29
|
+
self.warning_threshold = 300 # seconds, default 5 minutes
|
|
30
|
+
|
|
31
|
+
def self.enable(job)
|
|
32
|
+
if redis
|
|
33
|
+
redis.srem(DISABLED_KEY, job)
|
|
34
|
+
true
|
|
35
|
+
else
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.disable(job)
|
|
41
|
+
if redis
|
|
42
|
+
redis.sadd(DISABLED_KEY, job)
|
|
43
|
+
true
|
|
44
|
+
else
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.enabled?(job)
|
|
50
|
+
if redis
|
|
51
|
+
!redis.sismember(DISABLED_KEY, job)
|
|
52
|
+
else
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.disabled_jobs
|
|
58
|
+
if redis
|
|
59
|
+
Set.new(redis.smembers(DISABLED_KEY))
|
|
60
|
+
else
|
|
61
|
+
Set.new
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.last_runs
|
|
66
|
+
if redis
|
|
67
|
+
Hash[redis.hgetall(LAST_RUNS_KEY).map { |job, timestamp| [job, Time.at(timestamp.to_i)] }.sort_by { |job, time| [time, job] }]
|
|
68
|
+
else
|
|
69
|
+
{}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.set_last_run(job)
|
|
74
|
+
if redis
|
|
75
|
+
redis.hset(LAST_RUNS_KEY, job, Time.now.utc.to_i)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.last_heartbeat
|
|
80
|
+
if redis
|
|
81
|
+
timestamp = redis.get(HEARTBEAT_KEY)
|
|
82
|
+
if timestamp
|
|
83
|
+
Time.at(timestamp.to_i)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.heartbeat
|
|
89
|
+
if redis
|
|
90
|
+
heartbeat = Time.now.utc.to_i
|
|
91
|
+
if heartbeat % 10 == 0 # every 10 seconds
|
|
92
|
+
prev_heartbeat = redis.getset(HEARTBEAT_KEY, heartbeat).to_i
|
|
93
|
+
if prev_heartbeat >= heartbeat
|
|
94
|
+
redis.setex(STATUS_KEY, 60, "multiple")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.running?
|
|
101
|
+
last_heartbeat && last_heartbeat > Time.now.utc - running_threshold
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.multiple?
|
|
105
|
+
redis && redis.get(STATUS_KEY) == "multiple"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Runs at most once per hour across processes. When triggered, gathers overdue jobs and
|
|
109
|
+
# invokes the configured on_health_check callback if any are found.
|
|
110
|
+
def self.health_check
|
|
111
|
+
return unless on_health_check
|
|
112
|
+
|
|
113
|
+
now = Time.now.utc.to_i
|
|
114
|
+
proceed = false
|
|
115
|
+
|
|
116
|
+
if redis
|
|
117
|
+
last = redis.get(HEALTH_CHECK_KEY).to_i
|
|
118
|
+
if last == 0 || (now - last) >= 3600
|
|
119
|
+
prev = redis.getset(HEALTH_CHECK_KEY, now).to_i
|
|
120
|
+
proceed = (prev == last) || (now - prev) >= 3600
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
@last_health_check ||= 0
|
|
124
|
+
if (now - @last_health_check) >= 3600
|
|
125
|
+
@last_health_check = now
|
|
126
|
+
proceed = true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
return unless proceed
|
|
131
|
+
|
|
132
|
+
events = Clockwork.manager.events
|
|
133
|
+
last_runs = ClockworkWeb.last_runs
|
|
134
|
+
overdue_jobs = ClockworkWeb.overdue_details(events, last_runs)
|
|
135
|
+
ClockworkWeb.on_health_check.call(overdue_jobs: overdue_jobs) if overdue_jobs.any?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns the last time this event should have run before now.
|
|
139
|
+
# For @at schedules, computes the most recent scheduled time at the declared hour/minute,
|
|
140
|
+
# respecting common periods (daily, multi-day, hourly). For simple periodic jobs (no @at),
|
|
141
|
+
# returns last_run + period when that is in the past. Returns nil when it cannot be determined.
|
|
142
|
+
# Convert a given time to the event timezone if supported; default to UTC.
|
|
143
|
+
def self.now_in_event_timezone(event, base_now = Time.now.utc)
|
|
144
|
+
if event.respond_to?(:convert_timezone)
|
|
145
|
+
event.convert_timezone(base_now)
|
|
146
|
+
else
|
|
147
|
+
base_now
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.should_have_run_at(event, last_run_time, now = Time.now.utc)
|
|
152
|
+
period = event.instance_variable_get(:@period)
|
|
153
|
+
return nil unless period
|
|
154
|
+
|
|
155
|
+
at = event.instance_variable_get(:@at)
|
|
156
|
+
if at
|
|
157
|
+
now_for_event = now_in_event_timezone(event, now)
|
|
158
|
+
hour = at.instance_variable_get(:@hour) || 0
|
|
159
|
+
min = at.instance_variable_get(:@min) || 0
|
|
160
|
+
wday = at.instance_variable_get(:@wday) rescue nil
|
|
161
|
+
|
|
162
|
+
# Weekly or multi-week schedules with specific weekday
|
|
163
|
+
if !wday.nil?
|
|
164
|
+
step_weeks = (period % 604_800).zero? ? [(period / 604_800).to_i, 1].max : 1
|
|
165
|
+
days_ago = (now_for_event.wday - wday) % 7
|
|
166
|
+
day = now_for_event.to_date - days_ago
|
|
167
|
+
candidate = Time.new(day.year, day.month, day.day, hour, min, 0, now_for_event.utc_offset)
|
|
168
|
+
candidate -= 604_800 if candidate > now
|
|
169
|
+
if step_weeks > 1
|
|
170
|
+
anchor = last_run_time || candidate
|
|
171
|
+
while (((anchor.to_date - candidate.to_date).to_i / 7) % step_weeks) != 0
|
|
172
|
+
candidate -= 604_800
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
return candidate
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Daily or multi-day schedules
|
|
179
|
+
if (period % 86_400).zero?
|
|
180
|
+
step_days = [(period / 86_400).to_i, 1].max
|
|
181
|
+
base_day = now_for_event.to_date
|
|
182
|
+
# Try the most recent aligned day within one full cycle
|
|
183
|
+
0.upto(step_days - 1) do |offset|
|
|
184
|
+
day = base_day - offset
|
|
185
|
+
candidate = Time.new(day.year, day.month, day.day, hour, min, 0, now_for_event.utc_offset)
|
|
186
|
+
if candidate <= now_for_event
|
|
187
|
+
# Alignment: only consider days separated by the step length
|
|
188
|
+
return candidate if (base_day - day).to_i % step_days == 0
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
# Fallback to previous aligned cycle
|
|
192
|
+
day = base_day - step_days
|
|
193
|
+
return Time.new(day.year, day.month, day.day, hour, min, 0, now_for_event.utc_offset)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Hourly or multi-hour schedules (e.g., every 2 hours at minute 15)
|
|
197
|
+
if (period % 3600).zero?
|
|
198
|
+
step_hours = [(period / 3600).to_i, 1].max
|
|
199
|
+
aligned_hour = (now_for_event.hour / step_hours) * step_hours
|
|
200
|
+
candidate = Time.new(now_for_event.year, now_for_event.month, now_for_event.day, aligned_hour, min, 0, now_for_event.utc_offset)
|
|
201
|
+
candidate -= step_hours * 3600 if candidate > now
|
|
202
|
+
return candidate
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Fallback: treat as daily at the given time
|
|
206
|
+
candidate = Time.new(now_for_event.year, now_for_event.month, now_for_event.day, hour, min, 0, now_for_event.utc_offset)
|
|
207
|
+
candidate -= 86_400 if candidate > now_for_event
|
|
208
|
+
return candidate
|
|
209
|
+
else
|
|
210
|
+
# Simple periodic job (no @at) – use last_run anchor
|
|
211
|
+
return nil unless last_run_time
|
|
212
|
+
expected = last_run_time + period
|
|
213
|
+
return expected if expected <= (now || Time.now.utc)
|
|
214
|
+
return nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Determines whether an event is overdue given its schedule and last run.
|
|
219
|
+
def self.overdue?(event, last_run_time, now = Time.now.utc)
|
|
220
|
+
period = event.instance_variable_get(:@period) || 0
|
|
221
|
+
at_time = should_have_run_at(event, last_run_time, now)
|
|
222
|
+
now_for_event = now_in_event_timezone(event, now)
|
|
223
|
+
|
|
224
|
+
# If an if-lambda is present and evaluates false at current event-local time,
|
|
225
|
+
# do not consider the job overdue.
|
|
226
|
+
if_lambda = event.instance_variable_get(:@if)
|
|
227
|
+
if if_lambda
|
|
228
|
+
begin
|
|
229
|
+
allowed = if if_lambda.arity == 1
|
|
230
|
+
if_lambda.call(now_for_event)
|
|
231
|
+
else
|
|
232
|
+
if_lambda.call
|
|
233
|
+
end
|
|
234
|
+
return false unless allowed
|
|
235
|
+
rescue StandardError
|
|
236
|
+
return true
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if event.instance_variable_get(:@at)
|
|
241
|
+
return false unless at_time
|
|
242
|
+
# Overdue if the scheduled time has passed by more than the threshold and we haven't run since
|
|
243
|
+
return (now_for_event - at_time) > warning_threshold && (last_run_time.nil? || last_run_time < at_time)
|
|
244
|
+
else
|
|
245
|
+
return false unless last_run_time && period.positive?
|
|
246
|
+
return now_for_event > (last_run_time + period + warning_threshold)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Collect details about overdue events for alerting or diagnostics.
|
|
251
|
+
def self.overdue_details(events, last_runs, now = Time.now)
|
|
252
|
+
events.filter_map do |event|
|
|
253
|
+
next unless ClockworkWeb.enabled?(event.job)
|
|
254
|
+
lr = last_runs[event.job]
|
|
255
|
+
if overdue?(event, lr, now)
|
|
256
|
+
should_at = should_have_run_at(event, lr, now)
|
|
257
|
+
{
|
|
258
|
+
job: event.job,
|
|
259
|
+
should_have_run_at: should_at,
|
|
260
|
+
last_run: lr,
|
|
261
|
+
period: event.instance_variable_get(:@period),
|
|
262
|
+
at: event.instance_variable_get(:@at) && {
|
|
263
|
+
hour: event.instance_variable_get(:@at).instance_variable_get(:@hour),
|
|
264
|
+
min: event.instance_variable_get(:@at).instance_variable_get(:@min)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
module Clockwork
|
|
273
|
+
on(:before_tick) do
|
|
274
|
+
ClockworkWeb.heartbeat if ClockworkWeb.monitor
|
|
275
|
+
ClockworkWeb.health_check if ClockworkWeb.on_health_check
|
|
276
|
+
true
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
on(:before_run) do |event, t|
|
|
280
|
+
run = true
|
|
281
|
+
|
|
282
|
+
Safely.safely do
|
|
283
|
+
run = ClockworkWeb.enabled?(event.job)
|
|
284
|
+
unless run
|
|
285
|
+
manager.log "Skipping '#{event}'"
|
|
286
|
+
event.last = event.convert_timezone(t)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
run
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
on(:after_run) do |event, _t|
|
|
294
|
+
ClockworkWeb.set_last_run(event.job) if ClockworkWeb.enabled?(event.job)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: clockwork_web_plus
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andrew Kane
|
|
8
|
+
- Chedli Bourguiba
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: clockwork
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: safely_block
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.4'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.4'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: railties
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '6.1'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '6.1'
|
|
55
|
+
email:
|
|
56
|
+
- bourguiba.chedli@gmail.com
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- CHANGELOG.md
|
|
62
|
+
- LICENSE.txt
|
|
63
|
+
- README.md
|
|
64
|
+
- app/controllers/clockwork_web/home_controller.rb
|
|
65
|
+
- app/helpers/clockwork_web/home_helper.rb
|
|
66
|
+
- app/views/clockwork_web/home/index.html.erb
|
|
67
|
+
- config/routes.rb
|
|
68
|
+
- lib/clockwork_web.rb
|
|
69
|
+
- lib/clockwork_web/engine.rb
|
|
70
|
+
- lib/clockwork_web/version.rb
|
|
71
|
+
- lib/clockwork_web_plus.rb
|
|
72
|
+
- lib/clockwork_web_plus/version.rb
|
|
73
|
+
homepage: https://github.com/chedli/clockwork_web_plus
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata: {}
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '3.1'
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 3.6.9
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: A modern web interface for Clockwork with search, run-now & health checks
|
|
94
|
+
test_files: []
|