From 57fad2451a04fe5a0768815507b73d50a3e6224b Mon Sep 17 00:00:00 2001 From: Lucy Fu Date: Mon, 8 Dec 2025 09:54:49 -0500 Subject: [PATCH] Fixes #38941 - Implement CSV exports on the hosts overview --- app/controllers/hosts_controller.rb | 27 ++++++++++++-- config/initializers/foreman_register.rb | 4 ++ config/routes.rb | 1 + test/controllers/hosts_controller_test.rb | 37 +++++++++++++++++++ .../react_app/components/HostsIndex/index.js | 17 +++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index 1d92fe35b94..1d63bd4d237 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -56,7 +56,7 @@ def index(title = nil) end format.csv do @hosts = search.preload(included_associations - [:host_statuses, :token]) - csv_response(@hosts) + csv_response(@hosts, csv_columns, csv_headers) end end end @@ -892,9 +892,28 @@ def host_attributes_for_templates(host) end def csv_columns - Pagelets::Manager.pagelets_at("hosts/_list", 'hosts_table_column_header', filter: { selected: @selected_columns }) - .map { |pagelet| pagelet.opts[:export_data] || pagelet.opts[:export_key] || pagelet.opts[:key] } - .flatten + csv_pagelets.map { |pagelet| pagelet.opts[:export_data] || pagelet.opts[:export_key] || pagelet.opts[:key] }.flatten + end + + def csv_headers + csv_pagelets.map do |pagelet| + export_data = pagelet.opts[:export_data] + + # Handle array of ExportDefinitions (like installable_updates) + if export_data.is_a?(Array) + export_data.map(&:label) + elsif export_data.is_a?(CsvExporter::ExportDefinition) + export_data.label + else + # Check for explicit export_label first, then pagelet label, then derive from export_key/key + # Use the same logic as CsvExporter::ExportDefinition.derive_label + pagelet.opts[:export_label] || pagelet.opts[:label] || (pagelet.opts[:export_key] || pagelet.opts[:key]).to_s.titleize.gsub('.', ' - ') + end + end.flatten + end + + def csv_pagelets + @csv_pagelets ||= Pagelets::Manager.pagelets_at("hosts/_list", 'hosts_table_column_header', filter: { selected: @selected_columns }) end def origin_intervals_query(compare_with) diff --git a/config/initializers/foreman_register.rb b/config/initializers/foreman_register.rb index 629454ad392..28ccf3e2490 100644 --- a/config/initializers/foreman_register.rb +++ b/config/initializers/foreman_register.rb @@ -21,6 +21,10 @@ add_pagelet :hosts_table_column_content, key: :owner, callback: ->(host) { host_owner_column(host) }, class: common_class add_pagelet :hosts_table_column_header, key: :hostgroup, label: N_('Host group'), sortable: true, width: '15%', class: common_class add_pagelet :hosts_table_column_content, key: :hostgroup, callback: ->(host) { label_with_link host.hostgroup, 23, @hostgroup_authorizer }, class: common_class + add_pagelet :hosts_table_column_header, key: :organization, label: N_('Organization'), sortable: true, width: '12%', export_key: 'organization.name', class: common_class + add_pagelet :hosts_table_column_content, key: :organization, callback: ->(host) { host.organization&.name }, class: common_class + add_pagelet :hosts_table_column_header, key: :location, label: N_('Location'), sortable: true, width: '12%', export_key: 'location.name', class: common_class + add_pagelet :hosts_table_column_content, key: :location, callback: ->(host) { host.location&.name }, class: common_class add_pagelet :hosts_table_column_header, key: :boot_time, label: N_('Boot time'), sortable: true, width: '10%', export_key: 'reported_data.boot_time', class: common_class add_pagelet :hosts_table_column_content, key: :boot_time, callback: ->(host) { date_time_unless_empty(host.reported_data&.boot_time) }, class: common_class add_pagelet :hosts_table_column_header, key: :last_report, label: N_('Last report'), sortable: true, default_sort: 'DESC', width: '10%', class: common_class diff --git a/config/routes.rb b/config/routes.rb index 959f785f59f..45298419824 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -552,6 +552,7 @@ match 'host_statuses' => 'react#index', :via => :get match 'new/hosts/auto_complete_search', :via => :get, :to => 'hosts#auto_complete_search', :as => "auto_complete_search_hosts_new" + match 'new/hosts.csv', :via => :get, :to => 'hosts#index', :defaults => { :format => 'csv' }, :as => :new_hosts_csv constraints(id: /[^\/]+/) do match 'new/hosts/:id' => 'react#index', :via => :get, :as => :host_details_page end diff --git a/test/controllers/hosts_controller_test.rb b/test/controllers/hosts_controller_test.rb index 139e4c01c69..405bd72e4f4 100644 --- a/test/controllers/hosts_controller_test.rb +++ b/test/controllers/hosts_controller_test.rb @@ -51,6 +51,43 @@ def host_attributes(host) end end + test "csv_headers should use ExportDefinition labels for power_status" do + User.current.table_preferences.create(name: 'hosts', columns: ['power_status']) + get :index, params: { :format => 'csv' }, session: set_session_user + assert_response :success + buf = response.stream.instance_variable_get(:@buf) + header_line = buf.next + assert_includes header_line, "Power Status", "Header should use ExportDefinition label 'Power Status' not 'Power'" + end + + test "csv_headers should derive headers from export_key when available" do + User.current.table_preferences.create(name: 'hosts', columns: ['os_title', 'boot_time']) + get :index, params: { :format => 'csv' }, session: set_session_user + assert_response :success + buf = response.stream.instance_variable_get(:@buf) + header_line = buf.next + assert_includes header_line, "Operatingsystem", "Header should be derived from export_key 'operatingsystem'" + assert_includes header_line, "Reported Data - Boot Time", "Header should format dotted keys with ' - '" + end + + test "csv_columns should return columns from pagelets" do + User.current.table_preferences.create(name: 'hosts', columns: ['name', 'power_status']) + get :index, params: { :format => 'csv' }, session: set_session_user + assert_response :success + # Verify columns are properly extracted from pagelets + assert @controller.send(:csv_columns).any? { |col| col.is_a?(String) || col.is_a?(CsvExporter::ExportDefinition) } + end + + test "csv_pagelets should be memoized" do + User.current.table_preferences.create(name: 'hosts', columns: ['name']) + get :index, params: { :format => 'csv' }, session: set_session_user + + # Call csv_pagelets twice and verify it returns the same object (memoized) + pagelets1 = @controller.send(:csv_pagelets) + pagelets2 = @controller.send(:csv_pagelets) + assert_same pagelets1, pagelets2, "csv_pagelets should be memoized" + end + test "should include registered scope on index" do # remember the previous state old_scopes = HostsController.scopes_for(:index).dup diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index f468ae3cb92..b2856abcfb9 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -465,6 +465,23 @@ const HostsIndex = () => { selectedCount={selectedCount} /> + + +