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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +190 -0
  4. data/app/assets/flightdeck/flightdeck-00403457c5d9.css +3 -0
  5. data/app/assets/flightdeck/flightdeck-c02e38040ddb.js +414 -0
  6. data/app/assets/flightdeck/manifest.json +16 -0
  7. data/app/controllers/concerns/flightdeck/toasts.rb +25 -0
  8. data/app/controllers/flightdeck/application_controller.rb +96 -0
  9. data/app/controllers/flightdeck/assets_controller.rb +39 -0
  10. data/app/controllers/flightdeck/jobs/actions_controller.rb +167 -0
  11. data/app/controllers/flightdeck/jobs/discards_controller.rb +44 -0
  12. data/app/controllers/flightdeck/jobs/retries_controller.rb +23 -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 +49 -0
  16. data/app/controllers/flightdeck/queues_controller.rb +51 -0
  17. data/app/controllers/flightdeck/recurring_tasks_controller.rb +46 -0
  18. data/app/helpers/flightdeck/application_helper.rb +68 -0
  19. data/app/helpers/flightdeck/infrastructure_helper.rb +24 -0
  20. data/app/helpers/flightdeck/jobs_helper.rb +136 -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/error_summary.rb +64 -0
  27. data/app/models/flightdeck/grouped_failures.rb +24 -0
  28. data/app/models/flightdeck/job_detail.rb +163 -0
  29. data/app/models/flightdeck/job_row.rb +93 -0
  30. data/app/models/flightdeck/jobs_query.rb +241 -0
  31. data/app/models/flightdeck/metrics/bar_chart.rb +49 -0
  32. data/app/models/flightdeck/metrics/chart.rb +111 -0
  33. data/app/models/flightdeck/metrics/line_chart.rb +67 -0
  34. data/app/models/flightdeck/metrics/series.rb +165 -0
  35. data/app/models/flightdeck/metrics/sparkline.rb +45 -0
  36. data/app/models/flightdeck/metrics/time_bucket.rb +95 -0
  37. data/app/models/flightdeck/overview.rb +217 -0
  38. data/app/models/flightdeck/process_registry.rb +108 -0
  39. data/app/models/flightdeck/queue_stats.rb +88 -0
  40. data/app/models/flightdeck/recurring_catalog.rb +116 -0
  41. data/app/views/flightdeck/jobs/_failed_row.html.erb +33 -0
  42. data/app/views/flightdeck/jobs/_filter_bar.html.erb +29 -0
  43. data/app/views/flightdeck/jobs/_list.html.erb +89 -0
  44. data/app/views/flightdeck/jobs/_pager.html.erb +22 -0
  45. data/app/views/flightdeck/jobs/_row.html.erb +23 -0
  46. data/app/views/flightdeck/jobs/_state_tabs.html.erb +10 -0
  47. data/app/views/flightdeck/jobs/actions/create.turbo_stream.erb +18 -0
  48. data/app/views/flightdeck/jobs/index.html.erb +13 -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 +54 -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 +9 -0
  64. data/app/views/flightdeck/processes/prune.turbo_stream.erb +15 -0
  65. data/app/views/flightdeck/queues/_cards.html.erb +46 -0
  66. data/app/views/flightdeck/queues/index.html.erb +9 -0
  67. data/app/views/flightdeck/queues/update.turbo_stream.erb +15 -0
  68. data/app/views/flightdeck/recurring_tasks/_table.html.erb +53 -0
  69. data/app/views/flightdeck/recurring_tasks/index.html.erb +9 -0
  70. data/app/views/flightdeck/recurring_tasks/run.turbo_stream.erb +15 -0
  71. data/app/views/flightdeck/shared/_toast.html.erb +7 -0
  72. data/app/views/layouts/flightdeck/application.html.erb +110 -0
  73. data/config/routes.rb +40 -0
  74. data/lib/flightdeck/assets.rb +78 -0
  75. data/lib/flightdeck/configuration.rb +80 -0
  76. data/lib/flightdeck/engine.rb +31 -0
  77. data/lib/flightdeck/version.rb +5 -0
  78. data/lib/flightdeck.rb +31 -0
  79. 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>&#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,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 %> &middot; <%= pluralize(group.count, "job") %> on this page
43
+ <% if group.last_failed_at %>&middot; last <%= fd_ago(group.last_failed_at) %><% end %>
44
+ &nbsp;&middot;&nbsp;
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,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 %>