flightdeck 0.5.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/MIT-LICENSE +21 -0
- data/README.md +190 -0
- data/app/assets/flightdeck/flightdeck-00403457c5d9.css +3 -0
- data/app/assets/flightdeck/flightdeck-c02e38040ddb.js +414 -0
- data/app/assets/flightdeck/manifest.json +16 -0
- data/app/controllers/concerns/flightdeck/toasts.rb +25 -0
- data/app/controllers/flightdeck/application_controller.rb +96 -0
- data/app/controllers/flightdeck/assets_controller.rb +39 -0
- data/app/controllers/flightdeck/jobs/actions_controller.rb +167 -0
- data/app/controllers/flightdeck/jobs/discards_controller.rb +44 -0
- data/app/controllers/flightdeck/jobs/retries_controller.rb +23 -0
- data/app/controllers/flightdeck/jobs_controller.rb +39 -0
- data/app/controllers/flightdeck/overview_controller.rb +11 -0
- data/app/controllers/flightdeck/processes_controller.rb +49 -0
- data/app/controllers/flightdeck/queues_controller.rb +51 -0
- data/app/controllers/flightdeck/recurring_tasks_controller.rb +46 -0
- data/app/helpers/flightdeck/application_helper.rb +68 -0
- data/app/helpers/flightdeck/infrastructure_helper.rb +24 -0
- data/app/helpers/flightdeck/jobs_helper.rb +136 -0
- data/app/helpers/flightdeck/metrics_helper.rb +49 -0
- data/app/models/flightdeck/arguments_preview.rb +95 -0
- data/app/models/flightdeck/bulk_action.rb +83 -0
- data/app/models/flightdeck/cache.rb +51 -0
- data/app/models/flightdeck/cron_schedule.rb +102 -0
- data/app/models/flightdeck/error_summary.rb +64 -0
- data/app/models/flightdeck/grouped_failures.rb +24 -0
- data/app/models/flightdeck/job_detail.rb +163 -0
- data/app/models/flightdeck/job_row.rb +93 -0
- data/app/models/flightdeck/jobs_query.rb +241 -0
- data/app/models/flightdeck/metrics/bar_chart.rb +49 -0
- data/app/models/flightdeck/metrics/chart.rb +111 -0
- data/app/models/flightdeck/metrics/line_chart.rb +67 -0
- data/app/models/flightdeck/metrics/series.rb +165 -0
- data/app/models/flightdeck/metrics/sparkline.rb +45 -0
- data/app/models/flightdeck/metrics/time_bucket.rb +95 -0
- data/app/models/flightdeck/overview.rb +217 -0
- data/app/models/flightdeck/process_registry.rb +108 -0
- data/app/models/flightdeck/queue_stats.rb +88 -0
- data/app/models/flightdeck/recurring_catalog.rb +116 -0
- data/app/views/flightdeck/jobs/_failed_row.html.erb +33 -0
- data/app/views/flightdeck/jobs/_filter_bar.html.erb +29 -0
- data/app/views/flightdeck/jobs/_list.html.erb +89 -0
- data/app/views/flightdeck/jobs/_pager.html.erb +22 -0
- data/app/views/flightdeck/jobs/_row.html.erb +23 -0
- data/app/views/flightdeck/jobs/_state_tabs.html.erb +10 -0
- data/app/views/flightdeck/jobs/actions/create.turbo_stream.erb +18 -0
- data/app/views/flightdeck/jobs/index.html.erb +13 -0
- data/app/views/flightdeck/jobs/show.html.erb +100 -0
- data/app/views/flightdeck/metrics/_bar_chart.html.erb +34 -0
- data/app/views/flightdeck/metrics/_line_chart.html.erb +38 -0
- data/app/views/flightdeck/metrics/_range_control.html.erb +7 -0
- data/app/views/flightdeck/metrics/_sparkline.html.erb +9 -0
- data/app/views/flightdeck/overview/_completion.html.erb +19 -0
- data/app/views/flightdeck/overview/_failures.html.erb +28 -0
- data/app/views/flightdeck/overview/_fleet.html.erb +21 -0
- data/app/views/flightdeck/overview/_queues.html.erb +41 -0
- data/app/views/flightdeck/overview/_throughput.html.erb +23 -0
- data/app/views/flightdeck/overview/_tiles.html.erb +14 -0
- data/app/views/flightdeck/overview/index.html.erb +54 -0
- data/app/views/flightdeck/processes/_fleet.html.erb +54 -0
- data/app/views/flightdeck/processes/_row.html.erb +28 -0
- data/app/views/flightdeck/processes/index.html.erb +9 -0
- data/app/views/flightdeck/processes/prune.turbo_stream.erb +15 -0
- data/app/views/flightdeck/queues/_cards.html.erb +46 -0
- data/app/views/flightdeck/queues/index.html.erb +9 -0
- data/app/views/flightdeck/queues/update.turbo_stream.erb +15 -0
- data/app/views/flightdeck/recurring_tasks/_table.html.erb +53 -0
- data/app/views/flightdeck/recurring_tasks/index.html.erb +9 -0
- data/app/views/flightdeck/recurring_tasks/run.turbo_stream.erb +15 -0
- data/app/views/flightdeck/shared/_toast.html.erb +7 -0
- data/app/views/layouts/flightdeck/application.html.erb +110 -0
- data/config/routes.rb +40 -0
- data/lib/flightdeck/assets.rb +78 -0
- data/lib/flightdeck/configuration.rb +80 -0
- data/lib/flightdeck/engine.rb +31 -0
- data/lib/flightdeck/version.rb +5 -0
- data/lib/flightdeck.rb +31 -0
- metadata +150 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
# One row per queue, assembled from four grouped queries rather than the
|
|
5
|
+
# per-queue lookups SolidQueue::Queue#size / #latency would do — those are an
|
|
6
|
+
# N+1 as soon as an application has more than a handful of queues.
|
|
7
|
+
#
|
|
8
|
+
# Pausing and resuming still go through SolidQueue::Queue, which owns the
|
|
9
|
+
# semantics; Flightdeck never writes to solid_queue_pauses itself.
|
|
10
|
+
class QueueStats
|
|
11
|
+
RATE_WINDOW = 1.hour
|
|
12
|
+
|
|
13
|
+
Row = Struct.new(:name, :depth, :oldest_ready_at, :paused_at, :completed_in_window,
|
|
14
|
+
keyword_init: true) do
|
|
15
|
+
def paused? = paused_at.present?
|
|
16
|
+
|
|
17
|
+
# Latency only means something when something is actually waiting: an
|
|
18
|
+
# empty queue has no oldest job, and reporting "0s" would imply we
|
|
19
|
+
# measured rather than that there was nothing to measure.
|
|
20
|
+
def latency
|
|
21
|
+
return nil if depth.zero? || oldest_ready_at.nil?
|
|
22
|
+
|
|
23
|
+
Time.current - oldest_ready_at
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def paused_for
|
|
27
|
+
paused_at && Time.current - paused_at
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rate_per_hour = completed_in_window
|
|
31
|
+
def idle? = depth.zero? && completed_in_window.zero?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.all = new.rows
|
|
35
|
+
|
|
36
|
+
def rows
|
|
37
|
+
@rows ||= names.sort.map do |name|
|
|
38
|
+
Row.new(
|
|
39
|
+
name: name,
|
|
40
|
+
depth: depths.fetch(name, 0),
|
|
41
|
+
oldest_ready_at: oldest_ready.fetch(name, nil),
|
|
42
|
+
paused_at: pauses.fetch(name, nil),
|
|
43
|
+
completed_in_window: completions.fetch(name, 0)
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find(name)
|
|
49
|
+
rows.find { |row| row.name == name }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def any_paused? = rows.any?(&:paused?)
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
# A queue is worth showing if Solid Queue knows about it, if something is
|
|
56
|
+
# waiting on it, or if it is paused — a paused queue with nothing in it is
|
|
57
|
+
# exactly the one an operator needs to be reminded of.
|
|
58
|
+
def names
|
|
59
|
+
(queue_names + depths.keys + pauses.keys + completions.keys).compact.uniq
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def queue_names
|
|
63
|
+
SolidQueue::Queue.all.map(&:name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def depths
|
|
67
|
+
@depths ||= SolidQueue::ReadyExecution.group(:queue_name).count
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def oldest_ready
|
|
71
|
+
@oldest_ready ||= SolidQueue::ReadyExecution.group(:queue_name).minimum(:created_at)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pauses
|
|
75
|
+
@pauses ||= SolidQueue::Pause.pluck(:queue_name, :created_at).to_h
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Bounded by the window rather than by a row cap: a grouped count cannot
|
|
79
|
+
# be capped per group the way a single count can, and an hour of finished
|
|
80
|
+
# jobs is already a bounded, index-backed range scan.
|
|
81
|
+
def completions
|
|
82
|
+
@completions ||= SolidQueue::Job
|
|
83
|
+
.where(finished_at: RATE_WINDOW.ago..)
|
|
84
|
+
.group(:queue_name)
|
|
85
|
+
.count
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
# The recurring schedule: every task, when it last ran, how that run went, and
|
|
5
|
+
# when it is due next.
|
|
6
|
+
#
|
|
7
|
+
# "Next run" comes from the task's own Fugit-backed API rather than from a
|
|
8
|
+
# calculation of ours, so Flightdeck and the scheduler can never disagree
|
|
9
|
+
# about when something is due.
|
|
10
|
+
class RecurringCatalog
|
|
11
|
+
Row = Struct.new(:task, :last_run_at, :last_status, :last_job_id, keyword_init: true) do
|
|
12
|
+
def id = task.id
|
|
13
|
+
def key = task.key
|
|
14
|
+
def schedule = task.schedule
|
|
15
|
+
def queue_name = task.queue_name
|
|
16
|
+
def priority = task.priority
|
|
17
|
+
def description = task.description
|
|
18
|
+
def static? = task.static?
|
|
19
|
+
|
|
20
|
+
def job_class
|
|
21
|
+
task.class_name.presence || task.command.presence || "(command)"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def human_schedule
|
|
25
|
+
CronSchedule.humanize(schedule)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Fugit raises on schedules it cannot parse. A task that is on screen but
|
|
29
|
+
# unparseable should show "—" rather than take the page down.
|
|
30
|
+
def next_run_at
|
|
31
|
+
@next_run_at ||= task.next_time
|
|
32
|
+
rescue StandardError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def next_run_in
|
|
37
|
+
next_run_at && next_run_at - Time.current
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failed? = last_status == :failed
|
|
41
|
+
def ever_run? = last_run_at.present?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.all = new.rows
|
|
45
|
+
|
|
46
|
+
def rows
|
|
47
|
+
@rows ||= tasks.map do |task|
|
|
48
|
+
run = last_runs[task.key]
|
|
49
|
+
|
|
50
|
+
Row.new(
|
|
51
|
+
task: task,
|
|
52
|
+
last_run_at: run&.first,
|
|
53
|
+
last_job_id: run&.last,
|
|
54
|
+
last_status: status_for(run&.last)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find(id)
|
|
60
|
+
rows.find { |row| row.id == id.to_i }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def any? = rows.any?
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
def tasks
|
|
67
|
+
@tasks ||= SolidQueue::RecurringTask.order(:key).to_a
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def keys = tasks.map(&:key)
|
|
71
|
+
|
|
72
|
+
# [run_at, job_id] of the most recent execution of each task, in two
|
|
73
|
+
# queries: the maximum run_at per key, then the rows that match them.
|
|
74
|
+
def last_runs
|
|
75
|
+
@last_runs ||= compute_last_runs
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def compute_last_runs
|
|
79
|
+
return {} if keys.empty?
|
|
80
|
+
|
|
81
|
+
maxima = SolidQueue::RecurringExecution.where(task_key: keys).group(:task_key).maximum(:run_at)
|
|
82
|
+
return {} if maxima.empty?
|
|
83
|
+
|
|
84
|
+
SolidQueue::RecurringExecution
|
|
85
|
+
.where(task_key: maxima.keys, run_at: maxima.values)
|
|
86
|
+
.pluck(:task_key, :run_at, :job_id)
|
|
87
|
+
.each_with_object({}) do |(task_key, run_at, job_id), found|
|
|
88
|
+
next unless maxima[task_key] == run_at
|
|
89
|
+
|
|
90
|
+
found[task_key] = [ run_at, job_id ]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def status_for(job_id)
|
|
95
|
+
return nil if job_id.nil?
|
|
96
|
+
# The job has been purged by clear_finished_jobs_after: we know it ran,
|
|
97
|
+
# but not how it went. Say so rather than claim success.
|
|
98
|
+
return :unknown unless surviving_job_ids.include?(job_id)
|
|
99
|
+
|
|
100
|
+
failed_job_ids.include?(job_id) ? :failed : :ok
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def last_job_ids
|
|
104
|
+
@last_job_ids ||= last_runs.values.filter_map(&:last)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def surviving_job_ids
|
|
108
|
+
@surviving_job_ids ||= last_job_ids.empty? ? Set.new : SolidQueue::Job.where(id: last_job_ids).pluck(:id).to_set
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def failed_job_ids
|
|
112
|
+
@failed_job_ids ||=
|
|
113
|
+
last_job_ids.empty? ? Set.new : SolidQueue::FailedExecution.where(job_id: last_job_ids).pluck(:job_id).to_set
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<tr id="<%= dom_id_for_job(row.id) %>">
|
|
2
|
+
<td>
|
|
3
|
+
<input type="checkbox" name="job_ids[]" value="<%= row.id %>"
|
|
4
|
+
data-selection-target="row" data-action="selection#refresh"
|
|
5
|
+
data-group="<%= group_key %>"
|
|
6
|
+
aria-label="Select <%= row.class_name %> #<%= row.id %>">
|
|
7
|
+
</td>
|
|
8
|
+
<td class="cls">
|
|
9
|
+
<%= link_to row.class_name, job_path(row.id, state: :failed),
|
|
10
|
+
data: { turbo_frame: "_top" } %>
|
|
11
|
+
<div class="args"><%= fd_args_preview(row.args_preview) %></div>
|
|
12
|
+
</td>
|
|
13
|
+
<%# Inside a group the header already names the exception class, so the row
|
|
14
|
+
shows only the message. %>
|
|
15
|
+
<td class="args" style="max-width:260px" title="<%= row.error_summary.to_s %>">
|
|
16
|
+
<%= fd_error_cell(row.error_summary, grouped: group_key.present?) %>
|
|
17
|
+
</td>
|
|
18
|
+
<td><%= fd_queue_badge(row.queue_name) %></td>
|
|
19
|
+
<td class="num"><%= row.attempts || "—" %></td>
|
|
20
|
+
<td class="num dim"><%= row.failed_at ? fd_ago(row.failed_at) : "—" %></td>
|
|
21
|
+
<td class="num">
|
|
22
|
+
<%= link_to "Retry", retry_job_path(row.id, list_filters.merge(state: :failed)),
|
|
23
|
+
class: "fd-btn sm", data: {
|
|
24
|
+
turbo_method: :post,
|
|
25
|
+
turbo_confirm: "Retry #{row.class_name} ##{row.id}?"
|
|
26
|
+
} %>
|
|
27
|
+
<%= link_to "Discard", discard_job_path(row.id, list_filters.merge(state: :failed)),
|
|
28
|
+
class: "fd-btn sm danger", data: {
|
|
29
|
+
turbo_method: :post,
|
|
30
|
+
turbo_confirm: "Permanently discard #{row.class_name} ##{row.id}? This cannot be undone."
|
|
31
|
+
} %>
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<%= form_with url: jobs_path, method: :get, class: "fd-filterbar", data: { turbo_action: "advance" } do |form| %>
|
|
2
|
+
<%= form.hidden_field :state, value: params[:state], id: "fd-filter-state" %>
|
|
3
|
+
<%= form.search_field :q, value: params[:q],
|
|
4
|
+
placeholder: "Search job class…",
|
|
5
|
+
"aria-label": "Search jobs by class name" %>
|
|
6
|
+
<%= form.text_field :queue_name, value: params[:queue_name],
|
|
7
|
+
placeholder: "Queue", "aria-label": "Filter by queue" %>
|
|
8
|
+
<%= form.submit "Filter", class: "fd-btn" %>
|
|
9
|
+
|
|
10
|
+
<% if query.filters? %>
|
|
11
|
+
<%= link_to jobs_path(state: params[:state].presence), class: "fd-chip" do %>
|
|
12
|
+
clear filters <u>✕</u>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<% if query.state == :failed %>
|
|
17
|
+
<span class="spacer"></span>
|
|
18
|
+
<%# Grouped so the pair wraps as a unit rather than the second button
|
|
19
|
+
dropping onto a line of its own. %>
|
|
20
|
+
<div class="fd-filterbar-actions">
|
|
21
|
+
<%= button_to "Retry all matching", bulk_retry_jobs_path(list_filters.merge(scope: "all", state: :failed)),
|
|
22
|
+
class: "fd-btn", form_class: "fd-inline-form",
|
|
23
|
+
data: { turbo_confirm: "Retry up to #{number_with_delimiter(Flightdeck.config.bulk_action_limit)} failed jobs matching this filter?" } %>
|
|
24
|
+
<%= button_to "Discard all matching", bulk_discard_jobs_path(list_filters.merge(scope: "all", state: :failed)),
|
|
25
|
+
class: "fd-btn danger", form_class: "fd-inline-form",
|
|
26
|
+
data: { turbo_confirm: "Permanently discard up to #{number_with_delimiter(Flightdeck.config.bulk_action_limit)} failed jobs matching this filter? This cannot be undone." } %>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
29
|
+
<% end %>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<% if query.state == :failed %>
|
|
2
|
+
<%= form_with url: bulk_retry_jobs_path(list_filters.merge(state: :failed)), method: :post,
|
|
3
|
+
data: { controller: "selection" } do |form| %>
|
|
4
|
+
<div class="fd-bulkbar" data-selection-target="bar" hidden>
|
|
5
|
+
<span><b data-selection-target="count">0</b> selected</span>
|
|
6
|
+
<button type="submit" class="fd-btn sm primary"
|
|
7
|
+
data-turbo-confirm="Retry the selected jobs?">Retry selected</button>
|
|
8
|
+
<button type="submit" class="fd-btn sm danger"
|
|
9
|
+
formaction="<%= bulk_discard_jobs_path(list_filters.merge(state: :failed)) %>"
|
|
10
|
+
data-turbo-confirm="Permanently discard the selected jobs? This cannot be undone.">Discard selected</button>
|
|
11
|
+
<span class="spacer"></span>
|
|
12
|
+
<button type="submit" name="scope" value="all" class="fd-btn sm"
|
|
13
|
+
data-turbo-confirm="Retry all failed jobs matching the current filter?">
|
|
14
|
+
or apply to all <%= fd_count(query.count, capped: query.count_capped?) %> matching
|
|
15
|
+
</button>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="fd-card">
|
|
19
|
+
<div class="fd-scroll-x">
|
|
20
|
+
<table class="fd-data">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr>
|
|
23
|
+
<th style="width:34px">
|
|
24
|
+
<input type="checkbox" data-selection-target="all" data-action="selection#toggleAll"
|
|
25
|
+
aria-label="Select all failed jobs on this page">
|
|
26
|
+
</th>
|
|
27
|
+
<th>Job</th>
|
|
28
|
+
<th>Error</th>
|
|
29
|
+
<th>Queue</th>
|
|
30
|
+
<th class="num">Attempts</th>
|
|
31
|
+
<th class="num">Failed</th>
|
|
32
|
+
<th style="width:150px"></th>
|
|
33
|
+
</tr>
|
|
34
|
+
</thead>
|
|
35
|
+
<tbody>
|
|
36
|
+
<% if rows.empty? %>
|
|
37
|
+
<tr><td colspan="7"><div class="fd-empty">No failed jobs<%= " match this filter" if query.filters? %>.</div></td></tr>
|
|
38
|
+
<% elsif groups.present? %>
|
|
39
|
+
<% groups.each do |group| %>
|
|
40
|
+
<tr class="fd-group-row">
|
|
41
|
+
<td colspan="7">
|
|
42
|
+
<%= group.key.upcase %> · <%= pluralize(group.count, "job") %> on this page
|
|
43
|
+
<% if group.last_failed_at %>· last <%= fd_ago(group.last_failed_at) %><% end %>
|
|
44
|
+
·
|
|
45
|
+
<button type="button" class="fd-linkish"
|
|
46
|
+
data-action="selection#selectGroup"
|
|
47
|
+
data-selection-group-param="<%= group.key %>">select group</button>
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
<%= render partial: "flightdeck/jobs/failed_row", collection: group.rows, as: :row,
|
|
51
|
+
locals: { group_key: group.key } %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% else %>
|
|
54
|
+
<%= render partial: "flightdeck/jobs/failed_row", collection: rows, as: :row,
|
|
55
|
+
locals: { group_key: nil } %>
|
|
56
|
+
<% end %>
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
</div>
|
|
60
|
+
<%= render "flightdeck/jobs/pager", query: query, noun: "failed job" %>
|
|
61
|
+
</div>
|
|
62
|
+
<% end %>
|
|
63
|
+
<% else %>
|
|
64
|
+
<div class="fd-card">
|
|
65
|
+
<div class="fd-scroll-x">
|
|
66
|
+
<table class="fd-data">
|
|
67
|
+
<thead>
|
|
68
|
+
<tr>
|
|
69
|
+
<th>Job</th>
|
|
70
|
+
<th>Queue</th>
|
|
71
|
+
<th>State</th>
|
|
72
|
+
<th class="num">Priority</th>
|
|
73
|
+
<th class="num">Enqueued</th>
|
|
74
|
+
<th class="num">Progress</th>
|
|
75
|
+
<th style="width:90px"></th>
|
|
76
|
+
</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
<% if rows.empty? %>
|
|
80
|
+
<tr><td colspan="7"><div class="fd-empty">No jobs<%= " match this filter" if query.filters? %>.</div></td></tr>
|
|
81
|
+
<% else %>
|
|
82
|
+
<%= render partial: "flightdeck/jobs/row", collection: rows, as: :row %>
|
|
83
|
+
<% end %>
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
</div>
|
|
87
|
+
<%= render "flightdeck/jobs/pager", query: query, noun: "job" %>
|
|
88
|
+
</div>
|
|
89
|
+
<% end %>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<div class="fd-pager">
|
|
2
|
+
<span>
|
|
3
|
+
<%= fd_count(query.count, capped: query.count_capped?) %> <%= noun.pluralize %><%
|
|
4
|
+
%><% if query.count_capped? %> · counts capped at <%= number_with_delimiter(query.count_cap) %><% end %>
|
|
5
|
+
</span>
|
|
6
|
+
<div class="right">
|
|
7
|
+
<% if params[:before_id].present? %>
|
|
8
|
+
<%= link_to "‹ Newest", jobs_path(list_filters.merge(state: params[:state].presence).compact),
|
|
9
|
+
class: "fd-btn sm" %>
|
|
10
|
+
<% else %>
|
|
11
|
+
<span class="fd-btn sm disabled" aria-disabled="true">‹ Newest</span>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<% if query.next_page? %>
|
|
15
|
+
<%= link_to "Older ›",
|
|
16
|
+
jobs_path(list_filters.merge(state: params[:state].presence, before_id: query.next_cursor).compact),
|
|
17
|
+
class: "fd-btn sm" %>
|
|
18
|
+
<% else %>
|
|
19
|
+
<span class="fd-btn sm disabled" aria-disabled="true">Older ›</span>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<tr id="<%= dom_id_for_job(row.id) %>">
|
|
2
|
+
<td class="cls">
|
|
3
|
+
<%= link_to row.class_name, job_path(row.id, state: params[:state].presence),
|
|
4
|
+
data: { turbo_frame: "_top" } %>
|
|
5
|
+
<div class="args"><%= fd_args_preview(row.args_preview) %></div>
|
|
6
|
+
</td>
|
|
7
|
+
<td><%= fd_queue_badge(row.queue_name) %></td>
|
|
8
|
+
<td><%= fd_state_pill(row.state) %></td>
|
|
9
|
+
<td class="num"><%= row.priority %></td>
|
|
10
|
+
<td class="num dim"><%= fd_time(row.enqueued_at) %></td>
|
|
11
|
+
<td class="num dim"><%= fd_progress_label(row) %></td>
|
|
12
|
+
<td class="num">
|
|
13
|
+
<% if row.failed? %>
|
|
14
|
+
<%= button_to "Retry", retry_job_path(row.id, list_filters.merge(state: :failed)),
|
|
15
|
+
class: "fd-btn sm", form_class: "fd-inline-form",
|
|
16
|
+
data: { turbo_confirm: "Retry #{row.class_name} ##{row.id}?" } %>
|
|
17
|
+
<% elsif row.discardable? %>
|
|
18
|
+
<%= button_to "Discard", discard_job_path(row.id, list_filters.merge(state: row.state)),
|
|
19
|
+
class: "fd-btn sm danger", form_class: "fd-inline-form",
|
|
20
|
+
data: { turbo_confirm: "Permanently discard #{row.class_name} ##{row.id}? This cannot be undone." } %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</td>
|
|
23
|
+
</tr>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<div class="fd-statetabs">
|
|
2
|
+
<% Flightdeck::JobsQuery::STATES.each do |state| %>
|
|
3
|
+
<% counts = state_counts[state] %>
|
|
4
|
+
<%= link_to jobs_path(list_filters.merge(state: state == :all ? nil : state).compact),
|
|
5
|
+
class: ("on" if @query.state == state) do %>
|
|
6
|
+
<%= fd_state_label(state) %>
|
|
7
|
+
<span class="ct"><%= fd_count(counts[:count], capped: counts[:capped]) %></span>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<turbo-stream action="append" target="fd-toasts">
|
|
2
|
+
<template><%= render partial: "flightdeck/shared/toast", formats: [ :html ], locals: { toast: @toast } %></template>
|
|
3
|
+
</turbo-stream>
|
|
4
|
+
|
|
5
|
+
<%# Re-render the list the action was launched from, so counts, rows and the
|
|
6
|
+
state tabs all settle in the same round trip. A page that has no jobs frame
|
|
7
|
+
(the job detail page, say) simply ignores this stream. %>
|
|
8
|
+
<turbo-stream action="replace" target="fd-jobs">
|
|
9
|
+
<template>
|
|
10
|
+
<turbo-frame id="fd-jobs"
|
|
11
|
+
data-controller="refresh"
|
|
12
|
+
data-refresh-interval-value="<%= (Flightdeck.config.poll_interval.to_f * 1000).round %>"
|
|
13
|
+
data-refresh-url-value="<%= fd_refreshed_list_url %>">
|
|
14
|
+
<%= render partial: "flightdeck/jobs/list", formats: [ :html ],
|
|
15
|
+
locals: { query: @refreshed_query, rows: @refreshed_rows, groups: @refreshed_groups } %>
|
|
16
|
+
</turbo-frame>
|
|
17
|
+
</template>
|
|
18
|
+
</turbo-stream>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<% content_for :page_title, "Jobs" %>
|
|
2
|
+
|
|
3
|
+
<%= render "flightdeck/jobs/state_tabs", state_counts: @state_counts %>
|
|
4
|
+
<%= render "flightdeck/jobs/filter_bar", query: @query %>
|
|
5
|
+
|
|
6
|
+
<%# The frame re-requests the URL it is showing, so state, filters and cursor
|
|
7
|
+
all survive a poll. %>
|
|
8
|
+
<turbo-frame id="fd-jobs"
|
|
9
|
+
data-controller="refresh"
|
|
10
|
+
data-refresh-interval-value="<%= (Flightdeck.config.poll_interval.to_f * 1000).round %>"
|
|
11
|
+
data-refresh-url-value="<%= fd_current_list_url %>">
|
|
12
|
+
<%= render "flightdeck/jobs/list", query: @query, rows: @rows, groups: @groups %>
|
|
13
|
+
</turbo-frame>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<% content_for :page_title, "Job ##{@job.id}" %>
|
|
2
|
+
|
|
3
|
+
<div class="fd-jd-head">
|
|
4
|
+
<div class="t">
|
|
5
|
+
<h2><%= @job.class_name %></h2>
|
|
6
|
+
<div class="sub">
|
|
7
|
+
job #<%= number_with_delimiter(@job.id) %>
|
|
8
|
+
<% if @job.active_job_id.present? %>· active_job <%= @job.active_job_id %><% end %>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
<%= fd_state_pill(@job.state) %>
|
|
12
|
+
<div class="actions">
|
|
13
|
+
<% if @job.retryable? %>
|
|
14
|
+
<%= button_to "↻ Retry now", retry_job_path(@job.id), class: "fd-btn primary", form_class: "fd-inline-form",
|
|
15
|
+
data: { turbo_confirm: "Retry #{@job.class_name} ##{@job.id}?" } %>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% if @job.discardable? %>
|
|
18
|
+
<%= button_to "Discard", discard_job_path(@job.id, state: @job.state), class: "fd-btn danger",
|
|
19
|
+
form_class: "fd-inline-form",
|
|
20
|
+
data: { turbo_confirm: "Permanently discard #{@job.class_name} ##{@job.id}? This cannot be undone." } %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<%= link_to "Back to jobs", jobs_path(state: params[:state].presence), class: "fd-btn" %>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="fd-grid-jd">
|
|
27
|
+
<div class="fd-stack">
|
|
28
|
+
<% if @job.failed? %>
|
|
29
|
+
<div class="fd-card fd-error-box">
|
|
30
|
+
<div class="fd-card-hd">
|
|
31
|
+
<h3>Error</h3>
|
|
32
|
+
<span class="sub">failed <%= fd_full_time(@job.execution&.created_at) %></span>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="msg">
|
|
35
|
+
<b><%= @job.error_class.presence || "Unknown error" %>:</b>
|
|
36
|
+
<%= @job.error_message.presence || "(no message recorded)" %>
|
|
37
|
+
</div>
|
|
38
|
+
<% if @job.backtrace.any? %>
|
|
39
|
+
<details class="fd-bt" open>
|
|
40
|
+
<summary>
|
|
41
|
+
Backtrace · <%= pluralize(@job.backtrace_total, "frame") %><%
|
|
42
|
+
%><% if @job.backtrace_truncated? %> (showing <%= @job.backtrace.size %>)<% end %> · app frames highlighted
|
|
43
|
+
</summary>
|
|
44
|
+
<pre class="fd-code"><% @job.backtrace_frames.each do |frame| %><%
|
|
45
|
+
%><% if frame[:app] %><span class="app-line"><%= frame[:line] %></span><% else %><%= frame[:line] %><% end %>
|
|
46
|
+
<% end %></pre>
|
|
47
|
+
</details>
|
|
48
|
+
<% end %>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
52
|
+
<div class="fd-card">
|
|
53
|
+
<div class="fd-card-hd">
|
|
54
|
+
<h3>Arguments</h3>
|
|
55
|
+
<div class="right sub">
|
|
56
|
+
<%= number_to_human_size(@job.arguments_bytes) %>
|
|
57
|
+
<% if @job.arguments_truncated? %>· truncated for display<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<pre class="fd-code"><%= @job.arguments_preview %></pre>
|
|
61
|
+
<% if @job.arguments_truncated? %>
|
|
62
|
+
<div class="fd-card-bd">
|
|
63
|
+
<p class="fd-mono">Showing the first <%= number_to_human_size(Flightdeck::JobDetail::ARGUMENTS_DISPLAY_LIMIT) %>
|
|
64
|
+
of <%= number_to_human_size(@job.arguments_bytes) %>. Arguments are shown exactly as stored and are never deserialized.</p>
|
|
65
|
+
</div>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="fd-card">
|
|
70
|
+
<div class="fd-card-hd"><h3>Metadata</h3></div>
|
|
71
|
+
<div class="fd-meta-grid">
|
|
72
|
+
<div><div class="k">Queue</div><div class="v"><%= fd_queue_badge(@job.queue_name) %></div></div>
|
|
73
|
+
<div><div class="k">Priority</div><div class="v"><%= @job.priority %></div></div>
|
|
74
|
+
<div><div class="k">Enqueued</div><div class="v"><%= fd_full_time(@job.enqueued_at) %></div></div>
|
|
75
|
+
<div><div class="k">Scheduled</div><div class="v"><%= @job.scheduled_at ? fd_full_time(@job.scheduled_at) : "—" %></div></div>
|
|
76
|
+
<div><div class="k">Finished</div><div class="v"><%= @job.finished_at ? fd_full_time(@job.finished_at) : "—" %></div></div>
|
|
77
|
+
<div><div class="k">Active job ID</div><div class="v mono"><%= @job.active_job_id.presence || "—" %></div></div>
|
|
78
|
+
<div><div class="k">Concurrency key</div><div class="v mono"><%= @job.concurrency_key.presence || "—" %></div></div>
|
|
79
|
+
<div><div class="k">Worker</div><div class="v mono"><%= @job.process_label || "—" %></div></div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div class="fd-card">
|
|
85
|
+
<div class="fd-card-hd"><h3>Timeline</h3></div>
|
|
86
|
+
<div class="fd-timeline">
|
|
87
|
+
<% @job.timeline.each do |event| %>
|
|
88
|
+
<div class="fd-tl-item <%= event.status %>">
|
|
89
|
+
<div class="fd-tl-dot"></div>
|
|
90
|
+
<div class="fd-tl-body">
|
|
91
|
+
<div class="t"><%= event.label %></div>
|
|
92
|
+
<div class="d">
|
|
93
|
+
<%= event.at ? fd_full_time(event.at) : "—" %><% if event.detail.present? %> · <%= event.detail %><% end %>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<% end %>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<svg class="fd-chart" viewBox="0 0 <%= chart.width %> <%= chart.height %>"
|
|
2
|
+
preserveAspectRatio="xMidYMid meet" role="img"
|
|
3
|
+
aria-label="Jobs finished <%= chart.window.subtitle %>">
|
|
4
|
+
<% chart.gridlines.each do |line| %>
|
|
5
|
+
<line x1="<%= chart.plot_left %>" x2="<%= chart.plot_right %>"
|
|
6
|
+
y1="<%= line[:y] %>" y2="<%= line[:y] %>" class="fd-chart-grid"/>
|
|
7
|
+
<text x="<%= chart.plot_left - 6 %>" y="<%= line[:y] + 3 %>" class="fd-chart-label" text-anchor="end"><%= line[:label] %></text>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<% chart.bars.each do |bar| %>
|
|
11
|
+
<g>
|
|
12
|
+
<title><%= bar[:title] %></title>
|
|
13
|
+
<%# A transparent full-height rect gives every bucket — including empty
|
|
14
|
+
ones — a hover target for its native tooltip. %>
|
|
15
|
+
<rect x="<%= bar[:x] %>" y="<%= chart.plot_top %>" width="<%= bar[:width] %>"
|
|
16
|
+
height="<%= chart.plot_height %>" fill="transparent"/>
|
|
17
|
+
<% if bar[:succeeded][:height].positive? %>
|
|
18
|
+
<rect x="<%= bar[:x] %>" y="<%= bar[:succeeded][:y] %>" width="<%= bar[:width] %>"
|
|
19
|
+
height="<%= bar[:succeeded][:height] %>" rx="<%= bar[:corner] %>" class="fd-chart-bar-succeeded"/>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% if bar[:failed][:height].positive? %>
|
|
22
|
+
<rect x="<%= bar[:x] %>" y="<%= bar[:failed][:y] %>" width="<%= bar[:width] %>"
|
|
23
|
+
height="<%= bar[:failed][:height] %>" rx="<%= bar[:corner] %>" class="fd-chart-bar-failed"/>
|
|
24
|
+
<% end %>
|
|
25
|
+
</g>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<line x1="<%= chart.plot_left %>" x2="<%= chart.plot_right %>"
|
|
29
|
+
y1="<%= chart.plot_bottom %>" y2="<%= chart.plot_bottom %>" class="fd-chart-axis"/>
|
|
30
|
+
|
|
31
|
+
<% chart.x_ticks.each do |tick| %>
|
|
32
|
+
<text x="<%= tick[:x] %>" y="<%= chart.height - 6 %>" class="fd-chart-label" text-anchor="middle"><%= tick[:label] %></text>
|
|
33
|
+
<% end %>
|
|
34
|
+
</svg>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<svg class="fd-chart" viewBox="0 0 <%= chart.width %> <%= chart.height %>"
|
|
2
|
+
preserveAspectRatio="xMidYMid meet" role="img"
|
|
3
|
+
aria-label="Average time to completion <%= chart.window.subtitle %>">
|
|
4
|
+
<% chart.gridlines.each do |line| %>
|
|
5
|
+
<line x1="<%= chart.plot_left %>" x2="<%= chart.plot_right %>"
|
|
6
|
+
y1="<%= line[:y] %>" y2="<%= line[:y] %>" class="fd-chart-grid"/>
|
|
7
|
+
<text x="<%= chart.plot_left - 6 %>" y="<%= line[:y] + 3 %>" class="fd-chart-label" text-anchor="end"><%= line[:label] %></text>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<% chart.segments.each do |segment| %>
|
|
11
|
+
<path d="<%= chart.area_path(segment) %>" class="fd-chart-area"/>
|
|
12
|
+
<path d="<%= chart.line_path(segment) %>" class="fd-chart-line"/>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<% chart.markers.each do |marker| %>
|
|
16
|
+
<g>
|
|
17
|
+
<title><%= marker[:title] %></title>
|
|
18
|
+
<circle cx="<%= marker[:x] %>" cy="<%= marker[:y] %>" r="7" fill="transparent"/>
|
|
19
|
+
</g>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<% if chart.last_marker %>
|
|
23
|
+
<circle cx="<%= chart.last_marker[:x] %>" cy="<%= chart.last_marker[:y] %>"
|
|
24
|
+
r="<%= Flightdeck::Metrics::LineChart::DOT_RADIUS %>" class="fd-chart-dot"/>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<line x1="<%= chart.plot_left %>" x2="<%= chart.plot_right %>"
|
|
28
|
+
y1="<%= chart.plot_bottom %>" y2="<%= chart.plot_bottom %>" class="fd-chart-axis"/>
|
|
29
|
+
|
|
30
|
+
<% chart.x_ticks.each do |tick| %>
|
|
31
|
+
<text x="<%= tick[:x] %>" y="<%= chart.height - 6 %>" class="fd-chart-label" text-anchor="middle"><%= tick[:label] %></text>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<% unless chart.any_data? %>
|
|
35
|
+
<text x="<%= chart.plot_left + (chart.plot_width / 2) %>" y="<%= chart.plot_top + (chart.plot_height / 2) %>"
|
|
36
|
+
class="fd-chart-label" text-anchor="middle">no jobs finished in this window</text>
|
|
37
|
+
<% end %>
|
|
38
|
+
</svg>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% if sparkline.any? %>
|
|
2
|
+
<svg class="fd-sparkline" viewBox="0 0 <%= sparkline.width %> <%= sparkline.height %>"
|
|
3
|
+
preserveAspectRatio="none" aria-hidden="true">
|
|
4
|
+
<path d="<%= sparkline.area_path %>" class="fd-spark-area"/>
|
|
5
|
+
<path d="<%= sparkline.line_path %>" class="fd-spark-line" vector-effect="non-scaling-stroke"/>
|
|
6
|
+
</svg>
|
|
7
|
+
<% else %>
|
|
8
|
+
<div class="fd-spark-placeholder" aria-hidden="true"></div>
|
|
9
|
+
<% end %>
|