flightdeck 0.5.3 → 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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -199
  3. metadata +12 -96
  4. data/MIT-LICENSE +0 -21
  5. data/app/assets/flightdeck/flightdeck-3967b9e6c506.css +0 -3
  6. data/app/assets/flightdeck/flightdeck-3aac23f80afe.js +0 -414
  7. data/app/assets/flightdeck/manifest.json +0 -16
  8. data/app/controllers/concerns/flightdeck/toasts.rb +0 -35
  9. data/app/controllers/flightdeck/application_controller.rb +0 -110
  10. data/app/controllers/flightdeck/assets_controller.rb +0 -39
  11. data/app/controllers/flightdeck/jobs/actions_controller.rb +0 -166
  12. data/app/controllers/flightdeck/jobs/discards_controller.rb +0 -43
  13. data/app/controllers/flightdeck/jobs/retries_controller.rb +0 -22
  14. data/app/controllers/flightdeck/jobs_controller.rb +0 -39
  15. data/app/controllers/flightdeck/overview_controller.rb +0 -11
  16. data/app/controllers/flightdeck/processes_controller.rb +0 -54
  17. data/app/controllers/flightdeck/queues_controller.rb +0 -56
  18. data/app/controllers/flightdeck/recurring_tasks_controller.rb +0 -51
  19. data/app/helpers/flightdeck/application_helper.rb +0 -85
  20. data/app/helpers/flightdeck/infrastructure_helper.rb +0 -24
  21. data/app/helpers/flightdeck/jobs_helper.rb +0 -117
  22. data/app/helpers/flightdeck/metrics_helper.rb +0 -49
  23. data/app/models/flightdeck/arguments_preview.rb +0 -95
  24. data/app/models/flightdeck/bulk_action.rb +0 -83
  25. data/app/models/flightdeck/cache.rb +0 -51
  26. data/app/models/flightdeck/cron_schedule.rb +0 -102
  27. data/app/models/flightdeck/duration.rb +0 -23
  28. data/app/models/flightdeck/error_summary.rb +0 -64
  29. data/app/models/flightdeck/grouped_failures.rb +0 -24
  30. data/app/models/flightdeck/job_detail.rb +0 -156
  31. data/app/models/flightdeck/job_row.rb +0 -103
  32. data/app/models/flightdeck/jobs_query.rb +0 -233
  33. data/app/models/flightdeck/metrics/bar_chart.rb +0 -49
  34. data/app/models/flightdeck/metrics/chart.rb +0 -111
  35. data/app/models/flightdeck/metrics/line_chart.rb +0 -63
  36. data/app/models/flightdeck/metrics/series.rb +0 -165
  37. data/app/models/flightdeck/metrics/sparkline.rb +0 -45
  38. data/app/models/flightdeck/metrics/time_bucket.rb +0 -95
  39. data/app/models/flightdeck/overview.rb +0 -210
  40. data/app/models/flightdeck/process_registry.rb +0 -102
  41. data/app/models/flightdeck/queue_stats.rb +0 -86
  42. data/app/models/flightdeck/recurring_catalog.rb +0 -114
  43. data/app/views/flightdeck/jobs/_failed_row.html.erb +0 -33
  44. data/app/views/flightdeck/jobs/_filter_bar.html.erb +0 -29
  45. data/app/views/flightdeck/jobs/_list.html.erb +0 -94
  46. data/app/views/flightdeck/jobs/_pager.html.erb +0 -22
  47. data/app/views/flightdeck/jobs/_row.html.erb +0 -23
  48. data/app/views/flightdeck/jobs/_state_tabs.html.erb +0 -10
  49. data/app/views/flightdeck/jobs/index.html.erb +0 -10
  50. data/app/views/flightdeck/jobs/show.html.erb +0 -100
  51. data/app/views/flightdeck/metrics/_bar_chart.html.erb +0 -34
  52. data/app/views/flightdeck/metrics/_line_chart.html.erb +0 -38
  53. data/app/views/flightdeck/metrics/_range_control.html.erb +0 -7
  54. data/app/views/flightdeck/metrics/_sparkline.html.erb +0 -9
  55. data/app/views/flightdeck/overview/_completion.html.erb +0 -19
  56. data/app/views/flightdeck/overview/_failures.html.erb +0 -28
  57. data/app/views/flightdeck/overview/_fleet.html.erb +0 -21
  58. data/app/views/flightdeck/overview/_queues.html.erb +0 -41
  59. data/app/views/flightdeck/overview/_throughput.html.erb +0 -23
  60. data/app/views/flightdeck/overview/_tiles.html.erb +0 -14
  61. data/app/views/flightdeck/overview/index.html.erb +0 -29
  62. data/app/views/flightdeck/processes/_fleet.html.erb +0 -54
  63. data/app/views/flightdeck/processes/_row.html.erb +0 -28
  64. data/app/views/flightdeck/processes/index.html.erb +0 -5
  65. data/app/views/flightdeck/queues/_cards.html.erb +0 -46
  66. data/app/views/flightdeck/queues/index.html.erb +0 -5
  67. data/app/views/flightdeck/recurring_tasks/_table.html.erb +0 -53
  68. data/app/views/flightdeck/recurring_tasks/index.html.erb +0 -5
  69. data/app/views/flightdeck/shared/_toast.html.erb +0 -7
  70. data/app/views/flightdeck/shared/refresh.turbo_stream.erb +0 -14
  71. data/app/views/layouts/flightdeck/application.html.erb +0 -110
  72. data/config/routes.rb +0 -40
  73. data/lib/flightdeck/assets.rb +0 -72
  74. data/lib/flightdeck/configuration.rb +0 -82
  75. data/lib/flightdeck/engine.rb +0 -31
  76. data/lib/flightdeck/version.rb +0 -5
  77. data/lib/flightdeck.rb +0 -31
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Flightdeck
4
- # A failed execution's `error` column is JSON that can run to many kilobytes
5
- # once a backtrace is in it. List views only ever read a bounded prefix of it,
6
- # which means the JSON is usually truncated mid-string — so parse when we can
7
- # and fall back to scanning for the two fields we need.
8
- class ErrorSummary
9
- UNKNOWN = "UnknownError"
10
-
11
- attr_reader :exception_class, :message
12
-
13
- def self.parse(raw)
14
- new(raw)
15
- end
16
-
17
- def self.none
18
- new(nil)
19
- end
20
-
21
- def initialize(raw)
22
- @exception_class, @message = extract(raw)
23
- end
24
-
25
- def present?
26
- exception_class.present?
27
- end
28
-
29
- def group_key
30
- exception_class.presence || UNKNOWN
31
- end
32
-
33
- def to_s
34
- return "" unless present?
35
-
36
- message.present? ? "#{exception_class}: #{message}" : exception_class
37
- end
38
-
39
- private
40
- def extract(raw)
41
- return [ nil, nil ] if raw.blank?
42
-
43
- parsed = safe_parse(raw)
44
- return [ parsed["exception_class"].presence, parsed["message"].to_s ] if parsed.is_a?(Hash)
45
-
46
- [
47
- raw[/"exception_class"\s*:\s*"((?:[^"\\]|\\.)*)"/, 1]&.then { |value| unescape(value) },
48
- raw[/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/, 1]&.then { |value| unescape(value) }.to_s
49
- ]
50
- end
51
-
52
- def safe_parse(raw)
53
- JSON.parse(raw)
54
- rescue JSON::ParserError
55
- nil
56
- end
57
-
58
- def unescape(value)
59
- JSON.parse(%("#{value}"))
60
- rescue JSON::ParserError
61
- value
62
- end
63
- end
64
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Flightdeck
4
- # Groups the failed rows of the *current page* by exception class.
5
- #
6
- # Grouping happens in Ruby on purpose. Extracting the exception class in SQL
7
- # would mean a JSON function per adapter (and an unindexed scan of a large
8
- # text column either way), which is a bad trade for a header row.
9
- class GroupedFailures
10
- Group = Struct.new(:key, :rows, keyword_init: true) do
11
- def count = rows.size
12
- def job_ids = rows.map(&:id)
13
- def first_failed_at = rows.filter_map(&:failed_at).min
14
- def last_failed_at = rows.filter_map(&:failed_at).max
15
- end
16
-
17
- def self.build(rows)
18
- rows
19
- .group_by { |row| row.error_summary.group_key }
20
- .map { |key, grouped| Group.new(key: key, rows: grouped) }
21
- .sort_by { |group| [ -group.count, group.key ] }
22
- end
23
- end
24
- end
@@ -1,156 +0,0 @@
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 = JobRow.state_for(job, 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 load_execution(annotation)
144
- return nil unless annotation
145
-
146
- model = JobRow.model_for(state)
147
- model&.find_by(id: annotation[:execution_id])
148
- end
149
-
150
- def load_process
151
- return nil unless execution.respond_to?(:process_id) && execution.process_id
152
-
153
- SolidQueue::Process.find_by(id: execution.process_id)
154
- end
155
- end
156
- end
@@ -1,103 +0,0 @@
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
- # How a job's state is derived, in one place: `finished_at` wins, then
25
- # whichever execution table `annotate` found the job in, then :unknown
26
- # for the transition window where a job is between tables.
27
- def state_for(job, annotation)
28
- return :finished if job.finished_at.present?
29
- return annotation[:state] if annotation
30
-
31
- :unknown
32
- end
33
-
34
- # Resolves the state of an already-loaded page of jobs with one
35
- # `WHERE job_id IN (page_ids)` lookup per executions table. Returns
36
- # { job_id => { state:, execution_id:, process_id: } }.
37
- def annotate(job_ids)
38
- job_ids = Array(job_ids).compact.uniq
39
- return {} if job_ids.empty?
40
-
41
- EXECUTION_MODELS.each_with_object({}) do |(state, model_name), found|
42
- model = model_name.constantize
43
- columns = [ :id, :job_id ]
44
- columns << :process_id if model.column_names.include?("process_id")
45
-
46
- model.where(job_id: job_ids).pluck(*columns).each do |values|
47
- job_id = values[1]
48
- next if found.key?(job_id)
49
-
50
- found[job_id] = { state: state, execution_id: values[0], process_id: values[2] }
51
- end
52
- end
53
- end
54
- end
55
-
56
- attr_reader :job, :state, :execution, :args_preview, :error_summary, :process
57
-
58
- def initialize(job:, state:, execution: nil, args_preview: nil, error_summary: nil, process: nil)
59
- @job = job
60
- @state = state
61
- @execution = execution
62
- @args_preview = args_preview
63
- @error_summary = error_summary || ErrorSummary.none
64
- @process = process
65
- end
66
-
67
- def id = job.id
68
- def class_name = job.class_name
69
- def queue_name = job.queue_name
70
- def priority = job.priority
71
- def active_job_id = job.active_job_id
72
- def concurrency_key = job.concurrency_key
73
- def enqueued_at = job.created_at
74
- def scheduled_at = job.scheduled_at
75
- def finished_at = job.finished_at
76
-
77
- def failed? = state == :failed
78
- def discardable? = state != :in_progress
79
-
80
- # The moment the job first became eligible to run — what "waiting" is
81
- # measured from, so a job scheduled for next week is not reported as having
82
- # waited a week.
83
- def due_at
84
- scheduled_at || enqueued_at
85
- end
86
-
87
- def execution_started_at
88
- execution&.created_at if state == :in_progress
89
- end
90
-
91
- def failed_at
92
- execution&.created_at if failed?
93
- end
94
-
95
- # Attempt number, when ActiveJob recorded one in the serialized payload. We
96
- # only ever see a truncated prefix of the arguments, so this is best-effort
97
- # by design and returns nil rather than guessing.
98
- def attempts
99
- value = args_preview.to_s[/"executions"\s*:\s*(\d+)/, 1]
100
- value && value.to_i + 1
101
- end
102
- end
103
- end
@@ -1,233 +0,0 @@
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
- # The columns list queries must never drag along: `arguments` on jobs and
94
- # `error` on failed_executions can each run to kilobytes per row.
95
- PAYLOAD_COLUMNS = %w[arguments error].freeze
96
-
97
- # Only the filters change what a count means — the cursor and page size do
98
- # not, so they are deliberately absent from the key.
99
- def cache_filters
100
- { class_name: class_name, queue_name: queue_name, q: q }
101
- end
102
-
103
- def normalize_state(value)
104
- state = value.presence&.to_sym || :all
105
- raise InvalidState, "unknown job state #{value.inspect}" unless STATES.include?(state)
106
-
107
- state
108
- end
109
-
110
- def build_rows
111
- page = paginated_records
112
- @next_cursor = page.size > limit ? page[limit - 1].id : nil
113
- page = page.first(limit)
114
-
115
- return [] if page.empty?
116
-
117
- execution_backed? ? rows_from_executions(page) : rows_from_jobs(page)
118
- end
119
-
120
- def paginated_records
121
- relation = filtered_relation
122
- .reorder(model.arel_table[:id].desc)
123
- .limit(limit + 1)
124
-
125
- relation = relation.where(model.arel_table[:id].lt(before_id)) if before_id
126
- relation.select(driving_columns).to_a
127
- end
128
-
129
- # Never `SELECT *`: it would drag a payload column through every list
130
- # query.
131
- def driving_columns
132
- (model.column_names - PAYLOAD_COLUMNS).map { |name| model.arel_table[name] }
133
- end
134
-
135
- def rows_from_executions(executions)
136
- job_ids = executions.map(&:job_id)
137
- jobs = jobs_by_id(job_ids)
138
- previews = argument_previews(job_ids)
139
- errors = state == :failed ? error_previews(executions.map(&:id)) : {}
140
- processes = state == :in_progress ? processes_by_id(executions.map(&:process_id)) : {}
141
-
142
- executions.filter_map do |execution|
143
- job = jobs[execution.job_id]
144
- next unless job
145
-
146
- JobRow.new(
147
- job: job,
148
- state: state,
149
- execution: execution,
150
- args_preview: previews[job.id],
151
- error_summary: errors[execution.id],
152
- process: processes[execution.try(:process_id)]
153
- )
154
- end
155
- end
156
-
157
- def rows_from_jobs(jobs)
158
- job_ids = jobs.map(&:id)
159
- previews = argument_previews(job_ids)
160
- annotations = state == :finished ? {} : JobRow.annotate(job_ids)
161
-
162
- jobs.map do |job|
163
- JobRow.new(
164
- job: job,
165
- state: JobRow.state_for(job, annotations[job.id]),
166
- args_preview: previews[job.id]
167
- )
168
- end
169
- end
170
-
171
- def jobs_by_id(job_ids)
172
- SolidQueue::Job.where(id: job_ids).select(SolidQueue::Job.column_names - PAYLOAD_COLUMNS).index_by(&:id)
173
- end
174
-
175
- def processes_by_id(process_ids)
176
- ids = process_ids.compact.uniq
177
- return {} if ids.empty?
178
-
179
- SolidQueue::Process.where(id: ids).select(:id, :name, :hostname, :pid, :kind).index_by(&:id)
180
- end
181
-
182
- # SUBSTR(col, 1, n) is the one truncation spelling SQLite, PostgreSQL and
183
- # MySQL all agree on, so no adapter switch is needed here.
184
- def argument_previews(job_ids)
185
- return {} if job_ids.empty?
186
-
187
- SolidQueue::Job
188
- .where(id: job_ids)
189
- .pluck(:id, Arel.sql("SUBSTR(arguments, 1, #{ARGUMENTS_PREVIEW_BYTES})"))
190
- .to_h
191
- end
192
-
193
- def error_previews(execution_ids)
194
- return {} if execution_ids.empty?
195
-
196
- SolidQueue::FailedExecution
197
- .where(id: execution_ids)
198
- .pluck(:id, Arel.sql("SUBSTR(error, 1, #{ERROR_PREVIEW_BYTES})"))
199
- .to_h { |id, raw| [ id, ErrorSummary.parse(raw) ] }
200
- end
201
-
202
- def apply_filters(relation)
203
- relation = relation.where.not(finished_at: nil) if state == :finished
204
- relation = relation.joins(:job) if join_jobs?
205
-
206
- relation = relation.where(jobs_table[:class_name].eq(class_name)) if class_name
207
- relation = relation.where(queue_table[:queue_name].eq(queue_name)) if queue_name
208
- relation = relation.where(jobs_table[:class_name].matches("#{escape_like(q)}%", nil, true)) if q
209
-
210
- relation
211
- end
212
-
213
- def join_jobs?
214
- return false unless execution_backed?
215
-
216
- class_name.present? || q.present? || (queue_name.present? && !own_queue_column?)
217
- end
218
-
219
- def own_queue_column?
220
- model.column_names.include?("queue_name")
221
- end
222
-
223
- def jobs_table = SolidQueue::Job.arel_table
224
-
225
- def queue_table
226
- own_queue_column? ? model.arel_table : jobs_table
227
- end
228
-
229
- def escape_like(value)
230
- ActiveRecord::Base.sanitize_sql_like(value.to_s)
231
- end
232
- end
233
- end
@@ -1,49 +0,0 @@
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