resque-status 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +23 -0
- data/LICENSE +20 -0
- data/README.rdoc +145 -0
- data/Rakefile +60 -0
- data/examples/sleep_job.rb +35 -0
- data/init.rb +1 -0
- data/lib/resque/job_with_status.rb +187 -0
- data/lib/resque/server/views/status.erb +57 -0
- data/lib/resque/server/views/status_styles.erb +98 -0
- data/lib/resque/server/views/statuses.erb +60 -0
- data/lib/resque/status.rb +206 -0
- data/lib/resque/status_server.rb +44 -0
- data/resque-status.gemspec +74 -0
- data/test/redis-test.conf +132 -0
- data/test/test_helper.rb +82 -0
- data/test/test_resque-job_with_status.rb +180 -0
- data/test/test_resque-status.rb +107 -0
- metadata +115 -0
@@ -0,0 +1,57 @@
|
|
1
|
+
<%= status_view :status_styles, :layout => false %>
|
2
|
+
|
3
|
+
<h1 class='wi'>Statuses: <%= @status.uuid %>/<%= @status.name %></h1>
|
4
|
+
<p class='intro'>Viewing a specific job created with JobWithStatus. <a href="/statuses">Return to the list of statuses</a></p>
|
5
|
+
|
6
|
+
<div class="status-holder" rel="<%= @status.status %>" id="status_<%= @status.uuid %>">
|
7
|
+
<div class="status-progress">
|
8
|
+
<div class="status-progress-bar status-<%= @status.status %>" style="width: <%= @status.pct_complete %>%;"></div>
|
9
|
+
<p><%= @status.pct_complete %>%</p>
|
10
|
+
</div>
|
11
|
+
<div class="status-message"><%= @status.message %></div>
|
12
|
+
<div class="status-time"><%= @status.time? ? @status.time : 'Not started' %></div>
|
13
|
+
</div>
|
14
|
+
|
15
|
+
<script type="text/javascript" charset="utf-8">
|
16
|
+
jQuery(function($) {
|
17
|
+
|
18
|
+
// itterate over the holders
|
19
|
+
$('.status-holder').each(function() {
|
20
|
+
checkStatus($(this));
|
21
|
+
});
|
22
|
+
|
23
|
+
function checkStatus($status) {
|
24
|
+
var status_id = $status.attr('id').replace('status_', '');
|
25
|
+
$.getJSON('/statuses/' + status_id + '.js', function(json) {
|
26
|
+
if (json) {
|
27
|
+
var pct = "0%";
|
28
|
+
if (json.pct_complete) {
|
29
|
+
var pct = json.pct_complete + "%";
|
30
|
+
}
|
31
|
+
$status.find('.status-progress-bar').animate({width: pct});
|
32
|
+
$status.find('.status-progress p').text(pct)
|
33
|
+
if (json.message) {
|
34
|
+
$status.find('.status-message').html(json.message)
|
35
|
+
}
|
36
|
+
if (json.status) {
|
37
|
+
$status
|
38
|
+
.attr('rel', json.status)
|
39
|
+
.find('.status-progress-bar')
|
40
|
+
.attr('class', '')
|
41
|
+
.addClass('status-progress-bar status-' + json.status);
|
42
|
+
}
|
43
|
+
if (json.time) {
|
44
|
+
$status.find('.status-time').text(new Date(json.time * 1000).toString())
|
45
|
+
}
|
46
|
+
};
|
47
|
+
var status = $status.attr('rel');
|
48
|
+
if (status == 'working' || status == 'queued' || status == "") {
|
49
|
+
setTimeout(function() {
|
50
|
+
checkStatus($status)
|
51
|
+
}, 1500);
|
52
|
+
}
|
53
|
+
});
|
54
|
+
};
|
55
|
+
|
56
|
+
});
|
57
|
+
</script>
|
@@ -0,0 +1,98 @@
|
|
1
|
+
<style type="text/css" media="screen">
|
2
|
+
th.progress {
|
3
|
+
width: 100px;
|
4
|
+
}
|
5
|
+
td.progress {
|
6
|
+
position: relative;
|
7
|
+
width: 100px;
|
8
|
+
display:block;
|
9
|
+
}
|
10
|
+
td.status {
|
11
|
+
font-weight: bold;
|
12
|
+
}
|
13
|
+
td.progress .progress-bar {
|
14
|
+
position: absolute;
|
15
|
+
top: 0px;
|
16
|
+
left: 0px;
|
17
|
+
background: #999;
|
18
|
+
display:block;
|
19
|
+
height: 100%;
|
20
|
+
z-index: 0;
|
21
|
+
opacity: 0.5;
|
22
|
+
-moz-opacity: 0.5;
|
23
|
+
-webkit-opacity: 0.5;
|
24
|
+
}
|
25
|
+
td.progress .progress-pct {
|
26
|
+
z-index: 10;
|
27
|
+
color: #333;
|
28
|
+
}
|
29
|
+
|
30
|
+
.status-holder {
|
31
|
+
background: #F7F7F7;
|
32
|
+
border: 1px solid #E5E5E5;
|
33
|
+
padding: 20px;
|
34
|
+
font-size: 110%;
|
35
|
+
margin-bottom: 40px;
|
36
|
+
}
|
37
|
+
.status-progress {
|
38
|
+
width: 100%;
|
39
|
+
height: 30px;
|
40
|
+
border: 1px solid #CCC;
|
41
|
+
background: #E5E5E5;
|
42
|
+
position:relative;
|
43
|
+
margin: 5px 0px;
|
44
|
+
}
|
45
|
+
.status-progress-bar {
|
46
|
+
position:absolute;
|
47
|
+
top: 0px;
|
48
|
+
left: 0px;
|
49
|
+
height: 30px;
|
50
|
+
background: #CCC;
|
51
|
+
}
|
52
|
+
.status-progress p {
|
53
|
+
position:absolute;
|
54
|
+
top: 5px;
|
55
|
+
left: 10px;
|
56
|
+
z-index: 15;
|
57
|
+
display:block;
|
58
|
+
color: #FFF;
|
59
|
+
padding: 0px;
|
60
|
+
font-weight: bold;
|
61
|
+
}
|
62
|
+
.status-message {
|
63
|
+
font-weight: bold;
|
64
|
+
}
|
65
|
+
.status-time {
|
66
|
+
font-size: 70%;
|
67
|
+
padding: 10px 0px;
|
68
|
+
color: #999;
|
69
|
+
}
|
70
|
+
.status-progress-bar.status-completed {
|
71
|
+
background:#61BF55;
|
72
|
+
}
|
73
|
+
.status-progress-bar.status-failed {
|
74
|
+
background: #E47E74;
|
75
|
+
}
|
76
|
+
.status-progress-bar.status-working {
|
77
|
+
background: #528499;
|
78
|
+
}
|
79
|
+
.status-progress-bar.status-killed {
|
80
|
+
background: #B84F16;
|
81
|
+
}
|
82
|
+
.status-completed {
|
83
|
+
color:#61BF55;
|
84
|
+
}
|
85
|
+
.status-failed {
|
86
|
+
color: #E47E74;
|
87
|
+
}
|
88
|
+
.status-working {
|
89
|
+
color: #528499;
|
90
|
+
}
|
91
|
+
.status-killed {
|
92
|
+
color: #B84F16;
|
93
|
+
}
|
94
|
+
#main a.kill:link, #main a.kill:visited {
|
95
|
+
color: #B84F16;
|
96
|
+
font-weight: bold;
|
97
|
+
}
|
98
|
+
</style>
|
@@ -0,0 +1,60 @@
|
|
1
|
+
<%= status_view :status_styles, :layout => false %>
|
2
|
+
|
3
|
+
<h1 class='wi'>Statuses</h1>
|
4
|
+
<p class='intro'>These are recent jobs created with the JobWithStatus class</p>
|
5
|
+
<table>
|
6
|
+
<tr>
|
7
|
+
<th>ID</th>
|
8
|
+
<th>Name</th>
|
9
|
+
<th>Status</th>
|
10
|
+
<th>Last Updated</th>
|
11
|
+
<th class="progress">% Complete</th>
|
12
|
+
<th>Message</th>
|
13
|
+
<th>Kill</th>
|
14
|
+
</tr>
|
15
|
+
<% unless @statuses.empty? %>
|
16
|
+
<% @statuses.each do |status| %>
|
17
|
+
<tr>
|
18
|
+
<td><a href="/statuses/<%= status.uuid %>"><%= status.uuid %></a></td>
|
19
|
+
<td><%= status.name %></td>
|
20
|
+
<td class="status status-<%= status.status %>"><%= status.status %></td>
|
21
|
+
<td class="time"><%= status.time %></td>
|
22
|
+
<td class="progress">
|
23
|
+
<div class="progress-bar" style="width:<%= status.pct_complete %>%"> </div>
|
24
|
+
<div class="progress-pct"><%= status.pct_complete ? "#{status.pct_complete}%" : '' %></div>
|
25
|
+
</td>
|
26
|
+
<td><%= status.message %></td>
|
27
|
+
<td><% if status.killable? %><a href="/statuses/<%= status.uuid %>/kill" class="kill">Kill</a><% end %></td>
|
28
|
+
</tr>
|
29
|
+
<% end %>
|
30
|
+
<% else %>
|
31
|
+
<tr>
|
32
|
+
<td colspan="7" class='no-data'>No Statuses right now...</td>
|
33
|
+
</tr>
|
34
|
+
<% end %>
|
35
|
+
</table>
|
36
|
+
|
37
|
+
<script type="text/javascript" charset="utf-8">
|
38
|
+
jQuery(function($) {
|
39
|
+
|
40
|
+
$('a.kill').click(function(e) {
|
41
|
+
e.preventDefault();
|
42
|
+
var $link = $(this),
|
43
|
+
url = $link.attr('href'),
|
44
|
+
confirmed = confirm("Are you sure you want to kill this job? There is no undo.");
|
45
|
+
if (confirmed) {
|
46
|
+
$link.animate({opacity: 0.5});
|
47
|
+
$.ajax({
|
48
|
+
url: url,
|
49
|
+
type: 'post',
|
50
|
+
success: function() {
|
51
|
+
$link.remove();
|
52
|
+
}
|
53
|
+
});
|
54
|
+
} else {
|
55
|
+
return false
|
56
|
+
}
|
57
|
+
});
|
58
|
+
|
59
|
+
});
|
60
|
+
</script>
|
@@ -0,0 +1,206 @@
|
|
1
|
+
require 'resque'
|
2
|
+
require 'redisk'
|
3
|
+
require 'uuid'
|
4
|
+
|
5
|
+
module Resque
|
6
|
+
# Resque::Status is a Hash object that has helper methods for dealing with
|
7
|
+
# the common status attributes. It also has a number of class methods for
|
8
|
+
# creating/updating/retrieving status objects from Redis
|
9
|
+
class Status < Hash
|
10
|
+
VERSION = '0.1.0'
|
11
|
+
|
12
|
+
extend Resque::Helpers
|
13
|
+
|
14
|
+
# Create a status, generating a new UUID, passing the message to the status
|
15
|
+
# Returns the UUID of the new status.
|
16
|
+
def self.create(message = nil)
|
17
|
+
uuid = generate_uuid
|
18
|
+
set(uuid, message)
|
19
|
+
redis.zadd(set_key, Time.now.to_i, uuid)
|
20
|
+
redis.zremrangebyscore(set_key, 0, Time.now.to_i - @expire_in) if @expire_in
|
21
|
+
uuid
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get a status by UUID. Returns a Resque::Status
|
25
|
+
def self.get(uuid)
|
26
|
+
val = redis.get(status_key(uuid))
|
27
|
+
val ? Resque::Status.new(uuid, decode(val)) : nil
|
28
|
+
end
|
29
|
+
|
30
|
+
# set a status by UUID. <tt>messages</tt> can be any number of stirngs or hashes
|
31
|
+
# that are merged in order to create a single status.
|
32
|
+
def self.set(uuid, *messages)
|
33
|
+
val = Resque::Status.new(uuid, *messages)
|
34
|
+
redis.set(status_key(uuid), encode(val))
|
35
|
+
if expire_in
|
36
|
+
redis.expire(status_key(uuid), expire_in)
|
37
|
+
end
|
38
|
+
val
|
39
|
+
end
|
40
|
+
|
41
|
+
# returns a Redisk::Logger scoped to the UUID. Any options passed are passed
|
42
|
+
# to the logger initialization.
|
43
|
+
def self.logger(uuid, options = {})
|
44
|
+
Redisk::Logger.new(logger_key(uuid), options)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Return <tt>num</tt> Resque::Status objects in reverse chronological order.
|
48
|
+
# By default returns the entire set.
|
49
|
+
def self.statuses(num = -1)
|
50
|
+
status_ids(num).collect do |id|
|
51
|
+
get(id)
|
52
|
+
end.compact
|
53
|
+
end
|
54
|
+
|
55
|
+
# Return the <tt>num</tt> most recent status/job UUIDs in reverse chronological order.
|
56
|
+
def self.status_ids(num = -1)
|
57
|
+
redis.zrevrange set_key, 0, num
|
58
|
+
end
|
59
|
+
|
60
|
+
# Kill the job at UUID on its next iteration this works by adding the UUID to a
|
61
|
+
# kill list (a.k.a. a list of jobs to be killed. Each iteration the job checks
|
62
|
+
# if it _should_ be killed by calling <tt>tick</tt> or <tt>at</tt>. If so, it raises
|
63
|
+
# a <tt>Resque::JobWithStatus::Killed</tt> error and sets the status to 'killed'.
|
64
|
+
def self.kill(uuid)
|
65
|
+
redis.sadd(kill_key, uuid)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Remove the job at UUID from the kill list
|
69
|
+
def self.killed(uuid)
|
70
|
+
redis.srem(kill_key, uuid)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Return the UUIDs of the jobs on the kill list
|
74
|
+
def self.kill_ids
|
75
|
+
redis.smembers(kill_key)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Check whether a job with UUID is on the kill list
|
79
|
+
def self.should_kill?(uuid)
|
80
|
+
redis.sismember(kill_key, uuid)
|
81
|
+
end
|
82
|
+
|
83
|
+
# The time in seconds that jobs and statuses should expire from Redis (after
|
84
|
+
# the last time they are touched/updated)
|
85
|
+
def self.expire_in
|
86
|
+
@expire_in
|
87
|
+
end
|
88
|
+
|
89
|
+
# Set the <tt>expire_in</tt> time in seconds
|
90
|
+
def self.expire_in=(seconds)
|
91
|
+
@expire_in = seconds.nil? ? nil : seconds.to_i
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.status_key(uuid)
|
95
|
+
"status:#{uuid}"
|
96
|
+
end
|
97
|
+
|
98
|
+
def self.set_key
|
99
|
+
"_statuses"
|
100
|
+
end
|
101
|
+
|
102
|
+
def self.kill_key
|
103
|
+
"_kill"
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.logger_key(uuid)
|
107
|
+
"_log:#{uuid}"
|
108
|
+
end
|
109
|
+
|
110
|
+
def self.generate_uuid
|
111
|
+
UUID.generate(:compact)
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.hash_accessor(name, options = {})
|
115
|
+
options[:default] ||= nil
|
116
|
+
coerce = options[:coerce] ? ".#{options[:coerce]}" : ""
|
117
|
+
module_eval <<-EOT
|
118
|
+
def #{name}
|
119
|
+
value = (self['#{name}'] ? self['#{name}']#{coerce} : #{options[:default].inspect})
|
120
|
+
yield value if block_given?
|
121
|
+
value
|
122
|
+
end
|
123
|
+
|
124
|
+
def #{name}=(value)
|
125
|
+
self['#{name}'] = value
|
126
|
+
end
|
127
|
+
|
128
|
+
def #{name}?
|
129
|
+
!!self['#{name}']
|
130
|
+
end
|
131
|
+
EOT
|
132
|
+
end
|
133
|
+
|
134
|
+
STATUSES = %w{queued working completed failed killed}.freeze
|
135
|
+
|
136
|
+
hash_accessor :uuid
|
137
|
+
hash_accessor :name
|
138
|
+
hash_accessor :status
|
139
|
+
hash_accessor :message
|
140
|
+
hash_accessor :time
|
141
|
+
|
142
|
+
hash_accessor :num
|
143
|
+
hash_accessor :total
|
144
|
+
|
145
|
+
# Create a new Resque::Status object. If multiple arguments are passed
|
146
|
+
# it is assumed the first argument is the UUID and the rest are status objects.
|
147
|
+
# All arguments are subsequentily merged in order. Strings are assumed to
|
148
|
+
# be messages.
|
149
|
+
def initialize(*args)
|
150
|
+
super nil
|
151
|
+
base_status = {
|
152
|
+
'time' => Time.now.to_i,
|
153
|
+
'status' => 'queued'
|
154
|
+
}
|
155
|
+
base_status['uuid'] = args.shift if args.length > 1
|
156
|
+
status_hash = args.inject(base_status) do |final, m|
|
157
|
+
m = {'message' => m} if m.is_a?(String)
|
158
|
+
final.merge(m || {})
|
159
|
+
end
|
160
|
+
self.replace(status_hash)
|
161
|
+
end
|
162
|
+
|
163
|
+
# calculate the % completion of the job based on <tt>status</tt>, <tt>num</tt>
|
164
|
+
# and <tt>total</tt>
|
165
|
+
def pct_complete
|
166
|
+
case status
|
167
|
+
when 'completed' then 100
|
168
|
+
when 'queued' then 0
|
169
|
+
when 'failed' then 100
|
170
|
+
else
|
171
|
+
t = (total == 0 || total.nil?) ? 1 : total
|
172
|
+
(((num || 0).to_f / t.to_f) * 100).to_i
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Return the time of the status initialization. If set returns a <tt>Time</tt>
|
177
|
+
# object, otherwise returns nil
|
178
|
+
def time
|
179
|
+
time? ? Time.at(self['time']) : nil
|
180
|
+
end
|
181
|
+
|
182
|
+
STATUSES.each do |status|
|
183
|
+
define_method("#{status}?") do
|
184
|
+
self['status'] === status
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Can the job be killed? 'failed', 'completed', and 'killed' jobs cant be killed
|
189
|
+
# (for pretty obvious reasons)
|
190
|
+
def killable?
|
191
|
+
!['failed', 'completed', 'killed'].include?(self.status)
|
192
|
+
end
|
193
|
+
|
194
|
+
# Return a JSON representation of the current object.
|
195
|
+
def to_json
|
196
|
+
h = self.dup
|
197
|
+
h['pct_complete'] = pct_complete
|
198
|
+
self.class.encode(h)
|
199
|
+
end
|
200
|
+
|
201
|
+
def inspect
|
202
|
+
"#<Resque::Status #{super}>"
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'resque/status'
|
2
|
+
|
3
|
+
module Resque
|
4
|
+
module StatusServer
|
5
|
+
|
6
|
+
VIEW_PATH = File.join(File.dirname(__FILE__), 'server', 'views')
|
7
|
+
|
8
|
+
def self.registered(app)
|
9
|
+
|
10
|
+
app.get '/statuses' do
|
11
|
+
@statuses = Resque::Status.statuses
|
12
|
+
status_view(:statuses)
|
13
|
+
end
|
14
|
+
|
15
|
+
app.get '/statuses/:id.js' do
|
16
|
+
@status = Resque::Status.get(params[:id])
|
17
|
+
content_type :js
|
18
|
+
@status.to_json
|
19
|
+
end
|
20
|
+
|
21
|
+
app.get '/statuses/:id' do
|
22
|
+
@status = Resque::Status.get(params[:id])
|
23
|
+
status_view(:status)
|
24
|
+
end
|
25
|
+
|
26
|
+
app.post '/statuses/:id/kill' do
|
27
|
+
Resque::Status.kill(params[:id])
|
28
|
+
redirect '/statuses'
|
29
|
+
end
|
30
|
+
|
31
|
+
app.helpers do
|
32
|
+
def status_view(filename, options = {}, locals = {})
|
33
|
+
erb(File.read(File.join(VIEW_PATH, "#{filename}.erb")), options, locals)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
app.tabs << "Statuses"
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
Resque::Server.register Resque::StatusServer
|