solid_queue-flightdeck 0.6.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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +206 -0
  4. data/app/assets/flightdeck/flightdeck-3967b9e6c506.css +3 -0
  5. data/app/assets/flightdeck/flightdeck-ad1bcc693cfd.js +414 -0
  6. data/app/assets/flightdeck/manifest.json +16 -0
  7. data/app/controllers/concerns/flightdeck/toasts.rb +35 -0
  8. data/app/controllers/flightdeck/application_controller.rb +110 -0
  9. data/app/controllers/flightdeck/assets_controller.rb +39 -0
  10. data/app/controllers/flightdeck/jobs/actions_controller.rb +166 -0
  11. data/app/controllers/flightdeck/jobs/discards_controller.rb +43 -0
  12. data/app/controllers/flightdeck/jobs/retries_controller.rb +22 -0
  13. data/app/controllers/flightdeck/jobs_controller.rb +39 -0
  14. data/app/controllers/flightdeck/overview_controller.rb +11 -0
  15. data/app/controllers/flightdeck/processes_controller.rb +54 -0
  16. data/app/controllers/flightdeck/queues_controller.rb +56 -0
  17. data/app/controllers/flightdeck/recurring_tasks_controller.rb +51 -0
  18. data/app/helpers/flightdeck/application_helper.rb +85 -0
  19. data/app/helpers/flightdeck/infrastructure_helper.rb +24 -0
  20. data/app/helpers/flightdeck/jobs_helper.rb +117 -0
  21. data/app/helpers/flightdeck/metrics_helper.rb +49 -0
  22. data/app/models/flightdeck/arguments_preview.rb +95 -0
  23. data/app/models/flightdeck/bulk_action.rb +83 -0
  24. data/app/models/flightdeck/cache.rb +51 -0
  25. data/app/models/flightdeck/cron_schedule.rb +102 -0
  26. data/app/models/flightdeck/duration.rb +23 -0
  27. data/app/models/flightdeck/error_summary.rb +64 -0
  28. data/app/models/flightdeck/grouped_failures.rb +24 -0
  29. data/app/models/flightdeck/job_detail.rb +156 -0
  30. data/app/models/flightdeck/job_row.rb +103 -0
  31. data/app/models/flightdeck/jobs_query.rb +233 -0
  32. data/app/models/flightdeck/metrics/bar_chart.rb +49 -0
  33. data/app/models/flightdeck/metrics/chart.rb +111 -0
  34. data/app/models/flightdeck/metrics/line_chart.rb +63 -0
  35. data/app/models/flightdeck/metrics/series.rb +165 -0
  36. data/app/models/flightdeck/metrics/sparkline.rb +45 -0
  37. data/app/models/flightdeck/metrics/time_bucket.rb +95 -0
  38. data/app/models/flightdeck/overview.rb +210 -0
  39. data/app/models/flightdeck/process_registry.rb +102 -0
  40. data/app/models/flightdeck/queue_stats.rb +86 -0
  41. data/app/models/flightdeck/recurring_catalog.rb +114 -0
  42. data/app/views/flightdeck/jobs/_failed_row.html.erb +33 -0
  43. data/app/views/flightdeck/jobs/_filter_bar.html.erb +29 -0
  44. data/app/views/flightdeck/jobs/_list.html.erb +94 -0
  45. data/app/views/flightdeck/jobs/_pager.html.erb +22 -0
  46. data/app/views/flightdeck/jobs/_row.html.erb +23 -0
  47. data/app/views/flightdeck/jobs/_state_tabs.html.erb +10 -0
  48. data/app/views/flightdeck/jobs/index.html.erb +10 -0
  49. data/app/views/flightdeck/jobs/show.html.erb +100 -0
  50. data/app/views/flightdeck/metrics/_bar_chart.html.erb +34 -0
  51. data/app/views/flightdeck/metrics/_line_chart.html.erb +38 -0
  52. data/app/views/flightdeck/metrics/_range_control.html.erb +7 -0
  53. data/app/views/flightdeck/metrics/_sparkline.html.erb +9 -0
  54. data/app/views/flightdeck/overview/_completion.html.erb +19 -0
  55. data/app/views/flightdeck/overview/_failures.html.erb +28 -0
  56. data/app/views/flightdeck/overview/_fleet.html.erb +21 -0
  57. data/app/views/flightdeck/overview/_queues.html.erb +41 -0
  58. data/app/views/flightdeck/overview/_throughput.html.erb +23 -0
  59. data/app/views/flightdeck/overview/_tiles.html.erb +14 -0
  60. data/app/views/flightdeck/overview/index.html.erb +29 -0
  61. data/app/views/flightdeck/processes/_fleet.html.erb +54 -0
  62. data/app/views/flightdeck/processes/_row.html.erb +28 -0
  63. data/app/views/flightdeck/processes/index.html.erb +5 -0
  64. data/app/views/flightdeck/queues/_cards.html.erb +46 -0
  65. data/app/views/flightdeck/queues/index.html.erb +5 -0
  66. data/app/views/flightdeck/recurring_tasks/_table.html.erb +53 -0
  67. data/app/views/flightdeck/recurring_tasks/index.html.erb +5 -0
  68. data/app/views/flightdeck/shared/_toast.html.erb +7 -0
  69. data/app/views/flightdeck/shared/refresh.turbo_stream.erb +14 -0
  70. data/app/views/layouts/flightdeck/application.html.erb +110 -0
  71. data/config/routes.rb +40 -0
  72. data/lib/flightdeck/assets.rb +72 -0
  73. data/lib/flightdeck/configuration.rb +82 -0
  74. data/lib/flightdeck/engine.rb +31 -0
  75. data/lib/flightdeck/version.rb +5 -0
  76. data/lib/flightdeck.rb +31 -0
  77. data/lib/solid_queue-flightdeck.rb +5 -0
  78. metadata +149 -0
@@ -0,0 +1,86 @@
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 rows
35
+ @rows ||= names.sort.map do |name|
36
+ Row.new(
37
+ name: name,
38
+ depth: depths.fetch(name, 0),
39
+ oldest_ready_at: oldest_ready.fetch(name, nil),
40
+ paused_at: pauses.fetch(name, nil),
41
+ completed_in_window: completions.fetch(name, 0)
42
+ )
43
+ end
44
+ end
45
+
46
+ def find(name)
47
+ rows.find { |row| row.name == name }
48
+ end
49
+
50
+ def any_paused? = rows.any?(&:paused?)
51
+
52
+ private
53
+ # A queue is worth showing if Solid Queue knows about it, if something is
54
+ # waiting on it, or if it is paused — a paused queue with nothing in it is
55
+ # exactly the one an operator needs to be reminded of.
56
+ def names
57
+ (queue_names + depths.keys + pauses.keys + completions.keys).compact.uniq
58
+ end
59
+
60
+ def queue_names
61
+ SolidQueue::Queue.all.map(&:name)
62
+ end
63
+
64
+ def depths
65
+ @depths ||= SolidQueue::ReadyExecution.group(:queue_name).count
66
+ end
67
+
68
+ def oldest_ready
69
+ @oldest_ready ||= SolidQueue::ReadyExecution.group(:queue_name).minimum(:created_at)
70
+ end
71
+
72
+ def pauses
73
+ @pauses ||= SolidQueue::Pause.pluck(:queue_name, :created_at).to_h
74
+ end
75
+
76
+ # Bounded by the window rather than by a row cap: a grouped count cannot
77
+ # be capped per group the way a single count can, and an hour of finished
78
+ # jobs is already a bounded, index-backed range scan.
79
+ def completions
80
+ @completions ||= SolidQueue::Job
81
+ .where(finished_at: RATE_WINDOW.ago..)
82
+ .group(:queue_name)
83
+ .count
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,114 @@
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 rows
45
+ @rows ||= tasks.map do |task|
46
+ run = last_runs[task.key]
47
+
48
+ Row.new(
49
+ task: task,
50
+ last_run_at: run&.first,
51
+ last_job_id: run&.last,
52
+ last_status: status_for(run&.last)
53
+ )
54
+ end
55
+ end
56
+
57
+ def find(id)
58
+ rows.find { |row| row.id == id.to_i }
59
+ end
60
+
61
+ def any? = rows.any?
62
+
63
+ private
64
+ def tasks
65
+ @tasks ||= SolidQueue::RecurringTask.order(:key).to_a
66
+ end
67
+
68
+ def keys = tasks.map(&:key)
69
+
70
+ # [run_at, job_id] of the most recent execution of each task, in two
71
+ # queries: the maximum run_at per key, then the rows that match them.
72
+ def last_runs
73
+ @last_runs ||= compute_last_runs
74
+ end
75
+
76
+ def compute_last_runs
77
+ return {} if keys.empty?
78
+
79
+ maxima = SolidQueue::RecurringExecution.where(task_key: keys).group(:task_key).maximum(:run_at)
80
+ return {} if maxima.empty?
81
+
82
+ SolidQueue::RecurringExecution
83
+ .where(task_key: maxima.keys, run_at: maxima.values)
84
+ .pluck(:task_key, :run_at, :job_id)
85
+ .each_with_object({}) do |(task_key, run_at, job_id), found|
86
+ next unless maxima[task_key] == run_at
87
+
88
+ found[task_key] = [ run_at, job_id ]
89
+ end
90
+ end
91
+
92
+ def status_for(job_id)
93
+ return nil if job_id.nil?
94
+ # The job has been purged by clear_finished_jobs_after: we know it ran,
95
+ # but not how it went. Say so rather than claim success.
96
+ return :unknown unless surviving_job_ids.include?(job_id)
97
+
98
+ failed_job_ids.include?(job_id) ? :failed : :ok
99
+ end
100
+
101
+ def last_job_ids
102
+ @last_job_ids ||= last_runs.values.filter_map(&:last)
103
+ end
104
+
105
+ def surviving_job_ids
106
+ @surviving_job_ids ||= last_job_ids.empty? ? Set.new : SolidQueue::Job.where(id: last_job_ids).pluck(:id).to_set
107
+ end
108
+
109
+ def failed_job_ids
110
+ @failed_job_ids ||=
111
+ last_job_ids.empty? ? Set.new : SolidQueue::FailedExecution.where(job_id: last_job_ids).pluck(:job_id).to_set
112
+ end
113
+ end
114
+ 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>&#10005;</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,94 @@
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
+ <%# Each "all matching" button names its own action: a single neutral
13
+ "apply to all" button inherited the form's action and silently
14
+ retried when the user meant to discard. %>
15
+ <span class="fd-bulkbar-all">or all <%= fd_count(query.count, capped: query.count_capped?) %> matching:</span>
16
+ <button type="submit" name="scope" value="all" class="fd-btn sm primary"
17
+ data-turbo-confirm="Retry all failed jobs matching the current filter?">Retry all</button>
18
+ <button type="submit" name="scope" value="all" class="fd-btn sm danger"
19
+ formaction="<%= bulk_discard_jobs_path(list_filters.merge(state: :failed)) %>"
20
+ data-turbo-confirm="Permanently discard all failed jobs matching the current filter? This cannot be undone.">Discard all</button>
21
+ </div>
22
+
23
+ <div class="fd-card">
24
+ <div class="fd-scroll-x">
25
+ <table class="fd-data">
26
+ <thead>
27
+ <tr>
28
+ <th style="width:34px">
29
+ <input type="checkbox" data-selection-target="all" data-action="selection#toggleAll"
30
+ aria-label="Select all failed jobs on this page">
31
+ </th>
32
+ <th>Job</th>
33
+ <th>Error</th>
34
+ <th>Queue</th>
35
+ <th class="num">Attempts</th>
36
+ <th class="num">Failed</th>
37
+ <th style="width:150px"></th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ <% if rows.empty? %>
42
+ <tr><td colspan="7"><div class="fd-empty">No failed jobs<%= " match this filter" if query.filters? %>.</div></td></tr>
43
+ <% elsif groups.present? %>
44
+ <% groups.each do |group| %>
45
+ <tr class="fd-group-row">
46
+ <td colspan="7">
47
+ <%= group.key.upcase %> &middot; <%= pluralize(group.count, "job") %> on this page
48
+ <% if group.last_failed_at %>&middot; last <%= fd_ago(group.last_failed_at) %><% end %>
49
+ &nbsp;&middot;&nbsp;
50
+ <button type="button" class="fd-linkish"
51
+ data-action="selection#selectGroup"
52
+ data-selection-group-param="<%= group.key %>">select group</button>
53
+ </td>
54
+ </tr>
55
+ <%= render partial: "flightdeck/jobs/failed_row", collection: group.rows, as: :row,
56
+ locals: { group_key: group.key } %>
57
+ <% end %>
58
+ <% else %>
59
+ <%= render partial: "flightdeck/jobs/failed_row", collection: rows, as: :row,
60
+ locals: { group_key: nil } %>
61
+ <% end %>
62
+ </tbody>
63
+ </table>
64
+ </div>
65
+ <%= render "flightdeck/jobs/pager", query: query, noun: "failed job" %>
66
+ </div>
67
+ <% end %>
68
+ <% else %>
69
+ <div class="fd-card">
70
+ <div class="fd-scroll-x">
71
+ <table class="fd-data">
72
+ <thead>
73
+ <tr>
74
+ <th>Job</th>
75
+ <th>Queue</th>
76
+ <th>State</th>
77
+ <th class="num">Priority</th>
78
+ <th class="num">Enqueued</th>
79
+ <th class="num">Progress</th>
80
+ <th style="width:90px"></th>
81
+ </tr>
82
+ </thead>
83
+ <tbody>
84
+ <% if rows.empty? %>
85
+ <tr><td colspan="7"><div class="fd-empty">No jobs<%= " match this filter" if query.filters? %>.</div></td></tr>
86
+ <% else %>
87
+ <%= render partial: "flightdeck/jobs/row", collection: rows, as: :row %>
88
+ <% end %>
89
+ </tbody>
90
+ </table>
91
+ </div>
92
+ <%= render "flightdeck/jobs/pager", query: query, noun: "job" %>
93
+ </div>
94
+ <% 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,10 @@
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
+ <%= fd_refresh_frame "fd-jobs", url: fd_current_list_url, target: nil do %>
9
+ <%= render "flightdeck/jobs/list", query: @query, rows: @rows, groups: @groups %>
10
+ <% end %>
@@ -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,7 @@
1
+ <div class="fd-seg">
2
+ <% Flightdeck::Metrics::Series::WINDOWS.each_value do |option| %>
3
+ <%= link_to option.label, root_path(range: option.key),
4
+ class: ("on" if option.key == window.key),
5
+ data: { turbo_action: "advance" } %>
6
+ <% end %>
7
+ </div>
@@ -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 %>
@@ -0,0 +1,19 @@
1
+ <div class="fd-card">
2
+ <div class="fd-card-hd">
3
+ <h3>Time to completion</h3>
4
+ <span class="sub">enqueue → finish, average <%= window.subtitle %></span>
5
+ <div class="right">
6
+ <div class="fd-legend"><span><i class="fd-swatch-s3"></i>Average</span></div>
7
+ </div>
8
+ </div>
9
+ <div class="fd-chart-body">
10
+ <%= render "flightdeck/metrics/line_chart",
11
+ chart: Flightdeck::Metrics::LineChart.new(points: series.completion_time, window: window) %>
12
+ </div>
13
+ <%# Deliberately not "time to start": Solid Queue deletes the claim row when a
14
+ job finishes, so historical time-to-start cannot be recovered. %>
15
+ <div class="fd-chart-note">
16
+ Measured from enqueue to finish. Time spent waiting and time spent running are not
17
+ separable after the fact.
18
+ </div>
19
+ </div>