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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module InfrastructureHelper
5
+ RECURRING_STATUS = {
6
+ ok: [ "OK", "finished" ],
7
+ failed: [ "LAST RUN FAILED", "failed" ],
8
+ unknown: [ "PURGED", "scheduled" ]
9
+ }.freeze
10
+
11
+ def dom_id_for_queue(name) = "fd-queue-#{name.parameterize.presence || Digest::MD5.hexdigest(name)}"
12
+ def dom_id_for_process(id) = "fd-process-#{id}"
13
+ def dom_id_for_recurring_task(id) = "fd-recurring-task-#{id}"
14
+
15
+ # A task that has never run has no status to report, and a task whose job has
16
+ # been purged is reported as purged rather than as a success we did not see.
17
+ def fd_recurring_status_pill(status)
18
+ return tag.span("—", class: "dim") if status.nil?
19
+
20
+ label, pill = RECURRING_STATUS.fetch(status, [ status.to_s.upcase, "scheduled" ])
21
+ tag.span(label, class: "fd-pill #{pill}")
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module JobsHelper
5
+ STATE_LABELS = {
6
+ all: "All",
7
+ ready: "Ready",
8
+ scheduled: "Scheduled",
9
+ in_progress: "In progress",
10
+ blocked: "Blocked",
11
+ finished: "Finished",
12
+ failed: "Failed",
13
+ unknown: "Unknown"
14
+ }.freeze
15
+
16
+ PILL_CLASSES = {
17
+ ready: "ready",
18
+ scheduled: "scheduled",
19
+ in_progress: "progress",
20
+ blocked: "blocked",
21
+ finished: "finished",
22
+ failed: "failed",
23
+ unknown: "scheduled"
24
+ }.freeze
25
+
26
+ def fd_state_label(state) = STATE_LABELS.fetch(state.to_sym, state.to_s.humanize)
27
+
28
+ def fd_state_pill(state)
29
+ tag.span(fd_state_label(state).upcase,
30
+ class: "fd-pill #{PILL_CLASSES.fetch(state.to_sym, "scheduled")}")
31
+ end
32
+
33
+ def fd_queue_badge(queue_name)
34
+ tag.span(queue_name.presence || "—", class: "fd-qbadge")
35
+ end
36
+
37
+ # Counts are capped by JobsQuery, so a capped value is rendered as "500,000+"
38
+ # rather than pretending to be exact.
39
+ def fd_count(count, capped: false)
40
+ "#{number_with_delimiter(count)}#{"+" if capped}"
41
+ end
42
+
43
+ def fd_time(time)
44
+ return "—" if time.blank?
45
+
46
+ tag.span(time.in_time_zone(Flightdeck.config.display_timezone).strftime("%H:%M:%S"),
47
+ title: fd_full_time(time))
48
+ end
49
+
50
+ def fd_full_time(time)
51
+ return "—" if time.blank?
52
+
53
+ time.in_time_zone(Flightdeck.config.display_timezone).strftime("%Y-%m-%d %H:%M:%S %Z")
54
+ end
55
+
56
+ def fd_duration(seconds)
57
+ return "—" if seconds.nil?
58
+
59
+ seconds = seconds.to_f.abs
60
+ case seconds
61
+ when 0...1 then "#{(seconds * 1000).round}ms"
62
+ when 1...60 then "#{seconds.round(1)}s"
63
+ when 60...3600 then "#{(seconds / 60).round}m"
64
+ when 3600...86_400 then "#{(seconds / 3600).round(1)}h"
65
+ else "#{(seconds / 86_400).round(1)}d"
66
+ end
67
+ end
68
+
69
+ def fd_ago(time)
70
+ return "—" if time.blank?
71
+
72
+ "#{fd_duration(Time.current - time)} ago"
73
+ end
74
+
75
+ # Best-effort "what has this job been doing" column. Only ever derived from
76
+ # timestamps Solid Queue actually keeps — nothing here is estimated.
77
+ def fd_progress_label(row)
78
+ case row.state
79
+ when :ready
80
+ "waited #{fd_duration(Time.current - row.due_at)}"
81
+ when :scheduled
82
+ row.scheduled_at&.future? ? "due in #{fd_duration(row.scheduled_at - Time.current)}" : "due now"
83
+ when :in_progress
84
+ started = row.execution_started_at
85
+ return "running" unless started
86
+
87
+ "waited #{fd_duration(started - row.due_at)} · running #{fd_duration(Time.current - started)}"
88
+ when :blocked
89
+ row.concurrency_key.presence ? "concurrency: #{row.concurrency_key}" : "blocked"
90
+ when :finished
91
+ row.finished_at ? "total #{fd_duration(row.finished_at - row.enqueued_at)}" : "—"
92
+ when :failed
93
+ row.failed_at ? "failed after #{fd_duration(row.failed_at - row.due_at)}" : "—"
94
+ else
95
+ "—"
96
+ end
97
+ end
98
+
99
+ # Shows the job's own arguments rather than the ActiveJob envelope they
100
+ # are wrapped in; see Flightdeck::ArgumentsPreview.
101
+ def fd_args_preview(preview, length: 120)
102
+ Flightdeck::ArgumentsPreview.format(preview, length: length)
103
+ end
104
+
105
+ def fd_jobs_url(overrides = {})
106
+ jobs_path(list_filters.merge(state: params[:state].presence).compact.merge(overrides))
107
+ end
108
+
109
+ # The frame refreshes itself by re-requesting the URL it is showing, so
110
+ # filters, state tab and cursor all survive a poll.
111
+ def fd_current_list_url
112
+ jobs_path(request.query_parameters)
113
+ end
114
+
115
+ def dom_id_for_job(id) = "fd-job-#{id}"
116
+
117
+ # Under an exception-class group header, repeating the class on every row is
118
+ # noise — the message is the part that differs.
119
+ def fd_error_cell(summary, grouped: false)
120
+ return "—" unless summary.present?
121
+
122
+ text = grouped ? summary.message.presence || summary.exception_class : summary.to_s
123
+ text.to_s
124
+ end
125
+
126
+ # URL the refreshed frame should poll after an action, rebuilt from the
127
+ # filters the action carried rather than from the POST's own path.
128
+ def fd_refreshed_list_url
129
+ jobs_path(list_filters.merge(state: @refreshed_state == :all ? nil : @refreshed_state).compact)
130
+ end
131
+
132
+ def fd_toast_level_class(level)
133
+ level.to_sym == :error ? "fd-toast error" : "fd-toast"
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module MetricsHelper
5
+ TREND_GLYPHS = { up: "▲", down: "▼", flat: "▪" }.freeze
6
+
7
+ def fd_poll_ms = (Flightdeck.config.poll_interval.to_f * 1000).round
8
+ def fd_chart_poll_ms = (Flightdeck.config.chart_poll_interval.to_f * 1000).round
9
+
10
+ def fd_poll_seconds = Flightdeck.config.poll_interval.to_i
11
+
12
+ def fd_trend_glyph(trend) = TREND_GLYPHS.fetch(trend.to_sym, TREND_GLYPHS[:flat])
13
+
14
+ # --- topbar clock ---------------------------------------------------------
15
+ #
16
+ # Rendered server-side so the clock is right before JavaScript runs, and
17
+ # driven by an offset so it keeps showing the configured display timezone
18
+ # rather than the viewer's.
19
+
20
+ def fd_display_zone
21
+ ActiveSupport::TimeZone[Flightdeck.config.display_timezone] || ActiveSupport::TimeZone["UTC"]
22
+ end
23
+
24
+ def fd_clock_now = "#{Time.current.in_time_zone(fd_display_zone).strftime("%H:%M:%S")} #{fd_zone_label}"
25
+
26
+ def fd_zone_label
27
+ zone = fd_display_zone
28
+ zone.name == "UTC" ? "UTC" : Time.current.in_time_zone(zone).strftime("%Z")
29
+ end
30
+
31
+ def fd_zone_offset_seconds
32
+ fd_display_zone.now.utc_offset
33
+ end
34
+
35
+ # Colour follows whether the movement is good news, not which way the arrow
36
+ # points: more failures is an "up" arrow but bad news.
37
+ def fd_delta_class(tile)
38
+ return "flat" if tile.trend.to_sym == :flat
39
+
40
+ tile.good ? "up" : "down"
41
+ end
42
+
43
+ def fd_depth_share(depth, max)
44
+ return 0 if max.to_i.zero?
45
+
46
+ [ (depth.to_f / max * 100).round, 100 ].min
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # Turns a job's stored payload into the one part of it a list row wants: the
5
+ # actual job arguments.
6
+ #
7
+ # What Solid Queue stores is the whole ActiveJob serialization —
8
+ # `{"job_class":…,"job_id":…,"queue_name":…,"arguments":[…],…}` — so a raw
9
+ # preview is almost entirely boilerplate that is identical on every row. The
10
+ # job detail page still shows the full payload, where it is genuinely useful.
11
+ #
12
+ # The input is a byte prefix truncated by SQL, so the JSON is usually cut off
13
+ # mid-value. Parsing is lenient by design: extract what is there, and fall
14
+ # back to the raw prefix rather than showing nothing.
15
+ class ArgumentsPreview
16
+ KEY = /"arguments"\s*:\s*/
17
+ ELLIPSIS = "…"
18
+
19
+ def self.format(raw, length: 120)
20
+ new(raw).to_s(length: length)
21
+ end
22
+
23
+ def initialize(raw)
24
+ @raw = raw.to_s
25
+ end
26
+
27
+ def to_s(length: 120)
28
+ value = extract
29
+ return "" if value.blank?
30
+
31
+ value.gsub(/\s+/, " ").strip.truncate(length, omission: ELLIPSIS)
32
+ end
33
+
34
+ private
35
+ attr_reader :raw
36
+
37
+ def extract
38
+ return "" if raw.blank?
39
+
40
+ from_parsed_payload || from_truncated_payload || raw
41
+ end
42
+
43
+ # The happy path: the prefix happened to contain the whole payload.
44
+ def from_parsed_payload
45
+ parsed = JSON.parse(raw)
46
+ return nil unless parsed.is_a?(Hash) && parsed.key?("arguments")
47
+
48
+ JSON.generate(parsed["arguments"])
49
+ rescue JSON::ParserError
50
+ nil
51
+ end
52
+
53
+ # The usual path: the payload was cut off, so walk the arguments value
54
+ # ourselves and take as much of it as survived.
55
+ def from_truncated_payload
56
+ match = KEY.match(raw)
57
+ return nil unless match
58
+
59
+ balanced_prefix(raw[match.end(0)..].to_s)
60
+ end
61
+
62
+ # Reads one JSON value from the front of `text`, stopping either when it
63
+ # closes cleanly or when the string runs out mid-value.
64
+ def balanced_prefix(text)
65
+ return nil if text.empty?
66
+
67
+ depth = 0
68
+ in_string = false
69
+ escaped = false
70
+
71
+ text.each_char.with_index do |char, index|
72
+ if escaped
73
+ escaped = false
74
+ elsif char == "\\"
75
+ escaped = true
76
+ elsif char == '"'
77
+ in_string = !in_string
78
+ elsif !in_string
79
+ case char
80
+ when "[", "{" then depth += 1
81
+ when "]", "}"
82
+ depth -= 1
83
+ return text[0..index] if depth.zero?
84
+ when ","
85
+ # A top-level comma means the arguments value ended and the next
86
+ # payload key has begun.
87
+ return text[0...index] if depth.zero?
88
+ end
89
+ end
90
+ end
91
+
92
+ "#{text}#{ELLIPSIS}"
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # Applies a Solid Queue operation across a filtered relation in bounded,
5
+ # individually-committed batches.
6
+ #
7
+ # Three things keep this safe to run against a live queue:
8
+ # * a hard record limit (`Flightdeck.config.bulk_action_limit`),
9
+ # * a wall-clock deadline measured on the monotonic clock, and
10
+ # * a commit per batch, so stopping early still leaves completed work done.
11
+ #
12
+ # Stopping early is a normal outcome, not an error: the caller reports
13
+ # "Retried 1,000 of ~14,200" and the same request can simply be submitted
14
+ # again to continue.
15
+ class BulkAction
16
+ BATCH_SIZE = 100
17
+ DEADLINE = 10.seconds
18
+
19
+ Result = Struct.new(:processed, :failed, :total, :total_capped, :stopped, keyword_init: true) do
20
+ def stopped_early? = stopped.present?
21
+ def remaining = [ total - processed, 0 ].max
22
+ def remaining? = stopped_early? && remaining.positive?
23
+ def total_label = total_capped ? "#{total}+" : total.to_s
24
+ end
25
+
26
+ attr_reader :relation, :limit, :deadline, :count_cap
27
+
28
+ def initialize(relation:, limit: nil, deadline: DEADLINE, count_cap: nil)
29
+ @relation = relation
30
+ @limit = (limit || Flightdeck.config.bulk_action_limit).to_i
31
+ @deadline = deadline
32
+ @count_cap = (count_cap || Flightdeck.config.count_cap).to_i
33
+ end
34
+
35
+ def call(&operation)
36
+ total = relation.limit(count_cap).count
37
+ started_at = monotonic_now
38
+ processed = 0
39
+ failed = 0
40
+ stopped = nil
41
+
42
+ relation.in_batches(of: BATCH_SIZE, order: :desc) do |batch|
43
+ SolidQueue::Record.transaction do
44
+ batch.each do |record|
45
+ if processed + failed >= limit
46
+ stopped = :limit
47
+ break
48
+ end
49
+
50
+ begin
51
+ operation.call(record)
52
+ processed += 1
53
+ rescue ActiveRecord::RecordNotFound, ActiveRecord::Deadlocked
54
+ # The queue moved under us — a worker finished or retried this job
55
+ # first. Skip it; the caller's counts stay honest.
56
+ failed += 1
57
+ end
58
+ end
59
+ end
60
+
61
+ break if stopped
62
+
63
+ if monotonic_now - started_at >= deadline
64
+ stopped = :deadline
65
+ break
66
+ end
67
+ end
68
+
69
+ Result.new(
70
+ processed: processed,
71
+ failed: failed,
72
+ total: total,
73
+ total_capped: total >= count_cap,
74
+ stopped: stopped
75
+ )
76
+ end
77
+
78
+ private
79
+ def monotonic_now
80
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # Thin wrapper over Rails.cache for the dashboard's short-lived aggregates.
5
+ #
6
+ # Two things it guarantees:
7
+ # * it degrades to simply calling the block, so a host running the null
8
+ # store (or no cache at all) still gets a working dashboard;
9
+ # * a mutation can ask for fresh numbers via .bypass, so the response to a
10
+ # retry never shows the counts from before the retry.
11
+ module Cache
12
+ NAMESPACE = "flightdeck"
13
+
14
+ class << self
15
+ def fetch(*key_parts, expires_in:, &block)
16
+ store = Rails.cache
17
+ return block.call if store.nil? || expires_in.to_f <= 0
18
+
19
+ store.fetch(key_for(key_parts), expires_in: expires_in, force: bypass?, &block)
20
+ rescue StandardError
21
+ # A cache backend that is down must never take the dashboard with it.
22
+ block.call
23
+ end
24
+
25
+ # Inside this block every fetch recomputes and rewrites, so a page
26
+ # rendered straight after a mutation reflects it.
27
+ def bypass
28
+ previous = Thread.current[:flightdeck_cache_bypass]
29
+ Thread.current[:flightdeck_cache_bypass] = true
30
+ yield
31
+ ensure
32
+ Thread.current[:flightdeck_cache_bypass] = previous
33
+ end
34
+
35
+ def bypass? = Thread.current[:flightdeck_cache_bypass].present?
36
+
37
+ def key_for(parts)
38
+ [ NAMESPACE, *Array(parts).flatten.map { |part| normalize(part) } ].join(":")
39
+ end
40
+
41
+ private
42
+ def normalize(part)
43
+ case part
44
+ when Hash then part.sort_by { |key, _| key.to_s }.map { |key, value| "#{key}=#{value}" }.join(",")
45
+ when nil then ""
46
+ else part.to_s
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # Turns the common cron shapes into a readable sentence.
5
+ #
6
+ # Fugit parses cron but does not describe it, so this is ours. It only claims
7
+ # to understand patterns it genuinely does: anything else returns nil and the
8
+ # view shows the raw cron on its own. A confidently wrong description of a
9
+ # schedule is worse than no description.
10
+ class CronSchedule
11
+ DAYS = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
12
+ MONTHS = %w[- January February March April May June July August September October November December].freeze
13
+
14
+ attr_reader :raw
15
+
16
+ def initialize(raw)
17
+ @raw = raw.to_s.strip
18
+ end
19
+
20
+ def self.humanize(raw) = new(raw).to_human
21
+
22
+ # Cron fields, plus an optional trailing timezone that Solid Queue allows.
23
+ def fields
24
+ @fields ||= begin
25
+ parts = raw.split(/\s+/)
26
+ parts.size.between?(5, 6) ? parts : nil
27
+ end
28
+ end
29
+
30
+ def timezone
31
+ fields && fields.size == 6 ? fields[5] : nil
32
+ end
33
+
34
+ def to_human
35
+ return nil unless fields
36
+
37
+ minute, hour, day_of_month, month, day_of_week = fields.first(5)
38
+ return nil unless simple?(minute, hour, day_of_month, month, day_of_week)
39
+
40
+ [ frequency(minute, hour, day_of_month, month, day_of_week), timezone ].compact.join(" ").presence
41
+ end
42
+
43
+ private
44
+ # Only fields we can describe faithfully: a wildcard, a plain number, or a
45
+ # `*/n` step. Lists and ranges are left to the raw cron.
46
+ def simple?(*values)
47
+ values.all? { |value| value.match?(%r{\A(\*|\d+|\*/\d+)\z}) }
48
+ end
49
+
50
+ def frequency(minute, hour, day_of_month, month, day_of_week)
51
+ return every_n_minutes(minute) if step(minute) && hour == "*"
52
+ return "every minute" if minute == "*" && hour == "*"
53
+
54
+ return nil unless minute.match?(/\A\d+\z/)
55
+
56
+ return "every #{pluralize_hours(step(hour))} at :#{pad(minute)}" if step(hour)
57
+ return "every hour at :#{pad(minute)}" if hour == "*"
58
+ return nil unless hour.match?(/\A\d+\z/)
59
+
60
+ at = "at #{pad(hour)}:#{pad(minute)}"
61
+
62
+ if day_of_week != "*"
63
+ # cron ORs day-of-month with day-of-week when both are restricted, so
64
+ # "0 0 1 1 1" is not "Mondays" — it is "January 1st or any Monday in
65
+ # January". Refuse to describe that rather than describe it wrongly.
66
+ return nil unless day_of_month == "*" && month == "*"
67
+ return nil unless day_of_week.match?(/\A\d+\z/)
68
+
69
+ return "#{DAYS[day_of_week.to_i % 7]}s #{at}"
70
+ end
71
+
72
+ return "#{ordinal_day(day_of_month)} of #{month_name(month)} #{at}" if day_of_month != "*" && month != "*"
73
+ return "#{ordinal_day(day_of_month)} of every month #{at}" if day_of_month != "*"
74
+ return "every day of #{month_name(month)} #{at}" if month != "*"
75
+
76
+ "every day #{at}"
77
+ end
78
+
79
+ def every_n_minutes(minute)
80
+ n = step(minute)
81
+ n == 1 ? "every minute" : "every #{n} minutes"
82
+ end
83
+
84
+ def pluralize_hours(n) = n == 1 ? "hour" : "#{n} hours"
85
+
86
+ def ordinal_day(value)
87
+ return nil unless value.match?(/\A\d+\z/)
88
+
89
+ "day #{value.to_i}"
90
+ end
91
+
92
+ def month_name(value)
93
+ value.match?(/\A\d+\z/) ? MONTHS[value.to_i] : nil
94
+ end
95
+
96
+ def step(value)
97
+ value[%r{\A\*/(\d+)\z}, 1]&.to_i
98
+ end
99
+
100
+ def pad(value) = format("%02d", value.to_i)
101
+ end
102
+ end
@@ -0,0 +1,64 @@
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
@@ -0,0 +1,24 @@
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