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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +190 -0
- data/app/assets/flightdeck/flightdeck-00403457c5d9.css +3 -0
- data/app/assets/flightdeck/flightdeck-c02e38040ddb.js +414 -0
- data/app/assets/flightdeck/manifest.json +16 -0
- data/app/controllers/concerns/flightdeck/toasts.rb +25 -0
- data/app/controllers/flightdeck/application_controller.rb +96 -0
- data/app/controllers/flightdeck/assets_controller.rb +39 -0
- data/app/controllers/flightdeck/jobs/actions_controller.rb +167 -0
- data/app/controllers/flightdeck/jobs/discards_controller.rb +44 -0
- data/app/controllers/flightdeck/jobs/retries_controller.rb +23 -0
- data/app/controllers/flightdeck/jobs_controller.rb +39 -0
- data/app/controllers/flightdeck/overview_controller.rb +11 -0
- data/app/controllers/flightdeck/processes_controller.rb +49 -0
- data/app/controllers/flightdeck/queues_controller.rb +51 -0
- data/app/controllers/flightdeck/recurring_tasks_controller.rb +46 -0
- data/app/helpers/flightdeck/application_helper.rb +68 -0
- data/app/helpers/flightdeck/infrastructure_helper.rb +24 -0
- data/app/helpers/flightdeck/jobs_helper.rb +136 -0
- data/app/helpers/flightdeck/metrics_helper.rb +49 -0
- data/app/models/flightdeck/arguments_preview.rb +95 -0
- data/app/models/flightdeck/bulk_action.rb +83 -0
- data/app/models/flightdeck/cache.rb +51 -0
- data/app/models/flightdeck/cron_schedule.rb +102 -0
- data/app/models/flightdeck/error_summary.rb +64 -0
- data/app/models/flightdeck/grouped_failures.rb +24 -0
- data/app/models/flightdeck/job_detail.rb +163 -0
- data/app/models/flightdeck/job_row.rb +93 -0
- data/app/models/flightdeck/jobs_query.rb +241 -0
- data/app/models/flightdeck/metrics/bar_chart.rb +49 -0
- data/app/models/flightdeck/metrics/chart.rb +111 -0
- data/app/models/flightdeck/metrics/line_chart.rb +67 -0
- data/app/models/flightdeck/metrics/series.rb +165 -0
- data/app/models/flightdeck/metrics/sparkline.rb +45 -0
- data/app/models/flightdeck/metrics/time_bucket.rb +95 -0
- data/app/models/flightdeck/overview.rb +217 -0
- data/app/models/flightdeck/process_registry.rb +108 -0
- data/app/models/flightdeck/queue_stats.rb +88 -0
- data/app/models/flightdeck/recurring_catalog.rb +116 -0
- data/app/views/flightdeck/jobs/_failed_row.html.erb +33 -0
- data/app/views/flightdeck/jobs/_filter_bar.html.erb +29 -0
- data/app/views/flightdeck/jobs/_list.html.erb +89 -0
- data/app/views/flightdeck/jobs/_pager.html.erb +22 -0
- data/app/views/flightdeck/jobs/_row.html.erb +23 -0
- data/app/views/flightdeck/jobs/_state_tabs.html.erb +10 -0
- data/app/views/flightdeck/jobs/actions/create.turbo_stream.erb +18 -0
- data/app/views/flightdeck/jobs/index.html.erb +13 -0
- data/app/views/flightdeck/jobs/show.html.erb +100 -0
- data/app/views/flightdeck/metrics/_bar_chart.html.erb +34 -0
- data/app/views/flightdeck/metrics/_line_chart.html.erb +38 -0
- data/app/views/flightdeck/metrics/_range_control.html.erb +7 -0
- data/app/views/flightdeck/metrics/_sparkline.html.erb +9 -0
- data/app/views/flightdeck/overview/_completion.html.erb +19 -0
- data/app/views/flightdeck/overview/_failures.html.erb +28 -0
- data/app/views/flightdeck/overview/_fleet.html.erb +21 -0
- data/app/views/flightdeck/overview/_queues.html.erb +41 -0
- data/app/views/flightdeck/overview/_throughput.html.erb +23 -0
- data/app/views/flightdeck/overview/_tiles.html.erb +14 -0
- data/app/views/flightdeck/overview/index.html.erb +54 -0
- data/app/views/flightdeck/processes/_fleet.html.erb +54 -0
- data/app/views/flightdeck/processes/_row.html.erb +28 -0
- data/app/views/flightdeck/processes/index.html.erb +9 -0
- data/app/views/flightdeck/processes/prune.turbo_stream.erb +15 -0
- data/app/views/flightdeck/queues/_cards.html.erb +46 -0
- data/app/views/flightdeck/queues/index.html.erb +9 -0
- data/app/views/flightdeck/queues/update.turbo_stream.erb +15 -0
- data/app/views/flightdeck/recurring_tasks/_table.html.erb +53 -0
- data/app/views/flightdeck/recurring_tasks/index.html.erb +9 -0
- data/app/views/flightdeck/recurring_tasks/run.turbo_stream.erb +15 -0
- data/app/views/flightdeck/shared/_toast.html.erb +7 -0
- data/app/views/layouts/flightdeck/application.html.erb +110 -0
- data/config/routes.rb +40 -0
- data/lib/flightdeck/assets.rb +78 -0
- data/lib/flightdeck/configuration.rb +80 -0
- data/lib/flightdeck/engine.rb +31 -0
- data/lib/flightdeck/version.rb +5 -0
- data/lib/flightdeck.rb +31 -0
- 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
|