resque_admin-scheduler 1.0.2
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/bin/migrate_to_timestamps_set.rb +16 -0
- data/exe/resque-scheduler +5 -0
- data/lib/resque-scheduler.rb +4 -0
- data/lib/resque/scheduler.rb +447 -0
- data/lib/resque/scheduler/cli.rb +147 -0
- data/lib/resque/scheduler/configuration.rb +73 -0
- data/lib/resque/scheduler/delaying_extensions.rb +324 -0
- data/lib/resque/scheduler/env.rb +89 -0
- data/lib/resque/scheduler/extension.rb +13 -0
- data/lib/resque/scheduler/failure_handler.rb +11 -0
- data/lib/resque/scheduler/lock.rb +4 -0
- data/lib/resque/scheduler/lock/base.rb +61 -0
- data/lib/resque/scheduler/lock/basic.rb +27 -0
- data/lib/resque/scheduler/lock/resilient.rb +78 -0
- data/lib/resque/scheduler/locking.rb +104 -0
- data/lib/resque/scheduler/logger_builder.rb +72 -0
- data/lib/resque/scheduler/plugin.rb +31 -0
- data/lib/resque/scheduler/scheduling_extensions.rb +141 -0
- data/lib/resque/scheduler/server.rb +268 -0
- data/lib/resque/scheduler/server/views/delayed.erb +63 -0
- data/lib/resque/scheduler/server/views/delayed_schedules.erb +20 -0
- data/lib/resque/scheduler/server/views/delayed_timestamp.erb +26 -0
- data/lib/resque/scheduler/server/views/requeue-params.erb +23 -0
- data/lib/resque/scheduler/server/views/scheduler.erb +58 -0
- data/lib/resque/scheduler/server/views/search.erb +72 -0
- data/lib/resque/scheduler/server/views/search_form.erb +8 -0
- data/lib/resque/scheduler/signal_handling.rb +40 -0
- data/lib/resque/scheduler/tasks.rb +25 -0
- data/lib/resque/scheduler/util.rb +39 -0
- data/lib/resque/scheduler/version.rb +7 -0
- data/tasks/resque_scheduler.rake +2 -0
- metadata +267 -0
@@ -0,0 +1,31 @@
|
|
1
|
+
# vim:fileencoding=utf-8
|
2
|
+
|
3
|
+
module Resque
|
4
|
+
module Scheduler
|
5
|
+
module Plugin
|
6
|
+
def self.hooks(job, pattern)
|
7
|
+
job.methods.grep(/^#{pattern}/).sort
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.run_hooks(job, pattern, *args)
|
11
|
+
results = hooks(job, pattern).map do |hook|
|
12
|
+
job.send(hook, *args)
|
13
|
+
end
|
14
|
+
|
15
|
+
results.all? { |result| result != false }
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.run_before_delayed_enqueue_hooks(klass, *args)
|
19
|
+
run_hooks(klass, 'before_delayed_enqueue', *args)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.run_before_schedule_hooks(klass, *args)
|
23
|
+
run_hooks(klass, 'before_schedule', *args)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.run_after_schedule_hooks(klass, *args)
|
27
|
+
run_hooks(klass, 'after_schedule', *args)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# vim:fileencoding=utf-8
|
2
|
+
|
3
|
+
module Resque
|
4
|
+
module Scheduler
|
5
|
+
module SchedulingExtensions
|
6
|
+
# Accepts a new schedule configuration of the form:
|
7
|
+
#
|
8
|
+
# {
|
9
|
+
# "MakeTea" => {
|
10
|
+
# "every" => "1m" },
|
11
|
+
# "some_name" => {
|
12
|
+
# "cron" => "5/* * * *",
|
13
|
+
# "class" => "DoSomeWork",
|
14
|
+
# "args" => "work on this string",
|
15
|
+
# "description" => "this thing works it"s butter off" },
|
16
|
+
# ...
|
17
|
+
# }
|
18
|
+
#
|
19
|
+
# Hash keys can be anything and are used to describe and reference
|
20
|
+
# the scheduled job. If the "class" argument is missing, the key
|
21
|
+
# is used implicitly as "class" argument - in the "MakeTea" example,
|
22
|
+
# "MakeTea" is used both as job name and resque worker class.
|
23
|
+
#
|
24
|
+
# Any jobs that were in the old schedule, but are not
|
25
|
+
# present in the new schedule, will be removed.
|
26
|
+
#
|
27
|
+
# :cron can be any cron scheduling string
|
28
|
+
#
|
29
|
+
# :every can be used in lieu of :cron. see rufus-scheduler's 'every'
|
30
|
+
# usage for valid syntax. If :cron is present it will take precedence
|
31
|
+
# over :every.
|
32
|
+
#
|
33
|
+
# :class must be a resque worker class. If it is missing, the job name
|
34
|
+
# (hash key) will be used as :class.
|
35
|
+
#
|
36
|
+
# :args can be any yaml which will be converted to a ruby literal and
|
37
|
+
# passed in a params. (optional)
|
38
|
+
#
|
39
|
+
# :rails_envs is the list of envs where the job gets loaded. Envs are
|
40
|
+
# comma separated (optional)
|
41
|
+
#
|
42
|
+
# :description is just that, a description of the job (optional). If
|
43
|
+
# params is an array, each element in the array is passed as a separate
|
44
|
+
# param, otherwise params is passed in as the only parameter to
|
45
|
+
# perform.
|
46
|
+
def schedule=(schedule_hash)
|
47
|
+
@non_persistent_schedules = nil
|
48
|
+
prepared_schedules = prepare_schedules(schedule_hash)
|
49
|
+
|
50
|
+
prepared_schedules.each do |schedule, config|
|
51
|
+
set_schedule(schedule, config, false)
|
52
|
+
end
|
53
|
+
|
54
|
+
# ensure only return the successfully saved data!
|
55
|
+
reload_schedule!
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns the schedule hash
|
59
|
+
def schedule
|
60
|
+
@schedule ||= all_schedules
|
61
|
+
@schedule || {}
|
62
|
+
end
|
63
|
+
|
64
|
+
# reloads the schedule from redis and memory
|
65
|
+
def reload_schedule!
|
66
|
+
@schedule = all_schedules
|
67
|
+
end
|
68
|
+
|
69
|
+
# gets the schedules as it exists in redis
|
70
|
+
def all_schedules
|
71
|
+
non_persistent_schedules.merge(persistent_schedules)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Create or update a schedule with the provided name and configuration.
|
75
|
+
#
|
76
|
+
# Note: values for class and custom_job_class need to be strings,
|
77
|
+
# not constants.
|
78
|
+
#
|
79
|
+
# Resque.set_schedule('some_job', {:class => 'SomeJob',
|
80
|
+
# :every => '15mins',
|
81
|
+
# :queue => 'high',
|
82
|
+
# :args => '/tmp/poop'})
|
83
|
+
#
|
84
|
+
# Preventing a reload is optional and available to batch operations
|
85
|
+
def set_schedule(name, config, reload = true)
|
86
|
+
persist = config.delete(:persist) || config.delete('persist')
|
87
|
+
|
88
|
+
if persist
|
89
|
+
redis.hset(:persistent_schedules, name, encode(config))
|
90
|
+
else
|
91
|
+
non_persistent_schedules[name] = decode(encode(config))
|
92
|
+
end
|
93
|
+
|
94
|
+
redis.sadd(:schedules_changed, name)
|
95
|
+
reload_schedule! if reload
|
96
|
+
end
|
97
|
+
|
98
|
+
# retrive the schedule configuration for the given name
|
99
|
+
def fetch_schedule(name)
|
100
|
+
schedule[name]
|
101
|
+
end
|
102
|
+
|
103
|
+
# remove a given schedule by name
|
104
|
+
def remove_schedule(name)
|
105
|
+
non_persistent_schedules.delete(name)
|
106
|
+
redis.hdel(:persistent_schedules, name)
|
107
|
+
redis.sadd(:schedules_changed, name)
|
108
|
+
|
109
|
+
reload_schedule!
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
# we store our non-persistent schedules in this hash
|
115
|
+
def non_persistent_schedules
|
116
|
+
@non_persistent_schedules ||= {}
|
117
|
+
end
|
118
|
+
|
119
|
+
# reads the persistent schedules from redis
|
120
|
+
def persistent_schedules
|
121
|
+
redis.hgetall(:persistent_schedules).tap do |h|
|
122
|
+
h.each do |name, config|
|
123
|
+
h[name] = decode(config)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def prepare_schedules(schedule_hash)
|
129
|
+
prepared_hash = {}
|
130
|
+
schedule_hash.each do |name, job_spec|
|
131
|
+
job_spec = job_spec.dup
|
132
|
+
unless job_spec.key?('class') || job_spec.key?(:class)
|
133
|
+
job_spec['class'] = name
|
134
|
+
end
|
135
|
+
prepared_hash[name] = job_spec
|
136
|
+
end
|
137
|
+
prepared_hash
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,268 @@
|
|
1
|
+
# vim:fileencoding=utf-8
|
2
|
+
require 'resque-scheduler'
|
3
|
+
require 'resque/server'
|
4
|
+
require 'tilt/erb'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
# Extend Resque::Server to add tabs
|
8
|
+
module Resque
|
9
|
+
module Scheduler
|
10
|
+
module Server
|
11
|
+
TIMESTAMP_FORMAT = '%Y-%m-%d %H:%M:%S %z'.freeze
|
12
|
+
|
13
|
+
unless defined?(::Resque::Scheduler::Server::VIEW_PATH)
|
14
|
+
VIEW_PATH = File.join(File.dirname(__FILE__), 'server', 'views')
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.included(base)
|
18
|
+
base.class_eval do
|
19
|
+
helpers { include HelperMethods }
|
20
|
+
include ServerMethods
|
21
|
+
|
22
|
+
get('/schedule') { schedule }
|
23
|
+
post('/schedule/requeue') { schedule_requeue }
|
24
|
+
post('/schedule/requeue_with_params') do
|
25
|
+
schedule_requeue_with_params
|
26
|
+
end
|
27
|
+
delete('/schedule') { delete_schedule }
|
28
|
+
get('/delayed') { delayed }
|
29
|
+
get('/delayed/jobs/:klass') { delayed_jobs_klass }
|
30
|
+
post('/delayed/search') { delayed_search }
|
31
|
+
get('/delayed/:timestamp') { delayed_timestamp }
|
32
|
+
post('/delayed/queue_now') { delayed_queue_now }
|
33
|
+
post('/delayed/cancel_now') { delayed_cancel_now }
|
34
|
+
post('/delayed/clear') { delayed_clear }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
module ServerMethods
|
39
|
+
def schedule
|
40
|
+
Resque.reload_schedule! if Resque::Scheduler.dynamic
|
41
|
+
erb scheduler_template('scheduler')
|
42
|
+
end
|
43
|
+
|
44
|
+
def schedule_requeue
|
45
|
+
@job_name = params['job_name'] || params[:job_name]
|
46
|
+
config = Resque.schedule[@job_name]
|
47
|
+
@parameters = config['parameters'] || config[:parameters]
|
48
|
+
if @parameters
|
49
|
+
erb scheduler_template('requeue-params')
|
50
|
+
else
|
51
|
+
Resque::Scheduler.enqueue_from_config(config)
|
52
|
+
redirect u('/overview')
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def schedule_requeue_with_params
|
57
|
+
job_name = params['job_name'] || params[:job_name]
|
58
|
+
config = Resque.schedule[job_name]
|
59
|
+
# Build args hash from post data (removing the job name)
|
60
|
+
submitted_args = params.reject do |key, _value|
|
61
|
+
key == 'job_name' || key == :job_name
|
62
|
+
end
|
63
|
+
|
64
|
+
# Merge constructed args hash with existing args hash for
|
65
|
+
# the job, if it exists
|
66
|
+
config_args = config['args'] || config[:args] || {}
|
67
|
+
config_args = config_args.merge(submitted_args)
|
68
|
+
|
69
|
+
# Insert the args hash into config and queue the resque job
|
70
|
+
config = config.merge('args' => config_args)
|
71
|
+
Resque::Scheduler.enqueue_from_config(config)
|
72
|
+
redirect u('/overview')
|
73
|
+
end
|
74
|
+
|
75
|
+
def delete_schedule
|
76
|
+
if Resque::Scheduler.dynamic
|
77
|
+
job_name = params['job_name'] || params[:job_name]
|
78
|
+
Resque.remove_schedule(job_name)
|
79
|
+
end
|
80
|
+
redirect u('/schedule')
|
81
|
+
end
|
82
|
+
|
83
|
+
def delayed
|
84
|
+
erb scheduler_template('delayed')
|
85
|
+
end
|
86
|
+
|
87
|
+
def delayed_jobs_klass
|
88
|
+
begin
|
89
|
+
klass = Resque::Scheduler::Util.constantize(params[:klass])
|
90
|
+
@args = JSON.load(URI.decode(params[:args]))
|
91
|
+
@timestamps = Resque.scheduled_at(klass, *@args)
|
92
|
+
rescue
|
93
|
+
@timestamps = []
|
94
|
+
end
|
95
|
+
|
96
|
+
erb scheduler_template('delayed_schedules')
|
97
|
+
end
|
98
|
+
|
99
|
+
def delayed_search
|
100
|
+
@jobs = find_job(params[:search])
|
101
|
+
erb scheduler_template('search')
|
102
|
+
end
|
103
|
+
|
104
|
+
def delayed_timestamp
|
105
|
+
erb scheduler_template('delayed_timestamp')
|
106
|
+
end
|
107
|
+
|
108
|
+
def delayed_queue_now
|
109
|
+
timestamp = params['timestamp'].to_i
|
110
|
+
formatted_time = Time.at(timestamp).strftime(
|
111
|
+
::Resque::Scheduler::Server::TIMESTAMP_FORMAT
|
112
|
+
)
|
113
|
+
|
114
|
+
if timestamp > 0
|
115
|
+
unless Resque::Scheduler.enqueue_next_item(timestamp)
|
116
|
+
@error_message = "Unable to remove item at #{formatted_time}"
|
117
|
+
end
|
118
|
+
else
|
119
|
+
@error_message = "Incorrect timestamp #{formatted_time}"
|
120
|
+
end
|
121
|
+
|
122
|
+
erb scheduler_template('delayed')
|
123
|
+
end
|
124
|
+
|
125
|
+
def delayed_cancel_now
|
126
|
+
klass = Resque::Scheduler::Util.constantize(params['klass'])
|
127
|
+
timestamp = params['timestamp']
|
128
|
+
args = Resque.decode params['args']
|
129
|
+
Resque.remove_delayed_job_from_timestamp(timestamp, klass, *args)
|
130
|
+
redirect u('/delayed')
|
131
|
+
end
|
132
|
+
|
133
|
+
def delayed_clear
|
134
|
+
Resque.reset_delayed_queue
|
135
|
+
redirect u('delayed')
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
module HelperMethods
|
140
|
+
def format_time(t)
|
141
|
+
t.strftime(::Resque::Scheduler::Server::TIMESTAMP_FORMAT)
|
142
|
+
end
|
143
|
+
|
144
|
+
def show_job_arguments(args)
|
145
|
+
Array(args).map(&:inspect).join("\n")
|
146
|
+
end
|
147
|
+
|
148
|
+
def queue_from_class_name(class_name)
|
149
|
+
Resque.queue_from_class(
|
150
|
+
Resque::Scheduler::Util.constantize(class_name)
|
151
|
+
)
|
152
|
+
end
|
153
|
+
|
154
|
+
def find_job(worker)
|
155
|
+
worker = worker.downcase
|
156
|
+
results = working_jobs_for_worker(worker)
|
157
|
+
|
158
|
+
dels = delayed_jobs_for_worker(worker)
|
159
|
+
results += dels.select do |j|
|
160
|
+
j['class'].downcase.include?(worker) &&
|
161
|
+
j.merge!('where_at' => 'delayed')
|
162
|
+
end
|
163
|
+
|
164
|
+
Resque.queues.each do |queue|
|
165
|
+
queued = Resque.peek(queue, 0, Resque.size(queue))
|
166
|
+
queued = [queued] unless queued.is_a?(Array)
|
167
|
+
results += queued.select do |j|
|
168
|
+
j['class'].downcase.include?(worker) &&
|
169
|
+
j.merge!('queue' => queue, 'where_at' => 'queued')
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
results
|
174
|
+
end
|
175
|
+
|
176
|
+
def schedule_interval(config)
|
177
|
+
if config['every']
|
178
|
+
schedule_interval_every(config['every'])
|
179
|
+
elsif config['cron']
|
180
|
+
'cron: ' + config['cron'].to_s
|
181
|
+
else
|
182
|
+
'Not currently scheduled'
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def schedule_interval_every(every)
|
187
|
+
every = [*every]
|
188
|
+
s = 'every: ' << every.first
|
189
|
+
|
190
|
+
return s unless every.length > 1
|
191
|
+
|
192
|
+
s << ' ('
|
193
|
+
meta = every.last.map do |key, value|
|
194
|
+
"#{key.to_s.tr('_', ' ')} #{value}"
|
195
|
+
end
|
196
|
+
s << meta.join(', ') << ')'
|
197
|
+
end
|
198
|
+
|
199
|
+
def schedule_class(config)
|
200
|
+
if config['class'].nil? && !config['custom_job_class'].nil?
|
201
|
+
config['custom_job_class']
|
202
|
+
else
|
203
|
+
config['class']
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def scheduler_template(name)
|
208
|
+
File.read(
|
209
|
+
File.expand_path("../server/views/#{name}.erb", __FILE__)
|
210
|
+
)
|
211
|
+
end
|
212
|
+
|
213
|
+
def scheduled_in_this_env?(name)
|
214
|
+
return true if rails_env(name).nil?
|
215
|
+
rails_env(name).split(/[\s,]+/).include?(Resque::Scheduler.env)
|
216
|
+
end
|
217
|
+
|
218
|
+
def rails_env(name)
|
219
|
+
Resque.schedule[name]['rails_env'] || Resque.schedule[name]['env']
|
220
|
+
end
|
221
|
+
|
222
|
+
def scheduler_view(filename, options = {}, locals = {})
|
223
|
+
source = File.read(File.join(VIEW_PATH, "#{filename}.erb"))
|
224
|
+
erb source, options, locals
|
225
|
+
end
|
226
|
+
|
227
|
+
private
|
228
|
+
|
229
|
+
def working_jobs_for_worker(worker)
|
230
|
+
[].tap do |results|
|
231
|
+
working = [*Resque.working]
|
232
|
+
work = working.select do |w|
|
233
|
+
w.job && w.job['payload'] &&
|
234
|
+
w.job['payload']['class'].downcase.include?(worker)
|
235
|
+
end
|
236
|
+
work.each do |w|
|
237
|
+
results += [
|
238
|
+
w.job['payload'].merge(
|
239
|
+
'queue' => w.job['queue'], 'where_at' => 'working'
|
240
|
+
)
|
241
|
+
]
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def delayed_jobs_for_worker(_worker)
|
247
|
+
[].tap do |dels|
|
248
|
+
schedule_size = Resque.delayed_queue_schedule_size
|
249
|
+
Resque.delayed_queue_peek(0, schedule_size).each do |d|
|
250
|
+
Resque.delayed_timestamp_peek(
|
251
|
+
d, 0, Resque.delayed_timestamp_size(d)
|
252
|
+
).each do |j|
|
253
|
+
dels << j.merge!('timestamp' => d)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
Resque::Server.tabs << 'Schedule'
|
264
|
+
Resque::Server.tabs << 'Delayed'
|
265
|
+
|
266
|
+
Resque::Server.class_eval do
|
267
|
+
include Resque::Scheduler::Server
|
268
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
<h1>Delayed Jobs</h1>
|
2
|
+
<%- size = resque.delayed_queue_schedule_size %>
|
3
|
+
|
4
|
+
<%= scheduler_view :search_form, layout: false %>
|
5
|
+
|
6
|
+
<p style="font-color: red; font-weight: bold;">
|
7
|
+
<%= @error_message %>
|
8
|
+
</p>
|
9
|
+
|
10
|
+
<p class='intro'>
|
11
|
+
This list below contains the timestamps for scheduled delayed jobs.
|
12
|
+
Server local time: <%= Time.now %>
|
13
|
+
</p>
|
14
|
+
|
15
|
+
<p class='sub'>
|
16
|
+
Showing <%= start = params[:start].to_i %> to <%= start + 20 %> of <b><%= size %></b> timestamps
|
17
|
+
</p>
|
18
|
+
|
19
|
+
<table>
|
20
|
+
<tr>
|
21
|
+
<th></th>
|
22
|
+
<th>Timestamp</th>
|
23
|
+
<th>Job count</th>
|
24
|
+
<th>Class</th>
|
25
|
+
<th>Args</th>
|
26
|
+
<th>All schedules</th>
|
27
|
+
</tr>
|
28
|
+
<% resque.delayed_queue_peek(start, 20).each do |timestamp| %>
|
29
|
+
<tr>
|
30
|
+
<td>
|
31
|
+
<form action="<%= u "/delayed/queue_now" %>" method="post">
|
32
|
+
<input type="hidden" name="timestamp" value="<%= timestamp.to_i %>">
|
33
|
+
<input type="submit" value="Queue now">
|
34
|
+
</form>
|
35
|
+
</td>
|
36
|
+
<td><a href="<%= u "delayed/#{timestamp}" %>"><%= format_time(Time.at(timestamp)) %></a></td>
|
37
|
+
<td><%= delayed_timestamp_size = resque.delayed_timestamp_size(timestamp) %></td>
|
38
|
+
<% job = resque.delayed_timestamp_peek(timestamp, 0, 1).first %>
|
39
|
+
<td>
|
40
|
+
<% if job && delayed_timestamp_size == 1 %>
|
41
|
+
<%= h(job['class']) %>
|
42
|
+
<% else %>
|
43
|
+
<a href="<%= u "delayed/#{timestamp}" %>">see details</a>
|
44
|
+
<% end %>
|
45
|
+
</td>
|
46
|
+
<td><%= h(show_job_arguments(job['args'])) if job && delayed_timestamp_size == 1 %></td>
|
47
|
+
<td>
|
48
|
+
<% if job %>
|
49
|
+
<a href="<%=u URI("/delayed/jobs/#{job['class']}?args=" + URI.encode(job['args'].to_json)) %>">All schedules</a>
|
50
|
+
<% end %>
|
51
|
+
</td>
|
52
|
+
</tr>
|
53
|
+
<% end %>
|
54
|
+
</table>
|
55
|
+
|
56
|
+
<% if size > 0 %>
|
57
|
+
<br>
|
58
|
+
<form method="POST" action="<%=u 'delayed/clear'%>" class='clear-delayed'>
|
59
|
+
<input type='submit' name='' value='Clear Delayed Jobs' />
|
60
|
+
</form>
|
61
|
+
<% end %>
|
62
|
+
|
63
|
+
<%= partial :next_more, :start => start, :size => size %>
|