solid_queue_tui 0.1.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.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/sqtui +6 -0
  4. data/lib/solid_queue_tui/actions/discard_job.rb +34 -0
  5. data/lib/solid_queue_tui/actions/discard_scheduled_job.rb +33 -0
  6. data/lib/solid_queue_tui/actions/dispatch_scheduled_job.rb +35 -0
  7. data/lib/solid_queue_tui/actions/retry_job.rb +76 -0
  8. data/lib/solid_queue_tui/application.rb +468 -0
  9. data/lib/solid_queue_tui/cli.rb +48 -0
  10. data/lib/solid_queue_tui/components/header.rb +105 -0
  11. data/lib/solid_queue_tui/components/help_bar.rb +77 -0
  12. data/lib/solid_queue_tui/components/job_table.rb +122 -0
  13. data/lib/solid_queue_tui/connection.rb +58 -0
  14. data/lib/solid_queue_tui/data/failed_query.rb +118 -0
  15. data/lib/solid_queue_tui/data/jobs_query.rb +178 -0
  16. data/lib/solid_queue_tui/data/processes_query.rb +75 -0
  17. data/lib/solid_queue_tui/data/queues_query.rb +36 -0
  18. data/lib/solid_queue_tui/data/stats.rb +65 -0
  19. data/lib/solid_queue_tui/dev_reloader.rb +53 -0
  20. data/lib/solid_queue_tui/version.rb +5 -0
  21. data/lib/solid_queue_tui/views/blocked_view.rb +121 -0
  22. data/lib/solid_queue_tui/views/dashboard_view.rb +187 -0
  23. data/lib/solid_queue_tui/views/failed_view.rb +298 -0
  24. data/lib/solid_queue_tui/views/finished_view.rb +216 -0
  25. data/lib/solid_queue_tui/views/in_progress_view.rb +114 -0
  26. data/lib/solid_queue_tui/views/job_detail_view.rb +236 -0
  27. data/lib/solid_queue_tui/views/processes_view.rb +142 -0
  28. data/lib/solid_queue_tui/views/queues_view.rb +96 -0
  29. data/lib/solid_queue_tui/views/scheduled_view.rb +215 -0
  30. data/lib/solid_queue_tui.rb +46 -0
  31. metadata +157 -0
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ class DashboardView
6
+ def initialize(tui)
7
+ @tui = tui
8
+ @selected_row = 0
9
+ end
10
+
11
+ def update(stats:)
12
+ @stats = stats
13
+ end
14
+
15
+ def render(frame, area)
16
+ top, bottom = @tui.layout_split(
17
+ area,
18
+ direction: :vertical,
19
+ constraints: [
20
+ @tui.constraint_length(7),
21
+ @tui.constraint_fill(1)
22
+ ]
23
+ )
24
+
25
+ render_overview_panels(frame, top)
26
+ render_completion(frame, bottom)
27
+ end
28
+
29
+ def handle_input(event)
30
+ nil
31
+ end
32
+
33
+ def selected_item
34
+ nil
35
+ end
36
+
37
+ def bindings
38
+ [
39
+ { key: "Tab", action: "Next View" }
40
+ ]
41
+ end
42
+
43
+ def breadcrumb = "dashboard"
44
+
45
+ private
46
+
47
+ def render_overview_panels(frame, area)
48
+ return unless @stats
49
+
50
+ left, right = @tui.layout_split(
51
+ area,
52
+ direction: :horizontal,
53
+ constraints: [
54
+ @tui.constraint_percentage(50),
55
+ @tui.constraint_percentage(50)
56
+ ]
57
+ )
58
+
59
+ render_job_status_panel(frame, left)
60
+ render_process_panel(frame, right)
61
+ end
62
+
63
+ def render_job_status_panel(frame, area)
64
+ lines = [
65
+ status_line("Ready", @stats.ready, :green),
66
+ status_line("In Progress", @stats.claimed, :yellow),
67
+ status_line("Scheduled", @stats.scheduled, :blue),
68
+ status_line("Failed", @stats.failed, :red),
69
+ status_line("Blocked", @stats.blocked, :magenta)
70
+ ].compact
71
+
72
+ frame.render_widget(
73
+ @tui.paragraph(
74
+ text: lines,
75
+ block: @tui.block(
76
+ title: " Job Status ",
77
+ title_style: @tui.style(fg: :cyan, modifiers: [:bold]),
78
+ borders: [:all],
79
+ border_type: :rounded,
80
+ border_style: @tui.style(fg: :dark_gray)
81
+ )
82
+ ),
83
+ area
84
+ )
85
+ end
86
+
87
+ def render_process_panel(frame, area)
88
+ lines = if @stats.processes_by_kind.empty?
89
+ [@tui.text_line(spans: [
90
+ @tui.text_span(content: " No active processes", style: @tui.style(fg: :dark_gray))
91
+ ])]
92
+ else
93
+ @stats.processes_by_kind.map do |kind, count|
94
+ color = case kind
95
+ when "Worker" then :green
96
+ when "Dispatcher" then :yellow
97
+ when "Scheduler" then :blue
98
+ else :white
99
+ end
100
+
101
+ @tui.text_line(spans: [
102
+ @tui.text_span(content: " #{kind.ljust(18)}", style: @tui.style(fg: color)),
103
+ @tui.text_span(content: count.to_s.rjust(6), style: @tui.style(fg: :cyan, modifiers: [:bold]))
104
+ ])
105
+ end
106
+ end
107
+
108
+ total_line = @tui.text_line(spans: [
109
+ @tui.text_span(content: " Total", style: @tui.style(fg: :dark_gray)),
110
+ @tui.text_span(content: @stats.process_count.to_s.rjust(19), style: @tui.style(fg: :white, modifiers: [:bold]))
111
+ ])
112
+ lines << total_line
113
+
114
+ frame.render_widget(
115
+ @tui.paragraph(
116
+ text: lines,
117
+ block: @tui.block(
118
+ title: " Processes ",
119
+ title_style: @tui.style(fg: :cyan, modifiers: [:bold]),
120
+ borders: [:all],
121
+ border_type: :rounded,
122
+ border_style: @tui.style(fg: :dark_gray)
123
+ )
124
+ ),
125
+ area
126
+ )
127
+ end
128
+
129
+ def render_completion(frame, area)
130
+ return unless @stats
131
+
132
+ lines = [
133
+ @tui.text_line(spans: [
134
+ @tui.text_span(content: " Total: ", style: @tui.style(fg: :dark_gray)),
135
+ @tui.text_span(content: format_number(@stats.total_jobs), style: @tui.style(fg: :white, modifiers: [:bold])),
136
+ @tui.text_span(content: " Completed: ", style: @tui.style(fg: :dark_gray)),
137
+ @tui.text_span(content: format_number(@stats.completed_jobs), style: @tui.style(fg: :green))
138
+ ])
139
+ ]
140
+
141
+ if @stats.total_jobs > 0
142
+ completed_ratio = @stats.completed_jobs.to_f / @stats.total_jobs
143
+ bar_width = 40
144
+ filled = (completed_ratio * bar_width).round
145
+ empty = bar_width - filled
146
+
147
+ lines << @tui.text_line(spans: [
148
+ @tui.text_span(content: " Completion: ", style: @tui.style(fg: :dark_gray)),
149
+ @tui.text_span(content: "#{'█' * filled}#{'░' * empty}", style: @tui.style(fg: :green)),
150
+ @tui.text_span(content: " #{(completed_ratio * 100).round(1)}%", style: @tui.style(fg: :white))
151
+ ])
152
+ end
153
+
154
+ frame.render_widget(
155
+ @tui.paragraph(
156
+ text: lines,
157
+ block: @tui.block(
158
+ title: " Overview ",
159
+ title_style: @tui.style(fg: :cyan, modifiers: [:bold]),
160
+ borders: [:all],
161
+ border_type: :rounded,
162
+ border_style: @tui.style(fg: :dark_gray)
163
+ )
164
+ ),
165
+ area
166
+ )
167
+ end
168
+
169
+ def status_line(label, value, color)
170
+ bar_char = value.to_i > 0 ? "●" : "○"
171
+ @tui.text_line(spans: [
172
+ @tui.text_span(content: " #{bar_char} ", style: @tui.style(fg: color)),
173
+ @tui.text_span(content: label.ljust(14), style: @tui.style(fg: :white)),
174
+ @tui.text_span(
175
+ content: format_number(value).rjust(8),
176
+ style: @tui.style(fg: color, modifiers: [:bold])
177
+ )
178
+ ])
179
+ end
180
+
181
+ def format_number(n)
182
+ return "0" if n.nil? || n == 0
183
+ n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ class FailedView
6
+ def initialize(tui)
7
+ @tui = tui
8
+ @table_state = RatatuiRuby::TableState.new(nil)
9
+ @table_state.select(0)
10
+ @selected_row = 0
11
+ @failed_jobs = []
12
+ @filter = nil
13
+ @filter_mode = false
14
+ @filter_input = ""
15
+ @confirm_action = nil
16
+ end
17
+
18
+ def update(failed_jobs:)
19
+ @failed_jobs = failed_jobs
20
+ @selected_row = @selected_row.clamp(0, [@failed_jobs.size - 1, 0].max)
21
+ @table_state.select(@selected_row)
22
+ end
23
+
24
+ def render(frame, area)
25
+ if @confirm_action
26
+ content_area, confirm_area = @tui.layout_split(
27
+ area,
28
+ direction: :vertical,
29
+ constraints: [
30
+ @tui.constraint_fill(1),
31
+ @tui.constraint_length(3)
32
+ ]
33
+ )
34
+ render_failed_table(frame, content_area)
35
+ render_confirm(frame, confirm_area)
36
+ elsif @filter_mode
37
+ content_area, filter_area = @tui.layout_split(
38
+ area,
39
+ direction: :vertical,
40
+ constraints: [
41
+ @tui.constraint_fill(1),
42
+ @tui.constraint_length(3)
43
+ ]
44
+ )
45
+ render_failed_table(frame, content_area)
46
+ render_filter_input(frame, filter_area)
47
+ else
48
+ render_failed_table(frame, area)
49
+ end
50
+ end
51
+
52
+ def handle_input(event)
53
+ if @confirm_action
54
+ handle_confirm_input(event)
55
+ elsif @filter_mode
56
+ handle_filter_input(event)
57
+ else
58
+ handle_normal_input(event)
59
+ end
60
+ end
61
+
62
+ def selected_item
63
+ return nil if @failed_jobs.empty? || @selected_row >= @failed_jobs.size
64
+ @failed_jobs[@selected_row]
65
+ end
66
+
67
+ def filter = @filter
68
+
69
+ def bindings
70
+ if @confirm_action
71
+ [
72
+ { key: "y", action: "Confirm" },
73
+ { key: "n/Esc", action: "Cancel" }
74
+ ]
75
+ elsif @filter_mode
76
+ [
77
+ { key: "Enter", action: "Apply" },
78
+ { key: "Esc", action: "Cancel" }
79
+ ]
80
+ else
81
+ [
82
+ { key: "j/k", action: "Navigate" },
83
+ { key: "Enter", action: "Detail" },
84
+ { key: "R", action: "Retry" },
85
+ { key: "D", action: "Discard" },
86
+ { key: "A", action: "Retry All" },
87
+ { key: "/", action: "Filter" }
88
+ ]
89
+ end
90
+ end
91
+
92
+ def capturing_input?
93
+ @filter_mode || @confirm_action
94
+ end
95
+
96
+ def breadcrumb
97
+ @filter ? "failed:#{@filter}" : "failed"
98
+ end
99
+
100
+ private
101
+
102
+ def handle_normal_input(event)
103
+ case event
104
+ in { type: :key, code: "j" } | { type: :key, code: "up" }
105
+ move_selection(-1)
106
+ in { type: :key, code: "k" } | { type: :key, code: "down" }
107
+ move_selection(1)
108
+ in { type: :key, code: "g" }
109
+ jump_to_top
110
+ in { type: :key, code: "G" }
111
+ jump_to_bottom
112
+ in { type: :key, code: "R" }
113
+ @confirm_action = :retry if selected_item
114
+ nil
115
+ in { type: :key, code: "D" }
116
+ @confirm_action = :discard if selected_item
117
+ nil
118
+ in { type: :key, code: "A" }
119
+ @confirm_action = :retry_all unless @failed_jobs.empty?
120
+ nil
121
+ in { type: :key, code: "/" }
122
+ @filter_mode = true
123
+ @filter_input = @filter || ""
124
+ nil
125
+ in { type: :key, code: "esc" }
126
+ @filter = nil
127
+ @filter_input = ""
128
+ :refresh
129
+ else
130
+ nil
131
+ end
132
+ end
133
+
134
+ def handle_confirm_input(event)
135
+ case event
136
+ in { type: :key, code: "y" }
137
+ action = @confirm_action
138
+ @confirm_action = nil
139
+ case action
140
+ when :retry
141
+ item = selected_item
142
+ return nil unless item
143
+ Actions::RetryJob.call(item.id)
144
+ :refresh
145
+ when :discard
146
+ item = selected_item
147
+ return nil unless item
148
+ Actions::DiscardJob.call(item.id)
149
+ :refresh
150
+ when :retry_all
151
+ Actions::RetryJob.retry_all
152
+ :refresh
153
+ end
154
+ in { type: :key, code: "n" } | { type: :key, code: "esc" }
155
+ @confirm_action = nil
156
+ nil
157
+ else
158
+ nil
159
+ end
160
+ end
161
+
162
+ def handle_filter_input(event)
163
+ case event
164
+ in { type: :key, code: "enter" }
165
+ @filter = @filter_input.empty? ? nil : @filter_input
166
+ @filter_mode = false
167
+ @selected_row = 0
168
+ @table_state.select(0)
169
+ :refresh
170
+ in { type: :key, code: "esc" }
171
+ @filter_mode = false
172
+ nil
173
+ in { type: :key, code: "backspace" }
174
+ @filter_input = @filter_input[0...-1]
175
+ nil
176
+ in { type: :key, code: /\A.\z/ => char }
177
+ @filter_input += char
178
+ nil
179
+ else
180
+ nil
181
+ end
182
+ end
183
+
184
+ def move_selection(delta)
185
+ return if @failed_jobs.empty?
186
+ @selected_row = (@selected_row + delta).clamp(0, @failed_jobs.size - 1)
187
+ @table_state.select(@selected_row)
188
+ end
189
+
190
+ def jump_to_top
191
+ @selected_row = 0
192
+ @table_state.select(0)
193
+ end
194
+
195
+ def jump_to_bottom
196
+ return if @failed_jobs.empty?
197
+ @selected_row = @failed_jobs.size - 1
198
+ @table_state.select(@selected_row)
199
+ end
200
+
201
+ def render_failed_table(frame, area)
202
+ columns = [
203
+ { key: :id, label: "ID", width: 8 },
204
+ { key: :class_name, label: "JOB CLASS", width: :fill },
205
+ { key: :queue_name, label: "QUEUE", width: 14 },
206
+ { key: :error_class, label: "ERROR CLASS", width: :fill },
207
+ { key: :error_message, label: "MESSAGE", width: :fill },
208
+ { key: :failed_at, label: "FAILED", width: 12 }
209
+ ]
210
+
211
+ rows = @failed_jobs.map do |job|
212
+ {
213
+ id: job.job_id,
214
+ class_name: job.class_name,
215
+ queue_name: job.queue_name,
216
+ error_class: job.error_class,
217
+ error_message: truncate(job.error_message, 40),
218
+ failed_at: time_ago(job.failed_at)
219
+ }
220
+ end
221
+
222
+ title = @filter ? "Failed Jobs (filter: #{@filter})" : "Failed Jobs"
223
+
224
+ table = Components::JobTable.new(
225
+ @tui,
226
+ title: title,
227
+ columns: columns,
228
+ rows: rows,
229
+ selected_row: @selected_row,
230
+ empty_message: "No failed jobs — everything is running smoothly!"
231
+ )
232
+
233
+ table.render(frame, area, @table_state)
234
+ end
235
+
236
+ def render_confirm(frame, area)
237
+ message = case @confirm_action
238
+ when :retry
239
+ job = selected_item
240
+ "Retry job ##{job&.job_id} (#{job&.class_name})? [y/n]"
241
+ when :discard
242
+ job = selected_item
243
+ "Discard job ##{job&.job_id} (#{job&.class_name})? This cannot be undone. [y/n]"
244
+ when :retry_all
245
+ "Retry ALL #{@failed_jobs.size} failed jobs? [y/n]"
246
+ end
247
+
248
+ frame.render_widget(
249
+ @tui.paragraph(
250
+ text: " #{message}",
251
+ style: @tui.style(fg: :yellow, modifiers: [:bold]),
252
+ block: @tui.block(
253
+ title: " Confirm ",
254
+ title_style: @tui.style(fg: :red, modifiers: [:bold]),
255
+ borders: [:all],
256
+ border_type: :rounded,
257
+ border_style: @tui.style(fg: :red)
258
+ )
259
+ ),
260
+ area
261
+ )
262
+ end
263
+
264
+ def render_filter_input(frame, area)
265
+ frame.render_widget(
266
+ @tui.paragraph(
267
+ text: @filter_input + "█",
268
+ style: @tui.style(fg: :white),
269
+ block: @tui.block(
270
+ title: " Filter by class name ",
271
+ title_style: @tui.style(fg: :yellow),
272
+ borders: [:all],
273
+ border_type: :rounded,
274
+ border_style: @tui.style(fg: :cyan)
275
+ )
276
+ ),
277
+ area
278
+ )
279
+ end
280
+
281
+ def truncate(str, max)
282
+ return "" unless str
283
+ str.length > max ? "#{str[0...max - 3]}..." : str
284
+ end
285
+
286
+ def time_ago(time)
287
+ return "n/a" unless time
288
+ seconds = (Time.now.utc - time).to_i
289
+ case seconds
290
+ when 0..59 then "#{seconds}s ago"
291
+ when 60..3599 then "#{seconds / 60}m ago"
292
+ when 3600..86399 then "#{seconds / 3600}h ago"
293
+ else "#{seconds / 86400}d ago"
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ class FinishedView
6
+ def initialize(tui)
7
+ @tui = tui
8
+ @table_state = RatatuiRuby::TableState.new(nil)
9
+ @table_state.select(0)
10
+ @selected_row = 0
11
+ @jobs = []
12
+ @filter = nil
13
+ @filter_mode = false
14
+ @filter_input = ""
15
+ end
16
+
17
+ def update(jobs:)
18
+ @jobs = jobs
19
+ @selected_row = @selected_row.clamp(0, [@jobs.size - 1, 0].max)
20
+ @table_state.select(@selected_row)
21
+ end
22
+
23
+ def render(frame, area)
24
+ if @filter_mode
25
+ content_area, filter_area = @tui.layout_split(
26
+ area,
27
+ direction: :vertical,
28
+ constraints: [
29
+ @tui.constraint_fill(1),
30
+ @tui.constraint_length(3)
31
+ ]
32
+ )
33
+ render_table(frame, content_area)
34
+ render_filter_input(frame, filter_area)
35
+ else
36
+ render_table(frame, area)
37
+ end
38
+ end
39
+
40
+ def handle_input(event)
41
+ if @filter_mode
42
+ handle_filter_input(event)
43
+ else
44
+ handle_normal_input(event)
45
+ end
46
+ end
47
+
48
+ def selected_item
49
+ return nil if @jobs.empty? || @selected_row >= @jobs.size
50
+ @jobs[@selected_row]
51
+ end
52
+
53
+ def filter = @filter
54
+
55
+ def bindings
56
+ if @filter_mode
57
+ [
58
+ { key: "Enter", action: "Apply" },
59
+ { key: "Esc", action: "Cancel" }
60
+ ]
61
+ else
62
+ [
63
+ { key: "j/k", action: "Navigate" },
64
+ { key: "Enter", action: "Detail" },
65
+ { key: "/", action: "Filter" },
66
+ { key: "Esc", action: "Clear Filter" },
67
+ { key: "G/g", action: "Bottom/Top" }
68
+ ]
69
+ end
70
+ end
71
+
72
+ def capturing_input?
73
+ @filter_mode
74
+ end
75
+
76
+ def breadcrumb
77
+ @filter ? "finished:#{@filter}" : "finished"
78
+ end
79
+
80
+ private
81
+
82
+ def handle_normal_input(event)
83
+ case event
84
+ in { type: :key, code: "j" } | { type: :key, code: "up" }
85
+ move_selection(-1)
86
+ in { type: :key, code: "k" } | { type: :key, code: "down" }
87
+ move_selection(1)
88
+ in { type: :key, code: "g" }
89
+ jump_to_top
90
+ in { type: :key, code: "G" }
91
+ jump_to_bottom
92
+ in { type: :key, code: "/" }
93
+ @filter_mode = true
94
+ @filter_input = @filter || ""
95
+ in { type: :key, code: "esc" }
96
+ @filter = nil
97
+ @filter_input = ""
98
+ :refresh
99
+ else
100
+ nil
101
+ end
102
+ end
103
+
104
+ def handle_filter_input(event)
105
+ case event
106
+ in { type: :key, code: "enter" }
107
+ @filter = @filter_input.empty? ? nil : @filter_input
108
+ @filter_mode = false
109
+ @selected_row = 0
110
+ @table_state.select(0)
111
+ :refresh
112
+ in { type: :key, code: "esc" }
113
+ @filter_mode = false
114
+ @filter_input = @filter || ""
115
+ nil
116
+ in { type: :key, code: "backspace" }
117
+ @filter_input = @filter_input[0...-1]
118
+ nil
119
+ in { type: :key, code: /\A.\z/ => char }
120
+ @filter_input += char
121
+ nil
122
+ else
123
+ nil
124
+ end
125
+ end
126
+
127
+ def move_selection(delta)
128
+ return if @jobs.empty?
129
+ @selected_row = (@selected_row + delta).clamp(0, @jobs.size - 1)
130
+ @table_state.select(@selected_row)
131
+ end
132
+
133
+ def jump_to_top
134
+ @selected_row = 0
135
+ @table_state.select(0)
136
+ end
137
+
138
+ def jump_to_bottom
139
+ return if @jobs.empty?
140
+ @selected_row = @jobs.size - 1
141
+ @table_state.select(@selected_row)
142
+ end
143
+
144
+ def render_table(frame, area)
145
+ columns = [
146
+ { key: :id, label: "ID", width: 8 },
147
+ { key: :queue_name, label: "QUEUE", width: 14 },
148
+ { key: :class_name, label: "CLASS", width: :fill },
149
+ { key: :priority, label: "PRI", width: 5 },
150
+ { key: :finished_at, label: "FINISHED AT", width: 20 },
151
+ { key: :duration, label: "DURATION", width: 12 }
152
+ ]
153
+
154
+ rows = @jobs.map do |job|
155
+ {
156
+ id: job.id,
157
+ queue_name: job.queue_name,
158
+ class_name: job.class_name,
159
+ priority: job.priority,
160
+ finished_at: format_time(job.finished_at),
161
+ duration: format_duration(job.created_at, job.finished_at)
162
+ }
163
+ end
164
+
165
+ title = @filter ? "Finished (filter: #{@filter})" : "Finished"
166
+
167
+ table = Components::JobTable.new(
168
+ @tui,
169
+ title: title,
170
+ columns: columns,
171
+ rows: rows,
172
+ selected_row: @selected_row,
173
+ empty_message: @filter ? "No finished jobs matching '#{@filter}'" : "No finished jobs"
174
+ )
175
+
176
+ table.render(frame, area, @table_state)
177
+ end
178
+
179
+ def render_filter_input(frame, area)
180
+ frame.render_widget(
181
+ @tui.paragraph(
182
+ text: @filter_input + "\u2588",
183
+ style: @tui.style(fg: :white),
184
+ block: @tui.block(
185
+ title: " Filter by class name ",
186
+ title_style: @tui.style(fg: :yellow),
187
+ borders: [:all],
188
+ border_type: :rounded,
189
+ border_style: @tui.style(fg: :cyan)
190
+ )
191
+ ),
192
+ area
193
+ )
194
+ end
195
+
196
+ def format_time(time)
197
+ return "n/a" unless time
198
+ time.strftime("%Y-%m-%d %H:%M:%S")
199
+ end
200
+
201
+ def format_duration(created, finished)
202
+ return "n/a" unless created && finished
203
+ seconds = (finished - created).to_i
204
+ if seconds < 1
205
+ "<1s"
206
+ elsif seconds < 60
207
+ "#{seconds}s"
208
+ elsif seconds < 3600
209
+ "#{seconds / 60}m #{seconds % 60}s"
210
+ else
211
+ "#{seconds / 3600}h #{(seconds % 3600) / 60}m"
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end