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,16 @@
1
+ {
2
+ "flightdeck.css": {
3
+ "file": "flightdeck-00403457c5d9.css",
4
+ "digest": "00403457c5d9",
5
+ "sha256": "00403457c5d9dd7455ccced13249ca5aa8f953e4532163f4493d58c561157350",
6
+ "content_type": "text/css; charset=utf-8",
7
+ "size": 118411
8
+ },
9
+ "flightdeck.js": {
10
+ "file": "flightdeck-c02e38040ddb.js",
11
+ "digest": "c02e38040ddb",
12
+ "sha256": "c02e38040ddb22871c1cb96daa7cb78242ceda0dd243c1ab3678fcf6f022d73e",
13
+ "content_type": "text/javascript; charset=utf-8",
14
+ "size": 150889
15
+ }
16
+ }
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # Shared response shape for the small member actions on the infrastructure
5
+ # pages: a toast plus a re-rendered frame over Turbo Streams, or a redirect
6
+ # carrying the same sentence in the flash when Turbo is not in play.
7
+ module Toasts
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+ def toast(message, level: :success)
12
+ @toast = { message: message, level: level, continuable: false }
13
+ end
14
+
15
+ def respond_with_toast(template, fallback:)
16
+ respond_to do |format|
17
+ format.turbo_stream { render template, formats: :turbo_stream }
18
+ format.any do
19
+ flash[@toast[:level] == :error ? :alert : :notice] = @toast[:message]
20
+ redirect_to fallback
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/security_utils"
4
+
5
+ module Flightdeck
6
+ class ApplicationController < Flightdeck.base_controller_class
7
+ UNCONFIGURED_MESSAGE = <<~TEXT
8
+ Flightdeck is not configured for authentication, so it is refusing to
9
+ serve the dashboard.
10
+
11
+ Pick one of the following, then restart your app.
12
+
13
+ 1. HTTP Basic via environment variables:
14
+
15
+ FLIGHTDECK_USERNAME=admin FLIGHTDECK_PASSWORD=a-long-secret
16
+
17
+ 2. HTTP Basic via Rails credentials (bin/rails credentials:edit):
18
+
19
+ flightdeck:
20
+ username: admin
21
+ password: a-long-secret
22
+
23
+ 3. HTTP Basic in an initializer (config/initializers/flightdeck.rb):
24
+
25
+ Flightdeck.configure do |config|
26
+ config.http_basic = { username: "admin", password: "a-long-secret" }
27
+ end
28
+
29
+ 4. Reuse your application's own authentication by giving Flightdeck a
30
+ base controller to inherit from:
31
+
32
+ Flightdeck.configure do |config|
33
+ config.base_controller_class = "Admin::BaseController"
34
+ end
35
+
36
+ Flightdeck never serves unauthenticated requests, in any environment.
37
+ TEXT
38
+
39
+ protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
40
+
41
+ layout "flightdeck/application"
42
+
43
+ # Declared explicitly rather than relying on isolate_namespace: when the host
44
+ # supplies its own base controller, that controller's helper set is the one
45
+ # Rails wires up, and Flightdeck's own helpers would otherwise be missing.
46
+ helper Flightdeck::Engine.helpers
47
+
48
+ # Prepended so that an unauthenticated request is turned away before any
49
+ # other filter — including CSRF verification — gets a chance to answer it
50
+ # with a different status.
51
+ prepend_before_action :authenticate_flightdeck!
52
+
53
+ helper_method :list_filters
54
+
55
+ class << self
56
+ # True when the host handed us its own base controller: that controller's
57
+ # own filters are already in our callback chain, so Flightdeck must not
58
+ # layer a second, conflicting challenge on top of it.
59
+ def host_authenticated?
60
+ Flightdeck.config.base_controller_class.present?
61
+ end
62
+ end
63
+
64
+ private
65
+ # The filter set that identifies a list. Carried through pagination links,
66
+ # frame refreshes and "apply to all matching" so that every one of them is
67
+ # looking at exactly the same set of rows.
68
+ def list_filters
69
+ @list_filters ||= {
70
+ class_name: params[:class_name].presence,
71
+ queue_name: params[:queue_name].presence,
72
+ q: params[:q].presence
73
+ }.compact
74
+ end
75
+
76
+ def authenticate_flightdeck!
77
+ return if self.class.host_authenticated?
78
+
79
+ credentials = Flightdeck.config.resolve_http_basic
80
+ return render_unconfigured if credentials.nil?
81
+
82
+ authenticate_or_request_with_http_basic("Flightdeck") do |username, password|
83
+ secure_equal?(username, credentials[:username]) &
84
+ secure_equal?(password, credentials[:password])
85
+ end
86
+ end
87
+
88
+ def secure_equal?(given, expected)
89
+ ActiveSupport::SecurityUtils.secure_compare(given.to_s, expected.to_s)
90
+ end
91
+
92
+ def render_unconfigured
93
+ render plain: UNCONFIGURED_MESSAGE, status: :unauthorized, layout: false
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ # Deliberately not an ApplicationController subclass: assets are immutable,
5
+ # digest-addressed and carry no data, so they are served without auth and
6
+ # without the cost of the full ActionController::Base stack.
7
+ class AssetsController < ActionController::Metal
8
+ include ActionController::Head
9
+
10
+ CACHE_CONTROL = "public, max-age=31536000, immutable"
11
+
12
+ def show
13
+ entry = Flightdeck::Assets.find(params[:name])
14
+ return head(:not_found) unless entry
15
+
16
+ etag = %("#{entry["digest"]}")
17
+ headers["Cache-Control"] = CACHE_CONTROL
18
+ headers["ETag"] = etag
19
+ headers["X-Content-Type-Options"] = "nosniff"
20
+
21
+ return head(:not_modified) if stale_etag_match?(etag)
22
+
23
+ body = Flightdeck::Assets.read(params[:name])
24
+ return head(:not_found) unless body
25
+
26
+ headers["Content-Type"] = entry["content_type"] || Flightdeck::Assets.content_type_for(entry["file"])
27
+ self.status = 200
28
+ self.response_body = body
29
+ end
30
+
31
+ private
32
+ def stale_etag_match?(etag)
33
+ matches = request.get_header("HTTP_IF_NONE_MATCH")
34
+ return false if matches.nil?
35
+
36
+ matches == "*" || matches.split(",").any? { |candidate| candidate.strip == etag }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module Jobs
5
+ # Shared machinery for the two destructive jobs actions. Subclasses say what
6
+ # rows they act on and what Solid Queue method does the work; everything
7
+ # about scoping, batching, counting and responding lives here.
8
+ #
9
+ # Three scopes, one action:
10
+ # * one job POST /jobs/:id/retry
11
+ # * selected jobs POST /jobs/retry with job_ids[]
12
+ # * all matching POST /jobs/retry with scope=all + the list filters
13
+ class ActionsController < Flightdeck::ApplicationController
14
+ def create
15
+ result =
16
+ if params[:id].present?
17
+ act_on_single
18
+ elsif params[:scope] == "all"
19
+ act_on_all_matching
20
+ else
21
+ act_on_selected
22
+ end
23
+
24
+ respond_with(result)
25
+ end
26
+
27
+ private
28
+ # --- subclass contract ------------------------------------------------
29
+
30
+ # Relation of execution rows this action may act on, already filtered.
31
+ def target_relation
32
+ raise NotImplementedError
33
+ end
34
+
35
+ # The Solid Queue call that performs the action on one execution row.
36
+ def apply(execution)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def verb = raise(NotImplementedError)
41
+ def past_tense = raise(NotImplementedError)
42
+
43
+ # --- scopes -----------------------------------------------------------
44
+
45
+ def find_single(job_id)
46
+ target_relation.find_by(job_id: job_id)
47
+ end
48
+
49
+ def act_on_single
50
+ execution = find_single(params[:id])
51
+ return failure("That job is no longer #{blocked_reason}.") unless execution
52
+
53
+ apply(execution)
54
+ success("#{past_tense.capitalize} job ##{params[:id]}.")
55
+ rescue SolidQueue::Execution::UndiscardableError => error
56
+ failure(error.message)
57
+ end
58
+
59
+ # Selected ids are re-checked against the live relation rather than
60
+ # trusted: between rendering the page and clicking the button a worker
61
+ # may have retried, finished or discarded any of them.
62
+ def act_on_selected
63
+ ids = Array(params[:job_ids]).map { |id| Integer(id, exception: false) }.compact.uniq
64
+ return failure("Nothing selected.") if ids.empty?
65
+
66
+ if ids.size > Flightdeck.config.bulk_action_limit
67
+ return failure("Select at most #{number_with_delimiter(Flightdeck.config.bulk_action_limit)} jobs.")
68
+ end
69
+
70
+ executions = target_relation.where(job_id: ids).to_a
71
+ return failure("Those jobs are no longer #{blocked_reason}.") if executions.empty?
72
+
73
+ applied = 0
74
+ SolidQueue::Record.transaction do
75
+ executions.each do |execution|
76
+ apply(execution)
77
+ applied += 1
78
+ end
79
+ end
80
+
81
+ missing = ids.size - applied
82
+ message = "#{past_tense.capitalize} #{pluralize_jobs(applied)}."
83
+ message += " #{missing} had already moved on." if missing.positive?
84
+ success(message)
85
+ end
86
+
87
+ def act_on_all_matching
88
+ result = BulkAction.new(relation: target_relation).call { |execution| apply(execution) }
89
+
90
+ success(bulk_message(result), continuable: result.remaining?)
91
+ end
92
+
93
+ def bulk_message(result)
94
+ if result.stopped_early? && result.remaining?
95
+ "#{past_tense.capitalize} #{number_with_delimiter(result.processed)} of " \
96
+ "~#{result.total_label} — continue?"
97
+ else
98
+ "#{past_tense.capitalize} #{pluralize_jobs(result.processed)}."
99
+ end
100
+ end
101
+
102
+ # --- responses --------------------------------------------------------
103
+
104
+ def success(message, continuable: false)
105
+ { message: message, level: :success, continuable: continuable }
106
+ end
107
+
108
+ def failure(message)
109
+ { message: message, level: :error, continuable: false }
110
+ end
111
+
112
+ def respond_with(result)
113
+ @toast = result
114
+
115
+ respond_to do |format|
116
+ format.turbo_stream do
117
+ build_refreshed_list
118
+ render "flightdeck/jobs/actions/create", formats: :turbo_stream
119
+ end
120
+ format.html { redirect_back_to_list(result) }
121
+ format.any { redirect_back_to_list(result) }
122
+ end
123
+ end
124
+
125
+ # The list the action was launched from, re-queried so the same response
126
+ # that carries the toast also carries settled rows and counts.
127
+ def build_refreshed_list
128
+ # Bypass the count cache: this response exists precisely to show the
129
+ # effect of the action that just ran.
130
+ Flightdeck::Cache.bypass do
131
+ @refreshed_state = refreshed_state
132
+ @refreshed_query = JobsQuery.new(**list_filters, state: @refreshed_state)
133
+ @refreshed_rows = @refreshed_query.rows
134
+ @refreshed_groups = GroupedFailures.build(@refreshed_rows) if @refreshed_state == :failed
135
+ end
136
+ end
137
+
138
+ def refreshed_state
139
+ state = params[:state].presence&.to_sym || default_state
140
+ JobsQuery::STATES.include?(state) ? state : :all
141
+ end
142
+
143
+ def redirect_back_to_list(result)
144
+ flash[result[:level] == :error ? :alert : :notice] = result[:message]
145
+ redirect_to jobs_path(list_filters.merge(state: params[:state].presence || default_state))
146
+ end
147
+
148
+ def default_state = :failed
149
+
150
+ def blocked_reason = "eligible"
151
+
152
+ def pluralize_jobs(count)
153
+ "#{number_with_delimiter(count)} #{"job".pluralize(count)}"
154
+ end
155
+
156
+ def number_with_delimiter(value)
157
+ view_context.number_with_delimiter(value)
158
+ end
159
+
160
+ # The filters the list was showing, used to rebuild the exact same set
161
+ # server-side for "apply to all matching".
162
+ def scoped_query(state:)
163
+ JobsQuery.new(**list_filters, state: state)
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module Jobs
5
+ # Discarding delegates to Solid Queue's Execution#discard, which destroys the
6
+ # job and its execution row together and lets Solid Queue release whatever
7
+ # concurrency locks the job was holding.
8
+ #
9
+ # A job that is currently being executed cannot be discarded — Solid Queue
10
+ # raises UndiscardableError, and Flightdeck surfaces that rather than trying
11
+ # to work around it.
12
+ class DiscardsController < ActionsController
13
+ DISCARDABLE_STATES = %i[ready scheduled blocked failed].freeze
14
+
15
+ private
16
+ def target_relation
17
+ scoped_query(state: discard_state).filtered_relation
18
+ end
19
+
20
+ def discard_state
21
+ state = params[:state].presence&.to_sym || :failed
22
+ DISCARDABLE_STATES.include?(state) ? state : :failed
23
+ end
24
+
25
+ # A single discard may arrive from any list, so look for the job in every
26
+ # table it could legitimately be sitting in rather than trusting the
27
+ # state the page happened to be showing.
28
+ def find_single(job_id)
29
+ DISCARDABLE_STATES.lazy.filter_map do |state|
30
+ JobRow.model_for(state).find_by(job_id: job_id)
31
+ end.first
32
+ end
33
+
34
+ def apply(execution)
35
+ execution.discard
36
+ end
37
+
38
+ def verb = "discard"
39
+ def past_tense = "discarded"
40
+ def blocked_reason = "discardable"
41
+ def default_state = discard_state
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module Jobs
5
+ # Retrying is Solid Queue's own FailedExecution#retry: it resets the job's
6
+ # execution counters, re-dispatches it and deletes the failure row, all
7
+ # under a lock. Flightdeck never reimplements any of that.
8
+ class RetriesController < ActionsController
9
+ private
10
+ def target_relation
11
+ scoped_query(state: :failed).filtered_relation
12
+ end
13
+
14
+ def apply(execution)
15
+ execution.retry
16
+ end
17
+
18
+ def verb = "retry"
19
+ def past_tense = "retried"
20
+ def blocked_reason = "failed"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ class JobsController < ApplicationController
5
+ rescue_from JobsQuery::InvalidState, with: :unknown_state
6
+ rescue_from ActiveRecord::RecordNotFound, with: :job_gone
7
+
8
+ def index
9
+ @query = JobsQuery.new(**list_filters, state: state_param, before_id: params[:before_id])
10
+ @rows = @query.rows
11
+ @state_counts = JobsQuery.state_counts(**list_filters)
12
+ @groups = GroupedFailures.build(@rows) if state_param == :failed && group_failures?
13
+ end
14
+
15
+ def show
16
+ @job = JobDetail.find(params[:id])
17
+ end
18
+
19
+ private
20
+ def state_param
21
+ value = params[:state].presence&.to_sym || :all
22
+ raise JobsQuery::InvalidState, "unknown job state #{params[:state].inspect}" unless JobsQuery::STATES.include?(value)
23
+
24
+ value
25
+ end
26
+
27
+ def group_failures?
28
+ params[:group_by].blank? || params[:group_by] == "exception_class"
29
+ end
30
+
31
+ def unknown_state
32
+ redirect_to jobs_path, alert: "Unknown job state."
33
+ end
34
+
35
+ def job_gone
36
+ redirect_to jobs_path(state: params[:state]), alert: "That job no longer exists."
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ class OverviewController < ApplicationController
5
+ def index
6
+ @overview = Overview.new
7
+ @window = Metrics::Series.window_for(params[:range])
8
+ @series = @overview.series(window: @window.key)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ class ProcessesController < ApplicationController
5
+ include Toasts
6
+
7
+ before_action :load_registry
8
+
9
+ def index
10
+ end
11
+
12
+ # Pruning is Solid Queue's Process#prune: it fails the process's claimed
13
+ # executions with a ProcessPrunedError and deregisters the row. Flightdeck
14
+ # only decides *whether* a process may be pruned.
15
+ def prune
16
+ node = @registry.find(params[:id])
17
+ return missing unless node
18
+
19
+ unless node.prunable?
20
+ toast "#{node.kind} #{node.name} is still sending heartbeats — only unresponsive " \
21
+ "processes can be pruned.", level: :error
22
+ return respond_with_toast("flightdeck/processes/prune", fallback: processes_path)
23
+ end
24
+
25
+ claimed = node.claimed_count
26
+ node.process.prune
27
+ load_registry
28
+
29
+ toast "Pruned #{node.kind} #{node.name}#{released(claimed)}."
30
+ respond_with_toast "flightdeck/processes/prune", fallback: processes_path
31
+ end
32
+
33
+ private
34
+ def load_registry
35
+ @registry = ProcessRegistry.new
36
+ end
37
+
38
+ def released(claimed)
39
+ return "" if claimed.zero?
40
+
41
+ " and released #{helpers.pluralize(claimed, "claimed execution")}"
42
+ end
43
+
44
+ def missing
45
+ toast "That process is no longer registered.", level: :error
46
+ respond_with_toast "flightdeck/processes/prune", fallback: processes_path
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ class QueuesController < ApplicationController
5
+ include Toasts
6
+
7
+ before_action :load_stats
8
+ before_action :require_known_queue, only: %i[pause resume]
9
+
10
+ def index
11
+ end
12
+
13
+ # Pausing and resuming are Solid Queue's own Queue#pause / #resume: they own
14
+ # what a pause means, and Flightdeck never writes solid_queue_pauses itself.
15
+ def pause
16
+ SolidQueue::Queue.new(@queue_name).pause
17
+ reload_stats
18
+ toast "Paused #{@queue_name}. Workers will stop picking up its jobs."
19
+ respond_with_toast "flightdeck/queues/update", fallback: queues_path
20
+ end
21
+
22
+ def resume
23
+ SolidQueue::Queue.new(@queue_name).resume
24
+ reload_stats
25
+ toast "Resumed #{@queue_name}."
26
+ respond_with_toast "flightdeck/queues/update", fallback: queues_path
27
+ end
28
+
29
+ private
30
+ def load_stats
31
+ @stats = QueueStats.new
32
+ @sparklines = Metrics::Series.queue_sparklines
33
+ end
34
+
35
+ def reload_stats
36
+ Flightdeck::Cache.bypass { load_stats }
37
+ end
38
+
39
+ # Only queues Flightdeck is actually showing can be paused. That keeps an
40
+ # arbitrary parameter from creating pause rows for queues that do not
41
+ # exist.
42
+ def require_known_queue
43
+ @queue_name = params[:name].to_s
44
+
45
+ return if @stats.find(@queue_name)
46
+
47
+ toast "Unknown queue #{@queue_name.presence || "(blank)"}.", level: :error
48
+ respond_with_toast "flightdeck/queues/update", fallback: queues_path
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ class RecurringTasksController < ApplicationController
5
+ include Toasts
6
+
7
+ before_action :load_catalog
8
+
9
+ def index
10
+ end
11
+
12
+ # "Run now" is the task's own enqueue, so the recurring execution is
13
+ # recorded exactly as the scheduler would have recorded it.
14
+ def run
15
+ row = @catalog.find(params[:id])
16
+ return missing unless row
17
+
18
+ active_job = row.task.enqueue(at: Time.current)
19
+ load_catalog
20
+
21
+ if active_job
22
+ @enqueued_job_id = active_job.try(:provider_job_id)
23
+ toast "Enqueued #{row.key}#{job_reference}."
24
+ else
25
+ toast "#{row.key} was not enqueued — it has already run for this slot, or the job " \
26
+ "class refused to enqueue.", level: :error
27
+ end
28
+
29
+ respond_with_toast "flightdeck/recurring_tasks/run", fallback: recurring_tasks_path
30
+ end
31
+
32
+ private
33
+ def load_catalog
34
+ @catalog = RecurringCatalog.new
35
+ end
36
+
37
+ def job_reference
38
+ @enqueued_job_id ? " as job ##{@enqueued_job_id}" : ""
39
+ end
40
+
41
+ def missing
42
+ toast "That recurring task no longer exists.", level: :error
43
+ respond_with_toast "flightdeck/recurring_tasks/run", fallback: recurring_tasks_path
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flightdeck
4
+ module ApplicationHelper
5
+ # Path to a built asset, resolved through the committed manifest. Returns
6
+ # nil when assets have not been built yet (fresh checkout / dev), so the
7
+ # layout can simply omit the tag instead of blowing up.
8
+ def flightdeck_asset_path(logical_name)
9
+ digested = Flightdeck::Assets.digested_name(logical_name)
10
+ return nil unless digested
11
+
12
+ flightdeck.asset_file_path(name: digested)
13
+ end
14
+
15
+ # Sidebar counts are capped like every other count, and degrade to "—"
16
+ # rather than taking the whole page down if the queue database is
17
+ # unreachable.
18
+ def fd_nav_count(state)
19
+ query = Flightdeck::JobsQuery.new(state: state)
20
+ fd_count(query.count, capped: query.count_capped?)
21
+ rescue ActiveRecord::ActiveRecordError
22
+ "—"
23
+ end
24
+
25
+ # Infrastructure counts, cached like every other count and degrading to "—"
26
+ # rather than taking the whole page down if the queue database is away.
27
+ def fd_nav_queue_count
28
+ nav_count("queues") { Flightdeck::QueueStats.new.rows.size }
29
+ end
30
+
31
+ def fd_nav_process_count
32
+ nav_count("processes") { SolidQueue::Process.count }
33
+ end
34
+
35
+ def fd_nav_recurring_count
36
+ nav_count("recurring") { SolidQueue::RecurringTask.count }
37
+ end
38
+
39
+ def fd_nav_any_dead_processes?
40
+ nav_count("dead_processes") do
41
+ SolidQueue::Process.where(last_heartbeat_at: ..SolidQueue.process_alive_threshold.ago).exists?
42
+ end == true
43
+ end
44
+
45
+ def fd_nav_active?(state)
46
+ return false unless controller_name == "jobs" && action_name == "index"
47
+
48
+ current = params[:state].presence&.to_sym || :all
49
+ current == state
50
+ end
51
+
52
+ def nav_count(name, &block)
53
+ Flightdeck::Cache.fetch("nav", name, expires_in: Flightdeck.config.poll_interval, &block)
54
+ rescue ActiveRecord::ActiveRecordError
55
+ "—"
56
+ end
57
+
58
+ def flightdeck_brand_svg(size: 26)
59
+ tag.svg(width: size, height: size, viewBox: "0 0 26 26", fill: "none", "aria-hidden": true) do
60
+ safe_join([
61
+ tag.circle(cx: 13, cy: 13, r: 11.5, stroke: "var(--accent-ink)", "stroke-width": 1.6),
62
+ tag.path(d: "M3 15 C 8 10.5, 18 10.5, 23 15", stroke: "var(--accent-ink)", "stroke-width": 1.6, fill: "none"),
63
+ tag.path(d: "M9.5 13.2 h7 M13 11.4 v1.8", stroke: "var(--ink)", "stroke-width": 1.6, "stroke-linecap": "round")
64
+ ])
65
+ end
66
+ end
67
+ end
68
+ end