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 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
+ [![Build Status](https://github.com/chaadow/clockwork_web_plus/actions/workflows/build.yml/badge.svg)](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
+ &nbsp;<span class="badge danger" title="Job is disabled">Disabled</span>
348
+ <% end %>
349
+ <% if is_overdue %>
350
+ &nbsp;<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,6 @@
1
+ ClockworkWeb::Engine.routes.draw do
2
+ post "home/job"
3
+ post "home/execute"
4
+
5
+ root to: "home#index"
6
+ end
@@ -0,0 +1,10 @@
1
+ module ClockworkWeb
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ClockworkWeb
4
+
5
+ initializer "clockwork_web" do
6
+ ClockworkWeb.clock_path ||= Rails.root.join("clock")
7
+ require ClockworkWeb.clock_path.to_s
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ module ClockworkWeb
2
+ VERSION = "1.0.0"
3
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ module ClockworkWebPlus
2
+ VERSION = "1.0.0"
3
+ end
4
+
5
+
@@ -0,0 +1,6 @@
1
+ require "clockwork_web"
2
+
3
+ # New namespace that aliases the original, for easy drop-in replacement.
4
+ ClockworkWebPlus = ClockworkWeb unless defined?(ClockworkWebPlus)
5
+
6
+
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: []