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.
- checksums.yaml +4 -4
- data/README.md +8 -199
- metadata +12 -96
- data/MIT-LICENSE +0 -21
- data/app/assets/flightdeck/flightdeck-3967b9e6c506.css +0 -3
- data/app/assets/flightdeck/flightdeck-3aac23f80afe.js +0 -414
- data/app/assets/flightdeck/manifest.json +0 -16
- data/app/controllers/concerns/flightdeck/toasts.rb +0 -35
- data/app/controllers/flightdeck/application_controller.rb +0 -110
- data/app/controllers/flightdeck/assets_controller.rb +0 -39
- data/app/controllers/flightdeck/jobs/actions_controller.rb +0 -166
- data/app/controllers/flightdeck/jobs/discards_controller.rb +0 -43
- data/app/controllers/flightdeck/jobs/retries_controller.rb +0 -22
- data/app/controllers/flightdeck/jobs_controller.rb +0 -39
- data/app/controllers/flightdeck/overview_controller.rb +0 -11
- data/app/controllers/flightdeck/processes_controller.rb +0 -54
- data/app/controllers/flightdeck/queues_controller.rb +0 -56
- data/app/controllers/flightdeck/recurring_tasks_controller.rb +0 -51
- data/app/helpers/flightdeck/application_helper.rb +0 -85
- data/app/helpers/flightdeck/infrastructure_helper.rb +0 -24
- data/app/helpers/flightdeck/jobs_helper.rb +0 -117
- data/app/helpers/flightdeck/metrics_helper.rb +0 -49
- data/app/models/flightdeck/arguments_preview.rb +0 -95
- data/app/models/flightdeck/bulk_action.rb +0 -83
- data/app/models/flightdeck/cache.rb +0 -51
- data/app/models/flightdeck/cron_schedule.rb +0 -102
- data/app/models/flightdeck/duration.rb +0 -23
- data/app/models/flightdeck/error_summary.rb +0 -64
- data/app/models/flightdeck/grouped_failures.rb +0 -24
- data/app/models/flightdeck/job_detail.rb +0 -156
- data/app/models/flightdeck/job_row.rb +0 -103
- data/app/models/flightdeck/jobs_query.rb +0 -233
- data/app/models/flightdeck/metrics/bar_chart.rb +0 -49
- data/app/models/flightdeck/metrics/chart.rb +0 -111
- data/app/models/flightdeck/metrics/line_chart.rb +0 -63
- data/app/models/flightdeck/metrics/series.rb +0 -165
- data/app/models/flightdeck/metrics/sparkline.rb +0 -45
- data/app/models/flightdeck/metrics/time_bucket.rb +0 -95
- data/app/models/flightdeck/overview.rb +0 -210
- data/app/models/flightdeck/process_registry.rb +0 -102
- data/app/models/flightdeck/queue_stats.rb +0 -86
- data/app/models/flightdeck/recurring_catalog.rb +0 -114
- data/app/views/flightdeck/jobs/_failed_row.html.erb +0 -33
- data/app/views/flightdeck/jobs/_filter_bar.html.erb +0 -29
- data/app/views/flightdeck/jobs/_list.html.erb +0 -94
- data/app/views/flightdeck/jobs/_pager.html.erb +0 -22
- data/app/views/flightdeck/jobs/_row.html.erb +0 -23
- data/app/views/flightdeck/jobs/_state_tabs.html.erb +0 -10
- data/app/views/flightdeck/jobs/index.html.erb +0 -10
- data/app/views/flightdeck/jobs/show.html.erb +0 -100
- data/app/views/flightdeck/metrics/_bar_chart.html.erb +0 -34
- data/app/views/flightdeck/metrics/_line_chart.html.erb +0 -38
- data/app/views/flightdeck/metrics/_range_control.html.erb +0 -7
- data/app/views/flightdeck/metrics/_sparkline.html.erb +0 -9
- data/app/views/flightdeck/overview/_completion.html.erb +0 -19
- data/app/views/flightdeck/overview/_failures.html.erb +0 -28
- data/app/views/flightdeck/overview/_fleet.html.erb +0 -21
- data/app/views/flightdeck/overview/_queues.html.erb +0 -41
- data/app/views/flightdeck/overview/_throughput.html.erb +0 -23
- data/app/views/flightdeck/overview/_tiles.html.erb +0 -14
- data/app/views/flightdeck/overview/index.html.erb +0 -29
- data/app/views/flightdeck/processes/_fleet.html.erb +0 -54
- data/app/views/flightdeck/processes/_row.html.erb +0 -28
- data/app/views/flightdeck/processes/index.html.erb +0 -5
- data/app/views/flightdeck/queues/_cards.html.erb +0 -46
- data/app/views/flightdeck/queues/index.html.erb +0 -5
- data/app/views/flightdeck/recurring_tasks/_table.html.erb +0 -53
- data/app/views/flightdeck/recurring_tasks/index.html.erb +0 -5
- data/app/views/flightdeck/shared/_toast.html.erb +0 -7
- data/app/views/flightdeck/shared/refresh.turbo_stream.erb +0 -14
- data/app/views/layouts/flightdeck/application.html.erb +0 -110
- data/config/routes.rb +0 -40
- data/lib/flightdeck/assets.rb +0 -72
- data/lib/flightdeck/configuration.rb +0 -82
- data/lib/flightdeck/engine.rb +0 -31
- data/lib/flightdeck/version.rb +0 -5
- 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
|