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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # Everything the job show page needs, assembled from Solid Queue's own models.
5
+ #
6
+ # Arguments are shown exactly as they were stored. They are never handed to
7
+ # ActiveJob for deserialization: that would load host application classes (and
8
+ # run their code) just to render a dashboard.
9
+ class JobDetail
10
+ ARGUMENTS_DISPLAY_LIMIT = 10.kilobytes
11
+
12
+ # A frame belongs to the application unless it lives in an installed gem or
13
+ # in Ruby's own library directories.
14
+ VENDOR_FRAME = %r{
15
+ (?:\A|/)(?:gems|ruby|rubygems|bundler|vendor/bundle)/ |
16
+ \A<internal: | \A/usr/lib/ruby/
17
+ }x
18
+
19
+ attr_reader :job, :state, :execution, :process
20
+
21
+ def self.find(id)
22
+ job = SolidQueue::Job.find(id)
23
+ annotation = JobRow.annotate([ job.id ])[job.id]
24
+
25
+ new(job: job, annotation: annotation)
26
+ end
27
+
28
+ def initialize(job:, annotation: nil)
29
+ @job = job
30
+ @state = resolve_state(annotation)
31
+ @execution = load_execution(annotation)
32
+ @process = load_process
33
+ end
34
+
35
+ def id = job.id
36
+ def class_name = job.class_name
37
+ def queue_name = job.queue_name
38
+ def priority = job.priority
39
+ def active_job_id = job.active_job_id
40
+ def concurrency_key = job.concurrency_key
41
+ def enqueued_at = job.created_at
42
+ def scheduled_at = job.scheduled_at
43
+ def finished_at = job.finished_at
44
+
45
+ def failed? = state == :failed
46
+ def retryable? = failed?
47
+ def discardable? = state != :in_progress && state != :finished
48
+
49
+ def failed_execution
50
+ execution if failed?
51
+ end
52
+
53
+ # --- arguments ----------------------------------------------------------
54
+
55
+ # `job.arguments` is already a Ruby structure (Solid Queue stores it with a
56
+ # JSON coder); pretty-printing it is a formatting step, not deserialization.
57
+ def arguments_json
58
+ @arguments_json ||= begin
59
+ JSON.pretty_generate(job.arguments)
60
+ rescue StandardError
61
+ job.arguments.to_s
62
+ end
63
+ end
64
+
65
+ def arguments_truncated?
66
+ arguments_json.bytesize > ARGUMENTS_DISPLAY_LIMIT
67
+ end
68
+
69
+ def arguments_preview
70
+ return arguments_json unless arguments_truncated?
71
+
72
+ arguments_json.byteslice(0, ARGUMENTS_DISPLAY_LIMIT).scrub("")
73
+ end
74
+
75
+ def arguments_bytes = arguments_json.bytesize
76
+
77
+ # --- error --------------------------------------------------------------
78
+
79
+ def error_class
80
+ failed_execution&.exception_class
81
+ end
82
+
83
+ def error_message
84
+ failed_execution&.message
85
+ end
86
+
87
+ def backtrace
88
+ @backtrace ||= Array(failed_execution&.backtrace).first(Flightdeck.config.backtrace_lines)
89
+ end
90
+
91
+ def backtrace_total = Array(failed_execution&.backtrace).size
92
+
93
+ def backtrace_truncated? = backtrace_total > backtrace.size
94
+
95
+ def backtrace_frames
96
+ backtrace.map { |line| { line: line, app: app_frame?(line) } }
97
+ end
98
+
99
+ def app_frame?(line)
100
+ !VENDOR_FRAME.match?(line.to_s)
101
+ end
102
+
103
+ # --- timeline -----------------------------------------------------------
104
+
105
+ Event = Struct.new(:label, :at, :detail, :status, keyword_init: true)
106
+
107
+ # Built only from timestamps Solid Queue actually keeps. Steps whose row has
108
+ # already been consumed (a finished job's claim, say) are simply absent
109
+ # rather than invented.
110
+ def timeline
111
+ events = [ Event.new(label: "Enqueued", at: enqueued_at, detail: "queue #{queue_name}", status: :ok) ]
112
+
113
+ if scheduled_at.present? && scheduled_at > enqueued_at
114
+ events << Event.new(label: "Scheduled", at: scheduled_at,
115
+ detail: scheduled_at.future? ? "not yet due" : "became due", status: :ok)
116
+ end
117
+
118
+ case state
119
+ when :ready
120
+ events << Event.new(label: "Ready", at: execution&.created_at, detail: "waiting for a worker", status: :now)
121
+ when :blocked
122
+ events << Event.new(label: "Blocked", at: execution&.created_at,
123
+ detail: "concurrency key #{job.concurrency_key}", status: :now)
124
+ when :in_progress
125
+ events << Event.new(label: "Claimed", at: execution&.created_at, detail: process_label, status: :now)
126
+ when :failed
127
+ events << Event.new(label: "Failed", at: execution&.created_at, detail: error_class, status: :bad)
128
+ events << Event.new(label: "Awaiting decision", at: nil,
129
+ detail: "retry or discard", status: :now)
130
+ end
131
+
132
+ events << Event.new(label: "Finished", at: finished_at, detail: nil, status: :ok) if finished_at.present?
133
+ events.select { |event| event.at.present? || event.status == :now }
134
+ end
135
+
136
+ def process_label
137
+ return nil unless process
138
+
139
+ [ process.name, ("pid #{process.pid}" if process.pid) ].compact.join(" · ")
140
+ end
141
+
142
+ private
143
+ def resolve_state(annotation)
144
+ return :finished if job.finished_at.present?
145
+ return annotation[:state] if annotation
146
+
147
+ :unknown
148
+ end
149
+
150
+ def load_execution(annotation)
151
+ return nil unless annotation
152
+
153
+ model = JobRow.model_for(state)
154
+ model&.find_by(id: annotation[:execution_id])
155
+ end
156
+
157
+ def load_process
158
+ return nil unless execution.respond_to?(:process_id) && execution.process_id
159
+
160
+ SolidQueue::Process.find_by(id: execution.process_id)
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # One row of a jobs list. Deliberately dumb: JobsQuery assembles these from
5
+ # bounded queries and the view asks them formatting questions.
6
+ class JobRow
7
+ # Every table a job can be sitting in, in the order we report them. A job is
8
+ # only ever in one of these at a time, but we probe all five so a row is
9
+ # never silently mislabelled during a transition.
10
+ EXECUTION_MODELS = {
11
+ ready: "SolidQueue::ReadyExecution",
12
+ scheduled: "SolidQueue::ScheduledExecution",
13
+ in_progress: "SolidQueue::ClaimedExecution",
14
+ blocked: "SolidQueue::BlockedExecution",
15
+ failed: "SolidQueue::FailedExecution"
16
+ }.freeze
17
+
18
+ class << self
19
+ def model_for(state)
20
+ name = EXECUTION_MODELS[state.to_sym]
21
+ name && name.constantize
22
+ end
23
+
24
+ # Resolves the state of an already-loaded page of jobs with one
25
+ # `WHERE job_id IN (page_ids)` lookup per executions table. Returns
26
+ # { job_id => { state:, execution_id:, process_id: } }.
27
+ def annotate(job_ids)
28
+ job_ids = Array(job_ids).compact.uniq
29
+ return {} if job_ids.empty?
30
+
31
+ EXECUTION_MODELS.each_with_object({}) do |(state, model_name), found|
32
+ model = model_name.constantize
33
+ columns = [ :id, :job_id ]
34
+ columns << :process_id if model.column_names.include?("process_id")
35
+
36
+ model.where(job_id: job_ids).pluck(*columns).each do |values|
37
+ job_id = values[1]
38
+ next if found.key?(job_id)
39
+
40
+ found[job_id] = { state: state, execution_id: values[0], process_id: values[2] }
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ attr_reader :job, :state, :execution, :args_preview, :error_summary, :process
47
+
48
+ def initialize(job:, state:, execution: nil, args_preview: nil, error_summary: nil, process: nil)
49
+ @job = job
50
+ @state = state
51
+ @execution = execution
52
+ @args_preview = args_preview
53
+ @error_summary = error_summary || ErrorSummary.none
54
+ @process = process
55
+ end
56
+
57
+ def id = job.id
58
+ def class_name = job.class_name
59
+ def queue_name = job.queue_name
60
+ def priority = job.priority
61
+ def active_job_id = job.active_job_id
62
+ def concurrency_key = job.concurrency_key
63
+ def enqueued_at = job.created_at
64
+ def scheduled_at = job.scheduled_at
65
+ def finished_at = job.finished_at
66
+
67
+ def failed? = state == :failed
68
+ def discardable? = state != :in_progress
69
+
70
+ # The moment the job first became eligible to run — what "waiting" is
71
+ # measured from, so a job scheduled for next week is not reported as having
72
+ # waited a week.
73
+ def due_at
74
+ scheduled_at || enqueued_at
75
+ end
76
+
77
+ def execution_started_at
78
+ execution&.created_at if state == :in_progress
79
+ end
80
+
81
+ def failed_at
82
+ execution&.created_at if failed?
83
+ end
84
+
85
+ # Attempt number, when ActiveJob recorded one in the serialized payload. We
86
+ # only ever see a truncated prefix of the arguments, so this is best-effort
87
+ # by design and returns nil rather than guessing.
88
+ def attempts
89
+ value = args_preview.to_s[/"executions"\s*:\s*(\d+)/, 1]
90
+ value && value.to_i + 1
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # Builds every jobs list in Flightdeck.
5
+ #
6
+ # Rule zero: everything goes through SolidQueue's own models, so multi-database
7
+ # routing comes for free and the host's connection is never touched.
8
+ #
9
+ # Two shapes of query, chosen by state:
10
+ # * an execution-backed state drives from its own table (that is what makes
11
+ # the state true) and then loads the matching jobs;
12
+ # * "all" and "finished" drive from solid_queue_jobs and have their state
13
+ # annotated afterwards.
14
+ #
15
+ # The `arguments` and `error` columns are never in a list's driving query.
16
+ # Both are fetched afterwards for the current page only, and truncated in SQL
17
+ # so a page of pathological payloads still costs a bounded number of bytes.
18
+ class JobsQuery
19
+ STATES = %i[all ready scheduled in_progress blocked finished failed].freeze
20
+
21
+ # Enough that the ActiveJob envelope (job_class, job_id, queue_name…) is
22
+ # comfortably past and the arguments themselves are in the prefix.
23
+ ARGUMENTS_PREVIEW_BYTES = 800
24
+ ERROR_PREVIEW_BYTES = 1_000
25
+
26
+ class InvalidState < ArgumentError; end
27
+
28
+ attr_reader :state, :class_name, :queue_name, :q, :before_id, :limit, :count_cap
29
+
30
+ def initialize(state: :all, class_name: nil, queue_name: nil, q: nil, before_id: nil,
31
+ limit: nil, count_cap: nil)
32
+ @state = normalize_state(state)
33
+ @class_name = class_name.presence
34
+ @queue_name = queue_name.presence
35
+ @q = q.presence
36
+ @before_id = before_id.presence && Integer(before_id, exception: false)
37
+ @limit = (limit || Flightdeck.config.per_page).to_i.clamp(1, 200)
38
+ @count_cap = (count_cap || Flightdeck.config.count_cap).to_i
39
+ end
40
+
41
+ def rows
42
+ @rows ||= build_rows
43
+ end
44
+
45
+ def next_cursor
46
+ rows
47
+ @next_cursor
48
+ end
49
+
50
+ def next_page? = next_cursor.present?
51
+
52
+ # Capped so that a jobs table with tens of millions of rows cannot turn a
53
+ # page render into a full scan. Views render `count_capped?` as "500,000+".
54
+ #
55
+ # Cached for one poll interval: the state tabs and the sidebar issue several
56
+ # of these per render, and a count that is at most one poll stale is exactly
57
+ # as fresh as the page it appears on. Mutations wrap themselves in
58
+ # Cache.bypass so an action's own response never shows pre-action numbers.
59
+ def count
60
+ @count ||= Flightdeck::Cache.fetch("count", state, count_cap, cache_filters,
61
+ expires_in: Flightdeck.config.poll_interval) do
62
+ filtered_relation.limit(count_cap).count
63
+ end
64
+ end
65
+
66
+ def count_capped? = count >= count_cap
67
+
68
+ def filters?
69
+ class_name.present? || queue_name.present? || q.present?
70
+ end
71
+
72
+ # The filtered relation with no ordering, limit or cursor — what bulk
73
+ # "apply to all matching" re-runs server-side.
74
+ def filtered_relation
75
+ @filtered_relation ||= apply_filters(model.all)
76
+ end
77
+
78
+ def model
79
+ @model ||= JobRow.model_for(state) || SolidQueue::Job
80
+ end
81
+
82
+ def execution_backed? = model != SolidQueue::Job
83
+
84
+ # Counts for the state tabs, each capped independently.
85
+ def self.state_counts(**filters)
86
+ STATES.index_with do |state|
87
+ query = new(**filters.except(:state, :before_id), state: state)
88
+ { count: query.count, capped: query.count_capped? }
89
+ end
90
+ end
91
+
92
+ private
93
+ JOB_LIST_COLUMNS = -> { SolidQueue::Job.column_names - [ "arguments" ] }
94
+
95
+ # Only the filters change what a count means — the cursor and page size do
96
+ # not, so they are deliberately absent from the key.
97
+ def cache_filters
98
+ { class_name: class_name, queue_name: queue_name, q: q }
99
+ end
100
+
101
+ def normalize_state(value)
102
+ state = value.presence&.to_sym || :all
103
+ raise InvalidState, "unknown job state #{value.inspect}" unless STATES.include?(state)
104
+
105
+ state
106
+ end
107
+
108
+ def build_rows
109
+ page = paginated_records
110
+ @next_cursor = page.size > limit ? page[limit - 1].id : nil
111
+ page = page.first(limit)
112
+
113
+ return [] if page.empty?
114
+
115
+ execution_backed? ? rows_from_executions(page) : rows_from_jobs(page)
116
+ end
117
+
118
+ def paginated_records
119
+ relation = filtered_relation
120
+ .reorder(model.arel_table[:id].desc)
121
+ .limit(limit + 1)
122
+
123
+ relation = relation.where(model.arel_table[:id].lt(before_id)) if before_id
124
+ relation.select(driving_columns).to_a
125
+ end
126
+
127
+ # Never `SELECT *`: it would drag `arguments` (jobs) or `error`
128
+ # (failed_executions) through every list query.
129
+ def driving_columns
130
+ names = model.column_names - [ "arguments", "error" ]
131
+ names.map { |name| model.arel_table[name] }
132
+ end
133
+
134
+ def rows_from_executions(executions)
135
+ job_ids = executions.map(&:job_id)
136
+ jobs = jobs_by_id(job_ids)
137
+ previews = argument_previews(job_ids)
138
+ errors = state == :failed ? error_previews(executions.map(&:id)) : {}
139
+ processes = state == :in_progress ? processes_by_id(executions.map(&:process_id)) : {}
140
+
141
+ executions.filter_map do |execution|
142
+ job = jobs[execution.job_id]
143
+ next unless job
144
+
145
+ JobRow.new(
146
+ job: job,
147
+ state: state,
148
+ execution: execution,
149
+ args_preview: previews[job.id],
150
+ error_summary: errors[execution.id],
151
+ process: processes[execution.try(:process_id)]
152
+ )
153
+ end
154
+ end
155
+
156
+ def rows_from_jobs(jobs)
157
+ job_ids = jobs.map(&:id)
158
+ previews = argument_previews(job_ids)
159
+ annotations = state == :finished ? {} : JobRow.annotate(job_ids)
160
+
161
+ jobs.map do |job|
162
+ annotation = annotations[job.id]
163
+
164
+ JobRow.new(
165
+ job: job,
166
+ state: state_for(job, annotation),
167
+ args_preview: previews[job.id]
168
+ )
169
+ end
170
+ end
171
+
172
+ def state_for(job, annotation)
173
+ return :finished if job.finished_at.present?
174
+ return annotation[:state] if annotation
175
+
176
+ :unknown
177
+ end
178
+
179
+ def jobs_by_id(job_ids)
180
+ SolidQueue::Job.where(id: job_ids).select(JOB_LIST_COLUMNS.call).index_by(&:id)
181
+ end
182
+
183
+ def processes_by_id(process_ids)
184
+ ids = process_ids.compact.uniq
185
+ return {} if ids.empty?
186
+
187
+ SolidQueue::Process.where(id: ids).select(:id, :name, :hostname, :pid, :kind).index_by(&:id)
188
+ end
189
+
190
+ # SUBSTR(col, 1, n) is the one truncation spelling SQLite, PostgreSQL and
191
+ # MySQL all agree on, so no adapter switch is needed here.
192
+ def argument_previews(job_ids)
193
+ return {} if job_ids.empty?
194
+
195
+ SolidQueue::Job
196
+ .where(id: job_ids)
197
+ .pluck(:id, Arel.sql("SUBSTR(arguments, 1, #{ARGUMENTS_PREVIEW_BYTES})"))
198
+ .to_h
199
+ end
200
+
201
+ def error_previews(execution_ids)
202
+ return {} if execution_ids.empty?
203
+
204
+ SolidQueue::FailedExecution
205
+ .where(id: execution_ids)
206
+ .pluck(:id, Arel.sql("SUBSTR(error, 1, #{ERROR_PREVIEW_BYTES})"))
207
+ .to_h { |id, raw| [ id, ErrorSummary.parse(raw) ] }
208
+ end
209
+
210
+ def apply_filters(relation)
211
+ relation = relation.where.not(finished_at: nil) if state == :finished
212
+ relation = relation.joins(:job) if join_jobs?
213
+
214
+ relation = relation.where(jobs_table[:class_name].eq(class_name)) if class_name
215
+ relation = relation.where(queue_table[:queue_name].eq(queue_name)) if queue_name
216
+ relation = relation.where(jobs_table[:class_name].matches("#{escape_like(q)}%", nil, true)) if q
217
+
218
+ relation
219
+ end
220
+
221
+ def join_jobs?
222
+ return false unless execution_backed?
223
+
224
+ class_name.present? || q.present? || (queue_name.present? && !own_queue_column?)
225
+ end
226
+
227
+ def own_queue_column?
228
+ model.column_names.include?("queue_name")
229
+ end
230
+
231
+ def jobs_table = SolidQueue::Job.arel_table
232
+
233
+ def queue_table
234
+ own_queue_column? ? model.arel_table : jobs_table
235
+ end
236
+
237
+ def escape_like(value)
238
+ ActiveRecord::Base.sanitize_sql_like(value.to_s)
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module Metrics
5
+ # Succeeded stacked under failed, per bucket.
6
+ class BarChart < Chart
7
+ BAR_GAP = 2
8
+ CORNER = 1.5
9
+
10
+ def bars
11
+ points.each_with_index.map do |point, index|
12
+ x = plot_left + (slot_width * index)
13
+ bar_width = [ slot_width - BAR_GAP, 1 ].max
14
+
15
+ failed_height = height_for(point.failed)
16
+ succeeded_height = height_for(point.succeeded)
17
+ failed_y = plot_bottom - failed_height - succeeded_height
18
+
19
+ {
20
+ x: round(x + (BAR_GAP / 2.0)),
21
+ width: round(bar_width),
22
+ corner: CORNER,
23
+ succeeded: { y: round(plot_bottom - succeeded_height), height: round(succeeded_height) },
24
+ failed: { y: round(failed_y), height: round(failed_height) },
25
+ empty: point.total.zero?,
26
+ title: bar_title(point)
27
+ }
28
+ end
29
+ end
30
+
31
+ private
32
+ def raw_max = points.map(&:total).max.to_i
33
+
34
+ def height_for(value)
35
+ return 0 if value.to_i.zero? || axis_max.zero?
36
+
37
+ # Anything non-zero gets at least a hairline, so a bucket with a
38
+ # single job is visibly different from a bucket with none.
39
+ [ (value.to_f / axis_max) * plot_height, 1.0 ].max
40
+ end
41
+
42
+ def bar_title(point)
43
+ "#{full_time(point.at)} · #{number(point.succeeded)} succeeded · #{number(point.failed)} failed"
44
+ end
45
+
46
+ def number(value) = ActiveSupport::NumberHelper.number_to_delimited(value.to_i)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module Metrics
5
+ # Geometry for the server-rendered SVG charts.
6
+ #
7
+ # All the arithmetic lives here so the ERB partials stay readable: they
8
+ # place elements and nothing else. Colours are never computed here either —
9
+ # the partials reference the CSS custom properties, so charts re-theme with
10
+ # the rest of the page and there is no inline colour to get out of step.
11
+ class Chart
12
+ WIDTH = 720
13
+ HEIGHT = 190
14
+ PAD_LEFT = 40
15
+ PAD_RIGHT = 10
16
+ PAD_TOP = 12
17
+ PAD_BOTTOM = 22
18
+ GRID_INTERVALS = 3
19
+
20
+ attr_reader :points, :window
21
+
22
+ def initialize(points:, window:)
23
+ @points = points
24
+ @window = window
25
+ end
26
+
27
+ def width = WIDTH
28
+ def height = HEIGHT
29
+ def plot_left = PAD_LEFT
30
+ def plot_right = WIDTH - PAD_RIGHT
31
+ def plot_top = PAD_TOP
32
+ def plot_bottom = HEIGHT - PAD_BOTTOM
33
+ def plot_width = plot_right - plot_left
34
+ def plot_height = plot_bottom - plot_top
35
+ def empty? = points.empty?
36
+
37
+ def gridlines
38
+ (0..GRID_INTERVALS).map do |i|
39
+ value = axis_max * (GRID_INTERVALS - i) / GRID_INTERVALS.to_f
40
+ { y: round(plot_top + (plot_height * i / GRID_INTERVALS.to_f)), value: value, label: format_value(value) }
41
+ end
42
+ end
43
+
44
+ # Ticks are placed on bucket boundaries so a label always names a bucket
45
+ # that is actually on screen.
46
+ def x_ticks
47
+ every = [ window.tick_every.to_i, 1 ].max
48
+
49
+ points.each_with_index.filter_map do |point, index|
50
+ next unless (index % every).zero?
51
+
52
+ { x: round(tick_x(index)), label: format_time(point.at, window.tick_format) }
53
+ end
54
+ end
55
+
56
+ def axis_max
57
+ @axis_max ||= nice_ceiling(raw_max)
58
+ end
59
+
60
+ def format_time(time, pattern)
61
+ time.in_time_zone(Flightdeck.config.display_timezone).strftime(pattern)
62
+ end
63
+
64
+ def full_time(time)
65
+ format_time(time, "%Y-%m-%d %H:%M")
66
+ end
67
+
68
+ private
69
+ def raw_max = 0
70
+
71
+ def tick_x(index)
72
+ plot_left + (slot_width * index) + (slot_width / 2.0)
73
+ end
74
+
75
+ def slot_width
76
+ @slot_width ||= points.empty? ? plot_width : plot_width / points.size.to_f
77
+ end
78
+
79
+ def y_for(value)
80
+ return plot_bottom if axis_max.zero?
81
+
82
+ plot_bottom - ((value.to_f / axis_max) * plot_height)
83
+ end
84
+
85
+ # A round number at or above the data's peak, so gridline labels are
86
+ # readable numbers rather than whatever the maximum happened to be.
87
+ def nice_ceiling(value)
88
+ return 1 if value.nil? || value <= 0
89
+
90
+ magnitude = 10**Math.log10(value).floor
91
+ [ 1, 2, 2.5, 5, 10 ].each do |factor|
92
+ candidate = magnitude * factor
93
+ return candidate >= 10 ? candidate.round : candidate if candidate >= value
94
+ end
95
+ value
96
+ end
97
+
98
+ def format_value(value)
99
+ case value
100
+ when 0 then "0"
101
+ when 0...1 then value.round(2).to_s
102
+ when 1...1_000 then value < 10 ? value.round(1).to_s.sub(/\.0\z/, "") : value.round.to_s
103
+ when 1_000...1_000_000 then "#{(value / 1_000.0).round(1).to_s.sub(/\.0\z/, "")}k"
104
+ else "#{(value / 1_000_000.0).round(1).to_s.sub(/\.0\z/, "")}M"
105
+ end
106
+ end
107
+
108
+ def round(value) = value.round(2)
109
+ end
110
+ end
111
+ end