Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions app/controllers/concerns/flightdeck/toasts.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
# frozen_string_literal: true

module Flightdeck
# Shared response shape for the small member actions on the infrastructure
# pages: a toast plus a re-rendered frame over Turbo Streams, or a redirect
# carrying the same sentence in the flash when Turbo is not in play.
# Shared response shape for every mutating action: a toast plus a re-rendered
# polling frame over Turbo Streams, or a redirect carrying the same sentence
# in the flash when Turbo is not in play.
#
# The frame is described as data — `{ id:, url:, partial:, locals: }` — and
# rendered by the one shared template, so every controller's stream response
# is the same markup by construction.
module Toasts
extend ActiveSupport::Concern

private
def toast(message, level: :success)
@toast = { message: message, level: level, continuable: false }
def toast(message, level: :success, continuable: false)
@toast = { message: message, level: level, continuable: continuable }
end

def respond_with_toast(template, fallback:)
def respond_with_toast(frame, fallback:)
respond_to do |format|
format.turbo_stream { render template, formats: :turbo_stream }
format.any do
flash[@toast[:level] == :error ? :alert : :notice] = @toast[:message]
redirect_to fallback
end
format.turbo_stream { render_refresh_stream(frame) }
format.any { redirect_with_toast_flash(fallback) }
end
end

def render_refresh_stream(frame)
@frame = frame
render "flightdeck/shared/refresh", formats: :turbo_stream
end

# The one place that knows how a toast level maps onto the flash.
def redirect_with_toast_flash(fallback)
flash[@toast[:level] == :error ? :alert : :notice] = @toast[:message]
redirect_to fallback
end
end
end
83 changes: 41 additions & 42 deletions app/controllers/flightdeck/jobs/actions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,24 @@ module Jobs
# * selected jobs POST /jobs/retry with job_ids[]
# * all matching POST /jobs/retry with scope=all + the list filters
class ActionsController < Flightdeck::ApplicationController
include Toasts

def create
result =
if params[:id].present?
act_on_single
elsif params[:scope] == "all"
act_on_all_matching
else
act_on_selected
if params[:id].present?
act_on_single
elsif params[:scope] == "all"
act_on_all_matching
else
act_on_selected
end

respond_to do |format|
format.turbo_stream do
build_refreshed_list
render_refresh_stream(refreshed_frame)
end

respond_with(result)
format.any { redirect_with_toast_flash(list_url) }
end
end

private
Expand All @@ -37,7 +44,6 @@ def apply(execution)
raise NotImplementedError
end

def verb = raise(NotImplementedError)
def past_tense = raise(NotImplementedError)

# --- scopes -----------------------------------------------------------
Expand All @@ -48,27 +54,28 @@ def find_single(job_id)

def act_on_single
execution = find_single(params[:id])
return failure("That job is no longer #{blocked_reason}.") unless execution
return toast("That job is no longer #{blocked_reason}.", level: :error) unless execution

apply(execution)
success("#{past_tense.capitalize} job ##{params[:id]}.")
toast "#{past_tense.capitalize} job ##{params[:id]}."
rescue SolidQueue::Execution::UndiscardableError => error
failure(error.message)
toast error.message, level: :error
end

# Selected ids are re-checked against the live relation rather than
# trusted: between rendering the page and clicking the button a worker
# may have retried, finished or discarded any of them.
def act_on_selected
ids = Array(params[:job_ids]).map { |id| Integer(id, exception: false) }.compact.uniq
return failure("Nothing selected.") if ids.empty?
return toast("Nothing selected.", level: :error) if ids.empty?

if ids.size > Flightdeck.config.bulk_action_limit
return failure("Select at most #{number_with_delimiter(Flightdeck.config.bulk_action_limit)} jobs.")
return toast("Select at most #{number_with_delimiter(Flightdeck.config.bulk_action_limit)} jobs.",
level: :error)
end

executions = target_relation.where(job_id: ids).to_a
return failure("Those jobs are no longer #{blocked_reason}.") if executions.empty?
return toast("Those jobs are no longer #{blocked_reason}.", level: :error) if executions.empty?

applied = 0
SolidQueue::Record.transaction do
Expand All @@ -81,13 +88,13 @@ def act_on_selected
missing = ids.size - applied
message = "#{past_tense.capitalize} #{pluralize_jobs(applied)}."
message += " #{missing} had already moved on." if missing.positive?
success(message)
toast message
end

def act_on_all_matching
result = BulkAction.new(relation: target_relation).call { |execution| apply(execution) }

success(bulk_message(result), continuable: result.remaining?)
toast bulk_message(result), continuable: result.remaining?
end

def bulk_message(result)
Expand All @@ -101,27 +108,6 @@ def bulk_message(result)

# --- responses --------------------------------------------------------

def success(message, continuable: false)
{ message: message, level: :success, continuable: continuable }
end

def failure(message)
{ message: message, level: :error, continuable: false }
end

def respond_with(result)
@toast = result

respond_to do |format|
format.turbo_stream do
build_refreshed_list
render "flightdeck/jobs/actions/create", formats: :turbo_stream
end
format.html { redirect_back_to_list(result) }
format.any { redirect_back_to_list(result) }
end
end

# The list the action was launched from, re-queried so the same response
# that carries the toast also carries settled rows and counts.
def build_refreshed_list
Expand All @@ -135,14 +121,27 @@ def build_refreshed_list
end
end

# `target: nil` because the jobs frame is the one frame whose links
# navigate in place (the pager); it matches the index view's frame.
def refreshed_frame
{ id: "fd-jobs", target: nil, url: refreshed_list_url,
partial: "flightdeck/jobs/list",
locals: { query: @refreshed_query, rows: @refreshed_rows, groups: @refreshed_groups } }
end

# URL the refreshed frame should poll, rebuilt from the filters the
# action carried rather than from the POST's own path.
def refreshed_list_url
jobs_path(list_filters.merge(state: @refreshed_state == :all ? nil : @refreshed_state).compact)
end

def refreshed_state
state = params[:state].presence&.to_sym || default_state
JobsQuery::STATES.include?(state) ? state : :all
end

def redirect_back_to_list(result)
flash[result[:level] == :error ? :alert : :notice] = result[:message]
redirect_to jobs_path(list_filters.merge(state: params[:state].presence || default_state))
def list_url
jobs_path(list_filters.merge(state: params[:state].presence || default_state))
end

def default_state = :failed
Expand Down
1 change: 0 additions & 1 deletion app/controllers/flightdeck/jobs/discards_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ def apply(execution)
execution.discard
end

def verb = "discard"
def past_tense = "discarded"
def blocked_reason = "discardable"
def default_state = discard_state
Expand Down
1 change: 0 additions & 1 deletion app/controllers/flightdeck/jobs/retries_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ def apply(execution)
execution.retry
end

def verb = "retry"
def past_tense = "retried"
def blocked_reason = "failed"
end
Expand Down
11 changes: 8 additions & 3 deletions app/controllers/flightdeck/processes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,27 @@ def prune
unless node.prunable?
toast "#{node.kind} #{node.name} is still sending heartbeats — only unresponsive " \
"processes can be pruned.", level: :error
return respond_with_toast("flightdeck/processes/prune", fallback: processes_path)
return respond_with_toast(processes_frame, fallback: processes_path)
end

claimed = node.claimed_count
node.process.prune
load_registry

toast "Pruned #{node.kind} #{node.name}#{released(claimed)}."
respond_with_toast "flightdeck/processes/prune", fallback: processes_path
respond_with_toast processes_frame, fallback: processes_path
end

private
def load_registry
@registry = ProcessRegistry.new
end

def processes_frame
{ id: "fd-processes", url: processes_path,
partial: "flightdeck/processes/fleet", locals: { registry: @registry } }
end

def released(claimed)
return "" if claimed.zero?

Expand All @@ -43,7 +48,7 @@ def released(claimed)

def missing
toast "That process is no longer registered.", level: :error
respond_with_toast "flightdeck/processes/prune", fallback: processes_path
respond_with_toast processes_frame, fallback: processes_path
end
end
end
11 changes: 8 additions & 3 deletions app/controllers/flightdeck/queues_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ def pause
SolidQueue::Queue.new(@queue_name).pause
reload_stats
toast "Paused #{@queue_name}. Workers will stop picking up its jobs."
respond_with_toast "flightdeck/queues/update", fallback: queues_path
respond_with_toast queues_frame, fallback: queues_path
end

def resume
SolidQueue::Queue.new(@queue_name).resume
reload_stats
toast "Resumed #{@queue_name}."
respond_with_toast "flightdeck/queues/update", fallback: queues_path
respond_with_toast queues_frame, fallback: queues_path
end

private
Expand All @@ -36,6 +36,11 @@ def reload_stats
Flightdeck::Cache.bypass { load_stats }
end

def queues_frame
{ id: "fd-queues", url: queues_path,
partial: "flightdeck/queues/cards", locals: { stats: @stats, sparklines: @sparklines } }
end

# Only queues Flightdeck is actually showing can be paused. That keeps an
# arbitrary parameter from creating pause rows for queues that do not
# exist.
Expand All @@ -45,7 +50,7 @@ def require_known_queue
return if @stats.find(@queue_name)

toast "Unknown queue #{@queue_name.presence || "(blank)"}.", level: :error
respond_with_toast "flightdeck/queues/update", fallback: queues_path
respond_with_toast queues_frame, fallback: queues_path
end
end
end
9 changes: 7 additions & 2 deletions app/controllers/flightdeck/recurring_tasks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,26 @@ def run
"class refused to enqueue.", level: :error
end

respond_with_toast "flightdeck/recurring_tasks/run", fallback: recurring_tasks_path
respond_with_toast recurring_frame, fallback: recurring_tasks_path
end

private
def load_catalog
@catalog = RecurringCatalog.new
end

def recurring_frame
{ id: "fd-recurring", url: recurring_tasks_path,
partial: "flightdeck/recurring_tasks/table", locals: { catalog: @catalog } }
end

def job_reference
@enqueued_job_id ? " as job ##{@enqueued_job_id}" : ""
end

def missing
toast "That recurring task no longer exists.", level: :error
respond_with_toast "flightdeck/recurring_tasks/run", fallback: recurring_tasks_path
respond_with_toast recurring_frame, fallback: recurring_tasks_path
end
end
end
17 changes: 17 additions & 0 deletions app/helpers/flightdeck/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

module Flightdeck
module ApplicationHelper
# The polling <turbo-frame> every panel lives in: the refresh Stimulus
# controller, its interval, and the URL the frame re-requests. One owner for
# the markup, so the index views and the turbo-stream replacements can never
# drift apart.
#
# `target:` defaults to "_top" because most frames are lists whose links
# leave the frame; the jobs list opts out (`target: nil`) so its pager can
# navigate in place.
def fd_refresh_frame(id, url:, interval: fd_poll_ms, target: "_top", **options, &block)
tag.turbo_frame(
id: id,
target: target,
data: { controller: "refresh", refresh_interval_value: interval, refresh_url_value: url },
**options, &block
)
end

# Path to a built asset, resolved through the committed manifest. Returns
# nil when assets have not been built yet (fresh checkout / dev), so the
# layout can simply omit the tag instead of blowing up.
Expand Down
21 changes: 1 addition & 20 deletions app/helpers/flightdeck/jobs_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,7 @@ def fd_full_time(time)
end

def fd_duration(seconds)
return "—" if seconds.nil?

seconds = seconds.to_f.abs
case seconds
when 0...1 then "#{(seconds * 1000).round}ms"
when 1...60 then "#{seconds.round(1)}s"
when 60...3600 then "#{(seconds / 60).round}m"
when 3600...86_400 then "#{(seconds / 3600).round(1)}h"
else "#{(seconds / 86_400).round(1)}d"
end
seconds.nil? ? "—" : Flightdeck::Duration.humanize(seconds)
end

def fd_ago(time)
Expand Down Expand Up @@ -102,10 +93,6 @@ def fd_args_preview(preview, length: 120)
Flightdeck::ArgumentsPreview.format(preview, length: length)
end

def fd_jobs_url(overrides = {})
jobs_path(list_filters.merge(state: params[:state].presence).compact.merge(overrides))
end

# The frame refreshes itself by re-requesting the URL it is showing, so
# filters, state tab and cursor all survive a poll.
def fd_current_list_url
Expand All @@ -123,12 +110,6 @@ def fd_error_cell(summary, grouped: false)
text.to_s
end

# URL the refreshed frame should poll after an action, rebuilt from the
# filters the action carried rather than from the POST's own path.
def fd_refreshed_list_url
jobs_path(list_filters.merge(state: @refreshed_state == :all ? nil : @refreshed_state).compact)
end

def fd_toast_level_class(level)
level.to_sym == :error ? "fd-toast error" : "fd-toast"
end
Expand Down
Loading