rocketjob_mission_control 5.0.1 → 6.0.0.beta
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 +4 -4
- data/README.md +1 -1
- data/app/assets/config/manifest.js +3 -0
- data/app/assets/javascripts/rocket_job_mission_control/application.js +1 -1
- data/app/assets/javascripts/rocket_job_mission_control/nested_fields.js +112 -0
- data/app/assets/stylesheets/rocket_job_mission_control/{application.scss → application.css} +9 -8
- data/app/assets/stylesheets/rocket_job_mission_control/base.css +420 -0
- data/app/assets/stylesheets/rocket_job_mission_control/{callout.scss → callout.css} +50 -52
- data/app/assets/stylesheets/rocket_job_mission_control/jobs.css +57 -0
- data/app/assets/stylesheets/rocket_job_mission_control/worker_processes.css +7 -0
- data/app/controllers/rocket_job_mission_control/dirmon_entries_controller.rb +20 -20
- data/app/controllers/rocket_job_mission_control/jobs_controller.rb +44 -4
- data/app/datatables/rocket_job_mission_control/jobs_datatable.rb +1 -1
- data/app/helpers/rocket_job_mission_control/application_helper.rb +43 -26
- data/app/helpers/rocket_job_mission_control/dirmon_entries_helper.rb +8 -0
- data/app/helpers/rocket_job_mission_control/jobs_helper.rb +76 -8
- data/app/models/rocket_job_mission_control/dirmon_sanitizer.rb +68 -0
- data/app/models/rocket_job_mission_control/job_sanitizer.rb +35 -0
- data/app/views/rocket_job_mission_control/dirmon_entries/_form.html.erb +78 -42
- data/app/views/rocket_job_mission_control/dirmon_entries/_input_categories.html.erb +59 -0
- data/app/views/rocket_job_mission_control/dirmon_entries/_input_category_fields.html.erb +56 -0
- data/app/views/rocket_job_mission_control/dirmon_entries/_output_categories.html.erb +44 -0
- data/app/views/rocket_job_mission_control/dirmon_entries/_output_category_fields.html.erb +36 -0
- data/app/views/rocket_job_mission_control/dirmon_entries/_status.html.erb +22 -16
- data/app/views/rocket_job_mission_control/dirmon_entries/copy.html.erb +1 -1
- data/app/views/rocket_job_mission_control/dirmon_entries/edit.html.erb +7 -3
- data/app/views/rocket_job_mission_control/dirmon_entries/show.html.erb +2 -3
- data/app/views/rocket_job_mission_control/jobs/_attributes.html.erb +89 -0
- data/app/views/rocket_job_mission_control/jobs/_dates.html.erb +39 -0
- data/app/views/rocket_job_mission_control/jobs/_details.html.erb +86 -0
- data/app/views/rocket_job_mission_control/jobs/_exception.html.erb +33 -0
- data/app/views/rocket_job_mission_control/jobs/_input_categories.html.erb +126 -0
- data/app/views/rocket_job_mission_control/jobs/_input_category_fields.html.erb +56 -0
- data/app/views/rocket_job_mission_control/jobs/_output_categories.html.erb +60 -0
- data/app/views/rocket_job_mission_control/jobs/_output_category_fields.html.erb +36 -0
- data/app/views/rocket_job_mission_control/jobs/_retryable.html.erb +27 -0
- data/app/views/rocket_job_mission_control/jobs/_status.html.erb +21 -49
- data/app/views/rocket_job_mission_control/jobs/edit.html.erb +28 -0
- data/app/views/rocket_job_mission_control/jobs/exception.html.erb +1 -1
- data/app/views/rocket_job_mission_control/jobs/index.html.erb +24 -18
- data/app/views/rocket_job_mission_control/jobs/show.html.erb +32 -58
- data/app/views/rocket_job_mission_control/jobs/view_slice.html.erb +4 -4
- data/config/initializers/assets.rb +3 -4
- data/lib/rocket_job_mission_control/engine.rb +0 -1
- data/lib/rocket_job_mission_control/version.rb +1 -1
- data/test/controllers/rocket_job_mission_control/jobs_controller_test.rb +2 -1
- data/test/models/rocket_job_mission_control/dirmon_sanitizer_test.rb +146 -0
- data/test/test_helper.rb +5 -11
- data/vendor/assets/fonts/glyphicons-halflings-regular.eot +0 -0
- data/vendor/assets/fonts/glyphicons-halflings-regular.svg +288 -0
- data/vendor/assets/fonts/glyphicons-halflings-regular.ttf +0 -0
- data/vendor/assets/fonts/glyphicons-halflings-regular.woff +0 -0
- data/vendor/assets/fonts/glyphicons-halflings-regular.woff2 +0 -0
- data/vendor/assets/stylesheets/bootstrap.min.css.erb +6 -0
- metadata +37 -29
- data/app/assets/stylesheets/rocket_job_mission_control/base.scss +0 -436
- data/app/assets/stylesheets/rocket_job_mission_control/bootstrap_and_overrides.scss +0 -488
- data/app/assets/stylesheets/rocket_job_mission_control/jobs.scss +0 -72
- data/app/assets/stylesheets/rocket_job_mission_control/worker_processes.scss +0 -9
- data/vendor/assets/stylesheets/bootstrap.min.css +0 -6
@@ -3,5 +3,13 @@ module RocketJobMissionControl
|
|
3
3
|
def dirmon_counts_by_state(state)
|
4
4
|
RocketJob::DirmonEntry.counts_by_state.fetch(state.downcase.to_sym, 0)
|
5
5
|
end
|
6
|
+
|
7
|
+
|
8
|
+
def dirmon_entry_find_category(categories, category_name = :main)
|
9
|
+
return unless categories
|
10
|
+
|
11
|
+
categories.each { |category| return category if category_name == (category["name"] || :main).to_sym }
|
12
|
+
nil
|
13
|
+
end
|
6
14
|
end
|
7
15
|
end
|
@@ -1,5 +1,23 @@
|
|
1
1
|
module RocketJobMissionControl
|
2
2
|
module JobsHelper
|
3
|
+
# The fields that RJMC already displays, all other will be rendered under the custom section.
|
4
|
+
DISPLAYED_FIELDS = %w[
|
5
|
+
_id _type
|
6
|
+
completed_at created_at cron_schedule
|
7
|
+
description destroy_on_complete download_encryption
|
8
|
+
exception expires_at
|
9
|
+
failed_at_list failure_count
|
10
|
+
input_categories
|
11
|
+
log_level
|
12
|
+
output_categories output_path
|
13
|
+
percent_complete priority
|
14
|
+
record_count retry_limit run_at
|
15
|
+
started_at state statistics sub_state
|
16
|
+
throttle_group throttle_running_workers
|
17
|
+
upload_file_name
|
18
|
+
worker_name
|
19
|
+
]
|
20
|
+
|
3
21
|
def job_icon(job)
|
4
22
|
state = job_state(job)
|
5
23
|
state_icon(state)
|
@@ -15,6 +33,47 @@ module RocketJobMissionControl
|
|
15
33
|
end
|
16
34
|
end
|
17
35
|
|
36
|
+
def job_state_name(job)
|
37
|
+
job_state(@job).to_s.camelcase
|
38
|
+
end
|
39
|
+
|
40
|
+
def job_time(time)
|
41
|
+
return "" unless time
|
42
|
+
time.in_time_zone(Time.zone)
|
43
|
+
end
|
44
|
+
|
45
|
+
def job_state_time(job)
|
46
|
+
return job_time(job.run_at) if job.scheduled?
|
47
|
+
|
48
|
+
job_time(job.completed_at || job.started_at || job.created_at )
|
49
|
+
# job_time(job.running? ? job.started_at : job.completed_at)
|
50
|
+
end
|
51
|
+
|
52
|
+
def job_estimated_time_left(job)
|
53
|
+
if job.record_count && job.running? && job.record_count.positive?
|
54
|
+
percent = job.percent_complete
|
55
|
+
if percent >= 5
|
56
|
+
secs = job.seconds.to_f
|
57
|
+
RocketJob.seconds_as_duration((((secs / percent) * 100) - secs))
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def job_records_per_hour(job)
|
63
|
+
return unless job.completed?
|
64
|
+
|
65
|
+
secs = job.seconds.to_f
|
66
|
+
((job.record_count.to_f / secs) * 60 * 60).round if job.record_count&.positive? && (secs > 0.0)
|
67
|
+
end
|
68
|
+
|
69
|
+
def job_custom_fields(job)
|
70
|
+
attrs = job.attributes.dup
|
71
|
+
DISPLAYED_FIELDS.each { |key| attrs.delete(key) }
|
72
|
+
# Convert time zones for any custom time fields
|
73
|
+
attrs.keys { |key| attrs[key] = attrs[key].in_time_zone(Time.zone) if attrs[key].is_a?(Time) }
|
74
|
+
attrs
|
75
|
+
end
|
76
|
+
|
18
77
|
def job_states
|
19
78
|
@job_states ||= RocketJob::Job.aasm.states.map { |state| state.name.to_s }
|
20
79
|
end
|
@@ -25,10 +84,10 @@ module RocketJobMissionControl
|
|
25
84
|
|
26
85
|
def job_counts_by_state(state)
|
27
86
|
@job_counts ||= begin
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
87
|
+
counts = RocketJob::Job.counts_by_state
|
88
|
+
counts[:queued] = counts[:queued_now] || 0
|
89
|
+
counts
|
90
|
+
end
|
32
91
|
@job_counts.fetch(state.downcase.to_sym, 0)
|
33
92
|
end
|
34
93
|
|
@@ -38,8 +97,9 @@ module RocketJobMissionControl
|
|
38
97
|
path,
|
39
98
|
method: http_method,
|
40
99
|
title: "#{action} job",
|
41
|
-
class: "btn btn-default",
|
42
|
-
|
100
|
+
class: "btn btn-default btn-group",
|
101
|
+
role: "group",
|
102
|
+
data: { confirm: t(:confirm, scope: %i[job action], action: action) }
|
43
103
|
)
|
44
104
|
end
|
45
105
|
|
@@ -49,8 +109,9 @@ module RocketJobMissionControl
|
|
49
109
|
path,
|
50
110
|
method: http_method,
|
51
111
|
title: "#{action} job",
|
52
|
-
class: "btn btn-
|
53
|
-
|
112
|
+
class: "btn btn-default btn-group",
|
113
|
+
role: "group",
|
114
|
+
data: { confirm: t(:confirm, scope: %i[job action], action: action) }
|
54
115
|
)
|
55
116
|
end
|
56
117
|
|
@@ -61,5 +122,12 @@ module RocketJobMissionControl
|
|
61
122
|
""
|
62
123
|
end
|
63
124
|
end
|
125
|
+
|
126
|
+
def job_find_category(categories, category_name = :main)
|
127
|
+
return unless categories
|
128
|
+
|
129
|
+
categories.each { |category| return category if category_name == (category["name"] || :main).to_sym }
|
130
|
+
nil
|
131
|
+
end
|
64
132
|
end
|
65
133
|
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module RocketJobMissionControl
|
2
|
+
module DirmonSanitizer
|
3
|
+
DIRMON_FIELDS = %i(archive_directory job_class_name name pattern).freeze
|
4
|
+
|
5
|
+
def self.sanitize(params, job_class, target)
|
6
|
+
permissible_params = {}
|
7
|
+
|
8
|
+
DIRMON_FIELDS.each do |field_name|
|
9
|
+
value = params[field_name]
|
10
|
+
next if value.blank?
|
11
|
+
|
12
|
+
permissible_params[field_name] = value
|
13
|
+
end
|
14
|
+
|
15
|
+
if params.key?(:properties)
|
16
|
+
properties = JobSanitizer.sanitize(params[:properties], job_class, target, false)
|
17
|
+
permissible_params[:properties] = properties unless properties.blank?
|
18
|
+
end
|
19
|
+
|
20
|
+
permissible_params
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns [Hash] the difference between the supplied params and those already set in the job itself
|
24
|
+
def self.diff_properties(sanitized_properties, dirmon_entry)
|
25
|
+
default_job = dirmon_entry.job_class.new
|
26
|
+
updated_job = dirmon_entry.job_class.from_properties(sanitized_properties)
|
27
|
+
properties = {}
|
28
|
+
sanitized_properties&.each_pair do |name, value|
|
29
|
+
if name == :input_categories
|
30
|
+
categories = []
|
31
|
+
value.each do |category_properties|
|
32
|
+
category_name = category_properties[:name].to_sym
|
33
|
+
props = diff_category(category_properties, updated_job.input_category(category_name), default_job.input_category(category_name))
|
34
|
+
categories << props unless props.empty?
|
35
|
+
end
|
36
|
+
properties[:input_categories] = categories unless categories.empty?
|
37
|
+
elsif name == :output_categories
|
38
|
+
categories = []
|
39
|
+
value.each do |category_properties|
|
40
|
+
category_name = category_properties[:name].to_sym
|
41
|
+
props = diff_category(category_properties, updated_job.output_category(category_name), default_job.output_category(category_name))
|
42
|
+
categories << props unless props.empty?
|
43
|
+
end
|
44
|
+
properties[:output_categories] = categories unless categories.empty?
|
45
|
+
else
|
46
|
+
properties[name] = value unless default_job.public_send(name) == updated_job.public_send(name)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
properties
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.diff_category(properties, updated_category, default_category)
|
53
|
+
diff = {}
|
54
|
+
name = nil
|
55
|
+
properties&.each_pair do |key, value|
|
56
|
+
if key == :name
|
57
|
+
name = value
|
58
|
+
next
|
59
|
+
end
|
60
|
+
next if updated_category.public_send(key) == default_category.public_send(key)
|
61
|
+
|
62
|
+
diff[key] = value
|
63
|
+
end
|
64
|
+
diff[:name] = name unless diff.empty?
|
65
|
+
diff
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -1,5 +1,7 @@
|
|
1
1
|
module RocketJobMissionControl
|
2
2
|
module JobSanitizer
|
3
|
+
CATEGORIES_FIELDS = %i[id name format format_options mode skip_unknown slice_size columns].freeze
|
4
|
+
|
3
5
|
# Returns [Hash] the permissible params for the specified job class, after sanitizing.
|
4
6
|
# Parameters
|
5
7
|
# properties [Hash]
|
@@ -17,6 +19,7 @@ module RocketJobMissionControl
|
|
17
19
|
# Default: true
|
18
20
|
def self.sanitize(properties, job_class, target, nil_blank = true)
|
19
21
|
permissible_params = {}
|
22
|
+
|
20
23
|
job_class.user_editable_fields.each do |field_name|
|
21
24
|
next unless value = properties[field_name]
|
22
25
|
|
@@ -38,7 +41,39 @@ module RocketJobMissionControl
|
|
38
41
|
permissible_params[field_name] = value
|
39
42
|
end
|
40
43
|
end
|
44
|
+
|
45
|
+
if properties.key?(:input_categories_attributes)
|
46
|
+
categories = sanitize_categories(properties[:input_categories_attributes])
|
47
|
+
permissible_params[:input_categories] = categories unless categories == [{}]
|
48
|
+
end
|
49
|
+
|
50
|
+
if properties.key?(:output_categories_attributes)
|
51
|
+
categories = sanitize_categories(properties[:output_categories_attributes])
|
52
|
+
permissible_params[:output_categories] = categories unless categories == [{}]
|
53
|
+
end
|
54
|
+
|
41
55
|
permissible_params
|
42
56
|
end
|
57
|
+
|
58
|
+
def self.sanitize_categories(properties)
|
59
|
+
categories = []
|
60
|
+
|
61
|
+
properties.each_pair do |_, category|
|
62
|
+
hash = {}
|
63
|
+
CATEGORIES_FIELDS.each do |key|
|
64
|
+
next unless category.key?(key)
|
65
|
+
|
66
|
+
value = category[key]
|
67
|
+
next if value.blank?
|
68
|
+
next if (key == :columns) && value == [""]
|
69
|
+
|
70
|
+
value = JSON.parse(value) if key == :format_options
|
71
|
+
hash[key] = value
|
72
|
+
end
|
73
|
+
categories << hash
|
74
|
+
end
|
75
|
+
|
76
|
+
categories
|
77
|
+
end
|
43
78
|
end
|
44
79
|
end
|
@@ -1,69 +1,105 @@
|
|
1
1
|
<% action ||= :create %>
|
2
2
|
|
3
3
|
<% if @dirmon_entry.errors.present? %>
|
4
|
-
<div class=
|
4
|
+
<div class="alert alert-alert">Invalid Dirmon entry!</div>
|
5
|
+
|
5
6
|
<% @dirmon_entry.errors.messages.each_pair do |field, message| %>
|
6
|
-
<div class=
|
7
|
+
<div class="message"><%= field %>: <%= message %></div>
|
7
8
|
<% end %>
|
8
9
|
<% end %>
|
9
10
|
|
10
|
-
<%= form_for @dirmon_entry, url: {action: action} do |f| %>
|
11
|
-
<div class=
|
12
|
-
<div class=
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
</div>
|
11
|
+
<%= form_for @dirmon_entry, url: { action: action } do |f| %>
|
12
|
+
<div class="row">
|
13
|
+
<div class="col-md-12">
|
14
|
+
<div class="arguments">
|
15
|
+
<div class="panel panel-default">
|
16
|
+
<div class="panel-body">
|
17
|
+
<div class="job_arguments form-group"></div>
|
18
18
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
</div>
|
24
|
-
</div>
|
25
|
-
</div>
|
19
|
+
<div class="form-group">
|
20
|
+
<%= f.label :name %>
|
21
|
+
<%= f.text_field :name, class: "form-control" %>
|
22
|
+
</div>
|
26
23
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
24
|
+
<div class="form-group">
|
25
|
+
<%= f.label "Job Class" %>
|
26
|
+
<%= f.text_field :job_class_name, class: "form-control", disabled: action != :create %>
|
27
|
+
</div>
|
31
28
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
29
|
+
<div class="form-group">
|
30
|
+
<%= f.label :pattern %>
|
31
|
+
<%= f.text_field :pattern, class: "form-control" %>
|
32
|
+
</div>
|
33
|
+
|
34
|
+
<div class="form-group">
|
35
|
+
<%= f.label :archive_directory %>
|
36
|
+
<%= f.text_field :archive_directory, class: "form-control" %>
|
37
|
+
</div>
|
38
|
+
</div>
|
39
|
+
</div>
|
40
|
+
|
41
|
+
<% if @dirmon_entry.job_class %>
|
42
|
+
<% @job = @dirmon_entry.job_class.from_properties(@dirmon_entry.properties) %>
|
43
|
+
<%= f.fields_for :properties do |p| %>
|
44
|
+
<div class="panel panel-default">
|
45
|
+
<div class="panel-heading">
|
46
|
+
<strong>Properties</strong>
|
47
|
+
</div>
|
48
|
+
|
49
|
+
<div class="panel-body">
|
50
|
+
<% @dirmon_entry.job_class.user_editable_fields.sort.each do |property_name| %>
|
51
|
+
<% next if property_name == :run_at %>
|
52
|
+
|
53
|
+
<div class="form-group">
|
54
|
+
<%= p.label property_name.to_s %>
|
55
|
+
<%= editable_field_html(@dirmon_entry.job_class, property_name, @job.public_send(property_name), p) %>
|
56
|
+
</div>
|
57
|
+
<% end %>
|
58
|
+
</div>
|
59
|
+
</div>
|
60
|
+
|
61
|
+
<% if @dirmon_entry.job_class.respond_to?(:defined_input_categories) && @dirmon_entry.job_class.respond_to?(:defined_output_categories) %>
|
62
|
+
<div class="row">
|
63
|
+
<div class="col-sm-6">
|
64
|
+
<div class="panel panel-primary">
|
65
|
+
<div class="panel-body">
|
66
|
+
<div class='lead'>Input Categories</div>
|
36
67
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
68
|
+
<% @job.input_categories.each do |input_categories| %>
|
69
|
+
<%= p.fields_for "input_categories_attributes[]", input_categories do |i| %>
|
70
|
+
<%= render "input_category_fields.html", f: i %>
|
71
|
+
<% end %>
|
72
|
+
<% end %>
|
73
|
+
</div>
|
74
|
+
</div>
|
75
|
+
</div>
|
41
76
|
|
42
|
-
|
43
|
-
|
77
|
+
<div class="col-sm-6">
|
78
|
+
<div class="panel panel-success">
|
79
|
+
<div class="panel-body">
|
80
|
+
<div class='lead'>Output Categories</div>
|
44
81
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
82
|
+
<% @job.output_categories.each do |output_categories| %>
|
83
|
+
<%= p.fields_for "output_categories_attributes[]", output_categories do |o| %>
|
84
|
+
<%= render "output_category_fields.html", f: o %>
|
85
|
+
<% end %>
|
86
|
+
<% end %>
|
87
|
+
</div>
|
88
|
+
</div>
|
89
|
+
</div>
|
50
90
|
</div>
|
51
91
|
<% end %>
|
52
92
|
<% end %>
|
53
93
|
</div>
|
54
94
|
<% end %>
|
55
95
|
|
56
|
-
<div class='buttons
|
96
|
+
<div class='buttons'>
|
57
97
|
<%= f.submit action, class: 'btn btn-primary' %>
|
58
98
|
<%= link_to 'cancel', :back, class: 'btn btn-default' %>
|
59
99
|
</div>
|
60
100
|
|
61
101
|
<% if action == :create %>
|
62
|
-
<%= button_tag 'properties',
|
63
|
-
type: 'button',
|
64
|
-
class: 'btn btn-default',
|
65
|
-
id: 'properties',
|
66
|
-
data: { url: rocket_job_mission_control.new_dirmon_entry_path } %>
|
102
|
+
<%= button_tag 'properties', type: 'button', class: 'btn btn-default', id: 'properties', data: {url: rocket_job_mission_control.new_dirmon_entry_path} %>
|
67
103
|
<% end %>
|
68
104
|
</div>
|
69
105
|
</div>
|
@@ -0,0 +1,59 @@
|
|
1
|
+
<div class='lead'>Input Categories:</div>
|
2
|
+
<% @job.input_categories.each do |category| %>
|
3
|
+
<div class='row'>
|
4
|
+
<div class='col-md-12'>
|
5
|
+
<div class='status-message'><label><%= category.name.to_s.camelcase %>:</label></div>
|
6
|
+
<div class='row'>
|
7
|
+
<div class='col-md-12'>
|
8
|
+
<table>
|
9
|
+
<% if category.name == :main %>
|
10
|
+
<tr>
|
11
|
+
<td><label>Slice Size:</label></td>
|
12
|
+
<td><%= category.slice_size %></td>
|
13
|
+
</tr>
|
14
|
+
<% end %>
|
15
|
+
</tr>
|
16
|
+
<tr>
|
17
|
+
<td><label>Format:</label></td>
|
18
|
+
<td><%= category.format %></td>
|
19
|
+
</tr>
|
20
|
+
<tr>
|
21
|
+
<td><label>Skip Unknown Columns:</label></td>
|
22
|
+
<td><%= category.skip_unknown %></td>
|
23
|
+
</tr>
|
24
|
+
<tr>
|
25
|
+
<td><label>Mode:</label></td>
|
26
|
+
<td><%= category.mode %></td>
|
27
|
+
</tr>
|
28
|
+
<% if category.format_options %>
|
29
|
+
<tr>
|
30
|
+
<td><label>Format Options:</label></td>
|
31
|
+
<td>
|
32
|
+
<pre><code><%= category.format_options.ai(plain: true, ruby19_syntax: true, sort_keys: true) %></code></pre>
|
33
|
+
</td>
|
34
|
+
</tr>
|
35
|
+
<% end %>
|
36
|
+
<% if category.columns %>
|
37
|
+
<% first = true %>
|
38
|
+
<% category.columns.each do |item| %>
|
39
|
+
<% if first %>
|
40
|
+
<tr>
|
41
|
+
<td><label>Columns:</label></td>
|
42
|
+
<td><%= item %></td>
|
43
|
+
</tr>
|
44
|
+
<% first = false %>
|
45
|
+
<% else %>
|
46
|
+
<tr>
|
47
|
+
<td></td>
|
48
|
+
<td><%= item %></td>
|
49
|
+
</tr>
|
50
|
+
<% end %>
|
51
|
+
<% end %>
|
52
|
+
<% end %>
|
53
|
+
</table>
|
54
|
+
</div>
|
55
|
+
</div>
|
56
|
+
</div>
|
57
|
+
</div>
|
58
|
+
<br/>
|
59
|
+
<% end %>
|