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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- The dashboard no longer 500s when the engine is mounted under a named scope
or namespace (e.g. `namespace :admin { mount Flightdeck::Engine, at: "/jobs" }`)
or with a custom route name via `as:`.

## [0.5.1] - 2026-07-27

### Added
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
flightdeck (0.5.1)
flightdeck (0.5.2)
rails (>= 7.1)
solid_queue (>= 1.0)

Expand Down Expand Up @@ -357,7 +357,7 @@ CHECKSUMS
erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
flightdeck (0.5.1)
flightdeck (0.5.2)
fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions app/assets/flightdeck/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"size": 118411
},
"flightdeck.js": {
"file": "flightdeck-8fc2423da15f.js",
"digest": "8fc2423da15f",
"sha256": "8fc2423da15f28fd3fcfd89fa820027ab955bdb902377c58d225b7fd81617b25",
"file": "flightdeck-747ab6d1a6fa.js",
"digest": "747ab6d1a6fa",
"sha256": "747ab6d1a6fafd8eb6ff4eca9f487ad62a11fdb0df52e8293db0d2699a575dcd",
"content_type": "text/javascript; charset=utf-8",
"size": 150889
}
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/flightdeck/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def flightdeck_asset_path(logical_name)
digested = Flightdeck::Assets.digested_name(logical_name)
return nil unless digested

flightdeck.asset_file_path(name: digested)
asset_file_path(name: digested)
end

# Sidebar counts are capped like every other count, and degrade to "—"
Expand Down
14 changes: 7 additions & 7 deletions app/views/layouts/flightdeck/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Flightdeck" %></title>
<meta name="turbo-root" content="<%= flightdeck.root_path %>">
<meta name="turbo-root" content="<%= root_path %>">
<%= csrf_meta_tags %>
<% if (css = flightdeck_asset_path("flightdeck.css")) %>
<link rel="stylesheet" href="<%= css %>">
Expand All @@ -22,36 +22,36 @@
</div>

<nav class="fd-nav">
<%= link_to flightdeck.root_path, class: "fd-nav-link#{" on" if current_page?(flightdeck.root_path)}" do %>
<%= link_to root_path, class: "fd-nav-link#{" on" if current_page?(root_path)}" do %>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M1.5 13.5v-5h3v5m1.5 0v-9h3v9m1.5 0v-12h3v12" stroke-linejoin="round"/></svg>
Overview
<% end %>

<span class="fd-nav-section">Jobs</span>

<%= link_to flightdeck.jobs_path, class: "fd-nav-link#{" on" if fd_nav_active?(:all)}" do %>
<%= link_to jobs_path, class: "fd-nav-link#{" on" if fd_nav_active?(:all)}" do %>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1.5" y="2.5" width="12" height="10" rx="1.5"/><path d="M4.5 6h6M4.5 9h4"/></svg>
All jobs <span class="fd-nav-count"><%= fd_nav_count(:all) %></span>
<% end %>

<%= link_to flightdeck.jobs_path(state: :failed), class: "fd-nav-link#{" on" if fd_nav_active?(:failed)}" do %>
<%= link_to jobs_path(state: :failed), class: "fd-nav-link#{" on" if fd_nav_active?(:failed)}" do %>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="7.5" cy="7.5" r="6"/><path d="M7.5 4.5v3.5M7.5 10.4v.2"/></svg>
Failed <span class="fd-nav-count"><%= fd_nav_count(:failed) %></span>
<% end %>

<span class="fd-nav-section">Infrastructure</span>

<%= link_to flightdeck.queues_path, class: "fd-nav-link#{" on" if controller_name == "queues"}" do %>
<%= link_to queues_path, class: "fd-nav-link#{" on" if controller_name == "queues"}" do %>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2 4h11M2 7.5h11M2 11h7"/></svg>
Queues <span class="fd-nav-count"><%= fd_nav_queue_count %></span>
<% end %>

<%= link_to flightdeck.processes_path, class: "fd-nav-link#{" on" if controller_name == "processes"}" do %>
<%= link_to processes_path, class: "fd-nav-link#{" on" if controller_name == "processes"}" do %>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="2" y="2" width="11" height="7" rx="1.5"/><path d="M5 13h5M7.5 9v4"/></svg>
Processes <span class="fd-nav-count <%= "alert" if fd_nav_any_dead_processes? %>"><%= fd_nav_process_count %></span>
<% end %>

<%= link_to flightdeck.recurring_tasks_path, class: "fd-nav-link#{" on" if controller_name == "recurring_tasks"}" do %>
<%= link_to recurring_tasks_path, class: "fd-nav-link#{" on" if controller_name == "recurring_tasks"}" do %>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="7.5" cy="7.5" r="6"/><path d="M7.5 4v3.5l2.5 1.5"/></svg>
Recurring <span class="fd-nav-count"><%= fd_nav_recurring_count %></span>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion lib/flightdeck/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Flightdeck
VERSION = "0.5.1"
VERSION = "0.5.2"
end
31 changes: 30 additions & 1 deletion test/boot_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,33 @@ class Flightdeck::BootTest < ActiveSupport::TestCase
assert result["with_host_token_body_has_shell"], "the dashboard shell did not render"
end

# Regression: the layout used to call route helpers through the host's
# mounted-route proxy, hardcoded as `flightdeck`. That method's name comes
# from the mount's route name, so `namespace :admin { mount ... }` (route
# name admin_flightdeck) left every page raising NameError. This boots a host
# whose only mount is namespaced — the `flightdeck` proxy does not exist in
# the process — and whose own `jobs_path` competes with the engine's.
test "the dashboard renders when the only mount is inside a namespace" do
result = probe(
"FLIGHTDECK_TEST_NAMESPACED_MOUNT" => "1",
"FLIGHTDECK_TEST_BASE_CONTROLLER" => "HostBaseController",
"FLIGHTDECK_PROBE_REQUEST" => "1",
"FLIGHTDECK_PROBE_MOUNT" => "/admin/jobs"
)

assert result["booted"], "dummy app failed to boot: #{result["error"]} #{result["backtrace"]}"
assert_equal 200, result["with_host_token"],
"the dashboard did not render under the namespaced mount: #{result["with_host_token_error"]}"
assert_equal "/admin/jobs/", result["turbo_root"]

hrefs = result["nav_hrefs"]
assert_operator hrefs.size, :>=, 5, "expected the sidebar links, got #{hrefs.inspect}"
hrefs.each do |href|
assert href.start_with?("/admin/jobs/"),
"nav link #{href.inspect} is not under the namespaced mount (the host's own jobs_path must not win)"
end
end

test "without a base controller the engine still refuses unauthenticated requests" do
result = probe("FLIGHTDECK_PROBE_REQUEST" => "1")

Expand All @@ -47,7 +74,9 @@ def probe(env = {})
"FLIGHTDECK_USERNAME" => nil,
"FLIGHTDECK_PASSWORD" => nil,
"FLIGHTDECK_TEST_BASE_CONTROLLER" => nil,
"FLIGHTDECK_PROBE_REQUEST" => nil
"FLIGHTDECK_TEST_NAMESPACED_MOUNT" => nil,
"FLIGHTDECK_PROBE_REQUEST" => nil,
"FLIGHTDECK_PROBE_MOUNT" => nil
}

stdout, stderr, status = Open3.capture3(base.merge(env), RbConfig.ruby, PROBE)
Expand Down
42 changes: 33 additions & 9 deletions test/dummy/config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
# frozen_string_literal: true

Rails.application.routes.draw do
root to: redirect("/flightdeck")
if ENV["FLIGHTDECK_TEST_NAMESPACED_MOUNT"].present?
# The real-world shape that broke referencing engine routes through the
# host's mounted-route proxy: the only mount lives inside a namespace, so its
# route name is admin_flightdeck and no `flightdeck` proxy method exists
# anywhere in the process (see test/boot_test.rb). The host also claims
# `jobs_path` for itself — the engine's own helper must still win inside
# engine views.
Rails.application.routes.draw do
namespace :admin do
mount Flightdeck::Engine, at: "/jobs"
end

mount Flightdeck::Engine => "/flightdeck"
get "host-jobs", to: proc { [ 200, { "Content-Type" => "text/plain" }, [ "host" ] ] }, as: :jobs
end
else
Rails.application.routes.draw do
root to: redirect("/flightdeck")

# A second mount, deeper in the path, proves the engine is entirely
# mount-relative — every link, form action, asset URL and Turbo root has to
# come from the request's own script_name rather than from a remembered
# mount point. A distinct `as:` keeps the two route-helper names apart.
mount Flightdeck::Engine => "/admin/flightdeck", as: :admin_flightdeck
mount Flightdeck::Engine => "/flightdeck"

get "up", to: proc { [ 200, { "Content-Type" => "text/plain" }, [ "ok" ] ] }
# A second mount, deeper in the path and inside a namespace so its route
# name is the scope-derived admin_flightdeck, proves the engine is entirely
# mount-relative — every link, form action, asset URL and Turbo root has to
# come from the request's own script_name rather than from a remembered
# mount point or a hardcoded route-proxy name.
namespace :admin do
mount Flightdeck::Engine, at: "/flightdeck"
end

# A third mount whose route name and path share nothing with "flightdeck":
# Rails' script-name merging can collapse a duplicated path tail and mask a
# wrong-mount URL, so this one cannot pass by coincidence.
mount Flightdeck::Engine => "/ops/dash", as: :renamed

get "up", to: proc { [ 200, { "Content-Type" => "text/plain" }, [ "ok" ] ] }
end
end
47 changes: 44 additions & 3 deletions test/integration/nested_mount_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
# mount Flightdeck::Engine, at: "/flightdeck"
# end
#
# Nothing in the engine may assume it lives at /flightdeck. Every URL it emits
# has to be derived from the current request's script_name, so the dummy app
# mounts the engine twice and these tests drive the deeper mount.
# Nothing in the engine may assume it lives at /flightdeck — or that the mount's
# route name is `flightdeck`: a namespaced mount is named admin_flightdeck, and
# a host can pick anything with `as:`. Every URL the engine emits has to be
# derived from the current request's script_name, so the dummy app mounts the
# engine three times (plain, namespaced, renamed) and these tests drive the
# non-plain mounts.
class Flightdeck::NestedMountTest < FlightdeckIntegrationTest
MOUNT = "/admin/flightdeck"
OTHER_MOUNT = "/flightdeck"
RENAMED_MOUNT = "/ops/dash"

# Any absolute Flightdeck URL that is not under the nested mount means
# something emitted a path from the wrong mount point.
Expand Down Expand Up @@ -168,6 +172,43 @@ def assert_no_bare_mount_urls(body, context)
assert_no_bare_mount_urls response.body, "the range control"
end

# --- a mount with a custom route name (as: :renamed) ----------------------

test "the dashboard renders under a mount with a custom route name" do
create_full_scenario

get_fd RENAMED_MOUNT

assert_response :success
assert_select ".fd-tile", count: 6
assert_includes response.body, %(<meta name="turbo-root" content="#{RENAMED_MOUNT}/">)
end

test "every URL under the renamed mount stays inside it" do
create_failed_job

get_fd RENAMED_MOUNT

document = Nokogiri::HTML(response.body)
links = document.css(".fd-nav-link").map { |link| link["href"] }

assert_operator links.size, :>=, 5
links.each do |href|
assert href.start_with?("#{RENAMED_MOUNT}/"), "nav link #{href.inspect} is not under the renamed mount"
end

stray = response.body.scan(%r{(?:href|src|action|content)="(/(?:admin/)?flightdeck/[^"]*)"}).flatten
assert_empty stray, "the renamed mount emitted URLs from another mount: #{stray.uniq.first(5).inspect}"
end

test "asset links point at the renamed mount" do
get_fd RENAMED_MOUNT

css = Flightdeck::Assets.digested_name("flightdeck.css")

assert_includes response.body, %(href="#{RENAMED_MOUNT}/assets/#{css}")
end

# --- both mounts coexist --------------------------------------------------

test "the original mount is unaffected by the nested one" do
Expand Down
11 changes: 8 additions & 3 deletions test/support/probe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@
if ENV["FLIGHTDECK_PROBE_REQUEST"].present?
require "rack/test"

mount = ENV["FLIGHTDECK_PROBE_MOUNT"].presence || "/flightdeck"
session = Rack::Test::Session.new(Rails.application)

session.get "/flightdeck"
session.get mount
result["without_host_token"] = session.last_response.status

session.get "/flightdeck", {}, { "HTTP_X_HOST_TOKEN" => "hosted" }
session.get mount, {}, { "HTTP_X_HOST_TOKEN" => "hosted" }
result["with_host_token"] = session.last_response.status
result["with_host_token_body_has_shell"] = session.last_response.body.include?("fd-app")
body = session.last_response.body
result["with_host_token_body_has_shell"] = body.include?("fd-app")
result["with_host_token_error"] = body[/<title>(.*?)<\/title>/m, 1] unless session.last_response.ok?
result["turbo_root"] = body[/<meta name="turbo-root" content="([^"]*)"/, 1]
result["nav_hrefs"] = body.scan(/<a class="fd-nav-link[^"]*" href="([^"]*)"/).flatten
end
rescue Exception => error # rubocop:disable Lint/RescueException
result["error"] = "#{error.class}: #{error.message}"
Expand Down