qyu 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/.gitignore +56 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +90 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/server +17 -0
- data/bin/setup +8 -0
- data/examples/bin/simple +7 -0
- data/examples/config.rb +22 -0
- data/examples/simple/create_workflow.rb +18 -0
- data/examples/simple/enqueue_job.rb +8 -0
- data/examples/simple/worker.rb +32 -0
- data/lib/qyu.rb +74 -0
- data/lib/qyu/config.rb +35 -0
- data/lib/qyu/errors.rb +4 -0
- data/lib/qyu/errors/base.rb +8 -0
- data/lib/qyu/errors/could_not_fetch_task.rb +18 -0
- data/lib/qyu/errors/invalid_queue_name.rb +12 -0
- data/lib/qyu/errors/invalid_task_attributes.rb +12 -0
- data/lib/qyu/errors/job_not_found.rb +14 -0
- data/lib/qyu/errors/lock_already_acquired.rb +12 -0
- data/lib/qyu/errors/lock_not_acquired.rb +12 -0
- data/lib/qyu/errors/message_not_received.rb +12 -0
- data/lib/qyu/errors/not_implemented_error.rb +12 -0
- data/lib/qyu/errors/payload_validation_error.rb +12 -0
- data/lib/qyu/errors/task_not_found.rb +15 -0
- data/lib/qyu/errors/task_status_update_failed.rb +15 -0
- data/lib/qyu/errors/unknown_validation_option.rb +12 -0
- data/lib/qyu/errors/unsync_error.rb +12 -0
- data/lib/qyu/errors/workflow_descriptor_validation_error.rb +14 -0
- data/lib/qyu/errors/workflow_not_found.rb +15 -0
- data/lib/qyu/factory.rb +26 -0
- data/lib/qyu/models.rb +9 -0
- data/lib/qyu/models/concerns/workflow_descriptor_validator.rb +117 -0
- data/lib/qyu/models/enums/status.rb +44 -0
- data/lib/qyu/models/job.rb +174 -0
- data/lib/qyu/models/task.rb +218 -0
- data/lib/qyu/models/workflow.rb +85 -0
- data/lib/qyu/queue.rb +5 -0
- data/lib/qyu/queue/base.rb +46 -0
- data/lib/qyu/queue/memory/adapter.rb +90 -0
- data/lib/qyu/store.rb +5 -0
- data/lib/qyu/store/base.rb +106 -0
- data/lib/qyu/store/memory/adapter.rb +187 -0
- data/lib/qyu/ui.rb +56 -0
- data/lib/qyu/ui/helpers/pagination.rb +35 -0
- data/lib/qyu/ui/public/bootstrap.min.css +5 -0
- data/lib/qyu/ui/public/paper-dashboard.css +3315 -0
- data/lib/qyu/ui/public/script.js +28 -0
- data/lib/qyu/ui/public/style.css +6 -0
- data/lib/qyu/ui/views/footer.erb +18 -0
- data/lib/qyu/ui/views/helpers/pagination.erb +49 -0
- data/lib/qyu/ui/views/jobs.erb +58 -0
- data/lib/qyu/ui/views/kaminari/_first_page.html.erb +3 -0
- data/lib/qyu/ui/views/kaminari/_gap.html.erb +3 -0
- data/lib/qyu/ui/views/kaminari/_last_page.html.erb +3 -0
- data/lib/qyu/ui/views/kaminari/_next_page.html.erb +3 -0
- data/lib/qyu/ui/views/kaminari/_page.html.erb +9 -0
- data/lib/qyu/ui/views/kaminari/_paginator.html.erb +15 -0
- data/lib/qyu/ui/views/kaminari/_prev_page.html.erb +3 -0
- data/lib/qyu/ui/views/layout.erb +33 -0
- data/lib/qyu/ui/views/navbar.erb +29 -0
- data/lib/qyu/ui/views/pagination.erb +19 -0
- data/lib/qyu/ui/views/show_job.erb +55 -0
- data/lib/qyu/ui/views/sidebar.erb +17 -0
- data/lib/qyu/ui/views/task_row.erb +26 -0
- data/lib/qyu/utils.rb +17 -0
- data/lib/qyu/version.rb +3 -0
- data/lib/qyu/workers.rb +10 -0
- data/lib/qyu/workers/base.rb +126 -0
- data/lib/qyu/workers/concerns/callback.rb +38 -0
- data/lib/qyu/workers/concerns/failure_queue.rb +23 -0
- data/lib/qyu/workers/concerns/payload_validator.rb +124 -0
- data/lib/qyu/workers/sync.rb +63 -0
- data/qyu.gemspec +36 -0
- metadata +278 -0
data/lib/qyu/queue.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Qyu
|
4
|
+
module Queue
|
5
|
+
# Qyu::Queue::Base
|
6
|
+
class Base
|
7
|
+
# This class acts as an interface for any queue adapter implemented for Qyu
|
8
|
+
# Implement the following methods in any queue and it should work seemlessly
|
9
|
+
def self.valid_config?(_config)
|
10
|
+
fail Qyu::Errors::NotImplementedError
|
11
|
+
end
|
12
|
+
|
13
|
+
# Instance methods
|
14
|
+
def enqueue_tasks(queue_name, task_ids)
|
15
|
+
task_ids.each do |task_id|
|
16
|
+
enqueue_task(queue_name, task_id)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Instance methods to override
|
21
|
+
def enqueue_task(_queue_name, _task_id)
|
22
|
+
fail Qyu::Errors::NotImplementedError
|
23
|
+
end
|
24
|
+
|
25
|
+
def enqueue_task_to_failed_queue(_queue_name, _task_id)
|
26
|
+
fail Qyu::Errors::NotImplementedError
|
27
|
+
end
|
28
|
+
|
29
|
+
def fetch_next_message(_queue_name)
|
30
|
+
fail Qyu::Errors::NotImplementedError
|
31
|
+
end
|
32
|
+
|
33
|
+
def acknowledge_message(_queue_name, _message_id)
|
34
|
+
fail Qyu::Errors::NotImplementedError
|
35
|
+
end
|
36
|
+
|
37
|
+
def queues
|
38
|
+
fail Qyu::Errors::NotImplementedError
|
39
|
+
end
|
40
|
+
|
41
|
+
def size(_queue_name)
|
42
|
+
fail Qyu::Errors::NotImplementedError
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Qyu
|
4
|
+
module Queue
|
5
|
+
module Memory
|
6
|
+
class Adapter < Qyu::Queue::Base
|
7
|
+
TYPE = :memory
|
8
|
+
|
9
|
+
def initialize(_config)
|
10
|
+
@temp_store = Hash.new(false)
|
11
|
+
@queues = {}
|
12
|
+
@threads = []
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.valid_config?(_config)
|
16
|
+
# TODO
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
def enqueue_task(queue_name, task_id)
|
21
|
+
queue(queue_name) << { 'task_id' => task_id }
|
22
|
+
end
|
23
|
+
|
24
|
+
def enqueue_task_to_failed_queue(queue_name, task_id)
|
25
|
+
failed_queue_name = queue_name + '-failed'
|
26
|
+
enqueue_task(failed_queue_name, task_id)
|
27
|
+
end
|
28
|
+
|
29
|
+
# fetch_next_message
|
30
|
+
#
|
31
|
+
# @param [String] queue_name
|
32
|
+
# @return [Hash] the acknowledge message
|
33
|
+
#
|
34
|
+
# TODO Note the uglyness in `while ... empty?`; it's because of reasons
|
35
|
+
# mainly for this (http://stackoverflow.com/q/11660253) reason.
|
36
|
+
def fetch_next_message(queue_name)
|
37
|
+
sleep(1) while queue(queue_name).empty?
|
38
|
+
message = queue(queue_name).pop(true)
|
39
|
+
message_id = Qyu::Utils.uuid
|
40
|
+
schedule_requeue(message, message_id, queue_name)
|
41
|
+
{
|
42
|
+
'id' => message_id,
|
43
|
+
'task_id' => message['task_id']
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
def acknowledge_message(_queue_name, message_id)
|
48
|
+
@temp_store[message_id] = true
|
49
|
+
end
|
50
|
+
|
51
|
+
def queues
|
52
|
+
@queues.map do |name, queue|
|
53
|
+
{ name: name, messages: queue&.size }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def size(queue_name)
|
58
|
+
queue(queue_name).size
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def schedule_requeue(message, message_id, queue_name)
|
64
|
+
@threads << Thread.new(message_id, message) do |t_message_id, t_message|
|
65
|
+
sleep(5)
|
66
|
+
queue(queue_name) << t_message unless message_acknowledged?(t_message_id)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def message_acknowledged?(message_id)
|
71
|
+
@temp_store[message_id] == true
|
72
|
+
end
|
73
|
+
|
74
|
+
# queue, or "get_or_create_queue"
|
75
|
+
#
|
76
|
+
# @param [String] name The name of the queue to create if it does
|
77
|
+
# does not exist and return;
|
78
|
+
def queue(name)
|
79
|
+
if @queues[name]
|
80
|
+
Qyu.logger.debug "Queue `#{name}`: #{@queues[name].length} elements"
|
81
|
+
return @queues[name]
|
82
|
+
end
|
83
|
+
Qyu.logger.info "Could not find queue `#{name}`, creating it"
|
84
|
+
@queues[name] ||= ::Queue.new
|
85
|
+
end
|
86
|
+
alias get_or_create_queue queue
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
data/lib/qyu/store.rb
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Qyu
|
4
|
+
module Store
|
5
|
+
# Qyu::Store::Base
|
6
|
+
class Base
|
7
|
+
# This class acts as an interface for any store implemented for Qyu
|
8
|
+
# Implement the following methods in any store and it should work seemlessly
|
9
|
+
def self.valid_config?(_config)
|
10
|
+
fail Qyu::Errors::NotImplementedError
|
11
|
+
end
|
12
|
+
|
13
|
+
def transaction
|
14
|
+
fail Qyu::Errors::NotImplementedError
|
15
|
+
end
|
16
|
+
|
17
|
+
## Workflow
|
18
|
+
def persist_workflow(name, descriptor)
|
19
|
+
fail Qyu::Errors::NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
def find_workflow(_id)
|
23
|
+
fail Qyu::Errors::NotImplementedError
|
24
|
+
end
|
25
|
+
|
26
|
+
def find_workflow_by_name(_name)
|
27
|
+
fail Qyu::Errors::NotImplementedError
|
28
|
+
end
|
29
|
+
|
30
|
+
def delete_workflow(_id)
|
31
|
+
fail Qyu::Errors::NotImplementedError
|
32
|
+
end
|
33
|
+
|
34
|
+
def delete_workflow_by_name(name)
|
35
|
+
fail Qyu::Errors::NotImplementedError
|
36
|
+
end
|
37
|
+
|
38
|
+
## Job
|
39
|
+
def persist_job(_workflow, _payload)
|
40
|
+
fail Qyu::Errors::NotImplementedError
|
41
|
+
end
|
42
|
+
|
43
|
+
def find_job(_id)
|
44
|
+
fail Qyu::Errors::NotImplementedError
|
45
|
+
end
|
46
|
+
|
47
|
+
def select_jobs(_limit, _offset, _order = :asc)
|
48
|
+
fail Qyu::Errors::NotImplementedError
|
49
|
+
end
|
50
|
+
|
51
|
+
def count_jobs
|
52
|
+
fail Qyu::Errors::NotImplementedError
|
53
|
+
end
|
54
|
+
|
55
|
+
def delete_job(_id)
|
56
|
+
fail Qyu::Errors::NotImplementedError
|
57
|
+
end
|
58
|
+
|
59
|
+
def clear_completed_jobs
|
60
|
+
fail Qyu::Errors::NotImplementedError
|
61
|
+
end
|
62
|
+
|
63
|
+
## Task
|
64
|
+
|
65
|
+
def find_or_persist_task(_name, _payload, _job_id, _parent_task_id)
|
66
|
+
fail Qyu::Errors::NotImplementedError
|
67
|
+
end
|
68
|
+
|
69
|
+
def find_task(_id)
|
70
|
+
fail Qyu::Errors::NotImplementedError
|
71
|
+
end
|
72
|
+
|
73
|
+
def find_task_ids_by_job_id_and_name(_job_id, _name)
|
74
|
+
fail Qyu::Errors::NotImplementedError
|
75
|
+
end
|
76
|
+
|
77
|
+
def find_task_ids_by_job_id_name_and_parent_task_ids(_job_id, _name, _parent_task_ids)
|
78
|
+
fail Qyu::Errors::NotImplementedError
|
79
|
+
end
|
80
|
+
|
81
|
+
def lock_task!(_id, _lease_time)
|
82
|
+
fail Qyu::Errors::NotImplementedError
|
83
|
+
end
|
84
|
+
|
85
|
+
def unlock_task!(_id, _lease_token)
|
86
|
+
fail Qyu::Errors::NotImplementedError
|
87
|
+
end
|
88
|
+
|
89
|
+
def renew_lock_lease(_id, _lease_time, _lease_token)
|
90
|
+
fail Qyu::Errors::NotImplementedError
|
91
|
+
end
|
92
|
+
|
93
|
+
def update_status(_id, _status)
|
94
|
+
fail Qyu::Errors::NotImplementedError
|
95
|
+
end
|
96
|
+
|
97
|
+
def task_status_counts(_job_id)
|
98
|
+
fail Qyu::Errors::NotImplementedError
|
99
|
+
end
|
100
|
+
|
101
|
+
def select_tasks_by_job_id
|
102
|
+
fail Qyu::Errors::NotImplementedError
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Qyu
|
4
|
+
module Store
|
5
|
+
module Memory
|
6
|
+
class Adapter < Qyu::Store::Base
|
7
|
+
TYPE = :memory
|
8
|
+
|
9
|
+
def initialize(_config)
|
10
|
+
@workflows = {}
|
11
|
+
@jobs = {}
|
12
|
+
@tasks = {}
|
13
|
+
@locks = {}
|
14
|
+
@semaphore = Mutex.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.valid_config?(_config)
|
18
|
+
# TODO
|
19
|
+
true
|
20
|
+
end
|
21
|
+
|
22
|
+
def find_workflow(id)
|
23
|
+
@workflows[id]
|
24
|
+
end
|
25
|
+
|
26
|
+
def find_workflow_by_name(name)
|
27
|
+
@workflows.detect do |_id, wflow|
|
28
|
+
wflow['name'] == name
|
29
|
+
end.last
|
30
|
+
end
|
31
|
+
|
32
|
+
def persist_workflow(name, descriptor)
|
33
|
+
id = Qyu::Utils.uuid
|
34
|
+
@workflows[id] = {
|
35
|
+
'id' => id,
|
36
|
+
'name' => name,
|
37
|
+
'descriptor' => descriptor
|
38
|
+
}
|
39
|
+
id
|
40
|
+
end
|
41
|
+
|
42
|
+
def delete_workflow(id)
|
43
|
+
@workflows.delete(id)
|
44
|
+
end
|
45
|
+
|
46
|
+
def delete_workflow_by_name(name)
|
47
|
+
workflow = find_workflow_by_name(name)
|
48
|
+
return unless workflow
|
49
|
+
delete_workflow(workflow['id'])
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_job(id)
|
53
|
+
@jobs[id]
|
54
|
+
end
|
55
|
+
|
56
|
+
def select_jobs(limit, offset, order = :asc)
|
57
|
+
ids = @jobs.keys[offset, limit]
|
58
|
+
selected = ids.map { |id| { id: id }.merge(@jobs[id]) }
|
59
|
+
return selected if order == :asc
|
60
|
+
selected.reverse
|
61
|
+
end
|
62
|
+
|
63
|
+
def persist_job(workflow, payload)
|
64
|
+
id = Qyu::Utils.uuid
|
65
|
+
@jobs[id] = {
|
66
|
+
'payload' => payload,
|
67
|
+
'workflow' => workflow
|
68
|
+
}
|
69
|
+
id
|
70
|
+
end
|
71
|
+
|
72
|
+
def delete_job(id)
|
73
|
+
@jobs.delete(id)
|
74
|
+
end
|
75
|
+
|
76
|
+
def clear_completed_jobs
|
77
|
+
# TODO
|
78
|
+
end
|
79
|
+
|
80
|
+
def count_jobs
|
81
|
+
@jobs.count
|
82
|
+
end
|
83
|
+
|
84
|
+
## Task methods
|
85
|
+
def find_task(id)
|
86
|
+
@tasks[id]
|
87
|
+
end
|
88
|
+
|
89
|
+
def find_or_persist_task(name, queue_name, payload, job_id, parent_task_id)
|
90
|
+
matching_task = @tasks.detect do |_id, attrs|
|
91
|
+
attrs['job_id'] == job_id \
|
92
|
+
&& attrs['name'] == name \
|
93
|
+
&& attrs['payload'] == payload \
|
94
|
+
&& attrs['queue_name'] == queue_name \
|
95
|
+
&& attrs['parent_task_id'] == parent_task_id
|
96
|
+
end
|
97
|
+
return matching_task[0] if matching_task
|
98
|
+
|
99
|
+
id = Qyu::Utils.uuid
|
100
|
+
@tasks[id] = {
|
101
|
+
'name' => name,
|
102
|
+
'queue_name' => queue_name,
|
103
|
+
'parent_task_id' => parent_task_id,
|
104
|
+
'status' => Qyu::Status::QUEUED,
|
105
|
+
'payload' => payload,
|
106
|
+
'job_id' => job_id
|
107
|
+
}
|
108
|
+
yield(id)
|
109
|
+
id
|
110
|
+
end
|
111
|
+
|
112
|
+
def find_task_ids_by_job_id_and_name(job_id, name)
|
113
|
+
@tasks.select do |_id, attrs|
|
114
|
+
attrs['job_id'] == job_id && attrs['name'] == name
|
115
|
+
end.map { |(id, _attr)| id }
|
116
|
+
end
|
117
|
+
|
118
|
+
def find_task_ids_by_job_id_name_and_parent_task_ids(job_id, name, parent_task_ids)
|
119
|
+
@tasks.select do |_id, attrs|
|
120
|
+
attrs['job_id'] == job_id &&
|
121
|
+
attrs['name'] == name &&
|
122
|
+
parent_task_ids.include?(attrs['parent_task_id'])
|
123
|
+
end.map { |(id, _attr)| id }
|
124
|
+
end
|
125
|
+
|
126
|
+
def select_tasks_by_job_id(job_id)
|
127
|
+
@tasks.select { |_id, attrs| attrs['job_id'] == job_id }.map { |id, attrs| attrs.merge('id' => id) }
|
128
|
+
end
|
129
|
+
|
130
|
+
def task_status_counts(job_id)
|
131
|
+
# TODO
|
132
|
+
{}
|
133
|
+
end
|
134
|
+
|
135
|
+
def lock_task!(id, lease_time)
|
136
|
+
uuid = Qyu::Utils.uuid
|
137
|
+
locked = false
|
138
|
+
locked_until = nil
|
139
|
+
@semaphore.synchronize do
|
140
|
+
if @locks[id].nil? || @locks[id][:locked_until] < Time.now
|
141
|
+
locked_until = Qyu::Utils.seconds_after_time(lease_time)
|
142
|
+
@locks[id] = { locked_by: uuid, locked_until: locked_until }
|
143
|
+
locked = true
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
return [nil, nil] unless locked
|
148
|
+
|
149
|
+
[uuid, locked_until]
|
150
|
+
end
|
151
|
+
|
152
|
+
def unlock_task!(id, lease_token)
|
153
|
+
unlocked = false
|
154
|
+
@semaphore.synchronize do
|
155
|
+
if @locks[id][:locked_by] == lease_token
|
156
|
+
@locks.delete(id)
|
157
|
+
unlocked = true
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
unlocked
|
162
|
+
end
|
163
|
+
|
164
|
+
def update_status(id, status)
|
165
|
+
@tasks[id]['status'] = status
|
166
|
+
end
|
167
|
+
|
168
|
+
def renew_lock_lease(id, lease_time, lease_token)
|
169
|
+
locked_until = nil
|
170
|
+
@semaphore.synchronize do
|
171
|
+
if @locks[id][:locked_by] == lease_token && Time.now <= @locks[id][:locked_until]
|
172
|
+
locked_until = Qyu::Utils.seconds_after_time(lease_time)
|
173
|
+
@locks[id] = { locked_by: lease_token, locked_until: locked_until }
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
locked_until
|
178
|
+
end
|
179
|
+
|
180
|
+
def transaction
|
181
|
+
# TODO
|
182
|
+
yield
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
data/lib/qyu/ui.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sinatra'
|
4
|
+
require 'qyu/ui/helpers/pagination'
|
5
|
+
|
6
|
+
module Qyu
|
7
|
+
class UI < Sinatra::Base
|
8
|
+
set :port, ENV['PORT'] || 3000
|
9
|
+
set :host, ENV['HOST'] || '0.0.0.0'
|
10
|
+
|
11
|
+
set :views, "#{__dir__}/ui/views"
|
12
|
+
set :public_folder, "#{__dir__}/ui/public"
|
13
|
+
|
14
|
+
include Qyu::Helpers::Pagination
|
15
|
+
|
16
|
+
get '/' do
|
17
|
+
redirect to('/jobs')
|
18
|
+
end
|
19
|
+
|
20
|
+
get '/jobs' do
|
21
|
+
page = params[:page].to_i > 0 ? params[:page].to_i : 1
|
22
|
+
limit = 10
|
23
|
+
offset = (page - 1) * limit
|
24
|
+
|
25
|
+
jobs = PaginatableArray.new(
|
26
|
+
Qyu::Job.select(limit: limit, offset: offset, order: :desc),
|
27
|
+
limit: limit, offset: offset, total_count: Qyu::Job.count,
|
28
|
+
page: page
|
29
|
+
)
|
30
|
+
|
31
|
+
erb :jobs, layout: true, locals: { jobs: jobs }
|
32
|
+
end
|
33
|
+
|
34
|
+
get '/jobs/:id' do
|
35
|
+
job = Qyu::Job.find(params[:id])
|
36
|
+
tasks_records = Qyu::Task.select(job_id: job.id)
|
37
|
+
total_count = tasks_records.count
|
38
|
+
task_statuses = job.task_status_counts
|
39
|
+
tasks = tasks_records.group_by(&:parent_task_id)
|
40
|
+
erb :show_job,
|
41
|
+
layout: true,
|
42
|
+
locals: {
|
43
|
+
job: job,
|
44
|
+
tasks: tasks,
|
45
|
+
total_count: total_count,
|
46
|
+
task_statuses: task_statuses
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def raw_html(value)
|
53
|
+
String.respond_to?(:html_safe) ? value.html_safe : value
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|