├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── app ├── controllers │ └── clockwork_web │ │ └── home_controller.rb ├── helpers │ └── clockwork_web │ │ └── home_helper.rb └── views │ └── clockwork_web │ └── home │ └── index.html.erb ├── clockwork_web.gemspec ├── config └── routes.rb ├── lib ├── clockwork_web.rb └── clockwork_web │ ├── engine.rb │ └── version.rb └── test ├── controller_test.rb ├── internal ├── app │ └── assets │ │ └── config │ │ └── manifest.js ├── clock.rb └── config │ └── routes.rb └── test_helper.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: 3.4 11 | bundler-cache: true 12 | - run: bundle exec rake test 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | *.log 15 | /test/internal/tmp/ 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.4.0 (2025-04-03) 2 | 3 | - Dropped support for Ruby < 3.2 and Rails < 7.1 4 | 5 | ## 0.3.1 (2024-09-04) 6 | 7 | - Improved CSP support 8 | 9 | ## 0.3.0 (2024-06-24) 10 | 11 | - Dropped support for Clockwork < 3 12 | - Dropped support for Ruby < 3.1 and Rails < 6.1 13 | 14 | ## 0.2.0 (2023-02-01) 15 | 16 | - Dropped support for Ruby < 2.7 and Rails < 6 17 | 18 | ## 0.1.2 (2023-02-01) 19 | 20 | - Fixed CSRF vulnerability with Rails < 5.2 - [more info](https://github.com/ankane/clockwork_web/issues/4) 21 | 22 | ## 0.1.1 (2020-03-19) 23 | 24 | - Fixed load error 25 | 26 | ## 0.1.0 (2019-10-28) 27 | 28 | - Added `on_job_update` hook 29 | 30 | ## 0.0.5 (2015-05-13) 31 | 32 | - Added `running_threshold` option 33 | 34 | ## 0.0.4 (2015-03-15) 35 | 36 | - Better monitoring for multiple processes 37 | 38 | ## 0.0.3 (2015-02-14) 39 | 40 | - Added `running?` method 41 | - Added `multiple?` method 42 | - Added `monitor` option 43 | 44 | ## 0.0.2 (2015-02-13) 45 | 46 | - Added `clock_path` option 47 | 48 | ## 0.0.1 (2015-02-13) 49 | 50 | - First release 51 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2025 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. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clockwork Web 2 | 3 | A web interface for [Clockwork](https://github.com/Rykian/clockwork) 4 | 5 | [View the demo](https://clockwork.dokkuapp.com/) 6 | 7 | - see list of jobs 8 | - monitor jobs 9 | - disable jobs 10 | 11 | :tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource) 12 | 13 | [![Build Status](https://github.com/ankane/clockwork_web/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/clockwork_web/actions) 14 | 15 | ## Installation 16 | 17 | Add this line to your application’s Gemfile: 18 | 19 | ```ruby 20 | gem "clockwork_web" 21 | ``` 22 | 23 | And add it to your `config/routes.rb`. 24 | 25 | ```ruby 26 | mount ClockworkWeb::Engine, at: "clockwork" 27 | ``` 28 | 29 | Be sure to secure the dashboard in production. 30 | 31 | To monitor and disable jobs, hook up Redis in an initializer. 32 | 33 | ```ruby 34 | ClockworkWeb.redis = Redis.new 35 | ``` 36 | 37 | #### Basic Authentication 38 | 39 | Set the following variables in your environment or an initializer. 40 | 41 | ```ruby 42 | ENV["CLOCKWORK_USERNAME"] = "andrew" 43 | ENV["CLOCKWORK_PASSWORD"] = "secret" 44 | ``` 45 | 46 | #### Devise 47 | 48 | ```ruby 49 | authenticate :user, ->(user) { user.admin? } do 50 | mount ClockworkWeb::Engine, at: "clockwork" 51 | end 52 | ``` 53 | 54 | ## Monitoring 55 | 56 | ```ruby 57 | ClockworkWeb.running? 58 | ClockworkWeb.multiple? 59 | ``` 60 | 61 | ## Customize 62 | 63 | Change clock path 64 | 65 | ```ruby 66 | ClockworkWeb.clock_path = Rails.root.join("clock") # default 67 | ``` 68 | 69 | Turn off monitoring 70 | 71 | ```ruby 72 | ClockworkWeb.monitor = false 73 | ``` 74 | 75 | ## History 76 | 77 | View the [changelog](CHANGELOG.md) 78 | 79 | ## Contributing 80 | 81 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 82 | 83 | - [Report bugs](https://github.com/ankane/clockwork_web/issues) 84 | - Fix bugs and [submit pull requests](https://github.com/ankane/clockwork_web/pulls) 85 | - Write, clarify, or fix documentation 86 | - Suggest or add new features 87 | 88 | To get started with development: 89 | 90 | ```sh 91 | git clone https://github.com/ankane/clockwork_web.git 92 | cd clockwork_web 93 | bundle install 94 | bundle exec rake test 95 | ``` 96 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | task default: :test 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.pattern = "test/**/*_test.rb" 8 | end 9 | -------------------------------------------------------------------------------- /app/controllers/clockwork_web/home_controller.rb: -------------------------------------------------------------------------------- 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 | @events = 12 | Clockwork.manager.instance_variable_get(:@events).sort_by do |e| 13 | at = e.instance_variable_get(:@at) 14 | [ 15 | e.instance_variable_get(:@period), 16 | (at && at.instance_variable_get(:@hour)) || -1, 17 | (at && at.instance_variable_get(:@min)) || -1, 18 | e.job.to_s 19 | ] 20 | end 21 | 22 | @last_runs = ClockworkWeb.last_runs 23 | @disabled = ClockworkWeb.disabled_jobs 24 | @last_heartbeat = ClockworkWeb.last_heartbeat 25 | end 26 | 27 | def job 28 | job = params[:job] 29 | enable = params[:enable] == "true" 30 | if enable 31 | ClockworkWeb.enable(job) 32 | else 33 | ClockworkWeb.disable(job) 34 | end 35 | ClockworkWeb.on_job_update.call(job: job, enable: enable, user: try(ClockworkWeb.user_method)) if ClockworkWeb.on_job_update 36 | redirect_to root_path 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/helpers/clockwork_web/home_helper.rb: -------------------------------------------------------------------------------- 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 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/views/clockwork_web/home/index.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Clockwork 5 | 6 | 7 | 8 | <%= content_tag :style, nonce: request.content_security_policy_nonce_directives&.include?("style-src") ? content_security_policy_nonce : nil do %> 9 | body { 10 | font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; 11 | margin: 0; 12 | padding: 20px; 13 | font-size: 14px; 14 | line-height: 1.4; 15 | color: #333; 16 | } 17 | 18 | a, a:visited, a:active { 19 | color: #428bca; 20 | text-decoration: none; 21 | } 22 | 23 | a:hover { 24 | text-decoration: underline; 25 | } 26 | 27 | table { 28 | width: 100%; 29 | border-collapse: collapse; 30 | border-spacing: 0; 31 | margin-bottom: 20px; 32 | } 33 | 34 | th { 35 | text-align: left; 36 | border-bottom: solid 2px #ddd; 37 | } 38 | 39 | table td, table th { 40 | padding: 8px; 41 | } 42 | 43 | td { 44 | border-top: solid 1px #ddd; 45 | } 46 | 47 | .disabled { 48 | background-color: pink; 49 | } 50 | 51 | .width-15 { 52 | width: 15%; 53 | } 54 | <% end %> 55 | 56 | 57 |
58 | <% if ClockworkWeb.redis %> 59 | <% if ClockworkWeb.monitor %> 60 | <% if ClockworkWeb.multiple? %> 61 |

Multiple clockwork processes detected

62 | <% elsif ClockworkWeb.running? %> 63 |

Clockwork is running

64 | <% else %> 65 |

66 | Clockwork is not running 67 | <% if @last_heartbeat %> 68 | - last heartbeat was <%= time_ago_in_words(@last_heartbeat) %> ago 69 | <% end %> 70 |

71 | <% end %> 72 | <% end %> 73 | <% else %> 74 |

Add Redis for monitoring and disabling jobs

75 | <% end %> 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | <% @events.each do |event| %> 88 | <% enabled = !@disabled.include?(event.job) %> 89 | "> 90 | 91 | 101 | 102 | 103 | 104 | <% end %> 105 | 106 |
JobPeriodLast RunAction
<%= event.job %> 92 | <%= friendly_period(event.instance_variable_get(:@period)) %> 93 | <% at = event.instance_variable_get(:@at) %> 94 | <% if at %> 95 | at <%= friendly_time_part(at.instance_variable_get(:@hour)) %>:<%= friendly_time_part(at.instance_variable_get(:@min)) %> 96 | <% end %> 97 | <% if event.instance_variable_get(:@if) %> 98 | if __ 99 | <% end %> 100 | <%= last_run(@last_runs[event.job]) %><%= button_to enabled ? "Disable" : "Enable", home_job_path(job: event.job, enable: !enabled), disabled: !ClockworkWeb.redis %>
107 |
108 | 109 | 110 | -------------------------------------------------------------------------------- /clockwork_web.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/clockwork_web/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "clockwork_web" 5 | spec.version = ClockworkWeb::VERSION 6 | spec.summary = "A web interface for Clockwork" 7 | spec.homepage = "https://github.com/ankane/clockwork_web" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{app,config,lib}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.2" 17 | 18 | spec.add_dependency "clockwork", ">= 3" 19 | spec.add_dependency "safely_block", ">= 0.4" 20 | spec.add_dependency "railties", ">= 7.1" 21 | end 22 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | ClockworkWeb::Engine.routes.draw do 2 | post "home/job" 3 | root to: "home#index" 4 | end 5 | -------------------------------------------------------------------------------- /lib/clockwork_web.rb: -------------------------------------------------------------------------------- 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 | 15 | class << self 16 | attr_accessor :clock_path 17 | attr_accessor :redis 18 | attr_accessor :monitor 19 | attr_accessor :running_threshold 20 | attr_accessor :on_job_update 21 | attr_accessor :user_method 22 | end 23 | self.monitor = true 24 | self.running_threshold = 60 # seconds 25 | self.user_method = :current_user 26 | 27 | def self.enable(job) 28 | if redis 29 | redis.srem(DISABLED_KEY, job) 30 | true 31 | else 32 | false 33 | end 34 | end 35 | 36 | def self.disable(job) 37 | if redis 38 | redis.sadd(DISABLED_KEY, job) 39 | true 40 | else 41 | false 42 | end 43 | end 44 | 45 | def self.enabled?(job) 46 | if redis 47 | !redis.sismember(DISABLED_KEY, job) 48 | else 49 | true 50 | end 51 | end 52 | 53 | def self.disabled_jobs 54 | if redis 55 | Set.new(redis.smembers(DISABLED_KEY)) 56 | else 57 | Set.new 58 | end 59 | end 60 | 61 | def self.last_runs 62 | if redis 63 | Hash[redis.hgetall(LAST_RUNS_KEY).map { |job, timestamp| [job, Time.at(timestamp.to_i)] }.sort_by { |job, time| [time, job] }] 64 | else 65 | {} 66 | end 67 | end 68 | 69 | def self.set_last_run(job) 70 | if redis 71 | redis.hset(LAST_RUNS_KEY, job, Time.now.to_i) 72 | end 73 | end 74 | 75 | def self.last_heartbeat 76 | if redis 77 | timestamp = redis.get(HEARTBEAT_KEY) 78 | if timestamp 79 | Time.at(timestamp.to_i) 80 | end 81 | end 82 | end 83 | 84 | def self.heartbeat 85 | if redis 86 | heartbeat = Time.now.to_i 87 | if heartbeat % 10 == 0 88 | prev_heartbeat = redis.getset(HEARTBEAT_KEY, heartbeat).to_i 89 | if prev_heartbeat >= heartbeat 90 | redis.setex(STATUS_KEY, 60, "multiple") 91 | end 92 | end 93 | end 94 | end 95 | 96 | def self.running? 97 | last_heartbeat && last_heartbeat > Time.now - running_threshold 98 | end 99 | 100 | def self.multiple? 101 | redis && redis.get(STATUS_KEY) == "multiple" 102 | end 103 | end 104 | 105 | module Clockwork 106 | on(:before_tick) do 107 | ClockworkWeb.heartbeat if ClockworkWeb.monitor 108 | true 109 | end 110 | 111 | on(:before_run) do |event, t| 112 | run = true 113 | Safely.safely do 114 | run = ClockworkWeb.enabled?(event.job) 115 | if run 116 | ClockworkWeb.set_last_run(event.job) 117 | else 118 | manager.log "Skipping '#{event}'" 119 | event.last = event.convert_timezone(t) 120 | end 121 | end 122 | run 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/clockwork_web/engine.rb: -------------------------------------------------------------------------------- 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 11 | -------------------------------------------------------------------------------- /lib/clockwork_web/version.rb: -------------------------------------------------------------------------------- 1 | module ClockworkWeb 2 | VERSION = "0.4.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/controller_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class ControllerTest < ActionDispatch::IntegrationTest 4 | def test_root 5 | get clockwork_web.root_path 6 | assert_response :success 7 | assert_match "frequent.job", response.body 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/internal/app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankane/clockwork_web/01491c5a98d248378cbc1c4185d4b0bc270a28e2/test/internal/app/assets/config/manifest.js -------------------------------------------------------------------------------- /test/internal/clock.rb: -------------------------------------------------------------------------------- 1 | require "clockwork" 2 | 3 | module Clockwork 4 | handler do |job, time| 5 | puts "Running #{job}, at #{time}" 6 | end 7 | 8 | every(10.seconds, "frequent.job") 9 | end 10 | -------------------------------------------------------------------------------- /test/internal/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount ClockworkWeb::Engine, at: "/" 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "combustion" 3 | Bundler.require(:default) 4 | require "minitest/autorun" 5 | require "minitest/pride" 6 | 7 | Combustion.path = "test/internal" 8 | Combustion.initialize! :action_controller do 9 | config.load_defaults Rails::VERSION::STRING.to_f 10 | end 11 | --------------------------------------------------------------------------------